From 98d34a3dc1105eb0f1b1a528cba1d067dd5fa9f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 13:32:10 +0700 Subject: [PATCH 01/62] ci: run pull-request checks for every base branch (#7545) Stacked PRs and PRs into next got no CI because the pull_request trigger was limited to main. Drop the base filter from CI and desktop packaging, and lock it with a workflow contract test. --- .github/workflows/ci.yml | 2 +- .github/workflows/desktop-packages.yml | 1 - docs/release.md | 7 ++++--- scripts/ci-workflow.test.mjs | 14 +++++++++++++- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c2a397170..486c7333b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,8 @@ name: CI on: push: branches: [main] + # Every base branch, so stacked PRs and PRs into `next` get the same checks. pull_request: - branches: [main] merge_group: workflow_dispatch: diff --git a/.github/workflows/desktop-packages.yml b/.github/workflows/desktop-packages.yml index c30eea7da4..77e2735c6a 100644 --- a/.github/workflows/desktop-packages.yml +++ b/.github/workflows/desktop-packages.yml @@ -6,7 +6,6 @@ on: # Packaging leaves ordinary PR CI, but a PR that changes what gets packaged # or how it is smoked still proves the real artifacts before merge. pull_request: - branches: [main] paths: - "packages/desktop/**" - "plugins/**" diff --git a/docs/release.md b/docs/release.md index 751998c864..c6ba97e078 100644 --- a/docs/release.md +++ b/docs/release.md @@ -62,9 +62,10 @@ release. This applies to both beta and stable releases. the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated `next`. Create it fresh when needed again. -**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`, -and GitHub permits only squash merges. Enable checks and required-check protection -for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR +**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix +push runs target only `main`, and GitHub permits only squash merges. Enable +required-check protection for `next`, CI on its pushes, and merge commits for the +integration PR. Handle PR base changes (`edited` events) so retargeting runs checks against the new base; GitHub's default PR events do not cover this. Deployment triggers stay unchanged. diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 50d2ab448b..6fa13ba0ec 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -105,6 +105,18 @@ test("gated checks are statically named jobs with real job-level gating", () => } }); +test("pull-request checks run for every base branch, including stacked PRs", () => { + for (const workflowPath of [ + ciWorkflowPath, + new URL(".github/workflows/desktop-packages.yml", repoRoot), + ]) { + const trigger = readFileSync(workflowPath, "utf8").split("jobs:", 1)[0]; + const pullRequest = trigger.match(/^ pull_request:\n((?: .*\n)*)/m); + assert.ok(pullRequest, `${workflowPath.pathname} runs on pull requests`); + assert.doesNotMatch(pullRequest[1], /branches/); + } +}); + test("change gating allows superseded workflow runs to cancel", () => { const ci = readFileSync(ciWorkflowPath, "utf8"); assert.match( @@ -308,7 +320,7 @@ test("desktop packaging smokes main pushes and only the pull requests that touch const source = readFileSync(new URL(".github/workflows/desktop-packages.yml", repoRoot), "utf8"); const trigger = source.split("jobs:", 1)[0]; assert.match(trigger, /push:\s*\n\s+branches: \[main\]/); - assert.match(trigger, /pull_request:\s*\n\s+branches: \[main\]\s*\n\s+paths:/); + assert.match(trigger, /pull_request:\s*\n\s+paths:/); assert.match(trigger, /- "packages\/desktop\/\*\*"/); assert.doesNotMatch(source, /dorny\/paths-filter/); for (const action of ["actions/checkout", "actions/setup-node", "actions/upload-artifact"]) { From 30464cf2341ea54a46e6e00c89863549b67e1f52 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 13:40:46 +0700 Subject: [PATCH 02/62] Translate plugin content and labels in each app language (#7478) * feat(website): serve paseo.sh in Simplified Chinese Every page of the website now has a Simplified Chinese version under /zh-CN: the interface, docs, blog posts, comparison pages, legal pages, all changelog releases and the homepage mockup. English URLs are unchanged. The URL alone decides the language. There is no browser-language redirect and no stored preference, because either would have to redirect the unprefixed English URLs that people share and search engines index. Internal links carry the page's language instead, and hreflang alternates point Chinese search results at /zh-CN. Translated headings pin the English anchor with {#id}, so section links work in both languages. content-translations.test.ts fails when a page has no translation or a translation's headings drift from the English. Changelog releases and plugin listings without a translation render whole in English under a notice, so a release never waits on one. Plugin manifests and the registry gain optional translations and OVERVIEW.zh-CN.md fields; older clients and registries ignore them. * feat(website): replace the native language select with an EN/ZH menu The header showed an OS-native select whose popup did not match the site. The trigger now shows the language abbreviated (EN, ZH) and opens a menu in the app's dropdown style that names each language in itself, with its name in the page language underneath. Rows are real links to the same page in that language; arrows, Escape, outside presses and taps behave as a menu button, and the menu keeps itself on screen when a narrow header pushes the trigger toward an edge. Also drops the stray space after the full-width comma in the Chinese homepage subtitle. * docs(website): translate main's new docs and 0.12.0 release notes Main added Keep run workspaces to the schedules docs, rewrote plugin icons around inline SVG, and promoted 0.12.0 with new entries. Bring the Chinese pages and changelog in line so no section falls back to English. * fix(server): leave plugin listing translations to the registry The daemon rejected manifests whose translations the registry would refuse, so a plugin main installs could fail to load here. Paseo never reads translations; the registry validates them at publish. Docs now say which checks are automated at publish and which are human review. * docs(plugins): describe the registry's final overview translation discovery * style(server): format manifest test * feat(plugins): translate plugin UI in each app language Keep translation resources and subscriptions inside each installation. Resolve host labels against the presentation locale so live language changes preserve mounted drafts and RPC state. Existing descriptor types retain string defaults for source compatibility. * test(plugins): dismiss developer chrome in second translation client * test(app): measure sidebar rows one at a time After a reload the developer error toast appears while expectVerticalOrder measures every row at once. Each concurrent boundingBox runs the toast locator handler, and only one of them resumes, so the poll timed out on every attempt since #7424. Measuring sequentially keeps one action in flight. * test(app): return sidebar row measuring to main #7480 hides the developer toast instead of running a locator handler, so measuring the rows concurrently no longer races it. Reverts 41ac23016. * test(app): wait for the compact shell before opening compact Changes The shell survives the breakpoint, so right after the resize the wide Explorer could still be visible: the test skipped the Explorer toggle and the tab then disappeared, or toggled at the wrong time. Wait for the compact-shell marker, as the composer helpers do since #7424. * docs(website): translate main's plugin file panel docs --- docs/plugins.md | 6 + package-lock.json | 1 + .../app/e2e/browser/plugin-buttons.spec.ts | 8 + .../app/e2e/browser/plugin-host-ui.spec.ts | 8 + .../support/helpers/plugin-translations.ts | 164 ++++++++++++++ .../app/src/agent-stream/presentation.test.ts | 3 + packages/app/src/composer/index.tsx | 7 + .../src/file-panels/choice/presentation.ts | 2 +- packages/app/src/file-panels/panels.ts | 9 +- .../app/src/plugins/attachments/picker.tsx | 20 +- packages/app/src/plugins/attachments/pill.tsx | 16 +- .../app/src/plugins/buttons/model.test.ts | 81 +++++++ packages/app/src/plugins/buttons/model.ts | 29 ++- .../app/src/plugins/buttons/validation.ts | 49 ++++- packages/app/src/plugins/buttons/view.tsx | 9 +- packages/app/src/plugins/client-runtime.ts | 1 + .../plugins/client-slash-commands/index.ts | 4 +- .../command-center/contributions.test.ts | 3 + .../plugins/command-center/contributions.ts | 2 +- packages/app/src/plugins/evaluate.ts | 67 +++--- .../plugins/installation-provider.test.tsx | 3 + .../app/src/plugins/installation-provider.tsx | 10 +- .../src/plugins/registry-lifecycle.test.ts | 82 ++++++- .../src/plugins/registry-requirements.test.ts | 22 ++ packages/app/src/plugins/registry.ts | 31 ++- packages/app/src/plugins/settings/index.tsx | 2 +- .../app/src/plugins/sidebar-groups.test.ts | 3 + packages/app/src/plugins/sidebar-groups.ts | 4 +- .../app/src/plugins/sidebar-items/index.tsx | 6 +- .../src/plugins/surface-contribution.test.ts | 25 ++- .../app/src/plugins/surface-contribution.ts | 7 +- packages/app/src/plugins/surface-screen.tsx | 15 +- packages/app/src/plugins/theme.test.ts | 3 + packages/app/src/plugins/themes/index.ts | 11 +- .../app/src/plugins/timeline/model.test.ts | 3 + packages/app/src/plugins/types.ts | 39 +++- .../src/plugins/workspace-panels/locations.ts | 6 +- .../src/plugins/workspace-panels/panel.tsx | 4 +- .../workspace-panels/resolution.test.ts | 3 + .../plugins/workspace-panels/resolution.ts | 2 +- packages/app/src/screens/settings-screen.tsx | 9 +- .../app/src/workspace-tabs/launcher/index.tsx | 2 +- packages/plugin/package.json | 1 + packages/plugin/src/attachments.ts | 7 +- packages/plugin/src/boundaries.test.ts | 3 +- packages/plugin/src/client/buttons.ts | 35 ++- packages/plugin/src/client/contracts.ts | 92 ++++---- packages/plugin/src/client/host.ts | 12 +- packages/plugin/src/client/index.ts | 4 + .../src/client/runtime-context-bridge.tsx | 8 +- .../plugin/src/client/translations.test.ts | 104 +++++++++ packages/plugin/src/client/translations.tsx | 202 ++++++++++++++++++ packages/plugin/src/contracts.ts | 13 +- packages/plugin/src/index.ts | 2 + packages/plugin/src/skill-examples.test.ts | 75 +++++++ packages/plugin/src/text.ts | 67 ++++++ packages/plugin/tsconfig.examples.json | 3 +- plugin-examples/translations/OVERVIEW.md | 7 + .../translations/OVERVIEW.zh-CN.md | 7 + .../translations/client/locales/en.json | 23 ++ .../translations/client/locales/zh-CN.json | 21 ++ .../translations/client/messages.ts | 5 + plugin-examples/translations/client/notes.tsx | 151 +++++++++++++ plugin-examples/translations/index.client.tsx | 100 +++++++++ plugin-examples/translations/package.json | 16 ++ .../translations/paseo-plugin.json | 12 ++ plugin-examples/translations/tsconfig.json | 15 ++ public-docs/plugins/reference.md | 83 ++++++- public-docs/plugins/reference.zh-CN.md | 77 ++++++- 69 files changed, 1717 insertions(+), 209 deletions(-) create mode 100644 packages/app/e2e/support/helpers/plugin-translations.ts create mode 100644 packages/plugin/src/client/translations.test.ts create mode 100644 packages/plugin/src/client/translations.tsx create mode 100644 packages/plugin/src/text.ts create mode 100644 plugin-examples/translations/OVERVIEW.md create mode 100644 plugin-examples/translations/OVERVIEW.zh-CN.md create mode 100644 plugin-examples/translations/client/locales/en.json create mode 100644 plugin-examples/translations/client/locales/zh-CN.json create mode 100644 plugin-examples/translations/client/messages.ts create mode 100644 plugin-examples/translations/client/notes.tsx create mode 100644 plugin-examples/translations/index.client.tsx create mode 100644 plugin-examples/translations/package.json create mode 100644 plugin-examples/translations/paseo-plugin.json create mode 100644 plugin-examples/translations/tsconfig.json diff --git a/docs/plugins.md b/docs/plugins.md index fc000f7b15..facc815ef6 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -86,6 +86,12 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source never deletes it. The global `pluginsEnabled` switch remains available. +## Client translations + +Plugin runtime translations follow the connected app, so never send a daemon locale through RPCs or add plugin resources to the app catalog. The installation owns the translation engine; native portal bridges must carry it alongside RPC and client-state contexts. Keep host label resolution at presentation time so language changes preserve the installation lifetime. + +The author contract is in [runtime translations](../public-docs/plugins/reference.md#runtime-translations). + ## Built-in plugins Built-in plugins ship from `plugins//`, with `paseo-plugin.json`, `index.server.ts`, diff --git a/package-lock.json b/package-lock.json index ba33cc2f4f..f4bbd3e6ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42116,6 +42116,7 @@ "version": "0.12.0", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", + "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { diff --git a/packages/app/e2e/browser/plugin-buttons.spec.ts b/packages/app/e2e/browser/plugin-buttons.spec.ts index 22eb61910b..d3503f56ed 100644 --- a/packages/app/e2e/browser/plugin-buttons.spec.ts +++ b/packages/app/e2e/browser/plugin-buttons.spec.ts @@ -1,3 +1,4 @@ +import { verifyPluginToolTranslations } from "../support/helpers/plugin-translations"; import { test } from "../support/fixtures"; import { withButtonShowcase } from "../support/helpers/plugin-buttons"; @@ -13,3 +14,10 @@ test("plugin header and composer buttons share actions, menus, content, and upda await buttons.hideAndUnloadOpenButtons(); }); }); + +test("plugin tooltips and menu labels follow app language with a panel draft open", async ({ + page, +}, info) => { + test.setTimeout(180_000); + await verifyPluginToolTranslations(page, info); +}); diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index 75ea218a8f..d8fd175b5f 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -1,3 +1,4 @@ +import { verifyPluginTranslations } from "../support/helpers/plugin-translations"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -166,3 +167,10 @@ async function playPluginAudio(page: Page, button: string, result: string) { await page.getByRole("button", { name: button, exact: true }).click(); await expect(page.getByText(result, { exact: true })).toBeVisible(); } + +test("plugin content and host labels follow app language without losing an open draft", async ({ + page, +}, info) => { + test.setTimeout(180_000); + await verifyPluginTranslations(page, info); +}); diff --git a/packages/app/e2e/support/helpers/plugin-translations.ts b/packages/app/e2e/support/helpers/plugin-translations.ts new file mode 100644 index 0000000000..e88c6be31b --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-translations.ts @@ -0,0 +1,164 @@ +import { hideDeveloperToasts } from "../developer-ui.mjs"; +import { seedWorkspace } from "./seed-client"; +import { buildAgentRoute } from "./mock-agent"; +import { openCommandCenter } from "./command-center"; +import type { TestInfo, BrowserContext } from "@playwright/test"; +import { expect, type Page } from "../fixtures"; +import { gotoAppShell, openSettings } from "./app"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { copyPluginExample } from "./plugin-fixture"; + +async function chooseChinese(page: Page) { + await openSettings(page); + await page.getByRole("button", { name: "System", exact: true }).click(); + await page.getByRole("menuitem", { name: "简体中文 - Simplified Chinese", exact: true }).click(); + await page.goBack(); +} + +async function chooseEnglish(page: Page) { + await page.getByTestId("sidebar-settings").click(); + await page.getByRole("button", { name: "简体中文", exact: true }).click(); + await page.getByRole("menuitem", { name: "English - 英语", exact: true }).click(); + await page.goBack(); +} + +async function capture(page: Page, info: TestInfo, name: string) { + const file = info.outputPath(`${name}.png`); + await page.screenshot({ path: file, animations: "disabled" }); + await info.attach(name, { path: file, contentType: "image/png" }); +} + +export async function verifyPluginTranslations(page: Page, info: TestInfo) { + const example = await copyPluginExample("translations"); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const config = await client.getDaemonConfig(); + const browser = page.context().browser(); + if (!browser) throw new Error("Translation isolation requires a browser"); + let secondContext: BrowserContext | undefined; + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(example.directory); + await page.setViewportSize({ width: 1440, height: 1000 }); + await gotoAppShell(page); + await page.getByRole("button", { name: "Workspace notes", exact: true }).click(); + await expect(page.getByText("App language: en", { exact: true })).toBeVisible(); + await page + .getByRole("textbox", { name: "Your draft", exact: true }) + .fill("Remember the language switch"); + await capture(page, info, "01-english-draft"); + await page.getByRole("button", { name: "Preview draft", exact: true }).click(); + await expect(page.getByRole("button", { name: "Close preview", exact: true })).toBeVisible(); + await capture(page, info, "02-english-preview"); + await page.getByRole("button", { name: "Close preview", exact: true }).click(); + secondContext = await browser.newContext({ + storageState: await page.context().storageState(), + viewport: { width: 1440, height: 1000 }, + }); + const second = await secondContext.newPage(); + await hideDeveloperToasts(second); + await second.goto(page.url()); + await expect(second.getByText("App language: en", { exact: true })).toBeVisible(); + await chooseChinese(second); + await expect(second.getByText("应用语言:zh-CN", { exact: true })).toBeVisible(); + await second + .getByRole("textbox", { name: "你的草稿", exact: true }) + .fill("A separate client draft"); + await expect(page.getByText("App language: en", { exact: true })).toBeVisible(); + await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes"); + await capture(second, info, "03-independent-chinese-client"); + await chooseChinese(page); + await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue( + "Remember the language switch", + ); + await expect(page.getByTestId("plugin-surface-title")).toHaveText("工作区笔记"); + await expect(page.getByText("应用语言:zh-CN", { exact: true })).toBeVisible(); + await expect( + page.getByText("This English-only message demonstrates fallback.", { exact: true }), + ).toBeVisible(); + await capture(page, info, "04-chinese-draft-preserved"); + await page.getByRole("button", { name: "预览草稿", exact: true }).click(); + await expect(page.getByText("草稿预览", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "关闭预览", exact: true })).toBeVisible(); + await capture(page, info, "05-chinese-preview"); + await page.getByRole("button", { name: "关闭预览", exact: true }).click(); + await chooseEnglish(page); + await expect(page.getByRole("textbox", { name: "Your draft", exact: true })).toHaveValue( + "Remember the language switch", + ); + await expect(page.getByTestId("plugin-surface-title")).toHaveText("Workspace notes"); + await expect(second.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue( + "A separate client draft", + ); + await capture(page, info, "06-return-to-english-draft-preserved"); + await page.getByRole("button", { name: "Add note", exact: true }).click(); + await expect(page.getByText("1 note", { exact: true })).toBeVisible(); + await page.getByRole("textbox", { name: "Your draft", exact: true }).fill("Second note"); + await page.getByRole("button", { name: "Add note", exact: true }).click(); + await expect(page.getByText("2 notes", { exact: true })).toBeVisible(); + } finally { + await secondContext?.close().catch(() => undefined); + await client.removePlugin("translation-notes").catch(() => undefined); + await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled }); + await client.close(); + await example.cleanup(); + } +} + +export async function verifyPluginToolTranslations(page: Page, info: TestInfo) { + const workspace = await seedWorkspace({ + repoPrefix: "translation-tools-", + title: "Notes translation QA", + }); + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Notes", + model: "ten-second-stream", + modeId: "load-test", + }); + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const config = await client.getDaemonConfig(); + const example = await copyPluginExample("translations"); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(example.directory); + await page.setViewportSize({ width: 1440, height: 1000 }); + await gotoAppShell(page); + await page.goto(buildAgentRoute(workspace.workspaceId, agent.id)); + const commandCenter = await openCommandCenter(page); + await commandCenter.getByTestId("command-center-input").fill("Show notes tools"); + await commandCenter.getByRole("button", { name: "Show notes tools", exact: true }).click(); + const tools = page + .getByRole("toolbar", { name: "Workspace actions", exact: true }) + .getByRole("button", { name: "Notes tools", exact: true }); + await tools.hover(); + await expect(page.getByRole("tooltip")).toContainText("Notes tools"); + await capture(page, info, "07-english-tooltip"); + await tools.click(); + await page.getByRole("menuitem", { name: "Open notes panel", exact: true }).click(); + await page + .getByRole("textbox", { name: "Your draft", exact: true }) + .fill("A workspace panel draft"); + await chooseChinese(page); + await expect(page.getByRole("textbox", { name: "你的草稿", exact: true })).toHaveValue( + "A workspace panel draft", + ); + const translatedTools = page + .getByRole("toolbar") + .getByRole("button", { name: "笔记工具", exact: true }); + await translatedTools.hover(); + await expect(page.getByRole("tooltip")).toContainText("笔记工具"); + await capture(page, info, "08-chinese-tooltip-and-panel-draft"); + await translatedTools.click(); + await expect(page.getByRole("menuitem", { name: "打开笔记面板", exact: true })).toBeVisible(); + await capture(page, info, "09-chinese-menu"); + await page.keyboard.press("Escape"); + } finally { + await client.removePlugin("translation-notes").catch(() => undefined); + await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled }); + await client.close(); + await example.cleanup(); + await workspace.cleanup(); + } +} diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index dcb52fd24b..ef3a30e5c5 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate"; import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model"; import type { InstalledPlugin } from "@/plugins/types"; @@ -61,6 +62,8 @@ function installProbe( serverId: "host-1", clientBundle, lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 10d670fcae..ed9ccca4f7 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -315,6 +315,7 @@ interface PendingFileAttachment { } interface RenderAttachmentTrayArgs { + serverId: string; selectedAttachments: ComposerAttachment[]; pendingFiles: PendingFileAttachment[]; isComposerLocked: boolean; @@ -331,6 +332,7 @@ interface RenderAttachmentTrayArgs { function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { const { + serverId, selectedAttachments, pendingFiles, isComposerLocked, @@ -343,6 +345,7 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu {selectedAttachments.map((attachment, index) => renderComposerAttachmentPill({ + serverId, attachment, index, disabled: isComposerLocked, @@ -393,6 +396,7 @@ function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null { } interface RenderComposerAttachmentPillArgs { + serverId: string; attachment: ComposerAttachment; index: number; disabled: boolean; @@ -453,6 +457,7 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R if (attachment.kind === "plugin_resource") { return ( renderAttachmentTray({ + serverId, selectedAttachments, pendingFiles, isComposerLocked, @@ -2314,6 +2320,7 @@ function ComposerContentImpl({ handleOpenAttachment, handleRemoveAttachment, isComposerLocked, + serverId, selectedAttachments, pendingFiles, t, diff --git a/packages/app/src/file-panels/choice/presentation.ts b/packages/app/src/file-panels/choice/presentation.ts index 9c0de9170b..ffe319b4fe 100644 --- a/packages/app/src/file-panels/choice/presentation.ts +++ b/packages/app/src/file-panels/choice/presentation.ts @@ -18,7 +18,7 @@ export function filePanelIcon(panel: FilePanel): PluginIcon { export function filePanelTitle(t: TFunction, panel: FilePanel): string { return panel.builtin ? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`) - : panel.contribution.title; + : panel.plugin.i18n.resolve(panel.contribution.title, panel.plugin.locale); } /** "Markdown — Paseo", "Rich Markdown — markdown-panel". */ diff --git a/packages/app/src/file-panels/panels.ts b/packages/app/src/file-panels/panels.ts index 6ed609e07c..7646755074 100644 --- a/packages/app/src/file-panels/panels.ts +++ b/packages/app/src/file-panels/panels.ts @@ -1,12 +1,7 @@ import { useMemo } from "react"; -import type { - PluginFile, - PluginFileLocation, - PluginFilePanelContribution, - PluginFileTab, -} from "@getpaseo/plugin/client"; +import type { PluginFile, PluginFileLocation, PluginFileTab } from "@getpaseo/plugin/client"; import { useInstalledPlugins } from "@/plugins/registry"; -import type { InstalledPlugin } from "@/plugins/types"; +import type { PluginFilePanelContribution, InstalledPlugin } from "@/plugins/types"; import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog"; export interface PluginFilePanel { diff --git a/packages/app/src/plugins/attachments/picker.tsx b/packages/app/src/plugins/attachments/picker.tsx index 606fba5885..5c689676a4 100644 --- a/packages/app/src/plugins/attachments/picker.tsx +++ b/packages/app/src/plugins/attachments/picker.tsx @@ -2,11 +2,8 @@ import { useCallback, useMemo, useState, type ReactElement, type RefObject } fro import { View } from "react-native"; import { withUnistyles } from "react-native-unistyles"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { - PluginAttachmentItem, - PluginAttachmentSourceContribution, - PluginSerializableIcon, -} from "@getpaseo/plugin"; +import type { PluginAttachmentItem, PluginSerializableIcon } from "@getpaseo/plugin"; +import type { PluginAttachmentSourceContribution } from "../types"; import { searchPluginAttachments } from "@getpaseo/plugin/client/host"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AttachmentMenuItem } from "@/composer/input/input"; @@ -136,7 +133,7 @@ export function usePluginAttachmentPicker( { pluginId: active.plugin.id, sourceId: active.source.id, - sourceTitle: active.source.title, + sourceTitle: active.plugin.i18n.resolve(active.source.title, active.plugin.locale), sourceIcon: active.source.icon, }, item, @@ -148,9 +145,9 @@ export function usePluginAttachmentPicker( ); const menuItems = useMemo( () => - sources.map(({ key, source }) => ({ + sources.map(({ key, source, plugin }) => ({ id: `plugin:${key}`, - label: `Attach ${source.title}`, + label: `Attach ${plugin.i18n.resolve(source.title, plugin.locale)}`, icon: , onSelect: () => setActiveKey(key), })), @@ -165,8 +162,11 @@ export function usePluginAttachmentPicker( value="" onSelect={handleSelect} searchable - searchPlaceholder={active.source.searchPlaceholder} - title={active.source.pickerTitle} + searchPlaceholder={active.plugin.i18n.resolve( + active.source.searchPlaceholder, + active.plugin.locale, + )} + title={active.plugin.i18n.resolve(active.source.pickerTitle, active.plugin.locale)} open onOpenChange={handleOpenChange} onSearchQueryChange={setQuery} diff --git a/packages/app/src/plugins/attachments/pill.tsx b/packages/app/src/plugins/attachments/pill.tsx index 774a7d359d..3491d9244a 100644 --- a/packages/app/src/plugins/attachments/pill.tsx +++ b/packages/app/src/plugins/attachments/pill.tsx @@ -1,3 +1,4 @@ +import { useInstalledPlugin } from "../registry"; import { useCallback, useMemo } from "react"; import { withUnistyles } from "react-native-unistyles"; import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill"; @@ -16,6 +17,7 @@ function ResourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; colo const ThemedResourceIcon = withUnistyles(ResourceIcon); interface PluginResourceAttachmentPillProps { + serverId: string; attachment: PluginResourceComposerAttachment; index: number; disabled: boolean; @@ -26,6 +28,7 @@ interface PluginResourceAttachmentPillProps { } export function PluginResourceAttachmentPill({ + serverId, attachment, index, disabled, @@ -34,6 +37,13 @@ export function PluginResourceAttachmentPill({ openLabel, removeLabel, }: PluginResourceAttachmentPillProps) { + const plugin = useInstalledPlugin(serverId, attachment.pluginId); + const source = plugin?.attachmentSources.find( + (candidate) => candidate.id === attachment.sourceId, + ); + // Keep the saved label when the source is no longer installed. + const sourceTitle = + plugin && source ? plugin.i18n.resolve(source.title, plugin.locale) : attachment.sourceTitle; const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]); const handleRemove = useCallback(() => onRemove(index), [index, onRemove]); const icon = useMemo( @@ -45,14 +55,14 @@ export function PluginResourceAttachmentPill({ testID="composer-plugin-resource-attachment-pill" onOpen={handleOpen} onRemove={handleRemove} - openAccessibilityLabel={openLabel(attachment.sourceTitle, attachment.item.identifier)} - removeAccessibilityLabel={removeLabel(attachment.sourceTitle, attachment.item.identifier)} + openAccessibilityLabel={openLabel(sourceTitle, attachment.item.identifier)} + removeAccessibilityLabel={removeLabel(sourceTitle, attachment.item.identifier)} disabled={disabled} > ); diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index ff7219f2a9..8c07a8615e 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -1,3 +1,5 @@ +import { defineTranslations } from "@getpaseo/plugin/client"; +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { PluginButton } from "@getpaseo/plugin/client"; @@ -7,6 +9,8 @@ import { PluginButtonStore, buttonMatches } from "./model"; function installation(): InstalledPlugin { return { lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), id: "review", serverId: "host-a", clientBundle: "bundle", @@ -173,3 +177,80 @@ describe("plugin buttons", () => { expect(calls).toBe(2); }); }); + +it("updates translated tooltips and nested menu labels without closing open content", () => { + let locale = "en"; + const listeners = new Set<() => void>(); + const plugin = installation(); + plugin.i18n.dispose(); + plugin.i18n = new PluginTranslationRuntime({ + getLocale: () => locale, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }); + const translations = defineTranslations({ + resources: { + en: { title: "Notes", menu: "Open notes" }, + "zh-CN": { title: "笔记", menu: "打开笔记" }, + }, + }); + const buttons = store(); + const registration = buttons.addHeaderButton(plugin, { + id: "notes", + workspaceId: "workspace", + button: { + title: translations.message("title"), + icon: "Scan", + behavior: { + kind: "menu", + items: [ + { + kind: "item", + id: "open", + title: translations.message("menu"), + behavior: { kind: "action", onPress() {} }, + }, + ], + }, + }, + }); + const original = buttons.getSnapshot()[0]; + buttons.setOpen(original.key, true); + locale = "zh-CN"; + for (const listener of listeners) listener(); + const translated = buttons.getSnapshot()[0]; + expect(translated.key).toBe(original.key); + expect(translated.open).toBe(true); + expect(translated.button.title).toBe("笔记"); + expect(translated.button.behavior).toMatchObject({ + kind: "menu", + items: [{ title: "打开笔记" }], + }); + registration.update({ disabled: false }); + locale = "en"; + for (const listener of listeners) listener(); + expect(buttons.getSnapshot()[0].button.title).toBe("Notes"); + registration.remove(); + plugin.i18n.dispose(); + expect(listeners.size).toBe(0); + expect(buttons.getSnapshot()).toEqual([]); +}); + +it("keeps the previous descriptor after rejecting an invalid update", () => { + const buttons = store(); + const plugin = installation(); + const registration = buttons.addHeaderButton(plugin, { + id: "review", + workspaceId: "workspace", + button: button(), + }); + expect(() => registration.update({ title: "" })).toThrow("Plugin button needs title"); + expect(() => registration.update({ disabled: true })).not.toThrow(); + expect(buttons.getSnapshot()[0].button.title).toBe("Review"); + registration.remove(); + plugin.i18n.dispose(); +}); diff --git a/packages/app/src/plugins/buttons/model.ts b/packages/app/src/plugins/buttons/model.ts index 25e7490b33..4b81f95a84 100644 --- a/packages/app/src/plugins/buttons/model.ts +++ b/packages/app/src/plugins/buttons/model.ts @@ -1,5 +1,7 @@ import type { + PluginText, PluginButtonBehavior, + PluginButton, PluginButtonContext, PluginButtonRegistration, PluginHeaderButtonContribution, @@ -27,7 +29,7 @@ export interface RegisteredPluginButton { } function resolveAction( - behavior: PluginButtonBehavior, + behavior: PluginButtonBehavior, path: readonly string[], ): (() => void | Promise) | null { if (path.length === 0) return behavior.kind === "action" ? behavior.onPress : null; @@ -55,8 +57,8 @@ export class PluginButtonStore { addHeaderButton( installation: InstalledPlugin, - input: PluginHeaderButtonContribution, - ): PluginButtonRegistration { + input: PluginHeaderButtonContribution, + ): PluginButtonRegistration { return this.add(installation, input, "header", { context: "workspace", workspaceId: input.workspaceId.trim(), @@ -65,8 +67,8 @@ export class PluginButtonStore { addComposerPill( installation: InstalledPlugin, - input: PluginComposerPillContribution, - ): PluginButtonRegistration { + input: PluginComposerPillContribution, + ): PluginButtonRegistration { if (!input.agentId.trim()) throw new Error("Plugin composer pill needs an agent"); return this.add(installation, input, "composer", { context: "agent", @@ -77,10 +79,10 @@ export class PluginButtonStore { private add( installation: InstalledPlugin, - input: PluginHeaderButtonContribution, + input: PluginHeaderButtonContribution, placement: ButtonPlacement, context: PluginButtonContext, - ): PluginButtonRegistration { + ): PluginButtonRegistration { const id = requireButtonId(input.id); if (!context.workspaceId.trim()) throw new Error("Plugin button needs a workspace"); const duplicate = this.entries.some( @@ -94,7 +96,8 @@ export class PluginButtonStore { ); if (duplicate) throw new Error(`Duplicate plugin button: ${id}`); const key = this.nextKey++; - const initialButton = validateButton(input.button, this.validation); + let sourceButton: PluginButton = input.button; + const initialButton = validateButton(sourceButton, this.validation, installation.i18n.resolve); this.publish([ ...this.entries, { @@ -108,15 +111,23 @@ export class PluginButtonStore { open: false, }, ]); + const unsubscribe = installation.i18n.subscribe(() => { + this.replace(key, { + button: validateButton(sourceButton, this.validation, installation.i18n.resolve), + }); + }); return { update: (patch) => { const entry = this.entries.find((candidate) => candidate.key === key); if (!entry) return; - const button = validateButton({ ...entry.button, ...patch }, this.validation); + const nextSource = { ...sourceButton, ...patch }; + const button = validateButton(nextSource, this.validation, installation.i18n.resolve); + sourceButton = nextSource; const close = !button.visible || button.disabled || patch.behavior !== undefined; this.replace(key, { button, open: close ? false : entry.open }); }, remove: () => { + unsubscribe(); const next = this.entries.filter((entry) => entry.key !== key); if (next.length !== this.entries.length) this.publish(next); }, diff --git a/packages/app/src/plugins/buttons/validation.ts b/packages/app/src/plugins/buttons/validation.ts index 72e656a271..35fb580dbf 100644 --- a/packages/app/src/plugins/buttons/validation.ts +++ b/packages/app/src/plugins/buttons/validation.ts @@ -1,4 +1,12 @@ -import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client"; +import type { PluginText } from "@getpaseo/plugin/client"; +import type { + PluginButton as SDKPluginButton, + PluginButtonBehavior as SDKPluginButtonBehavior, + PluginButtonIcon, +} from "@getpaseo/plugin/client"; + +type PluginButton = SDKPluginButton; +type PluginButtonBehavior = SDKPluginButtonBehavior; export interface ButtonValidation { /** Returns the normalized icon, or throws naming why the icon is unusable. */ @@ -18,7 +26,12 @@ function optionalBoolean(value: boolean | undefined, fallback: boolean): boolean return value; } -function requireText(value: string, field: string): string { +function requireText( + value: PluginText, + field: string, + resolve: (text: PluginText) => string, +): string { + value = resolve(value); if (typeof value !== "string" || !value.trim()) throw new Error(`Plugin button needs ${field}`); return value.trim(); } @@ -33,7 +46,8 @@ function isComponent(value: unknown): boolean { function validateBehavior( behavior: PluginButtonBehavior, validation: ButtonValidation, -): PluginButtonBehavior { + resolve: (text: PluginText) => string, +): ResolvedPluginButtonBehavior { switch (behavior.kind) { case "action": if (typeof behavior.onPress !== "function") @@ -55,10 +69,10 @@ function validateBehavior( return { ...item, icon: item.icon === undefined ? undefined : validation.parseIcon(item.icon), - title: requireText(item.title, "menu item title"), + title: requireText(item.title, "menu item title", resolve), visible: optionalBoolean(item.visible, true), disabled: optionalBoolean(item.disabled, false), - behavior: validateBehavior(item.behavior, validation), + behavior: validateBehavior(item.behavior, validation, resolve), }; }), }; @@ -68,23 +82,40 @@ function validateBehavior( } } -export type ResolvedPluginButton = PluginButton & { +export type ResolvedPluginButtonBehavior = + | Exclude + | { kind: "menu"; items: readonly ResolvedPluginButtonMenuEntry[] }; +export type ResolvedPluginButtonMenuEntry = + | { kind: "separator"; id: string } + | { + kind: "item"; + id: string; + title: string; + icon?: PluginButtonIcon; + visible?: boolean; + disabled?: boolean; + behavior: ResolvedPluginButtonBehavior; + }; +export type ResolvedPluginButton = Omit & { + title: string; visible: boolean; disabled: boolean; label: string | undefined; + behavior: ResolvedPluginButtonBehavior; }; export function validateButton( button: PluginButton, validation: ButtonValidation, + resolve: (text: PluginText) => string, ): ResolvedPluginButton { return { ...button, icon: validation.parseIcon(button.icon), - title: requireText(button.title, "title"), - label: button.label === undefined ? undefined : requireText(button.label, "label"), + title: requireText(button.title, "title", resolve), + label: button.label === undefined ? undefined : requireText(button.label, "label", resolve), visible: optionalBoolean(button.visible, true), disabled: optionalBoolean(button.disabled, false), - behavior: validateBehavior(button.behavior, validation), + behavior: validateBehavior(button.behavior, validation, resolve), }; } diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index 1a092c6cf1..2cd3e1bd0f 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -1,9 +1,8 @@ import type { - PluginButtonBehavior, - PluginButtonIcon, - PluginButtonMenuEntry, - PluginHostProps, -} from "@getpaseo/plugin/client"; + ResolvedPluginButtonBehavior as PluginButtonBehavior, + ResolvedPluginButtonMenuEntry as PluginButtonMenuEntry, +} from "./validation"; +import type { PluginButtonIcon, PluginHostProps } from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react"; import { Pressable, Text, View, useWindowDimensions } from "react-native"; diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index f9074383bd..61e921bd0f 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -24,6 +24,7 @@ export function createPluginClientRuntime( ); return { ...capabilities, + i18n: installation.i18n, playAudio: createPlayAudio(audio, installation.lifetime.signal), hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), addComposerPill(contribution) { diff --git a/packages/app/src/plugins/client-slash-commands/index.ts b/packages/app/src/plugins/client-slash-commands/index.ts index 9ab66ac195..7938ff7c18 100644 --- a/packages/app/src/plugins/client-slash-commands/index.ts +++ b/packages/app/src/plugins/client-slash-commands/index.ts @@ -36,8 +36,8 @@ export function usePluginClientSlashCommands(input: { { pluginId: plugin.id, name: contribution.name, - description: contribution.description, - argumentHint: contribution.argumentHint, + description: plugin.i18n.resolve(contribution.description, plugin.locale), + argumentHint: plugin.i18n.resolve(contribution.argumentHint, plugin.locale), async run(args: string) { const context = contribution.context === "agent" diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 932cae646a..e70304b883 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -61,6 +62,8 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { serverId: "host-1", clientBundle: "bundle", lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), paseo: createPaseoApi( new DaemonClient({ url: "ws://127.0.0.1:1", diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index 400e2ded86..1dc0e37b29 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -81,7 +81,7 @@ export function buildPluginCommandCenterContributions( visibility: "always", presentation: { kind: "action", - title: item.title, + title: plugin.i18n.resolve(item.title, plugin.locale), sectionTitle: plugin.id, icon: getCommandCenterIcon(pluginIconComponent(item.icon)), }, diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index e54a2efd31..0cc1a7666e 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -1,3 +1,4 @@ +import { requirePluginText } from "@getpaseo/plugin/client/host"; import type { createPluginHosts } from "./hosts"; import { openExternalUrl } from "@/utils/open-external-url"; import * as pluginUiRuntime from "./react-native/ui"; @@ -11,27 +12,29 @@ import * as ReactNative from "react-native"; // eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers. import * as ReactQuery from "@tanstack/react-query"; import * as Zod from "zod"; +import { type PluginCleanup } from "@getpaseo/plugin"; import { - type PluginAttachmentSourceContribution, - type PluginCleanup, - type PluginThemeContribution, -} from "@getpaseo/plugin"; -import { - type PluginCommandCenterItemContribution, type PluginClientContext, - type PluginClientSlashCommandContribution, - type PluginSidebarContribution, - type PluginScreenContribution, type PluginScreenProps, type PluginScreenTitle, - type PluginSidebarItemContribution, + type PluginText, type PluginSurfaceProps, type PluginTimelineRendererContribution, type PluginTimelineTransformerContribution, - type PluginWorkspacePanelContribution, type PluginButtonRegistration, } from "@getpaseo/plugin/client"; -import type { EvaluatedPlugin, PluginSidebarSection } from "./types"; +import type { + EvaluatedPlugin, + PluginSidebarSection, + PluginAttachmentSourceContribution, + PluginThemeContribution, + PluginCommandCenterItemContribution, + PluginClientSlashCommandContribution, + PluginSidebarContribution, + PluginScreenContribution, + PluginSidebarItemContribution, + PluginWorkspacePanelContribution, +} from "./types"; import type { ComponentType } from "react"; import { parsePluginIcon, parseSerializablePluginIcon } from "./icons"; import { parsePluginThemeContribution } from "./themes"; @@ -75,6 +78,10 @@ function isComponentType(value: unknown): boolean { ); } +function normalizePluginText(text: import("@getpaseo/plugin/client").PluginText) { + return typeof text === "string" ? text.trim() : requirePluginText(text); +} + function requireId(value: string, label: string): string { const id = value.trim(); if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`); @@ -82,14 +89,17 @@ function requireId(value: string, label: string): string { } /** A non-empty string, or a function of the screen's params resolved when the header renders. */ -function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle { - if (typeof title === "function") return title as PluginScreenTitle; - if (typeof title === "string" && title.trim()) return title.trim(); +function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle { + if (typeof title === "function") return title as PluginScreenTitle; + try { + return requirePluginText(title); + } catch {} throw new Error(`Screen ${screenId} needs a title: a non-empty string or a function of params`); } export type PluginClientRuntime = Pick< PluginClientContext, + | "i18n" | "playAudio" | "paseo" | "rpc" @@ -138,7 +148,9 @@ export function runPluginClientBundle( const removals = new Set(); let setupComplete = false; let stopped = false; - function trackButton(registration: PluginButtonRegistration): PluginButtonRegistration { + function trackButton( + registration: PluginButtonRegistration, + ): PluginButtonRegistration { let active = true; const remove = () => { if (!active) return; @@ -181,7 +193,7 @@ export function runPluginClientBundle( const normalizedId = requireId(contribution.id, `sidebar ${section} item id`); if (ids.has(normalizedId)) throw new Error(`Duplicate sidebar ${section} item: ${normalizedId}`); - const title = contribution.title.trim(); + const title = normalizePluginText(contribution.title); if (!title) throw new Error(`Sidebar item ${normalizedId} has no title`); if (!isComponentType(contribution.Component)) { throw new Error(`Sidebar item ${normalizedId} is not a component`); @@ -199,7 +211,8 @@ export function runPluginClientBundle( const screenId = requireId(contribution.id, "settings screen id"); if (settingsScreenIds.has(screenId)) throw new Error(`Duplicate settings screen: ${screenId}`); - if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`); + if (!normalizePluginText(contribution.title)) + throw new Error(`Invalid settings screen: ${screenId}`); const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon); settingsScreenIds.add(screenId); return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () => @@ -244,7 +257,7 @@ export function runPluginClientBundle( const normalizedId = requireId(contribution.id, "sidebar item id"); const legacy = { id: normalizedId, - title: contribution.title.trim(), + title: normalizePluginText(contribution.title), icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon), surface: requireId(contribution.surface, "sidebar surface id"), }; @@ -258,7 +271,7 @@ export function runPluginClientBundle( addFilePanel(contribution) { const panelId = requireId(contribution.id, "file panel id"); if (filePanelIds.has(panelId)) throw new Error(`Duplicate file panel: ${panelId}`); - const title = contribution.title.trim(); + const title = normalizePluginText(contribution.title); if (!title) throw new Error(`File panel ${panelId} has no title`); const icon = parsePluginIcon(`File panel ${panelId}`, contribution.icon); const files = contribution.files; @@ -304,7 +317,7 @@ export function runPluginClientBundle( if (workspacePanelIds.has(normalizedId)) { throw new Error(`Duplicate workspace panel: ${normalizedId}`); } - const title = contribution.title.trim(); + const title = normalizePluginText(contribution.title); if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`); const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon); if (contribution.context !== "workspace" && contribution.context !== "agent") { @@ -332,7 +345,7 @@ export function runPluginClientBundle( if (commandCenterItemIds.has(normalizedId)) { throw new Error(`Duplicate Command Center item: ${normalizedId}`); } - const title = contribution.title.trim(); + const title = normalizePluginText(contribution.title); if (!title) throw new Error(`Command Center item ${normalizedId} has no title`); const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon); if ( @@ -363,7 +376,7 @@ export function runPluginClientBundle( if (clientSlashCommandNames.has(name)) { throw new Error(`Duplicate client slash command: ${name}`); } - const description = contribution.description.trim(); + const description = normalizePluginText(contribution.description); if (!description) throw new Error(`Client slash command ${name} has no description`); if (contribution.context !== "workspace" && contribution.context !== "agent") { throw new Error(`Client slash command ${name} has invalid context`); @@ -378,7 +391,7 @@ export function runPluginClientBundle( ...contribution, name, description, - argumentHint: contribution.argumentHint.trim(), + argumentHint: normalizePluginText(contribution.argumentHint), }, () => clientSlashCommandNames.delete(name), ); @@ -388,9 +401,9 @@ export function runPluginClientBundle( if (attachmentSourceIds.has(normalizedId)) { throw new Error(`Duplicate attachment source: ${normalizedId}`); } - const title = contribution.title.trim(); - const pickerTitle = contribution.pickerTitle.trim(); - const searchPlaceholder = contribution.searchPlaceholder.trim(); + const title = normalizePluginText(contribution.title); + const pickerTitle = normalizePluginText(contribution.pickerTitle); + const searchPlaceholder = normalizePluginText(contribution.searchPlaceholder); const method = contribution.search.name.trim(); if (!title) throw new Error(`Attachment source ${normalizedId} has no title`); const icon = parseSerializablePluginIcon( diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx index a6d9d42ffc..18af532044 100644 --- a/packages/app/src/plugins/installation-provider.test.tsx +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { usePaseo, useRpc } from "@getpaseo/plugin/client"; import { defineRpc } from "@getpaseo/plugin"; import type { PaseoApi } from "@getpaseo/client"; @@ -21,6 +22,8 @@ function installation(invoked: string[]): InstalledPlugin { serverId: "host", clientBundle: "", lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), paseo: { dispose: async () => undefined } as PaseoApi, invoke: async (method) => { invoked.push(method); diff --git a/packages/app/src/plugins/installation-provider.tsx b/packages/app/src/plugins/installation-provider.tsx index 40d06ef035..552f8080b8 100644 --- a/packages/app/src/plugins/installation-provider.tsx +++ b/packages/app/src/plugins/installation-provider.tsx @@ -1,5 +1,9 @@ import { QueryClientProvider } from "@tanstack/react-query"; -import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host"; +import { + PaseoApiProvider, + PluginRpcProvider, + PluginTranslationProvider, +} from "@getpaseo/plugin/client/host"; import React, { type ReactNode } from "react"; import type { InstalledPlugin } from "./types"; @@ -14,7 +18,9 @@ export function PluginInstallationProvider({ return ( - {children} + + {children} + ); diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index ecec420e3c..18330dc949 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -1,3 +1,5 @@ +import { filePanelTitle } from "@/file-panels/choice/presentation"; +import { i18n } from "@/i18n/i18next"; import { createPluginHosts } from "./hosts"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { PaseoApi } from "@getpaseo/client"; @@ -26,9 +28,15 @@ function observingClient() { return { client, open }; } -function registry() { +function registry( + localeSource: import("@getpaseo/plugin/client/host").PluginLocaleSource = { + getLocale: () => "en", + subscribe: () => () => {}, + }, +) { const setupClients = new Map(); const plugins = new PluginRegistry({ + localeSource, version: "0.8.0", createRuntime: (installation) => { setupClients.set(installation.id, installation.paseo); @@ -43,6 +51,7 @@ function registry() { installation.lifetime.signal, ), paseo: installation.paseo, + i18n: installation.i18n, rpc: async () => { throw new Error("Unexpected plugin RPC"); }, @@ -145,3 +154,74 @@ test("an invalid async client entry is disposed and its rejected continuation is await new Promise((resolve) => setTimeout(resolve, 0)); expect(h.plugins.getSnapshot()).toEqual([]); }); + +test("locale snapshots preserve installation state and observations, then release locale subscriptions", async () => { + let locale = "en"; + const listeners = new Set<() => void>(); + const h = registry({ + getLocale: () => locale, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }); + const translated = { + ...catalog("notes"), + clientBundle: `(function(require) { + const {defineTranslations} = require("@getpaseo/plugin/client"); + const messages = defineTranslations({resources:{en:{title:"Notes"},"zh-CN":{title:"笔记"}}}); + return {default(plugin) { + plugin.paseo.observeEvents(["notes"]); + plugin.addScreen({id:"notes",title:messages.message("title"),Component:function Notes(){return null;}}); + plugin.addFilePanel({id:"notes",title:messages.message("title"),icon:"FileText",files:{extensions:["notes"]},Component:function NotesFile(){return null;}}); + return function(){}; + }}; + })`, + }; + h.plugins.installCatalog("host", [translated], { client: h.client, audio }); + expect(h.plugins.getEvaluationError("host", "notes")).toBeUndefined(); + const [before] = h.plugins.getSnapshot(); + function fileTitle(plugin: typeof before) { + const contribution = plugin.filePanels[0]; + return filePanelTitle(i18n.t, { + id: "plugin:notes/notes", + builtin: false, + files: contribution.files, + plugin, + contribution, + }); + } + expect(fileTitle(before)).toBe("Notes"); + expect(before.locale).toBe("en"); + expect( + before.i18n.resolve( + before.surfaces[0].title as import("@getpaseo/plugin/client").PluginText, + before.locale, + ), + ).toBe("Notes"); + locale = "zh-CN"; + for (const listener of listeners) listener(); + const [after] = h.plugins.getSnapshot(); + expect(after.locale).toBe("zh-CN"); + expect(fileTitle(after)).toBe("笔记"); + expect(fileTitle(before)).toBe("Notes"); + expect(after.filePanels[0].Component).toBe(before.filePanels[0].Component); + expect( + after.i18n.resolve( + after.surfaces[0].title as import("@getpaseo/plugin/client").PluginText, + after.locale, + ), + ).toBe("笔记"); + expect(after.lifetime).toBe(before.lifetime); + expect(after.queryClient).toBe(before.queryClient); + expect(after.paseo).toBe(before.paseo); + expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component); + expect(h.open).toEqual(new Set(["notes"])); + h.plugins.installCatalog("host", [translated], { client: h.client, audio }); + expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n); + h.plugins.removeHost("host"); + expect(listeners.size).toBe(0); + await expect.poll(() => h.open.size).toBe(0); +}); diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index f067111820..dedefe0b0d 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -11,6 +11,7 @@ function registry(version: string) { let starts = 0; let cleanups = 0; const result = new PluginRegistry({ + localeSource: { getLocale: () => "en", subscribe: () => () => {} }, version, createRuntime(installation) { starts++; @@ -25,6 +26,7 @@ function registry(version: string) { installation.lifetime.signal, ), paseo: installation.paseo, + i18n: installation.i18n, rpc: async () => { throw new Error("No RPC in this plugin"); }, @@ -100,6 +102,26 @@ it("unloads on a requirement-only edit and recovers after correction", () => { expect(result.getEvaluationError("host", "example")).toBeUndefined(); }); +it("rejects the unreleased translation API on 0.12.0 before evaluating its imports", () => { + const { result, starts } = registry("0.12.0"); + result.installCatalog( + "host", + [ + { + id: "translated", + requirements: { paseo: ">=0.12.1" }, + clientBundle: `(function(require) { require("@getpaseo/plugin/client").defineTranslations({ resources: { en: { title: "Notes" } } }); throw new Error("unsupported API evaluated"); })`, + }, + ], + { client, audio }, + ); + expect(starts()).toBe(0); + expect(result.getSnapshot()).toEqual([]); + expect(result.getEvaluationError("host", "translated")).toBe( + 'Plugin "translated" requires Paseo >=0.12.1. Your app is 0.12.0. Use a compatible plugin version or update the app.', + ); +}); + it("skips file-panel bundles on older apps and installs them on the required app", () => { const entry = { id: "files", diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index 1b44290706..adbdabc93c 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -1,3 +1,5 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; +import { i18n } from "@/i18n/i18next"; import type { AudioEngine } from "@/audio"; import { useMemo, useSyncExternalStore } from "react"; import { QueryClient } from "@tanstack/react-query"; @@ -20,6 +22,7 @@ export class PluginRegistry { constructor( private readonly dependencies: { + localeSource: import("@getpaseo/plugin/client/host").PluginLocaleSource; version: string | null; createRuntime: typeof createPluginClientRuntime; }, @@ -81,6 +84,8 @@ export class PluginRegistry { const client = options.client; installation = { lifetime: new AbortController(), + locale: this.dependencies.localeSource.getLocale(), + i18n: new PluginTranslationRuntime(this.dependencies.localeSource), paseo: createPaseoApi(client), invoke: (method, input) => client.invokePluginRpc(entry.id, method, input), id: entry.id, @@ -102,6 +107,10 @@ export class PluginRegistry { timelineTransformers: [], timelineRenderers: [], }; + const unsubscribeTranslations = installation.i18n.subscribe(() => this.publish()); + installation.lifetime.signal.addEventListener("abort", unsubscribeTranslations, { + once: true, + }); const runtime = this.dependencies.createRuntime(installation, options.audio); const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () => this.publish(), @@ -121,6 +130,7 @@ export class PluginRegistry { return [installation]; } catch (error) { installation?.lifetime.abort(); + installation?.i18n.dispose(); void installation?.paseo .dispose() .catch((failure) => console.warn(`[Plugins] API cleanup failed for ${key}`, failure)); @@ -136,7 +146,13 @@ export class PluginRegistry { } } this.byHost.set(serverId, installed); - this.publish(); + if ( + installed.length !== catalog.length || + installed.length !== previous.length || + installed.some((plugin, index) => plugin !== previous[index]) + ) { + this.publish(); + } const installedTimelineBundles = installed .filter((plugin) => plugin.timelineTransformers.length > 0) .map((plugin) => `${plugin.id}\0${plugin.clientBundle}`); @@ -161,6 +177,7 @@ export class PluginRegistry { if (this.disposed.has(plugin)) return; this.disposed.add(plugin); plugin.lifetime.abort(); + plugin.i18n.dispose(); plugin.queryClient.clear(); try { void Promise.resolve(plugin.cleanup()).catch((error) => { @@ -174,6 +191,9 @@ export class PluginRegistry { private publish(): void { this.snapshot = [...this.byHost.values()] .flat() + // A new presentation snapshot invalidates label selectors without replacing the installation lifetime. + // eslint-disable-next-line no-map-spread + .map((plugin) => ({ ...plugin, locale: plugin.i18n.locale })) .sort((left, right) => `${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`), ); @@ -182,6 +202,15 @@ export class PluginRegistry { } export const pluginRegistry = new PluginRegistry({ + localeSource: { + getLocale: () => i18n.resolvedLanguage ?? i18n.language, + subscribe: (listener) => { + i18n.on("languageChanged", listener); + return () => { + i18n.off("languageChanged", listener); + }; + }, + }, version: resolveAppVersion(), createRuntime: createPluginClientRuntime, }); diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index 0fda205671..3399f241ac 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -57,7 +57,7 @@ export function PluginSettingsMenuItems({ serverId={serverId} pluginId={pluginId} screenId={screen.id} - title={screen.title} + title={plugin.i18n.resolve(screen.title, plugin.locale)} disabled={disabled} /> ))} diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index 799699d246..e204c28d9d 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { InstalledPlugin } from "./types"; @@ -20,6 +21,8 @@ function installed( serverId, clientBundle: serverId, lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, diff --git a/packages/app/src/plugins/sidebar-groups.ts b/packages/app/src/plugins/sidebar-groups.ts index 2d4411acad..9ed037edb0 100644 --- a/packages/app/src/plugins/sidebar-groups.ts +++ b/packages/app/src/plugins/sidebar-groups.ts @@ -64,7 +64,7 @@ export function groupPluginSidebarItems( key, pluginId: plugin.id, contributionId: item.id, - title: item.title, + title: plugin.i18n.resolve(item.title, plugin.locale), icon: item.icon, targets: [{ plugin, item }], }); @@ -80,7 +80,7 @@ export function groupPluginSidebarItems( key, pluginId: plugin.id, contributionId: item.id, - title: item.title, + title: plugin.i18n.resolve(item.title, plugin.locale), targets: [{ plugin, item }], }); } diff --git a/packages/app/src/plugins/sidebar-items/index.tsx b/packages/app/src/plugins/sidebar-items/index.tsx index 11657c5d35..b77d1d8593 100644 --- a/packages/app/src/plugins/sidebar-items/index.tsx +++ b/packages/app/src/plugins/sidebar-items/index.tsx @@ -205,7 +205,7 @@ function SidebarItemContent({ const frame = useMemo( () => ({ section, - title: item.title, + title: plugin.i18n.resolve(item.title, plugin.locale), testID: section === "header" ? `plugin-sidebar-${plugin.id}-${item.id}` @@ -214,7 +214,7 @@ function SidebarItemContent({ offerAnchor, releaseAnchor, }), - [anchorTo, item.id, item.title, offerAnchor, plugin.id, releaseAnchor, section], + [anchorTo, item.id, item.title, offerAnchor, plugin, releaseAnchor, section], ); const itemProps = useMemo( () => ({ ...hostProps, currentScreen, openScreen, openPopover }), @@ -228,7 +228,7 @@ function SidebarItemContent({ {Popover ? ( diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 4742768572..7e5f603ec1 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { InstalledPlugin } from "./types"; @@ -20,6 +21,8 @@ function installation( serverId, clientBundle: serverId, lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, @@ -178,6 +181,8 @@ describe("parsePluginOpenScreenInput", () => { }); describe("resolvePluginScreenTitle", () => { + const resolve = new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }) + .resolve; const Component = () => null; const botNames: Record = { "bot-1": "Bot 1", "bot-2": "Bot 2" }; const bot = { @@ -187,18 +192,18 @@ describe("resolvePluginScreenTitle", () => { }; it("uses a string title as is", () => { - expect(resolvePluginScreenTitle({ id: "deploys", title: "Deploys", Component }, null, {})).toBe( - "Deploys", - ); + expect( + resolvePluginScreenTitle({ id: "deploys", title: "Deploys", Component }, null, {}, resolve), + ).toBe("Deploys"); }); it("derives a function title from the params it is given", () => { - expect(resolvePluginScreenTitle(bot, null, { botId: "bot-2" })).toBe("Bot 2"); - expect(resolvePluginScreenTitle(bot, null, { botId: "bot-1" })).toBe("Bot 1"); + expect(resolvePluginScreenTitle(bot, null, { botId: "bot-2" }, resolve)).toBe("Bot 2"); + expect(resolvePluginScreenTitle(bot, null, { botId: "bot-1" }, resolve)).toBe("Bot 1"); }); it("falls back to the screen id when the function returns empty or throws", () => { - expect(resolvePluginScreenTitle(bot, null, { botId: "missing" })).toBe("bot"); + expect(resolvePluginScreenTitle(bot, null, { botId: "missing" }, resolve)).toBe("bot"); const throwing = { id: "bot", title: () => { @@ -206,13 +211,13 @@ describe("resolvePluginScreenTitle", () => { }, Component, }; - expect(resolvePluginScreenTitle(throwing, null, {})).toBe("bot"); + expect(resolvePluginScreenTitle(throwing, null, {}, resolve)).toBe("bot"); }); it("uses the title of a legacy item that points at the screen", () => { const legacy = { id: "entry", title: "Legacy entry", icon: "Server", surface: "main" }; - expect(resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {})).toBe( - "Legacy entry", - ); + expect( + resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {}, resolve), + ).toBe("Legacy entry"); }); }); diff --git a/packages/app/src/plugins/surface-contribution.ts b/packages/app/src/plugins/surface-contribution.ts index 830677f164..a226e1f55c 100644 --- a/packages/app/src/plugins/surface-contribution.ts +++ b/packages/app/src/plugins/surface-contribution.ts @@ -42,11 +42,12 @@ export function resolvePluginScreenTitle( screen: InstalledPlugin["surfaces"][number], legacySidebarItem: InstalledPlugin["legacySidebarItems"][number] | null, params: PluginScreenParams, + resolve: (text: import("@getpaseo/plugin/client").PluginText) => string, ): string { - if (legacySidebarItem) return legacySidebarItem.title; - if (typeof screen.title === "string") return screen.title; + if (legacySidebarItem) return resolve(legacySidebarItem.title); + if (typeof screen.title !== "function") return resolve(screen.title); try { - const title = screen.title(params); + const title = resolve(screen.title(params)); if (typeof title === "string" && title.trim()) return title.trim(); } catch (error) { console.warn(`[Plugins] Screen ${screen.id} title failed`, error); diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index 85447e7d71..849775addd 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -149,13 +149,14 @@ export function PluginSurfaceScreen() { const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity); return hosts.filter((candidate) => serverIds.includes(candidate.serverId)); }, [hosts, identity, installations, pluginId]); - const title = useMemo( - () => - surface - ? resolvePluginScreenTitle(surface, sidebarItem, params) - : (sidebarItem?.title ?? (pluginId || "Plugin")), - [params, pluginId, sidebarItem, surface], - ); + const title = useMemo(() => { + if (surface && plugin) + return resolvePluginScreenTitle(surface, sidebarItem, params, (text) => + plugin.i18n.resolve(text, plugin.locale), + ); + if (sidebarItem && plugin) return plugin.i18n.resolve(sidebarItem.title, plugin.locale); + return pluginId || "Plugin"; + }, [params, plugin, pluginId, sidebarItem, surface]); const icon = sidebarItem?.icon ?? null; const close = useCallback(() => { if (router.canGoBack()) router.back(); diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index 7b1b7395a9..08ac674ca0 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import type { PluginThemeContribution } from "@getpaseo/plugin"; import { describe, expect, it } from "vitest"; @@ -50,6 +51,8 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install serverId, clientBundle: serverId, lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, diff --git a/packages/app/src/plugins/themes/index.ts b/packages/app/src/plugins/themes/index.ts index 5a4aab4e3d..fdebdfb52b 100644 --- a/packages/app/src/plugins/themes/index.ts +++ b/packages/app/src/plugins/themes/index.ts @@ -1,6 +1,8 @@ +import type { PluginMessage } from "@getpaseo/plugin/client"; +import { isPluginMessage } from "@getpaseo/plugin/client/host"; import { useMemo } from "react"; import { z } from "zod"; -import type { PluginThemeContribution } from "@getpaseo/plugin"; +import type { PluginThemeContribution } from "../types"; import { useHostFeatureMap } from "@/runtime/host-features"; import { buildDarkSemanticColors, @@ -24,7 +26,7 @@ const hexColorSchema = z const contributionSchema: z.ZodType = z.strictObject({ id: z.string(), - name: z.string().trim().min(1).max(60), + name: z.union([z.string().trim().min(1).max(60), z.custom(isPluginMessage)]), appearance: z.enum(["light", "dark"]), colors: z.strictObject({ background: hexColorSchema, @@ -49,6 +51,7 @@ export interface PluginThemeOption { interface PluginThemeTarget { serverId: string; contribution: PluginThemeContribution; + plugin: InstalledPlugin; } export function parsePluginThemeContribution(value: unknown): PluginThemeContribution { @@ -133,7 +136,7 @@ export function collectPluginThemes( if (!supportedHosts.has(plugin.serverId)) continue; for (const contribution of plugin.themes) { const id = `${plugin.id}/theme/${contribution.id}`; - const target = { serverId: plugin.serverId, contribution }; + const target = { serverId: plugin.serverId, contribution, plugin }; const targets = targetsById.get(id); if (targets) targets.push(target); else targetsById.set(id, [target]); @@ -145,7 +148,7 @@ export function collectPluginThemes( return { id, serverId: target.serverId, - name: target.contribution.name, + name: target.plugin.i18n.resolve(target.contribution.name, target.plugin.locale), swatch: target.contribution.colors.background, theme: buildPluginTheme(target.contribution), }; diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 1177ea7eaf..ffd33ef0ff 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import type { InstalledPlugin } from "../types"; @@ -12,6 +13,8 @@ function plugin(input: { serverId: "host-1", clientBundle: "bundle", lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index fb910b7a97..63d5f1c9f9 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -1,26 +1,39 @@ +import type { PluginText } from "@getpaseo/plugin/client"; import type { PaseoApi } from "@getpaseo/client"; import type { QueryClient } from "@tanstack/react-query"; import type { PluginRequirements } from "@getpaseo/protocol/messages"; import type { - PluginAttachmentSourceContribution, + PluginAttachmentSourceContribution as SDKPluginAttachmentSourceContribution, PluginCleanup, - PluginThemeContribution, + PluginThemeContribution as SDKPluginThemeContribution, } from "@getpaseo/plugin"; import type { - PluginFilePanelContribution, - PluginCommandCenterItemContribution, - PluginClientSlashCommandContribution, - PluginComposerPillContribution, - PluginSidebarContribution, - PluginSidebarItemContribution, - PluginScreenContribution, - PluginSettingsScreenContribution, + PluginFilePanelContribution as SDKPluginFilePanelContribution, + PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution, + PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution, + PluginComposerPillContribution as SDKPluginComposerPillContribution, + PluginSidebarContribution as SDKPluginSidebarContribution, + PluginSidebarItemContribution as SDKPluginSidebarItemContribution, + PluginScreenContribution as SDKPluginScreenContribution, + PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, PluginPanelLocation, - PluginWorkspacePanelContribution, + PluginWorkspacePanelContribution as SDKPluginWorkspacePanelContribution, } from "@getpaseo/plugin/client"; +type PluginFilePanelContribution = SDKPluginFilePanelContribution; +type PluginAttachmentSourceContribution = SDKPluginAttachmentSourceContribution; +type PluginThemeContribution = SDKPluginThemeContribution; +type PluginCommandCenterItemContribution = SDKPluginCommandCenterItemContribution; +type PluginClientSlashCommandContribution = SDKPluginClientSlashCommandContribution; +type PluginComposerPillContribution = SDKPluginComposerPillContribution; +type PluginSidebarContribution = SDKPluginSidebarContribution; +type PluginSidebarItemContribution = SDKPluginSidebarItemContribution; +type PluginScreenContribution = SDKPluginScreenContribution; +type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution; +type PluginWorkspacePanelContribution = SDKPluginWorkspacePanelContribution; + export type PluginSidebarSection = "header" | "footer"; export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelContribution & { @@ -48,6 +61,9 @@ export interface EvaluatedPlugin { export interface InstalledPlugin extends EvaluatedPlugin { lifetime: AbortController; + /** Immutable presentation locale, refreshed without replacing the installation lifetime. */ + locale: string; + i18n: import("@getpaseo/plugin/client/host").PluginTranslationRuntime; /** The plugin's one Paseo client, `usePaseo()` in every surface; disposed at teardown. */ paseo: PaseoApi; /** Calls one of the plugin's server RPC methods on its host. */ @@ -59,6 +75,7 @@ export interface InstalledPlugin extends EvaluatedPlugin { } export type { + PluginFilePanelContribution, PluginAttachmentSourceContribution, PluginCommandCenterItemContribution, PluginClientSlashCommandContribution, diff --git a/packages/app/src/plugins/workspace-panels/locations.ts b/packages/app/src/plugins/workspace-panels/locations.ts index 5800aecf72..8abd52c801 100644 --- a/packages/app/src/plugins/workspace-panels/locations.ts +++ b/packages/app/src/plugins/workspace-panels/locations.ts @@ -1,7 +1,5 @@ -import type { - PluginPanelLocation, - PluginWorkspacePanelContribution, -} from "@getpaseo/plugin/client"; +import type { PluginWorkspacePanelContribution } from "../types"; +import type { PluginPanelLocation } from "@getpaseo/plugin/client"; import type { PaneHost } from "@/panels/panel-manifest"; import { panelSupportsHost } from "@/panels/panel-manifest"; import type { PluginWorkspaceTabTarget, WorkspaceTabTarget } from "@/workspace-tabs/model"; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 48a83686be..5534f78113 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -140,9 +140,9 @@ function usePluginPanelDescriptor( }; } return { - label: panel.title, + label: plugin!.i18n.resolve(panel.title, plugin!.locale), subtitle: target.pluginId, - tooltip: panel.title, + tooltip: plugin!.i18n.resolve(panel.title, plugin!.locale), titleState: "ready", icon: pluginIconComponent(panel.icon), statusBucket: null, diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index e489440d4e..1208106721 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -1,3 +1,4 @@ +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { InstalledPlugin } from "../types"; @@ -9,6 +10,8 @@ function installed(): InstalledPlugin { serverId: "host-1", clientBundle: "bundle", lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), queryClient: new QueryClient(), paseo: {} as InstalledPlugin["paseo"], invoke: async () => undefined, diff --git a/packages/app/src/plugins/workspace-panels/resolution.ts b/packages/app/src/plugins/workspace-panels/resolution.ts index 7a9355e72a..4c3af57c35 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.ts @@ -1,4 +1,4 @@ -import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin/client"; +import type { PluginWorkspacePanelContribution } from "../types"; import type { InstalledPlugin } from "../types"; import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model"; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index ef687a1585..3e3a4ef553 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1331,10 +1331,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const installedPlugins = useInstalledPlugins(); const detailTitle = ((): string | undefined => { if (view.kind === "plugin") { - const screen = installedPlugins - .find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId) - ?.settingsScreens.find((candidate) => candidate.id === view.screenId); - return `${view.pluginId} · ${screen?.title ?? t("settings.title")}`; + const plugin = installedPlugins.find( + (candidate) => candidate.serverId === view.serverId && candidate.id === view.pluginId, + ); + const screen = plugin?.settingsScreens.find((candidate) => candidate.id === view.screenId); + return `${view.pluginId} · ${plugin && screen ? plugin.i18n.resolve(screen.title, plugin.locale) : t("settings.title")}`; } if (view.kind === "host") { const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index 984a756dab..8da5a563ee 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -209,7 +209,7 @@ export function useWorkspaceTabLaunchCatalog(input: { }; pluginItems.push({ id: `plugin:${plugin.id}:${panel.id}`, - label: panel.title, + label: plugin.i18n.resolve(panel.title, plugin.locale), Icon: pluginIconComponent(panel.icon), disabled: false, panelKind: "plugin", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 95afed2b0e..55661a6276 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -68,6 +68,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", + "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { diff --git a/packages/plugin/src/attachments.ts b/packages/plugin/src/attachments.ts index 27ca706dd7..0997076254 100644 --- a/packages/plugin/src/attachments.ts +++ b/packages/plugin/src/attachments.ts @@ -1,9 +1,10 @@ +import type { PluginText } from "./text.js"; import { z } from "zod"; import type { PluginAttachmentSourceContribution } from "./contracts.js"; -export function defineAttachmentSource( - definition: Definition, -): Definition { +export function defineAttachmentSource< + Definition extends PluginAttachmentSourceContribution, +>(definition: Definition): Definition { return definition; } diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index eb6850b05c..d69f5f44b0 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -29,7 +29,8 @@ const clientModule = /^client\//; function resolveLocal(importer: string, specifier: string): string { const base = path.resolve(path.dirname(importer), specifier.replace(/\.js$/, "")); - const result = [base + ".ts", base + ".tsx"].find(existsSync); + const candidates = specifier.endsWith(".json") ? [base] : [base + ".ts", base + ".tsx"]; + const result = candidates.find(existsSync); if (!result) throw new Error(`Cannot resolve ${specifier} from ${importer}`); return result; } diff --git a/packages/plugin/src/client/buttons.ts b/packages/plugin/src/client/buttons.ts index 76e5531143..2865a117d6 100644 --- a/packages/plugin/src/client/buttons.ts +++ b/packages/plugin/src/client/buttons.ts @@ -1,3 +1,4 @@ +import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; import type { PluginIconProps } from "../icons.js"; import type { PluginHostProps, PluginIcon } from "./contracts.js"; @@ -12,46 +13,44 @@ export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { export type PluginButtonIcon = PluginIcon; -export type PluginButtonBehavior = +export type PluginButtonBehavior = | { kind: "action"; onPress(): void | Promise } - | { kind: "menu"; items: readonly PluginButtonMenuEntry[] } + | { kind: "menu"; items: readonly PluginButtonMenuEntry[] } | { kind: "popover"; Content: ComponentType }; - -export type PluginButtonMenuEntry = +export type PluginButtonMenuEntry = | { kind: "separator"; id: string } | { kind: "item"; id: string; - title: string; + title: Text; icon?: PluginButtonIcon; visible?: boolean; disabled?: boolean; - behavior: PluginButtonBehavior; + behavior: PluginButtonBehavior; }; -export interface PluginButton { - title: string; +export interface PluginButton { + title: Text; icon: PluginButtonIcon; /** Omit for an icon-only header button. Composer pills use title when omitted. */ - label?: string; + label?: Text; visible?: boolean; disabled?: boolean; - behavior: PluginButtonBehavior; + behavior: PluginButtonBehavior; } - -export interface PluginButtonRegistration { +export interface PluginButtonRegistration { /** Updates presentation in place. Supply a complete behavior to replace it. */ - update(patch: Partial): void; + update(patch: Partial>): void; /** Idempotent. Updates after removal do nothing. */ remove(): void; } - -export interface PluginHeaderButtonContribution { +export interface PluginHeaderButtonContribution { id: string; workspaceId: string; - button: PluginButton; + button: PluginButton; } - -export interface PluginComposerPillContribution extends PluginHeaderButtonContribution { +export interface PluginComposerPillContribution< + Text extends PluginText = string, +> extends PluginHeaderButtonContribution { agentId: string; } diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 0a5ae3caa0..5e268d0849 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -1,3 +1,4 @@ +import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; import type { PaseoApi } from "@getpaseo/client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; @@ -82,10 +83,10 @@ export interface PluginSidebarItemProps extends PluginHostProps { openPopover(Content: ComponentType): void; } -export interface PluginSidebarItemContribution { +export interface PluginSidebarItemContribution { id: string; /** Settings row label, accessibility label, and default label for SidebarRow. */ - title: string; + title: Text; Component: ComponentType; } @@ -103,9 +104,9 @@ export interface PluginOpenPanelOptions { location?: PluginPanelLocation; } -interface PluginWorkspacePanelBase { +interface PluginWorkspacePanelBase { id: string; - title: string; + title: Text; icon: PluginIcon; locations?: readonly PluginPanelLocation[]; } @@ -129,9 +130,9 @@ export interface PluginFilePanelProps extends PluginNavigableHostProps { location: PluginFileLocation; } -export interface PluginFilePanelContribution { +export interface PluginFilePanelContribution { id: string; - title: string; + title: Text; icon: PluginIcon; /** Extensions without the dot, case-insensitive, and exact filenames. */ files: { extensions: readonly string[]; filenames?: readonly string[] }; @@ -148,24 +149,31 @@ interface PluginClientContextAliases { /** @deprecated Use `addScreen`. */ addSurface(id: string, Component: ComponentType): PluginCleanup; /** @deprecated Use `addSidebarHeaderItem`. */ - addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; + addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; } export interface PluginClientContext extends PluginCommandCapabilities, PluginClientContextAliases { + readonly i18n: import("./translations.js").PluginI18n; /** Play a base64-encoded audio file on this client; resolves when playback ends. */ playAudio(source: { base64: string; mimeType: string }): Promise; - addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; - addScreen(contribution: PluginScreenContribution): PluginCleanup; - addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; - addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; - addFilePanel(contribution: PluginFilePanelContribution): PluginCleanup; - addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; - addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup; - addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; - addHeaderButton(contribution: PluginHeaderButtonContribution): PluginButtonRegistration; - addComposerPill(contribution: PluginComposerPillContribution): PluginButtonRegistration; - addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; - addTheme(contribution: PluginThemeContribution): PluginCleanup; + addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; + addScreen(contribution: PluginScreenContribution): PluginCleanup; + addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; + addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; + addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; + addCommandCenterItem( + contribution: PluginCommandCenterItemContribution, + ): PluginCleanup; + addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; + addHeaderButton( + contribution: PluginHeaderButtonContribution, + ): PluginButtonRegistration; + addComposerPill( + contribution: PluginComposerPillContribution, + ): PluginButtonRegistration; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; + addTheme(contribution: PluginThemeContribution): PluginCleanup; + addFilePanel(contribution: PluginFilePanelContribution): PluginCleanup; addTimelineTransformer( contribution: PluginTimelineTransformerContribution, ): PluginCleanup; @@ -177,29 +185,31 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup; -export type PluginWorkspacePanelContribution = - | (PluginWorkspacePanelBase & { +export type PluginWorkspacePanelContribution = + | (PluginWorkspacePanelBase & { context: "workspace"; Component: ComponentType; }) - | (PluginWorkspacePanelBase & { + | (PluginWorkspacePanelBase & { context: "agent"; Component: ComponentType; }); -export interface PluginSettingsScreenContribution { +export interface PluginSettingsScreenContribution { id: string; - title: string; + title: Text; icon: PluginIcon; Component: ComponentType; } /** The screen header's title: fixed, or derived from the params the screen was opened with. */ -export type PluginScreenTitle = string | ((params: PluginScreenParams) => string); +export type PluginScreenTitle = + | Text + | ((params: PluginScreenParams) => Text); -export interface PluginScreenContribution { +export interface PluginScreenContribution { id: string; - title: PluginScreenTitle; + title: PluginScreenTitle; Component: ComponentType; } @@ -209,9 +219,9 @@ export interface PluginSurfaceContribution { } /** @deprecated Use `PluginSidebarItemContribution` with `addSidebarHeaderItem`. */ -export interface PluginSidebarContribution { +export interface PluginSidebarContribution { id: string; - title: string; + title: Text; icon: PluginIcon; surface: string; } @@ -278,39 +288,39 @@ export interface PluginAgentCommandContext extends PluginCommandCapabilities { openPanel(id: string, options?: PluginOpenPanelOptions): void; } -interface PluginCommandCenterItemBase { +interface PluginCommandCenterItemBase { id: string; - title: string; + title: Text; icon: PluginIcon; keywords?: readonly string[]; } -export type PluginCommandCenterItemContribution = - | (PluginCommandCenterItemBase & { +export type PluginCommandCenterItemContribution = + | (PluginCommandCenterItemBase & { context: "global"; onSelect(context: PluginGlobalCommandContext): void | Promise; }) - | (PluginCommandCenterItemBase & { + | (PluginCommandCenterItemBase & { context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise; }) - | (PluginCommandCenterItemBase & { + | (PluginCommandCenterItemBase & { context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise; }); -interface PluginClientSlashCommandBase { +interface PluginClientSlashCommandBase { name: string; - description: string; - argumentHint: string; + description: Text; + argumentHint: Text; } -export type PluginClientSlashCommandContribution = - | (PluginClientSlashCommandBase & { +export type PluginClientSlashCommandContribution = + | (PluginClientSlashCommandBase & { context: "workspace"; onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise; }) - | (PluginClientSlashCommandBase & { + | (PluginClientSlashCommandBase & { context: "agent"; onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise; }); diff --git a/packages/plugin/src/client/host.ts b/packages/plugin/src/client/host.ts index 6cd42863e5..987f047109 100644 --- a/packages/plugin/src/client/host.ts +++ b/packages/plugin/src/client/host.ts @@ -1,3 +1,4 @@ +import type { PluginText } from "../text.js"; import { PluginAttachmentSearchPayloadSchema } from "../attachments.js"; export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; export { @@ -10,7 +11,7 @@ import { PaseoApiProvider } from "./paseo-context.js"; import { callPluginRpc } from "../rpc.js"; export async function searchPluginAttachments( - source: PluginAttachmentSourceContribution, + source: PluginAttachmentSourceContribution, invoke: (method: string, input: unknown) => Promise, query: string, ) { @@ -19,3 +20,12 @@ export async function searchPluginAttachments( } export { callPluginRpc, PaseoApiProvider, PluginRpcProvider }; + +export { + PluginTranslationRuntime, + PluginTranslationProvider, + usePluginTranslationRuntime, + requirePluginText, + isPluginMessage, +} from "./translations.js"; +export type { PluginLocaleSource } from "./translations.js"; diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index b3f7e97b2b..db4292453e 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -87,3 +87,7 @@ export declare function useHosts(): readonly PluginHostSummary[]; export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi; /** Open an absolute HTTP(S) URL using the client platform’s external opener. */ export declare function openExternalUrl(url: string): Promise; + +export { defineTranslations, useTranslation } from "./translations.js"; +export type { PluginI18n } from "./translations.js"; +export type { PluginText, PluginMessage, PluginTranslations } from "../text.js"; diff --git a/packages/plugin/src/client/runtime-context-bridge.tsx b/packages/plugin/src/client/runtime-context-bridge.tsx index 6f0b303e3b..b18588b32c 100644 --- a/packages/plugin/src/client/runtime-context-bridge.tsx +++ b/packages/plugin/src/client/runtime-context-bridge.tsx @@ -1,3 +1,4 @@ +import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js"; import { useCallback, type ReactNode } from "react"; import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js"; import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js"; @@ -7,6 +8,7 @@ export type PluginRuntimeContextBridge = (children: ReactNode) => ReactNode; /** Rebuilds plugin runtime contexts inside React Native portal hosts. */ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge { + const translations = usePluginTranslationRuntime(); const paseo = usePaseoContextValue(); const rpc = usePluginRpcContextValue(); const state = usePluginClientStateSource(); @@ -24,10 +26,12 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge { ); return ( - {content} + + {content} + ); }, - [paseo, rpc, state], + [paseo, rpc, state, translations], ); } diff --git a/packages/plugin/src/client/translations.test.ts b/packages/plugin/src/client/translations.test.ts new file mode 100644 index 0000000000..d69d021bca --- /dev/null +++ b/packages/plugin/src/client/translations.test.ts @@ -0,0 +1,104 @@ +import { expect, it } from "vitest"; +import { defineTranslations, PluginTranslationRuntime } from "./translations.js"; + +function language(initial = "en") { + let locale = initial; + const listeners = new Set<() => void>(); + return { + getLocale: () => locale, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + set(next: string) { + locale = next; + for (const listener of listeners) listener(); + }, + subscriptions: () => listeners.size, + }; +} +const messages = defineTranslations({ + resources: { + en: { + screen: { + title: "Notes", + fallback: "English fallback", + notes_one: "{{count}} note for {{name}}", + notes_other: "{{count}} notes for {{name}}", + }, + }, + "zh-CN": { screen: { title: "笔记", notes_other: "{{name}}的 {{count}} 条笔记" } }, + }, +}); +it("resolves messages, interpolation, plurals and English fallback with the current app locale", () => { + const source = language(); + const runtime = new PluginTranslationRuntime(source); + const title = messages.message("screen.title"); + expect(runtime.resolve(title)).toBe("Notes"); + expect(runtime.t(messages, "screen.notes", { count: 1, name: "Ada" })).toBe("1 note for Ada"); + expect(runtime.t(messages, "screen.notes", { count: 2, name: "Ada" })).toBe("2 notes for Ada"); + source.set("zh-CN"); + expect(runtime.resolve(title)).toBe("笔记"); + expect(runtime.t(messages, "screen.notes", { count: 2, name: "Ada" })).toBe("Ada的 2 条笔记"); + expect(runtime.t(messages, "screen.fallback")).toBe("English fallback"); + expect(runtime.locale).toBe("zh-CN"); + source.set("fr"); + expect(runtime.resolve(title)).toBe("Notes"); + expect(runtime.locale).toBe("fr"); + runtime.dispose(); +}); +it("isolates installations, resources and clients, and unsubscribes on unload", () => { + const first = language("zh-CN"); + const second = language(); + const a = new PluginTranslationRuntime(first); + const b = new PluginTranslationRuntime(second); + const other = defineTranslations({ resources: { en: { screen: { title: "Other plugin" } } } }); + expect(a.t(messages, "screen.title")).toBe("笔记"); + expect(b.t(messages, "screen.title")).toBe("Notes"); + expect(a.t(other, "screen.title")).toBe("Other plugin"); + let updates = 0; + a.subscribe(() => updates++); + first.set("en"); + expect(updates).toBe(1); + expect(first.subscriptions()).toBe(1); + a.dispose(); + a.dispose(); + expect(first.subscriptions()).toBe(0); + first.set("zh-CN"); + expect(updates).toBe(1); + expect(b.t(messages, "screen.title")).toBe("Notes"); + b.dispose(); +}); + +it("rejects unknown keys, malformed resources, noncanonical locales and placeholder drift", () => { + expect(() => + defineTranslations({ resources: { en: { title: "Notes" }, fr: { typo: "Notes" } } }), + ).toThrow("Unknown translation key"); + expect(() => + defineTranslations({ resources: { en: { title: "Notes" }, "zh-cn": { title: "笔记" } } }), + ).toThrow("canonical"); + expect(() => + defineTranslations({ + resources: { en: { title: "Hello {{name}}" }, fr: { title: "Bonjour {{other}}" } }, + }), + ).toThrow("placeholders differ"); + expect(() => defineTranslations({ resources: { en: {} } })).toThrow("English messages"); + expect(() => defineTranslations({ resources: { en: { value: 42 } } } as never)).toThrow( + "objects of strings", + ); +}); + +it("uses Intl formatting and reports missing JSON interpolation values", () => { + const source = language("en"); + const runtime = new PluginTranslationRuntime(source); + const en: { amount: string; welcome: string } = { + amount: "{{value, number}}", + welcome: "Hello {{name}}", + }; + const formatted = defineTranslations({ resources: { en } }); + expect(runtime.t(formatted, "amount", { value: 1234.5 })).toBe("1,234.5"); + expect(() => runtime.t(formatted, "welcome")).toThrow("Missing translation value: name"); + runtime.dispose(); +}); diff --git a/packages/plugin/src/client/translations.tsx b/packages/plugin/src/client/translations.tsx new file mode 100644 index 0000000000..e6d5687de4 --- /dev/null +++ b/packages/plugin/src/client/translations.tsx @@ -0,0 +1,202 @@ +import { + createContext, + useCallback, + useContext, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { createInstance, type i18n } from "i18next"; +import type { + PluginMessage, + PluginText, + PluginTranslations, + TranslationKey, + TranslationArguments, + TranslationResource, + TranslationOptions, +} from "../text.js"; + +type Resources = PluginTranslations["resources"]; +interface MessageDefinition { + resources: Resources; + key: string; + options?: TranslationOptions; +} +const messageDefinitions = new WeakMap(); + +export function defineTranslations(definition: { + resources: Readonly> & { en: Resource }; +}): PluginTranslations { + const english = validateResources(definition.resources); + return { + resources: definition.resources, + message: (key, ...args) => { + const source = english.get(key) ?? english.get(`${key}_other`); + if (source === undefined) throw new Error(`Unknown translation key: ${key}`); + const options = args[0]; + for (const name of placeholders(source)) { + if (options?.[name] === undefined) + throw new Error(`Missing translation value: ${name} (${key})`); + } + const message = Object.freeze({}) as PluginMessage; + messageDefinitions.set(message, { resources: definition.resources, key, options }); + return message; + }, + }; +} + +/** App language source. Each installation subscribes for exactly its own lifetime. */ +export interface PluginLocaleSource { + getLocale(): string; + subscribe(listener: () => void): () => void; +} + +export interface PluginI18n { + readonly locale: string; + t>( + messages: PluginTranslations, + key: Key, + ...args: TranslationArguments + ): string; +} + +export class PluginTranslationRuntime implements PluginI18n { + private readonly engines = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly unsubscribe: () => void; + private stopped = false; + constructor(private readonly source: PluginLocaleSource) { + this.unsubscribe = source.subscribe(() => { + for (const listener of this.listeners) listener(); + }); + } + get locale(): string { + return this.source.getLocale(); + } + getSnapshot = (): string => this.locale; + subscribe = (listener: () => void): (() => void) => { + if (this.stopped) throw new Error("Plugin translations have stopped"); + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + t>( + messages: PluginTranslations, + key: Key, + ...args: TranslationArguments + ): string { + return this.resolve(messages.message(key, ...args)); + } + resolve = (text: PluginText, locale = this.locale): string => { + if (typeof text === "string") return text; + if (this.stopped) throw new Error("Plugin translations have stopped"); + const message = messageDefinitions.get(text); + if (!message) throw new Error("Plugin labels must use messages.message()"); + let engine = this.engines.get(message.resources); + if (!engine) { + engine = createInstance(); + void engine.init({ + initAsync: false, + lng: this.locale, + fallbackLng: "en", + resources: Object.fromEntries( + Object.entries(message.resources).map(([language, translation]) => [ + language, + { translation }, + ]), + ), + interpolation: { escapeValue: false }, + returnNull: false, + returnObjects: false, + }); + this.engines.set(message.resources, engine); + } + return engine.t(message.key, { ...message.options, lng: locale }); + }; + dispose(): void { + if (this.stopped) return; + this.stopped = true; + this.unsubscribe(); + this.listeners.clear(); + this.engines.clear(); + } +} + +export function isPluginMessage(value: unknown): value is PluginMessage { + return ( + typeof value === "object" && value !== null && messageDefinitions.has(value as PluginMessage) + ); +} +export function requirePluginText(text: unknown): PluginText { + if (typeof text === "string" && text.trim()) return text.trim(); + if (isPluginMessage(text)) return text; + throw new Error("Plugin label must be a non-empty string or a translation message"); +} + +function flatten(resource: TranslationResource, prefix = ""): Map { + if (!resource || typeof resource !== "object" || Array.isArray(resource)) + throw new Error("Translation resources must be objects of strings"); + const result = new Map(); + for (const [key, value] of Object.entries(resource)) { + if (!key || key.includes(".") || ["__proto__", "constructor", "prototype"].includes(key)) + throw new Error(`Invalid translation key: ${key}`); + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value === "string") result.set(path, value); + else for (const entry of flatten(value, path)) result.set(...entry); + } + return result; +} +function pluralBase(key: string): string { + return key.replace(/_(zero|one|two|few|many|other)$/, ""); +} +function placeholders(value: string): string[] { + return [ + ...new Set([...value.matchAll(/{{-?\s*([^},\s]+)(?:[^}]*)}}/g)].map((match) => match[1])), + ].sort(); +} +function validateResources(resources: Resources): Map { + const english = flatten(resources.en); + if (!english.size) throw new Error("Plugin translations require English messages"); + for (const [locale, resource] of Object.entries(resources)) { + if (Intl.getCanonicalLocales(locale)[0] !== locale) + throw new Error(`Translation locale must be canonical: ${locale}`); + for (const [key, value] of flatten(resource)) { + const source = english.get(key) ?? english.get(`${pluralBase(key)}_other`); + if (source === undefined) throw new Error(`Unknown translation key in ${locale}: ${key}`); + if (placeholders(value).join(",") !== placeholders(source).join(",")) + throw new Error(`Translation placeholders differ in ${locale}: ${key}`); + } + } + return english; +} + +const Context = createContext(null); +export function PluginTranslationProvider({ + runtime, + children, +}: { + runtime: PluginTranslationRuntime; + children: ReactNode; +}) { + return {children}; +} +export function usePluginTranslationRuntime(): PluginTranslationRuntime { + const runtime = useContext(Context); + if (!runtime) throw new Error("Plugin translations must run inside a contributed plugin surface"); + return runtime; +} +export function useTranslation( + messages: PluginTranslations, +) { + const runtime = usePluginTranslationRuntime(); + const locale = useSyncExternalStore(runtime.subscribe, runtime.getSnapshot, runtime.getSnapshot); + const t = useCallback( + >( + key: Key, + ...args: TranslationArguments + ) => runtime.resolve(messages.message(key, ...args), locale), + [runtime, messages, locale], + ); + return { locale, t }; +} diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 6694f05aea..7e71cb22c5 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -1,3 +1,4 @@ +import type { PluginText } from "./text.js"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { PluginSerializableIcon } from "./icons.js"; import type { PluginRpcContract } from "./rpc.js"; @@ -64,19 +65,19 @@ export interface PluginThemeColors { ring: string; } -export interface PluginThemeContribution { +export interface PluginThemeContribution { id: string; - name: string; + name: Text; appearance: "light" | "dark"; colors: PluginThemeColors; } -export interface PluginAttachmentSourceContribution { +export interface PluginAttachmentSourceContribution { id: string; - title: string; + title: Text; icon: PluginSerializableIcon; - pickerTitle: string; - searchPlaceholder: string; + pickerTitle: Text; + searchPlaceholder: Text; search: PluginRpcContract; } diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 29f5e72089..2bc20fa9ac 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -20,6 +20,8 @@ export { type PluginAttachmentSearchPayload, } from "./attachments.js"; export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js"; + +export type { PluginText, PluginMessage } from "./text.js"; export { findSvgIconProblem, PLUGIN_SVG_ICON_MAX_BYTES, diff --git a/packages/plugin/src/skill-examples.test.ts b/packages/plugin/src/skill-examples.test.ts index 3a21f58c33..6296178b3d 100644 --- a/packages/plugin/src/skill-examples.test.ts +++ b/packages/plugin/src/skill-examples.test.ts @@ -38,3 +38,78 @@ describe("paseo-plugin skill examples", () => { expect(typeErrors(sectionCode("Add a composer pill"))).toEqual([]); }); }); + +describe("plugin translation author types", () => { + it("checks JSON keys, plural counts, literal interpolation names and imperative translation", () => { + expect( + typeErrors(` + import { defineTranslations, useTranslation, type PluginClientContext } from "@getpaseo/plugin/client"; + import en from "../../translations/client/locales/en.json"; + const json = defineTranslations({ resources: { en } }); + const literal = defineTranslations({ resources: { en: { greeting: "Hello {{name}}", value: "{{amount, number}}" } } }); + function Screen() { + const { t } = useTranslation(json); + t("notes", { count: 2 }); + // @ts-expect-error unknown JSON key + t("missing"); + // @ts-expect-error plural base keys require count + t("notes"); + // @ts-expect-error count is numeric + t("notes", { count: "two" }); + literal.message("greeting", { name: "Ada" }); + // @ts-expect-error inline literal resources infer interpolation names + literal.message("greeting"); + return null; + } + function contribute(client: PluginClientContext) { + client.addScreen({ id: "notes", title: json.message("title"), Component: Screen }); + client.i18n.t(json, "notes", { count: 2 }); + // @ts-expect-error imperative keys are checked + client.i18n.t(json, "missing"); + } + `), + ).toEqual([]); + }); +}); + +it("keeps historical string descriptor helpers while accepting translated registrations", () => { + expect( + typeErrors(` + import { defineTranslations, type PluginText, type PluginFilePanelContribution, type PluginClientContext, type PluginScreenContribution, type PluginWorkspacePanelContribution, type PluginSettingsScreenContribution, type PluginSidebarItemContribution, type PluginSidebarContribution, type PluginCommandCenterItemContribution, type PluginClientSlashCommandContribution, type PluginButton, type PluginHeaderButtonContribution, type PluginComposerPillContribution, type PluginButtonMenuEntry } from "@getpaseo/plugin/client"; + import { z } from "zod"; + import { defineAttachmentSource, type PluginThemeContribution, type PluginAttachmentSourceContribution } from "@getpaseo/plugin"; + const messages = defineTranslations({ resources: { en: { title: "Notes" } } }); + const Component = () => null; + function historicalScreen(screen: PluginScreenContribution) { + return typeof screen.title === "function" ? screen.title({}).trim() : screen.title.trim(); + } + function historicalLabels(filePanel: PluginFilePanelContribution, panel: PluginWorkspacePanelContribution, settings: PluginSettingsScreenContribution, sidebar: PluginSidebarItemContribution, legacy: PluginSidebarContribution, command: PluginCommandCenterItemContribution, slash: PluginClientSlashCommandContribution, button: PluginButton, header: PluginHeaderButtonContribution, pill: PluginComposerPillContribution, menu: PluginButtonMenuEntry, theme: PluginThemeContribution, source: PluginAttachmentSourceContribution) { + filePanel.title.trim(); panel.title.trim(); settings.title.trim(); sidebar.title.trim(); legacy.title.trim(); command.title.trim(); + slash.description.trim(); slash.argumentHint.trim(); button.title.trim(); button.label?.trim(); + header.button.title.trim(); pill.button.title.trim(); if (menu.kind === "item") menu.title.trim(); + theme.name.trim(); source.title.trim(); source.pickerTitle.trim(); source.searchPlaceholder.trim(); + } + function contribute(client: PluginClientContext) { + const oldScreen: PluginScreenContribution = { id: "old", title: (params) => params.name ?? "Notes", Component }; + historicalScreen(oldScreen); + client.addScreen(oldScreen); + client.addScreen({ id: "translated", title: messages.message("title"), Component }); + client.addScreen({ id: "callback", title: (params) => messages.message("title"), Component }); + const translated: PluginScreenContribution = { id: "typed", title: messages.message("title"), Component }; + client.addScreen(translated); + client.addSidebarHeaderItem({ id: "notes", title: messages.message("title"), Component }); + client.addFilePanel({ id: "notes", title: messages.message("title"), icon: "File", files: { extensions: ["notes"] }, Component }); + client.addSettingsScreen({ id: "notes", title: messages.message("title"), icon: "File", Component }); + client.addWorkspacePanel({ id: "notes", title: messages.message("title"), icon: "File", context: "workspace", Component }); + client.addCommandCenterItem({ id: "notes", title: messages.message("title"), icon: "File", context: "global", onSelect() {} }); + client.addSlashCommand({ name: "notes", description: messages.message("title"), argumentHint: "", context: "workspace", onSubmit() {} }); + const button = client.addHeaderButton({ id: "notes", workspaceId: "workspace", button: { title: messages.message("title"), icon: "File", behavior: { kind: "menu", items: [{ kind: "item", id: "open", title: messages.message("title"), behavior: { kind: "action", onPress() {} } }] } } }); + button.update({ label: messages.message("title") }); + const source = defineAttachmentSource({ id: "source", title: messages.message("title"), pickerTitle: messages.message("title"), searchPlaceholder: messages.message("title"), icon: "File", search: { name: "source.search", input: z.object({}), output: z.object({}) } }); + client.addAttachmentSource(source); + // @ts-expect-error a translated reference is never typed as a string + source.title.trim(); + } + `), + ).toEqual([]); +}); diff --git a/packages/plugin/src/text.ts b/packages/plugin/src/text.ts new file mode 100644 index 0000000000..646775ea1c --- /dev/null +++ b/packages/plugin/src/text.ts @@ -0,0 +1,67 @@ +/** A persistent label resolved in the presenting app's language. */ +declare const pluginMessage: unique symbol; +export interface PluginMessage { + readonly [pluginMessage]: true; +} +export type PluginText = string | PluginMessage; +export interface TranslationResource { + readonly [key: string]: string | TranslationResource; +} +export interface TranslationOptions { + readonly count?: number; + readonly [name: string]: unknown; +} +export type TranslationKey = { + [Key in keyof Resource & string]: Resource[Key] extends string + ? + | Key + | (Key extends `${infer Base}_${"zero" | "one" | "two" | "few" | "many" | "other"}` + ? Base + : never) + : Resource[Key] extends TranslationResource + ? `${Key}.${TranslationKey}` + : never; +}[keyof Resource & string]; +export interface PluginTranslations { + readonly resources: Readonly> & { readonly en: Resource }; + message>( + key: Key, + ...args: TranslationArguments + ): PluginMessage; +} + +type Lookup = Key extends `${infer Head}.${infer Tail}` + ? Head extends keyof Resource + ? Lookup + : never + : Key extends keyof Resource + ? Resource[Key] + : never; +type Trim = Value extends ` ${infer Rest}` | `${infer Rest} ` + ? Trim + : Value; +type InterpolationName = + Trim extends `-${infer Rest}` + ? InterpolationName + : Trim extends `${infer Name},${string}` + ? Trim + : Trim; +type InterpolationNames = Value extends string + ? string extends Value + ? never + : Value extends `${string}{{${infer Name}}}${infer Rest}` + ? InterpolationName | InterpolationNames + : never + : never; +type MessageOptions = TranslationOptions & + ([Lookup] extends [never] ? {} : { count: number }) & { + [Name in InterpolationNames | Lookup>]: + | string + | number + | boolean + | Date; + }; +export type TranslationArguments = + {} extends MessageOptions + ? [options?: MessageOptions] + : [options: MessageOptions]; diff --git a/packages/plugin/tsconfig.examples.json b/packages/plugin/tsconfig.examples.json index 59a2b702c8..843dcb6378 100644 --- a/packages/plugin/tsconfig.examples.json +++ b/packages/plugin/tsconfig.examples.json @@ -10,7 +10,8 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "noEmit": true, - "types": ["node"] + "types": ["node"], + "resolveJsonModule": true }, "include": ["../../plugin-examples/**/*.ts", "../../plugin-examples/**/*.tsx"] } diff --git a/plugin-examples/translations/OVERVIEW.md b/plugin-examples/translations/OVERVIEW.md new file mode 100644 index 0000000000..17edf994e7 --- /dev/null +++ b/plugin-examples/translations/OVERVIEW.md @@ -0,0 +1,7 @@ +# Workspace notes + +Keep a draft beside your workspace and preview it in a modal. Switch Paseo between English and Simplified Chinese to see the content, sidebar row, screen header, panel title, and commands follow your language while your draft stays intact. Notes and drafts last while the surface is mounted. + +Open **Workspace notes** in the sidebar or **Open workspace notes** in the Command Center. The panel also appears in workspace and Explorer tab menus. An English-only message shows fallback when Chinese is selected. + +This example uses an unreleased translation API. Its `>=0.12.1` requirement is provisional. Assign the release containing the API before publishing it. Listing translations and this overview are independent of the bundled client JSON resources. diff --git a/plugin-examples/translations/OVERVIEW.zh-CN.md b/plugin-examples/translations/OVERVIEW.zh-CN.md new file mode 100644 index 0000000000..496f05de84 --- /dev/null +++ b/plugin-examples/translations/OVERVIEW.zh-CN.md @@ -0,0 +1,7 @@ +# 工作区笔记 + +在工作区旁保留草稿,并在模态框中预览。在 Paseo 中切换英语与简体中文,内容、侧边栏项、界面标题、面板标题和命令会跟随语言变化,草稿保持不变。笔记和草稿在界面挂载期间保留。 + +在侧边栏打开 **工作区笔记**,或在 Command Center 选择 **打开工作区笔记**。工作区和 Explorer 的标签页菜单也提供此面板。选择中文时,一条仅有英语的消息展示回退行为。 + +此示例使用尚未发布的翻译 API。`>=0.12.1` 版本要求为临时值,发布前须改为实际包含该 API 的版本。列表翻译与本文独立于打包到客户端中的 JSON 资源。 diff --git a/plugin-examples/translations/client/locales/en.json b/plugin-examples/translations/client/locales/en.json new file mode 100644 index 0000000000..91cbc4cf69 --- /dev/null +++ b/plugin-examples/translations/client/locales/en.json @@ -0,0 +1,23 @@ +{ + "title": "Workspace notes", + "intro": "A small space for your next idea.", + "draft": "Your draft", + "placeholder": "What would you like to remember?", + "add": "Add note", + "empty": "No notes yet. Start with a thought above.", + "notes_one": "{{count}} note", + "notes_other": "{{count}} notes", + "preview": "Preview draft", + "close": "Close preview", + "previewTitle": "Draft preview", + "draftEmpty": "Your draft is empty.", + "locale": "App language: {{locale}}", + "fallback": "This English-only message demonstrates fallback.", + "open": "Open workspace notes", + "description": "Open the notes screen", + "hint": "[optional text]", + "copyTitle": "Copy notes title", + "tools": "Notes tools", + "pin": "Show notes tools", + "openPanel": "Open notes panel" +} diff --git a/plugin-examples/translations/client/locales/zh-CN.json b/plugin-examples/translations/client/locales/zh-CN.json new file mode 100644 index 0000000000..02e9ce2f95 --- /dev/null +++ b/plugin-examples/translations/client/locales/zh-CN.json @@ -0,0 +1,21 @@ +{ + "title": "工作区笔记", + "intro": "为下一个灵感留一点空间。", + "draft": "你的草稿", + "placeholder": "有什么想记下来的?", + "add": "添加笔记", + "empty": "暂无笔记。在上方写下你的想法吧。", + "notes_other": "{{count}} 条笔记", + "preview": "预览草稿", + "close": "关闭预览", + "previewTitle": "草稿预览", + "draftEmpty": "草稿为空。", + "locale": "应用语言:{{locale}}", + "open": "打开工作区笔记", + "description": "打开笔记页面", + "hint": "[可选文本]", + "copyTitle": "复制笔记标题", + "tools": "笔记工具", + "pin": "显示笔记工具", + "openPanel": "打开笔记面板" +} diff --git a/plugin-examples/translations/client/messages.ts b/plugin-examples/translations/client/messages.ts new file mode 100644 index 0000000000..ed1fc9f21e --- /dev/null +++ b/plugin-examples/translations/client/messages.ts @@ -0,0 +1,5 @@ +import { defineTranslations } from "@getpaseo/plugin/client"; +import en from "./locales/en.json"; +import zhCN from "./locales/zh-CN.json"; + +export const messages = defineTranslations({ resources: { en, "zh-CN": zhCN } }); diff --git a/plugin-examples/translations/client/notes.tsx b/plugin-examples/translations/client/notes.tsx new file mode 100644 index 0000000000..e234b67fec --- /dev/null +++ b/plugin-examples/translations/client/notes.tsx @@ -0,0 +1,151 @@ +import { useCallback, useMemo, useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { Modal, TextInput, ScrollView } from "@getpaseo/plugin/client/ui"; +import { useTranslation, type PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { messages } from "./messages"; + +function useStyles(theme: PluginSurfaceProps["theme"], compact: boolean) { + return useMemo(() => { + const primary = { padding: 12, borderRadius: 8, backgroundColor: theme.colors.accent }; + return StyleSheet.create({ + screen: { flex: 1, backgroundColor: theme.colors.surface0 }, + content: { + padding: compact ? 20 : 36, + gap: 20, + width: "100%", + maxWidth: 760, + alignSelf: "center", + }, + heading: { gap: 8 }, + title: { color: theme.colors.foreground, fontSize: 28, fontWeight: "600" }, + text: { color: theme.colors.foreground }, + muted: { color: theme.colors.foregroundMuted }, + label: { color: theme.colors.foreground, fontWeight: "600" }, + card: { + gap: 12, + padding: 20, + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + }, + input: { + color: theme.colors.foreground, + minHeight: 112, + padding: 12, + borderRadius: 8, + borderColor: theme.colors.border, + borderWidth: 1, + backgroundColor: theme.colors.surface0, + }, + actions: { flexDirection: "row", gap: 12, flexWrap: "wrap" }, + primary, + primaryDisabled: { ...primary, opacity: 0.5 }, + primaryText: { color: theme.colors.accentForeground }, + secondary: { padding: 12, borderRadius: 8, borderWidth: 1, borderColor: theme.colors.border }, + preview: { gap: 16 }, + close: { padding: 12 }, + closeText: { color: theme.colors.accent }, + }); + }, [theme, compact]); +} + +function Preview({ + draft, + theme, + close, +}: { + draft: string; + theme: PluginSurfaceProps["theme"]; + close(): void; +}) { + // This hook also runs inside native portal hosts. + const { t, locale } = useTranslation(messages); + const styles = useStyles(theme, false); + return ( + + {draft || t("draftEmpty")} + {t("locale", { locale })} + + {t("close")} + + + ); +} + +export function Notes({ theme, layout }: PluginSurfaceProps) { + const { t, locale } = useTranslation(messages); + const [draft, setDraft] = useState(""); + const [notes, setNotes] = useState<{ id: number; text: string }[]>([]); + const [preview, setPreview] = useState(false); + const styles = useStyles(theme, layout.compact); + const showPreview = useCallback(() => setPreview(true), []); + const closePreview = useCallback(() => setPreview(false), []); + const addNote = useCallback(() => { + setNotes((previous) => [...previous, { id: previous.length, text: draft }]); + setDraft(""); + }, [draft]); + return ( + + + {t("title")} + {t("intro")} + + + {t("draft")} + + + + {t("add")} + + + {t("preview")} + + + + {t("notes", { count: notes.length })} + {notes.length === 0 ? ( + {t("empty")} + ) : ( + notes.map((note) => ( + + {note.text} + + )) + )} + + {t("locale", { locale })} + {t("fallback")} + + + + + + + + ); +} diff --git a/plugin-examples/translations/index.client.tsx b/plugin-examples/translations/index.client.tsx new file mode 100644 index 0000000000..407b0816c6 --- /dev/null +++ b/plugin-examples/translations/index.client.tsx @@ -0,0 +1,100 @@ +import { useCallback } from "react"; +import { copyText, SidebarRow } from "@getpaseo/plugin/client/ui"; +import type { + PluginButtonRegistration, + PluginClientContext, + PluginSidebarItemProps, +} from "@getpaseo/plugin/client"; +import { messages } from "./client/messages"; +import { Notes } from "./client/notes"; + +function NotesItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + const open = useCallback(() => openScreen({ screenId: "notes" }), [openScreen]); + return ( + + ); +} +export default function contribute(client: PluginClientContext) { + let tools: PluginButtonRegistration | undefined; + client.addScreen({ id: "notes", title: messages.message("title"), Component: Notes }); + client.addSidebarHeaderItem({ + id: "notes", + title: messages.message("title"), + Component: NotesItem, + }); + client.addWorkspacePanel({ + id: "notes", + title: messages.message("title"), + icon: "NotebookPen", + context: "workspace", + locations: ["workspace", "explorer"], + Component: Notes, + }); + client.addSettingsScreen({ + id: "notes", + title: messages.message("title"), + icon: "NotebookPen", + Component: Notes, + }); + client.addCommandCenterItem({ + id: "notes", + title: messages.message("open"), + icon: "NotebookPen", + context: "global", + onSelect({ openScreen }) { + openScreen({ screenId: "notes" }); + }, + }); + client.addSlashCommand({ + name: "notes", + description: messages.message("description"), + argumentHint: messages.message("hint"), + context: "workspace", + onSubmit({ openScreen }) { + openScreen({ screenId: "notes" }); + }, + }); + client.addCommandCenterItem({ + id: "copy-title", + title: messages.message("copyTitle"), + icon: "Copy", + context: "global", + onSelect() { + return copyText(client.i18n.t(messages, "title")); + }, + }); + client.addCommandCenterItem({ + id: "tools", + title: messages.message("pin"), + icon: "NotebookPen", + context: "workspace", + onSelect({ workspace, openPanel }) { + tools?.remove(); + tools = client.addHeaderButton({ + id: "notes-tools", + workspaceId: workspace.id, + button: { + title: messages.message("tools"), + icon: "NotebookPen", + behavior: { + kind: "menu", + items: [ + { + kind: "item", + id: "open", + title: messages.message("openPanel"), + behavior: { + kind: "action", + onPress() { + openPanel("notes"); + }, + }, + }, + ], + }, + }, + }); + }, + }); + return () => tools?.remove(); +} diff --git a/plugin-examples/translations/package.json b/plugin-examples/translations/package.json new file mode 100644 index 0000000000..97a2c54892 --- /dev/null +++ b/plugin-examples/translations/package.json @@ -0,0 +1,16 @@ +{ + "name": "paseo-translation-notes-example", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@getpaseo/plugin": "file:../../packages/plugin", + "@types/react": "~19.2.0", + "react": "19.1.0", + "react-native": "0.81.5", + "typescript": "^5.9.3" + } +} diff --git a/plugin-examples/translations/paseo-plugin.json b/plugin-examples/translations/paseo-plugin.json new file mode 100644 index 0000000000..1d2ebe74e4 --- /dev/null +++ b/plugin-examples/translations/paseo-plugin.json @@ -0,0 +1,12 @@ +{ + "id": "translation-notes", + "name": "Workspace notes", + "description": "A bilingual notes example that keeps your draft while the app language changes.", + "translations": { + "zh-CN": { + "name": "工作区笔记", + "description": "跟随应用语言切换的双语笔记示例,切换时保留草稿。" + } + }, + "requirements": { "paseo": ">=0.12.1" } +} diff --git a/plugin-examples/translations/tsconfig.json b/plugin-examples/translations/tsconfig.json new file mode 100644 index 0000000000..b562ddf622 --- /dev/null +++ b/plugin-examples/translations/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["index.client.tsx", "client/**/*"] +} diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index d386017ab7..e456e625bb 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -90,6 +90,8 @@ Empty strings, invalid ranges, and unknown manifest requirement keys are rejecte Prerelease Paseo versions also satisfy a range their stable core (`major.minor.patch`) satisfies, so `0.8.0-beta.1` satisfies `>=0.8.0` but not `<0.8.0`. +A prerelease minimum such as `>=0.12.0-beta.4` therefore also admits `0.12.0-beta.3` through its stable core `0.12.0`. Use a different stable core when you need to exclude earlier builds of that core. + `paseo plugin init` writes `>=` followed by the current CLI version and pins the matching SDK for typechecking. Raise the minimum when adopting a newer API. Add an upper bound when a later release is incompatible; a minimum alone does not promise protection from future breaking changes. @@ -130,6 +132,79 @@ plugin root is a compile error. | `server/` | Daemon bundle only | Node APIs, local resources, credentials, and RPC handlers. | | `shared/` | Both | Zod RPC contracts and plain values imported by both runtimes. | +## Runtime translations {#runtime-translations} + +**Unreleased API.** Set `requirements.paseo` to the release that introduces these APIs before publishing your plugin. The [translation example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/translations) uses `>=0.12.1` provisionally; the introducing release has not been assigned. + +Ship JSON resources with your client bundle. English (`en`) is required; other keys use canonical language tags such as `zh-CN`. Resources contain nested objects with string leaves. Other languages may omit keys, which fall back to English. Unknown keys, malformed resources, noncanonical locale tags, and mismatched interpolation placeholders are rejected when defining translations. + +```ts +// client/messages.ts +import { defineTranslations } from "@getpaseo/plugin/client"; +import en from "./locales/en.json"; +import zhCN from "./locales/zh-CN.json"; + +export const messages = defineTranslations({ resources: { en, "zh-CN": zhCN } }); +``` + +Enable `resolveJsonModule` in your plugin's `tsconfig.json`. Paseo bundles these imports locally; no network loader runs. For example, the resource pair can contain: + +`client/locales/en.json`: + +```json +{ + "title": "Workspace notes", + "add": "Add note", + "notes_one": "{{count}} note", + "notes_other": "{{count}} notes" +} +``` + +`client/locales/zh-CN.json`: + +```json +{ + "title": "工作区笔记", + "add": "添加笔记", + "notes_other": "{{count}} 条笔记" +} +``` + +Call the plugin hook in React UI: + +```tsx +import { useTranslation } from "@getpaseo/plugin/client"; +import { Text } from "react-native"; +import { messages } from "./messages"; + +function NoteCount({ count }: { count: number }) { + const { t, locale } = useTranslation(messages); + return {t("notes", { count })}; +} +``` + +Use `messages.message(key, options?)` for persistent host-rendered labels: + +```tsx +client.addScreen({ + id: "notes", + title: messages.message("title"), + Component: Notes, +}); +``` + +`PluginText` is `string | PluginMessage`. Message references work in screen titles (including title functions), settings and sidebar titles, workspace and file panel titles, Command Center titles, slash-command descriptions and argument hints, button titles and labels, nested menu titles, attachment-source titles/picker titles/search placeholders, and theme names. Existing literal strings remain valid. Keep IDs, slash-command names, icons, URLs, and command search keywords stable. + +Descriptor types keep their existing string defaults. When explicitly annotating translated descriptors, use the text parameter, for example `PluginScreenContribution`. Inline registrations infer this automatically. + +For transient text in callbacks, call `client.i18n.t(messages, "add")`; it reads the app's current language at invocation. `client.i18n.locale` and the hook's `locale` report the resolved app language even when a message falls back to English. Do not store the result of `t` as a persistent registration label. + +Keys come from English. Plural base keys such as `notes` require a numeric `count`; i18next selects the locale's `_zero`, `_one`, `_two`, `_few`, `_many`, or `_other` form. Use `{{name}}` for named interpolation and pass `{ name: value }`. JSON string values are widened by TypeScript, so interpolation names from imported JSON are checked at runtime; inline literal resources also infer required interpolation names. i18next formatting syntax such as `{{amount, number}}` uses the resolved language's `Intl` formatter. + +An explicit app language wins over the device language; System follows the app's supported-locale resolution. Separate connected apps have independent languages. Changing language updates mounted UI and host labels without reloading the plugin, resetting component state, or replacing its RPC/query cache. Modal and popover content receives the same installation context on web and native. + +Return language-neutral structured RPC data and translate its presentation in the client. Keep user drafts, agent output, raw daemon errors, logs, and terminal text unchanged. Manifest listing translations and `OVERVIEW..md` are separate publication metadata; see [your listing page](publishing#your-listing-page). + ## Runtime modules Paseo builds each bundle from its matching entry. An import from `client/` into the daemon bundle, @@ -939,10 +1014,10 @@ export default function contribute(client: PluginClientContext) { Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the screen body. `addScreen({ id, title, Component })` registers a screen. `title` is the screen header's title: a -string, or a function that takes the screen's params and returns one, so a bot screen can show the -bot's name. The function runs again when the params change. Registration throws on an empty title -or one that is neither a string nor a function. A title function that throws or returns an empty -string shows the screen ID instead; the screen still renders. +[display text](#runtime-translations), or a function that takes the screen's params and returns +display text. The function runs again when params or language change. Invalid or empty literal +titles are rejected. A title function that throws or resolves to an empty string shows the screen +ID instead; the screen still renders. `openScreen({ screenId, params })` opens a screen with params, such as the bot a bot screen shows. Params live in the screen's URL query, so a reload, back and forward, and a link to the screen keep diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 9468ec96bd..4f43b2b616 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -78,6 +78,8 @@ my-plugin/ 预发布的 Paseo 版本也满足其稳定核心版本(`major.minor.patch`)所满足的范围,因此 `0.8.0-beta.1` 满足 `>=0.8.0`,但不满足 `<0.8.0`。 +`>=0.12.0-beta.4` 这样的预发布最低版本也会通过稳定核心 `0.12.0` 接受 `0.12.0-beta.3`。若要排除同一核心的较早构建,请使用不同的稳定核心版本。 + `paseo plugin init` 会写入 `>=` 加当前 CLI 版本,并为类型检查固定匹配的 SDK。采用更新的 API 时,请提高最低版本。当某个之后的版本不兼容时,请添加上限;只有下限并不能保证免受将来破坏性变更的影响。 daemon 会在安装、运行准备命令或加载插件之前检查自己的版本,并在启动、启用和重新加载时再次检查。被拒绝的更新会保留已安装的修订。每个已连接的应用在执行客户端代码之前会检查自己的版本,并在 **设置 → 插件** 中显示不兼容。兼容的 daemon 并不能让较旧的应用变得兼容。没有客户端入口的插件不要求已连接的应用匹配。 @@ -105,6 +107,79 @@ daemon 会在安装、运行准备命令或加载插件之前检查自己的版 | `server/` | 仅 daemon 的包 | Node API、本地资源、凭据和 RPC 处理函数。 | | `shared/` | 两者 | Zod RPC 契约以及两个运行时都会导入的普通值。 | +## 运行时翻译 {#runtime-translations} + +**尚未发布的 API。** 发布插件前,请将 `requirements.paseo` 设置为首次包含这些 API 的版本。[翻译示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/translations) 暂用 `>=0.12.1`,实际引入版本尚未确定。 + +将 JSON 资源随客户端代码一起打包。必须提供英语(`en`);其他语言使用规范语言标签,例如 `zh-CN`。资源由嵌套对象和字符串叶节点组成。其他语言可省略键,缺失时回退到英语。定义翻译时会拒绝未知键、无效资源、非规范语言标签,以及与英语不一致的插值占位符。 + +```ts +// client/messages.ts +import { defineTranslations } from "@getpaseo/plugin/client"; +import en from "./locales/en.json"; +import zhCN from "./locales/zh-CN.json"; + +export const messages = defineTranslations({ resources: { en, "zh-CN": zhCN } }); +``` + +在插件的 `tsconfig.json` 中启用 `resolveJsonModule`。Paseo 在本地打包这些导入,不通过网络加载。资源对示例: + +`client/locales/en.json`: + +```json +{ + "title": "Workspace notes", + "add": "Add note", + "notes_one": "{{count}} note", + "notes_other": "{{count}} notes" +} +``` + +`client/locales/zh-CN.json`: + +```json +{ + "title": "工作区笔记", + "add": "添加笔记", + "notes_other": "{{count}} 条笔记" +} +``` + +在 React UI 中使用插件的 hook: + +```tsx +import { useTranslation } from "@getpaseo/plugin/client"; +import { Text } from "react-native"; +import { messages } from "./messages"; + +function NoteCount({ count }: { count: number }) { + const { t, locale } = useTranslation(messages); + return {t("notes", { count })}; +} +``` + +对于由宿主持续渲染的标签,使用 `messages.message(key, options?)`: + +```tsx +client.addScreen({ + id: "notes", + title: messages.message("title"), + Component: Notes, +}); +``` + +`PluginText` 为 `string | PluginMessage`。消息引用支持界面标题(包括标题函数)、设置与侧边栏标题、工作区与文件面板标题、Command Center 标题、斜杠命令描述与参数提示、按钮标题与标签、嵌套菜单标题、附件来源标题/选择器标题/搜索占位文本,以及主题名称。现有的字符串仍然有效。保持 ID、斜杠命令名称、图标、URL 和命令搜索关键词不变。 + +描述符类型保留现有的字符串默认值。为翻译描述符显式标注类型时,传入文本类型参数,例如 `PluginScreenContribution`。内联注册会自动推导类型。 + +回调中的临时文本使用 `client.i18n.t(messages, "add")`,它读取调用时的应用语言。`client.i18n.locale` 和 hook 的 `locale` 返回解析后的应用语言,即使某条消息回退到英语。不要将 `t` 的返回值用作持续注册的标签。 + +键由英语资源推导。`notes` 等复数基础键要求数字类型的 `count`;i18next 根据语言选择 `_zero`、`_one`、`_two`、`_few`、`_many` 或 `_other` 形式。命名插值使用 `{{name}}`,调用时传入 `{ name: value }`。TypeScript 将导入 JSON 的字符串值拓宽为 `string`,因此 JSON 插值名称在运行时检查;内联字面量资源还能推导必需的插值名称。`{{amount, number}}` 等 i18next 格式化语法使用解析后语言的 `Intl` 格式化器。 + +显式选择的应用语言优先于设备语言;“跟随系统”使用应用支持的语言解析规则。连接到同一 daemon 的不同应用各自保留语言设置。切换语言会更新已挂载的 UI 和宿主标签,不重新加载插件、不重置组件状态,也不替换 RPC/查询缓存。网页和原生的模态框与弹出层均收到相同的安装上下文。 + +RPC 返回与语言无关的结构化数据,在客户端翻译呈现内容。保持用户草稿、agent 输出、原始 daemon 错误、日志与终端文本不变。manifest 列表翻译和 `OVERVIEW..md` 是独立的发布元数据,参见[你的列表页面](publishing#your-listing-page)。 + ## 运行时模块 {#runtime-modules} Paseo 从对应的入口构建每个包。把 `client/` 导入 daemon 的包、把 `server/` 导入应用的包,或在应用的包中任何位置导入 Node 模块,都会导致编译错误。服务端导入 React、React Native 或客户端 SDK 入口也会失败。共享代码只能导入共享代码:不能导入 Node、React、特定运行时的 SDK 入口或特定运行时的类型。 @@ -800,7 +875,7 @@ export default function contribute(client: PluginClientContext) { 路由、标题栏、关闭操作、主机选择器、错误边界和 query client 由 Paseo 负责。界面主体由插件负责。 -`addScreen({ id, title, Component })` 注册一个界面。`title` 是界面标题栏的标题:可以是字符串,也可以是一个接收界面参数并返回字符串的函数,这样机器人界面就可以显示机器人的名称。参数变化时,该函数会再次运行。标题为空,或既不是字符串也不是函数时,注册会抛出错误。如果标题函数抛出错误或返回空字符串,会改为显示界面 ID;界面仍然会渲染。 +`addScreen({ id, title, Component })` 注册一个界面。`title` 是界面标题栏的[显示文本](#runtime-translations),也可以是接收界面参数并返回显示文本的函数。参数或语言变化时,该函数会再次运行。无效标题和空字符串标题会被拒绝。如果标题函数抛出错误或解析为空字符串,会改为显示界面 ID;界面仍然会渲染。 `openScreen({ screenId, params })` 以参数打开一个界面,例如机器人界面要显示的那个机器人。参数保存在界面 URL 的查询字符串中,因此重新加载、前进后退以及指向该界面的链接都会保留它们。任何字符串键都可以;值必须是字符串,其他类型会抛出错误。用其他参数打开同一个界面会显示新的参数。 From 2545250b2f1af263cfe613d17d8a5e487d38293e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 13:45:16 +0700 Subject: [PATCH 03/62] fix(app): keep saved workspace tabs when a store changes before hydration (#7547) On every page load the layout mode publisher sets the workspace layout store before its stored state has been read. Zustand persist wrote the unhydrated defaults, and hydration merges the stored layout into memory without writing it back, so storage held no tabs until the next layout change. Leaving the page inside that window lost every workspace's tabs. Validated persist storage now drops writes that arrive while its read is in flight. --- .../src/storage/validated-persist-storage.ts | 44 ++++++++++++------- .../workspace-layout-persistence.test.ts | 36 +++++++++++++++ 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/packages/app/src/storage/validated-persist-storage.ts b/packages/app/src/storage/validated-persist-storage.ts index e9a438daf9..fae43edbc9 100644 --- a/packages/app/src/storage/validated-persist-storage.ts +++ b/packages/app/src/storage/validated-persist-storage.ts @@ -13,29 +13,43 @@ export function createValidatedPersistStorage( }); const accepted = new Map>(); + const reading = new Set(); + + async function read(name: string): Promise | null> { + const raw = await backingStorage.getItem(name); + if (raw === null) return null; + + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + await backingStorage.removeItem(name); + return null; + } + + const result = envelopeSchema.safeParse(decoded); + if (!result.success) { + await backingStorage.removeItem(name); + return null; + } + return result.data; + } return { getItem: async (name) => { accepted.delete(name); - const raw = await backingStorage.getItem(name); - if (raw === null) return null; - - let decoded: unknown; + reading.add(name); try { - decoded = JSON.parse(raw); - } catch { - await backingStorage.removeItem(name); - return null; + return await read(name); + } finally { + reading.delete(name); } - - const result = envelopeSchema.safeParse(decoded); - if (!result.success) { - await backingStorage.removeItem(name); - return null; - } - return result.data; }, setItem: async (name, value) => { + // A store that changes before hydration settles writes the defaults it started with. + // Hydration then merges the stored state in memory without writing it back, so that + // write would replace the stored state until the next change. + if (reading.has(name)) return; const previous = accepted.get(name); if ( options.skipUnchanged && diff --git a/packages/app/src/stores/workspace-layout-persistence.test.ts b/packages/app/src/stores/workspace-layout-persistence.test.ts index d386a40298..3a54204598 100644 --- a/packages/app/src/stores/workspace-layout-persistence.test.ts +++ b/packages/app/src/stores/workspace-layout-persistence.test.ts @@ -52,3 +52,39 @@ it("skips unchanged reconciliations and immediately saves real layout changes", store.getState().layoutByWorkspace[workspaceKey], ); }); + +it("keeps the saved layout when the shell publishes its layout mode before hydration", async () => { + const values = new Map(); + const storage: StateStorage = { + getItem: (name) => values.get(name) ?? null, + setItem: (name, value) => { + values.set(name, value); + }, + removeItem: (name) => { + values.delete(name); + }, + }; + const workspaceKey = "server:workspace"; + const saved = createWorkspaceLayoutStore(undefined, storage); + await saved.persist.rehydrate(); + saved.getState().openTab({ + workspaceKey, + target: { kind: "file", path: "notes.md" }, + intent: "reveal", + }); + + // A page load: the shell publishes its mode from the first commit, before the read settles. + const loaded = createWorkspaceLayoutStore(undefined, storage); + loaded.getState().setLayoutMode("single-pane"); + await new Promise((resolve) => loaded.persist.onFinishHydration(() => resolve())); + expect(loaded.getState().layoutByWorkspace[workspaceKey]).toEqual( + saved.getState().layoutByWorkspace[workspaceKey], + ); + + // Leaving the page before any other layout change must not lose the tabs. + const reloaded = createWorkspaceLayoutStore(undefined, storage); + await reloaded.persist.rehydrate(); + expect(reloaded.getState().layoutByWorkspace[workspaceKey]).toEqual( + saved.getState().layoutByWorkspace[workspaceKey], + ); +}); From 006fd053ac5057bf835d1106cca9c10011ef5904 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 13:48:05 +0700 Subject: [PATCH 04/62] Record Android freezes in opt-in diagnostic builds (#7456) * feat(app): record Android stalls in opt-in diagnostic builds * refactor(app): own watchdog sinks and bounded log storage * fix(app): preserve cumulative browser profiling history --- .github/workflows/ci.yml | 8 ++ CLAUDE.md | 2 + docs/android.md | 72 ++++++++---- package.json | 9 +- packages/app/app.config.js | 6 +- packages/app/index.ts | 2 + packages/app/metro.config.cjs | 10 ++ .../paseo-native-trace/android/build.gradle | 4 + .../java/sh/paseo/trace/DiagnosticRecorder.kt | 77 +++++++++++++ .../sh/paseo/trace/DownloadsLogStorage.kt | 36 ++++++ .../java/sh/paseo/trace/HeartbeatWatchdog.kt | 38 +++++++ .../sh/paseo/trace/PaseoNativeTraceModule.kt | 14 +++ .../main/java/sh/paseo/trace/RotatingLog.kt | 61 ++++++++++ .../sh/paseo/trace/HeartbeatWatchdogTest.kt | 40 +++++++ .../java/sh/paseo/trace/RotatingLogTest.kt | 59 ++++++++++ packages/app/package.json | 13 ++- packages/app/src/performance/native-trace.ts | 49 +++++--- packages/app/src/root-app.tsx | 7 +- packages/app/src/runtime/host-runtime.ts | 17 ++- packages/app/src/utils/render-profiler.tsx | 70 ++++++------ packages/app/src/watchdog/index.ts | 35 ++++++ .../src/watchdog/internal/recorder.test.ts | 103 +++++++++++++++++ .../app/src/watchdog/internal/recorder.ts | 79 +++++++++++++ packages/client/src/daemon-client.test.ts | 101 +++++++++++------ packages/client/src/daemon-client.ts | 105 ++++++++++++++---- scripts/build-apk.mjs | 52 +++++++++ 26 files changed, 929 insertions(+), 140 deletions(-) create mode 100644 packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DiagnosticRecorder.kt create mode 100644 packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DownloadsLogStorage.kt create mode 100644 packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/HeartbeatWatchdog.kt create mode 100644 packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/RotatingLog.kt create mode 100644 packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/HeartbeatWatchdogTest.kt create mode 100644 packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/RotatingLogTest.kt create mode 100644 packages/app/src/watchdog/index.ts create mode 100644 packages/app/src/watchdog/internal/recorder.test.ts create mode 100644 packages/app/src/watchdog/internal/recorder.ts create mode 100644 scripts/build-apk.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 486c7333b1..e632cb4b96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -417,6 +417,14 @@ jobs: - name: Run app unit tests run: npm run test --workspace=@getpaseo/app + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Run Android module tests + run: npm run test:android --workspace=@getpaseo/app + playwright-1: name: playwright (shard 1/8) needs: changes diff --git a/CLAUDE.md b/CLAUDE.md index 1fb575614d..809d6668fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,8 @@ npm run format:check # Check formatting without writing Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`. +When Mo requests an APK, use `npm run build:apk:arm64` for an ARM64 build: writes `artifacts/paseo-dogfood-arm64.apk` with diagnostics without starting a device; `npm run build:apk` builds all four Android architectures. + See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging. ## Release branches diff --git a/docs/android.md b/docs/android.md index c90a27efec..9b83240c52 100644 --- a/docs/android.md +++ b/docs/android.md @@ -64,36 +64,62 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted From repo root: ```bash -npm run android:development # Debug build -npm run android:production # Release build -npm run android:clear # Remove generated Android project +npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86 +npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones +npm run dev:android # Build/install the debug app and start development +npm run dev:ios # Build/install the iOS development app ``` -For a production-ID release APK that local Android profiling tools can attach to: +`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes +`artifacts/paseo-dogfood-arm64.apk`. Neither command launches or installs on a device. Both enable +`PASEO_WATCHDOG_BUILD=1`, the production React profiling renderer, and Android's shell-profileable +flag. These builds keep the production package ID (`sh.paseo`) and release optimizations. Use this APK for +personal dogfooding. Android system-trace spans require the separate `PASEO_PROFILE_BUILD=1` flag; +watchdog recording does not send duplicate spans to that sink by default. Normal store/release +workflows leave diagnostics disabled. + +The older `android`, `android:development`, and `ios` commands remain aliases for development. +`android:production` still builds and installs a release through Expo; use `build:apk` when you +need an artifact. `android:clear` removes the generated Android project. + +Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain. + +### Diagnostic logs without ADB + +In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all +retained `.jsonl` files. No app screen, storage permission, or attached debugger is required. +The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation +replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall +can leave older Downloads files that the new installation cannot manage. + +Each file identifies its build and session. Events include application-transport frame sizes and +message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree +render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents +are excluded; connection identifiers include host addresses. Each size has a unit: **bytes** when +already available, otherwise **UTF-16 code units** for text. These are +client transport observations, after relay decryption where applicable, not packet arrival times. + +The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval). +A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration. +Backgrounding suspends detection. `js.stall.started` can be written while JS remains blocked; +`js.stall.recovered` records the gap when JS resumes. Recovery can appear without a start event +when JS resumes before the next native sampling tick. Process death leaves earlier files intact, +but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash. + +`react.render.slow` records subtree rendering taking at least 16 ms; one-second summaries include +faster renders. Nested profiler timings overlap: don't add parent and child times. React's render +measurements do not measure native layout/paint or the complete commit phase. `recorder.dropped` +means the bounded queue overflowed; missing events in that interval must not be treated as absence +of activity. Storage failure stops recording rather than blocking the app. + +For an ordinary release APK with system trace markers only: ```bash PASEO_PROFILE_BUILD=1 npm run android:production ``` -This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds -`` and enables local Android trace markers for workspace mounts -and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents, -and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`). - -Or from `packages/app`: - -```bash -# Debug -npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive -npx cross-env APP_VARIANT=development expo run:android --variant=debug - -# Release -npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive -npx cross-env APP_VARIANT=production expo run:android --variant=release - -# Clear generated Android project -rm -rf android -``` +Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`); +they do not produce Downloads files. ## Running on an emulator against a worktree daemon diff --git a/package.json b/package.json index 67ca259ffa..54514212cf 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,16 @@ "dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"", "dev:server:raw": "npm run dev --workspace=@getpaseo/server", "dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh", + "dev:android": "npm run dev:android --workspace=@getpaseo/app", + "dev:ios": "npm run dev:ios --workspace=@getpaseo/app", "test:e2e:mobile": "./scripts/test-mobile-agent-device.sh", "test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh", "dev:website": "npm run dev --workspace=@getpaseo/website", "postinstall": "node scripts/postinstall-patches.mjs", "prepare": "lefthook install --force", "build": "npm run build --workspaces --if-present", + "build:apk": "node scripts/build-apk.mjs", + "build:apk:arm64": "node scripts/build-apk.mjs arm64", "build:highlight": "npm run build --workspace=@getpaseo/highlight", "build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight", "build:plugin": "npm run build --workspace=@getpaseo/plugin", @@ -80,15 +84,14 @@ "acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift", "acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update", "start": "npm run start --workspace=@getpaseo/server", - "android": "npm run android --workspace=@getpaseo/app", + "android": "npm run dev:android", "android:development": "npm run android:development --workspace=@getpaseo/app", "android:production": "npm run android:production --workspace=@getpaseo/app", "android:release": "npm run android:production --workspace=@getpaseo/app", "android:clear": "npm run android:clear --workspace=@getpaseo/app", - "android:clean": "npm run android:clean --workspace=@getpaseo/app", "fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs", "fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check", - "ios": "npm run ios --workspace=@getpaseo/app", + "ios": "npm run dev:ios", "web": "npm run web --workspace=@getpaseo/app", "dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop", "dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop", diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 17bc6fe636..7ddfc95029 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -10,6 +10,7 @@ const withAndroidScroll = require("./modules/paseo-scroll/app.plugin"); const { getNativeReleaseVersion } = require("./native-release-version"); const appVariant = process.env.APP_VARIANT ?? "production"; const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1"; +const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1"; const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1"; const buildProfile = isFdroidBuild @@ -181,7 +182,7 @@ export default { }, ], ...buildProfile.fdroidPlugins, - ...(isProfileBuild ? [withAndroidProfileable] : []), + ...(isProfileBuild || isWatchdogBuild ? [withAndroidProfileable] : []), ], experiments: { typedRoutes: true, @@ -191,6 +192,9 @@ export default { extra: { fdroidBuild: isFdroidBuild, profileBuild: isProfileBuild, + watchdogBuild: isWatchdogBuild, + diagnosticRevision: process.env.PASEO_DIAGNOSTIC_REVISION, + diagnosticBuildTime: process.env.PASEO_DIAGNOSTIC_BUILD_TIME, router: {}, eas: { projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a", diff --git a/packages/app/index.ts b/packages/app/index.ts index 82d47bfe8c..d103c5cb79 100644 --- a/packages/app/index.ts +++ b/packages/app/index.ts @@ -9,6 +9,8 @@ import "@expo/metro-runtime"; // oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects. import "expo-router/build/fast-refresh"; import { renderRootComponent } from "expo-router/build/renderRootComponent"; +import { startWatchdog } from "./src/watchdog"; import { RootApp } from "./src/root-app"; +startWatchdog(); renderRootComponent(RootApp); diff --git a/packages/app/metro.config.cjs b/packages/app/metro.config.cjs index c1fdbdef77..96a965e1f9 100644 --- a/packages/app/metro.config.cjs +++ b/packages/app/metro.config.cjs @@ -5,6 +5,7 @@ const path = require("path"); const projectRoot = __dirname; const appNodeModulesRoot = path.resolve(projectRoot, "node_modules"); const appSrcRoot = path.resolve(projectRoot, "src"); +const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1"; const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1"; const fdroidModuleOverrides = { "expo-camera": path.resolve(appSrcRoot, "fdroid/expo-camera.tsx"), @@ -16,6 +17,7 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "") .toLowerCase(); const config = getDefaultConfig(projectRoot); +config.cacheVersion = isWatchdogBuild ? "watchdog" : "standard"; const defaultResolveRequest = config.resolver.resolveRequest ?? resolve; // Keep app exports deterministic across dev machines and CI. Metro's Watchman @@ -75,6 +77,14 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) { } config.resolver.resolveRequest = (context, moduleName, platform) => { + if ( + isWatchdogBuild && + platform === "android" && + /[/\\]Renderer[/\\]shims[/\\]/.test(context.originModulePath) && + /implementations\/(ReactFabric|ReactNativeRenderer)-prod$/.test(moduleName) + ) { + return defaultResolveRequest(context, moduleName.replace(/-prod$/, "-profiling"), platform); + } if (isFdroidBuild && platform === "android" && fdroidModuleOverrides[moduleName]) { return resolveWithCustomWebOverlay(context, fdroidModuleOverrides[moduleName], platform); } diff --git a/packages/app/modules/paseo-native-trace/android/build.gradle b/packages/app/modules/paseo-native-trace/android/build.gradle index bfd0eec72a..c0340730ff 100644 --- a/packages/app/modules/paseo-native-trace/android/build.gradle +++ b/packages/app/modules/paseo-native-trace/android/build.gradle @@ -13,3 +13,7 @@ useDefaultAndroidSdkVersions() android { namespace "sh.paseo.trace" } + +dependencies { + testImplementation "junit:junit:4.13.2" +} diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DiagnosticRecorder.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DiagnosticRecorder.kt new file mode 100644 index 0000000000..512795838c --- /dev/null +++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DiagnosticRecorder.kt @@ -0,0 +1,77 @@ +package sh.paseo.trace + +import android.content.Context +import android.os.SystemClock +import android.util.Log +import org.json.JSONObject +import java.util.UUID +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +/** All serialization and storage belongs to the worker, never the JS/UI threads. */ +internal class DiagnosticRecorder(private val context: Context, private val metadata: Map) { + private data class Event(val name: String, val data: Map, val monoMs: Long = SystemClock.elapsedRealtime(), val wallMs: Long = System.currentTimeMillis()) + private val queue = ArrayBlockingQueue(2048) + private val dropped = AtomicLong() + private val worker = Executors.newSingleThreadScheduledExecutor() + private val session = UUID.randomUUID().toString() + @Volatile private var failed = false + private val watchdog = HeartbeatWatchdog(SystemClock.elapsedRealtime(), ::record) + + private val log = RotatingLog(DownloadsLogStorage(context, session), { part -> + serialize(Event("file.start", metadata + mapOf("schema" to "1", "part" to "$part"))) + }, { error -> + failed = true + queue.clear() + Log.e("PaseoDiagnostics", "Diagnostic recording stopped: storage unavailable", error) + }) + + init { + record("session.start", metadata + mapOf("session" to session, "schema" to "1", "heartbeatMs" to "${HeartbeatTiming.intervalMs}", "stallThresholdMs" to "${HeartbeatTiming.overdueMs}")) + worker.scheduleWithFixedDelay({ drain() }, 0, HeartbeatTiming.intervalMs, TimeUnit.MILLISECONDS) + } + + fun record(name: String, data: Map) { + if (failed) return + // Limit native memory even if an instrumentation caller accidentally supplies large fields. + val bounded = data.entries.take(24).associate { it.key.take(80) to it.value.take(256) } + if (!queue.offer(Event(name.take(120), bounded))) dropped.incrementAndGet() + } + + fun heartbeat() = watchdog.heartbeat(SystemClock.elapsedRealtime()) + fun active(value: Boolean) = watchdog.active(value, SystemClock.elapsedRealtime()) + + private fun drain() { + if (failed) return + watchdog.check(SystemClock.elapsedRealtime()) + val lost = dropped.getAndSet(0) + if (lost > 0) write(Event("recorder.dropped", mapOf("count" to "$lost"))) + var remaining = 2048 + while (remaining-- > 0 && !failed) { + val event = queue.poll() ?: break + write(event) + } + log.finishBatch() + } + + private fun write(event: Event) = log.write(serialize(event)) + + private fun serialize(event: Event): ByteArray = (JSONObject().apply { + put("event", event.name) + put("session", session) + put("wallMs", event.wallMs) + put("monoMs", event.monoMs) + put("data", JSONObject(event.data)) + }.toString() + "\n").toByteArray(Charsets.UTF_8) + + fun close() { + worker.execute { + record("session.end", emptyMap()) + drain() + log.finishBatch() + } + worker.shutdown() + } +} diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DownloadsLogStorage.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DownloadsLogStorage.kt new file mode 100644 index 0000000000..63608a354c --- /dev/null +++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/DownloadsLogStorage.kt @@ -0,0 +1,36 @@ +package sh.paseo.trace + +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import java.io.OutputStream + +internal class DownloadsLogStorage(private val context: Context, private val session: String) : LogStorage { + private val resolver = context.contentResolver + private val collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI + private val directory = "Download/Paseo diagnostics/" + + override fun create(part: Int): String { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "paseo-diagnostics-${System.currentTimeMillis()}-${session.take(8)}-$part.jsonl") + put(MediaStore.MediaColumns.MIME_TYPE, "application/x-ndjson") + put(MediaStore.MediaColumns.RELATIVE_PATH, directory) + } + return checkNotNull(resolver.insert(collection, values)).toString() + } + + override fun append(id: String): OutputStream = + checkNotNull(resolver.openOutputStream(Uri.parse(id), "wa")).buffered(64 * 1024) + + override fun newest(): List = buildList { + resolver.query(collection, arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?", + arrayOf(directory, context.packageName), "${MediaStore.MediaColumns._ID} DESC")?.use { cursor -> + while (cursor.moveToNext()) add(ContentUris.withAppendedId(collection, cursor.getLong(0)).toString()) + } + } + + override fun delete(id: String) { resolver.delete(Uri.parse(id), null, null) } +} diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/HeartbeatWatchdog.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/HeartbeatWatchdog.kt new file mode 100644 index 0000000000..dcf5d1aba1 --- /dev/null +++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/HeartbeatWatchdog.kt @@ -0,0 +1,38 @@ +package sh.paseo.trace + +internal object HeartbeatTiming { + const val intervalMs = 250L + const val overdueMs = 500L + const val gapMs = intervalMs + overdueMs +} + +/** Monotonic time is supplied by the owner, so suspension and recovery are deterministic. */ +internal class HeartbeatWatchdog(startMs: Long, private val emit: (String, Map) -> Unit) { + private var foreground = true + private var lastHeartbeat = startMs + private var overdue = false + + @Synchronized fun heartbeat(now: Long) { + if (foreground && now - lastHeartbeat > HeartbeatTiming.gapMs) { + emit("js.stall.recovered", mapOf("heartbeatGapMs" to "${now - lastHeartbeat}", "overdueMs" to "${now - lastHeartbeat - HeartbeatTiming.intervalMs}")) + } + lastHeartbeat = now + overdue = false + } + + @Synchronized fun active(value: Boolean, now: Long) { + if (foreground == value) return + emit("app.lifecycle", mapOf("state" to if (value) "foreground" else "background", "stallOpen" to "$overdue")) + foreground = value + lastHeartbeat = now + overdue = false + } + + @Synchronized fun check(now: Long) { + val gap = now - lastHeartbeat + if (foreground && gap > HeartbeatTiming.gapMs && !overdue) { + emit("js.stall.started", mapOf("heartbeatGapMs" to "$gap", "lastHeartbeatMonoMs" to "$lastHeartbeat")) + overdue = true + } + } +} diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt index 4219993e36..5a048b30e1 100644 --- a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt +++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/PaseoNativeTraceModule.kt @@ -5,9 +5,23 @@ import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class PaseoNativeTraceModule : Module() { + private var recorder: DiagnosticRecorder? = null + override fun definition() = ModuleDefinition { Name("PaseoNativeTrace") + Function("startRecorder") { metadata: Map -> + if (recorder == null) { + appContext.reactContext?.applicationContext?.let { recorder = DiagnosticRecorder(it, metadata) } + } + HeartbeatTiming.intervalMs + } + Function("record") { name: String, data: Map -> recorder?.record(name, data) } + Function("heartbeat") { recorder?.heartbeat() } + OnActivityEntersForeground { recorder?.active(true) } + OnActivityEntersBackground { recorder?.active(false) } + OnDestroy { recorder?.close(); recorder = null } + Function("beginSection") { name: String -> Trace.beginSection(name.take(127)) } diff --git a/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/RotatingLog.kt b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/RotatingLog.kt new file mode 100644 index 0000000000..194b30aa8a --- /dev/null +++ b/packages/app/modules/paseo-native-trace/android/src/main/java/sh/paseo/trace/RotatingLog.kt @@ -0,0 +1,61 @@ +package sh.paseo.trace + +import java.io.OutputStream + +/** Storage supplies installation-owned files, newest first. */ +internal interface LogStorage { + fun create(part: Int): String + fun append(id: String): OutputStream + fun newest(): List + fun delete(id: String) +} + +/** Owns file identity, bounded retention and the terminal storage-failure state. */ +internal class RotatingLog( + private val storage: LogStorage, + private val header: (Int) -> ByteArray, + private val onFailure: (Exception) -> Unit, + private val maxBytes: Int = 5 * 1024 * 1024, +) { + private var current: String? = null + private var output: OutputStream? = null + private var size = 0 + private var part = 0 + var failed = false + private set + + fun write(bytes: ByteArray) = attempt { + if (current == null || size + bytes.size > maxBytes) { + finishBatch() + if (failed) return@attempt + val nextPart = part++ + current = storage.create(nextPart) + output = storage.append(checkNotNull(current)) + val first = header(nextPart) + output!!.write(first) + size = first.size + storage.newest().drop(8).forEach(storage::delete) + } + if (output == null) output = storage.append(checkNotNull(current)) + output!!.write(bytes) + size += bytes.size + } + + // Closing publishes size and modification time in Android Files. + fun finishBatch() = attempt { + output?.close() + output = null + } + + private fun attempt(action: () -> Unit) { + if (failed) return + try { + action() + } catch (error: Exception) { + failed = true + runCatching { output?.close() } + output = null + onFailure(error) + } + } +} diff --git a/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/HeartbeatWatchdogTest.kt b/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/HeartbeatWatchdogTest.kt new file mode 100644 index 0000000000..870c031997 --- /dev/null +++ b/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/HeartbeatWatchdogTest.kt @@ -0,0 +1,40 @@ +package sh.paseo.trace + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HeartbeatWatchdogTest { + @Test fun recordsOneStartAndRecoveryWithMeasuredGap() { + val events = mutableListOf>>() + val watchdog = HeartbeatWatchdog(0) { name, fields -> events.add(name to fields) } + watchdog.heartbeat(250) + watchdog.check(1000) + assertEquals(emptyList>>(), events) + watchdog.check(1001) + watchdog.check(2000) + watchdog.heartbeat(2250) + assertEquals(listOf( + "js.stall.started" to mapOf("heartbeatGapMs" to "751", "lastHeartbeatMonoMs" to "250"), + "js.stall.recovered" to mapOf("heartbeatGapMs" to "2000", "overdueMs" to "1750") + ), events) + } + + @Test fun backgroundAndResumeDoNotLookLikeStalls() { + val events = mutableListOf() + val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) } + watchdog.active(false, 100) + watchdog.check(300000) + watchdog.heartbeat(300100) + watchdog.active(true, 400000) + watchdog.heartbeat(400250) + watchdog.check(400500) + assertEquals(listOf("app.lifecycle", "app.lifecycle"), events) + } + + @Test fun recoveryIsReportedEvenIfNativeSamplingMissedTheGap() { + val events = mutableListOf() + val watchdog = HeartbeatWatchdog(0) { name, _ -> events.add(name) } + watchdog.heartbeat(900) + assertEquals(listOf("js.stall.recovered"), events) + } +} diff --git a/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/RotatingLogTest.kt b/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/RotatingLogTest.kt new file mode 100644 index 0000000000..ae021278a2 --- /dev/null +++ b/packages/app/modules/paseo-native-trace/android/src/test/java/sh/paseo/trace/RotatingLogTest.kt @@ -0,0 +1,59 @@ +package sh.paseo.trace + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.OutputStream +import org.junit.Assert.* +import org.junit.Test + +class RotatingLogTest { + private class MemoryStorage : LogStorage { + val files = linkedMapOf() + var fail = false + var opens = 0 + override fun create(part: Int): String { + val id = "part-$part" + files[id] = ByteArrayOutputStream() + return id + } + override fun append(id: String): OutputStream { + opens++ + if (fail) throw IOException("disk full") + return files.getValue(id) + } + override fun newest() = files.keys.reversed() + override fun delete(id: String) { files.remove(id) } + } + + @Test fun rotatesAtByteLimitAndKeepsEightIncludingCurrent() { + val storage = MemoryStorage() + val log = RotatingLog(storage, { "header:$it\n".toByteArray() }, { throw it }, 20) + repeat(10) { log.write("123456789012".toByteArray()); log.finishBatch() } + assertEquals((2..9).map { "part-$it" }, storage.files.keys.toList()) + for (part in 2..9) assertEquals("header:$part\n123456789012", storage.files.getValue("part-$part").toString("UTF-8")) + assertFalse(log.failed) + } + + @Test fun reopeningBatchAppendsWithoutRepeatingHeaderAndExactLimitFits() { + val storage = MemoryStorage() + val log = RotatingLog(storage, { "H".toByteArray() }, { throw it }, 5) + log.write("12".toByteArray()) + log.finishBatch() + log.write("34".toByteArray()) + assertEquals(listOf("part-0"), storage.files.keys.toList()) + assertEquals("H1234", storage.files.getValue("part-0").toString("UTF-8")) + log.write("5".toByteArray()) + assertEquals("H5", storage.files.getValue("part-1").toString("UTF-8")) + } + + @Test fun storageFailureStopsRecordingWithoutRetryStorm() { + val storage = MemoryStorage() + var failures = 0 + val log = RotatingLog(storage, { byteArrayOf() }, { failures++ }) + storage.fail = true + repeat(100) { log.write(byteArrayOf(1)); log.finishBatch() } + assertTrue(log.failed) + assertEquals(1, failures) + assertEquals(1, storage.opens) + } +} diff --git a/packages/app/package.json b/packages/app/package.json index 7f1ff46029..a2e44700e1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,12 +8,12 @@ "start:expo": "cross-env APP_VARIANT=development expo start", "reset-project": "node ./scripts/reset-project.js", "eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview", - "android": "npm run android:development", - "android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug", + "android": "npm run dev:android", + "android:development": "npm run dev:android", "android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release", "android:release": "npm run android:production", "android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"", - "ios": "npm --prefix ../.. run build:client && expo run:ios", + "ios": "npm run dev:ios", "ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release", "web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"", "web:expo": "expo start --web", @@ -36,7 +36,12 @@ "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs", - "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs" + "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs", + "dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug", + "dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios", + "build:apk": "npm --prefix ../.. run build:apk", + "build:apk:arm64": "npm --prefix ../.. run build:apk:arm64", + "test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false" }, "dependencies": { "@codemirror/commands": "6.10.4", diff --git a/packages/app/src/performance/native-trace.ts b/packages/app/src/performance/native-trace.ts index b5e8e73a60..4f3cc45784 100644 --- a/packages/app/src/performance/native-trace.ts +++ b/packages/app/src/performance/native-trace.ts @@ -1,3 +1,4 @@ +import { watchdog } from "@/watchdog"; import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client"; import { requireOptionalNativeModule } from "expo-modules-core"; import { isProfileBuild } from "@/constants/build-profile"; @@ -9,17 +10,14 @@ interface PaseoNativeTraceModule { const traceModule = requireOptionalNativeModule("PaseoNativeTrace"); -export const nativePerformanceTrace: DaemonClientTrace = { - isEnabled() { - return isProfileBuild && traceModule !== null; - }, - beginSection(name, args) { - traceModule?.beginSection(formatSectionName(name, args)); - }, - endSection() { - traceModule?.endSection(); - }, -}; +const nativeSink: DaemonClientTrace | null = + isProfileBuild && traceModule + ? { + isEnabled: () => true, + beginSection: (name, args) => traceModule.beginSection(formatSectionName(name, args)), + endSection: () => traceModule.endSection(), + } + : null; function formatSectionName(name: string, args?: Record): string { if (!args) { @@ -29,10 +27,29 @@ function formatSectionName(name: string, args?: Record): string return `${name}|${fields.join(";")}`; } +const instantSinks: ((name: string, args?: Record) => void)[] = []; +if (watchdog) instantSinks.push(watchdog.record); +if (nativeSink) + instantSinks.push((name, args) => { + nativeSink.beginSection(name, args); + nativeSink.endSection(); + }); export function traceInstant(name: string, args?: Record): void { - if (!nativePerformanceTrace.isEnabled()) { - return; - } - nativePerformanceTrace.beginSection(name, args); - nativePerformanceTrace.endSection(); + for (const write of instantSinks) write(name, args); +} + +/** Each client owns its section stack; overlapping hosts never share span identity. */ +export function createPerformanceTrace(context: Record): DaemonClientTrace { + const sinks = [watchdog?.trace(context), nativeSink].filter( + (sink): sink is DaemonClientTrace => sink != null, + ); + return { + isEnabled: () => sinks.length > 0, + beginSection(name, args) { + for (const sink of sinks) sink.beginSection(name, args); + }, + endSection() { + for (const sink of sinks) sink.endSection(); + }, + }; } diff --git a/packages/app/src/root-app.tsx b/packages/app/src/root-app.tsx index cae9b169d6..746a008d58 100644 --- a/packages/app/src/root-app.tsx +++ b/packages/app/src/root-app.tsx @@ -1,3 +1,4 @@ +import { RenderProfile } from "@/utils/render-profiler"; import { useCallback, useState, type ComponentProps } from "react"; import { ExpoRoot } from "expo-router"; import Head from "expo-router/head"; @@ -13,7 +14,11 @@ const { ctx: context } = require("expo-router/_ctx") as { }; export function RootApp() { - return ; + return ( + + + + ); } export function RootRouter({ context: routes }: Pick, "context">) { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 55298ce5a1..85d077ee0a 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,3 +1,4 @@ +import { watchdog } from "@/watchdog"; import { useSyncExternalStore, useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; @@ -83,7 +84,7 @@ import { type ViewedTimelineOwnerPorts, } from "@/timeline/viewed-timeline-sync"; import { projectIconCache } from "@/projects/icon-cache"; -import { nativePerformanceTrace } from "@/performance/native-trace"; +import { createPerformanceTrace } from "@/performance/native-trace"; import { revokePushNotifications } from "@/push-notifications"; import { createAppWebSocketFactory } from "./websocket-factory"; @@ -544,7 +545,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { appVersion: resolveAppVersion() ?? undefined, runtimeGeneration, capabilities: appCapabilities, - trace: nativePerformanceTrace, + trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), providerSnapshots: "wire", ...(host.password ? { password: host.password } : {}), localCredential: () => readDesktopManagedLocalCredential(connection), @@ -604,7 +605,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps { localCredential: () => readDesktopManagedLocalCredential(connection), ...(timeoutMs !== undefined ? { timeoutMs } : {}), capabilities: appCapabilities, - trace: nativePerformanceTrace, + trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), }), getClientId: () => getOrCreateClientId(), mountClientHandlers: ({ client, host }) => { @@ -1159,12 +1160,18 @@ export class HostRuntimeController { }); } - private logConnectionTransition(_input: { + private logConnectionTransition(input: { from: HostRuntimeConnectionMachineState["tag"]; to: HostRuntimeConnectionMachineState["tag"]; event: HostRuntimeConnectionMachineEvent; }): void { - // Intentionally empty - logging removed. + watchdog?.record("host.transition", { + host: this.host.serverId, + from: input.from, + to: input.to, + event: input.event.type, + epoch: String(this.connectionEpoch), + }); } private trackConnectionFirstSeen(): void { diff --git a/packages/app/src/utils/render-profiler.tsx b/packages/app/src/utils/render-profiler.tsx index cefecffde2..04a1be77df 100644 --- a/packages/app/src/utils/render-profiler.tsx +++ b/packages/app/src/utils/render-profiler.tsx @@ -1,3 +1,4 @@ +import { watchdog } from "@/watchdog"; import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from "react"; export interface RenderProfileSample { @@ -28,12 +29,40 @@ function getSearchParam(name: string): string | null { return new URLSearchParams(search).get(name); } -export function isRenderProfileEnabled(): boolean { - return ( - globalThis.__PASEO_RENDER_PROFILE_ENABLED__ === true || getSearchParam("renderProfile") === "1" - ); +interface RenderSink { + isEnabled(): boolean; + render(sample: RenderProfileSample): void; + reasons(id: string, reasons: string[]): void; } +function createBrowserSink(): RenderSink { + return { + isEnabled: () => + globalThis.__PASEO_RENDER_PROFILE_ENABLED__ === true || + getSearchParam("renderProfile") === "1", + render(sample) { + globalThis.__PASEO_RENDER_PROFILE__ ??= []; + globalThis.__PASEO_RESET_RENDER_PROFILE__ ??= () => { + globalThis.__PASEO_RENDER_PROFILE__ = []; + globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {}; + }; + // Browser QA compares cumulative commit counts until an explicit reset. + globalThis.__PASEO_RENDER_PROFILE__.push(sample); + }, + reasons(id, reasons) { + globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {}; + const counts = (globalThis.__PASEO_RENDER_PROFILE_REASONS__[id] ??= {}); + for (const reason of reasons) counts[reason] = (counts[reason] ?? 0) + 1; + }, + }; +} + +// Choose the destination once. Browser QA can still enable capture at runtime. +const sink: RenderSink = watchdog + ? { isEnabled: () => true, render: watchdog.render, reasons: () => {} } + : createBrowserSink(); + +export const isRenderProfileEnabled = sink.isEnabled; const onRender: ProfilerOnRenderCallback = ( id, phase, @@ -41,30 +70,10 @@ const onRender: ProfilerOnRenderCallback = ( baseDuration, startTime, commitTime, -) => { - globalThis.__PASEO_RENDER_PROFILE__ ??= []; - globalThis.__PASEO_RENDER_PROFILE__.push({ - id, - phase, - actualDuration, - baseDuration, - startTime, - commitTime, - }); -}; +) => sink.render({ id, phase, actualDuration, baseDuration, startTime, commitTime }); export function RenderProfile({ id, children }: { id: string; children: ReactNode }) { - if (!isRenderProfileEnabled()) { - return children; - } - - globalThis.__PASEO_RENDER_PROFILE__ ??= []; - globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {}; - globalThis.__PASEO_RESET_RENDER_PROFILE__ = () => { - globalThis.__PASEO_RENDER_PROFILE__ = []; - globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {}; - }; - + if (!sink.isEnabled()) return children; return ( {children} @@ -73,12 +82,5 @@ export function RenderProfile({ id, children }: { id: string; children: ReactNod } export function recordRenderProfileReasons(id: string, reasons: string[]) { - if (!isRenderProfileEnabled() || reasons.length === 0) { - return; - } - globalThis.__PASEO_RENDER_PROFILE_REASONS__ ??= {}; - const counts = (globalThis.__PASEO_RENDER_PROFILE_REASONS__[id] ??= {}); - for (const reason of reasons) { - counts[reason] = (counts[reason] ?? 0) + 1; - } + if (sink.isEnabled()) sink.reasons(id, reasons); } diff --git a/packages/app/src/watchdog/index.ts b/packages/app/src/watchdog/index.ts new file mode 100644 index 0000000000..2a1863e54e --- /dev/null +++ b/packages/app/src/watchdog/index.ts @@ -0,0 +1,35 @@ +import { requireOptionalNativeModule } from "expo-modules-core"; +import Constants from "expo-constants"; +import { resolveAppVersion } from "@/utils/app-version"; +import { createRecorder } from "./internal/recorder"; + +interface NativeRecorder { + startRecorder(metadata: Record): number; + record(name: string, fields: Record): void; + heartbeat(): void; +} + +const native = + Constants.expoConfig?.extra?.watchdogBuild === true + ? requireOptionalNativeModule("PaseoNativeTrace") + : null; +export const watchdog = native + ? createRecorder( + (name, fields) => native.record(name, fields), + () => performance.now(), + ) + : null; +let started = false; + +export function startWatchdog(): void { + if (!native || !watchdog || started) return; + started = true; + const heartbeatMs = native.startRecorder({ + version: resolveAppVersion() ?? "unknown", + build: String(Constants.expoConfig?.extra?.diagnosticRevision ?? "unknown"), + builtAt: String(Constants.expoConfig?.extra?.diagnosticBuildTime ?? "unknown"), + reactProfiling: "true", + }); + setInterval(() => native.heartbeat(), heartbeatMs); + setInterval(() => watchdog.flushRenders(), 1000); +} diff --git a/packages/app/src/watchdog/internal/recorder.test.ts b/packages/app/src/watchdog/internal/recorder.test.ts new file mode 100644 index 0000000000..072ca2a708 --- /dev/null +++ b/packages/app/src/watchdog/internal/recorder.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "vitest"; +import { createRecorder } from "./recorder"; + +function fixture() { + const events: { name: string; fields: Record }[] = []; + let now = 0; + const recorder = createRecorder( + (name, fields) => events.push({ name, fields }), + () => now, + ); + return { + recorder, + events, + advance: (ms: number) => { + now += ms; + }, + }; +} + +test("interleaved hosts keep independent parent spans and durations", () => { + const { recorder, events, advance } = fixture(); + const a = recorder.trace({ host: "a" }); + const b = recorder.trace({ host: "b" }); + a.beginSection("frame"); + advance(5); + b.beginSection("frame"); + a.beginSection("parse"); + advance(15); + a.endSection(); + b.endSection(); + a.endSection(); + expect( + events.map(({ name, fields }) => [ + name, + fields.host, + fields.span, + fields.parent, + fields.durationMs, + ]), + ).toEqual([ + ["frame.begin", "a", "1:1", "", undefined], + ["frame.begin", "b", "2:1", "", undefined], + ["parse.begin", "a", "1:2", "1:1", undefined], + ["parse.end", "a", "1:2", undefined, "15"], + ["frame.end", "b", "2:1", undefined, "15"], + ["frame.end", "a", "1:1", undefined, "20"], + ]); +}); + +test("slow renders preserve commit identity while fast renders are summarized", () => { + const { recorder, events } = fixture(); + for (const actualDuration of [1, 2, 30]) + recorder.render({ + id: "Stream", + phase: "update", + actualDuration, + startTime: 4, + commitTime: 40, + }); + recorder.flushRenders(); + recorder.flushRenders(); + expect(events).toEqual([ + { + name: "react.render.slow", + fields: { + region: "Stream", + phase: "update", + renderMs: "30", + startMs: "4", + commitMs: "40", + jsMonoMs: "0", + }, + }, + { + name: "react.render.summary", + fields: { region: "Stream", count: "3", totalMs: "33", maxMs: "30", jsMonoMs: "0" }, + }, + ]); +}); + +test("dynamic regions are bounded and summaries reset each interval", () => { + const { recorder, events } = fixture(); + for (let i = 0; i < 10000; i++) + recorder.render({ + id: String(i), + phase: "mount", + actualDuration: 1, + startTime: 0, + commitTime: 1, + }); + recorder.flushRenders(); + expect(events.length).toBe(129); + expect(events.at(-1)?.fields).toEqual({ + region: "other", + count: "9872", + totalMs: "9872", + maxMs: "1", + jsMonoMs: "0", + }); + recorder.render({ id: "new", phase: "mount", actualDuration: 1, startTime: 0, commitTime: 1 }); + recorder.flushRenders(); + expect(events.at(-1)?.fields.region).toBe("new"); +}); diff --git a/packages/app/src/watchdog/internal/recorder.ts b/packages/app/src/watchdog/internal/recorder.ts new file mode 100644 index 0000000000..9af879abe6 --- /dev/null +++ b/packages/app/src/watchdog/internal/recorder.ts @@ -0,0 +1,79 @@ +import type { DaemonClientTrace } from "@getpaseo/client/internal/daemon-client"; + +type Fields = Record; +interface RenderSample { + id: string; + phase: string; + actualDuration: number; + startTime: number; + commitTime: number; +} + +/** Bounded render aggregation and scoped trace identity; no payloads enter this boundary. */ +export function createRecorder(write: (name: string, fields: Fields) => void, now: () => number) { + let nextClient = 0; + const renders = new Map(); + const record = (name: string, fields: Fields = {}) => + write(name, { ...fields, jsMonoMs: String(now()) }); + return { + record, + trace(context: Fields): DaemonClientTrace { + const client = String(++nextClient); + let sequence = 0; + const sections: { id: string; name: string; start: number }[] = []; + return { + isEnabled: () => true, + beginSection(name, fields) { + const id = `${client}:${++sequence}`; + record(`${name}.begin`, { + ...context, + ...fields, + client, + span: id, + parent: sections.at(-1)?.id ?? "", + }); + sections.push({ id, name, start: now() }); + }, + endSection() { + const section = sections.pop(); + if (section) + record(`${section.name}.end`, { + ...context, + client, + span: section.id, + durationMs: String(now() - section.start), + }); + }, + }; + }, + render(sample: RenderSample) { + if (sample.actualDuration >= 16) { + record("react.render.slow", { + region: sample.id, + phase: sample.phase, + renderMs: String(sample.actualDuration), + startMs: String(sample.startTime), + commitMs: String(sample.commitTime), + }); + } + // Dynamic agent/tab IDs cannot grow this map without bound. + const key = renders.has(sample.id) || renders.size < 128 ? sample.id : "other"; + const summary = renders.get(key) ?? { count: 0, totalMs: 0, maxMs: 0 }; + summary.count++; + summary.totalMs += sample.actualDuration; + summary.maxMs = Math.max(summary.maxMs, sample.actualDuration); + renders.set(key, summary); + }, + flushRenders() { + for (const [region, summary] of renders) { + record("react.render.summary", { + region, + count: String(summary.count), + totalMs: String(summary.totalMs), + maxMs: String(summary.maxMs), + }); + } + renders.clear(); + }, + }; +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 7469470ad1..7f183ccfa4 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -200,38 +200,75 @@ test("traces WebSocket frames, message types, and JSON parse duration", async () mock.triggerOpen({ preserveSent: true }); await connectPromise; - expect(recorder.records).toEqual([ - { - phase: "begin", - name: "paseo.ws.message.outbound", - args: { envelopeType: "hello", messageType: "hello" }, - }, - { phase: "end" }, - { - phase: "begin", - name: "paseo.ws.frame.outbound", - args: { kind: "text", size: expect.any(String) }, - }, - { phase: "end" }, - { - phase: "begin", - name: "paseo.ws.frame.inbound", - args: { kind: "text", size: expect.any(String) }, - }, - { - phase: "begin", - name: "paseo.ws.json.parse", - args: { size: expect.any(String) }, - }, - { phase: "end" }, - { - phase: "begin", - name: "paseo.ws.message.inbound", - args: { envelopeType: "session", messageType: "status" }, - }, - { phase: "end" }, - { phase: "end" }, - ]); + const response = client.fetchAgents({ requestId: "diagnostic-request", scope: "active" }); + mock.triggerMessage( + wrapSessionMessage({ + type: "fetch_agents_response", + payload: { + requestId: "diagnostic-request", + entries: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }, + }), + ); + await response; + const spans = recorder.records.filter((record) => record.phase === "begin"); + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "paseo.ws.frame.inbound", + args: { + kind: "text", + size: expect.any(String), + sizeUnit: "utf16-code-units", + connectionAttempt: "1", + }, + }), + expect.objectContaining({ name: "paseo.ws.json.parse" }), + expect.objectContaining({ name: "paseo.ws.validate" }), + expect.objectContaining({ + name: "paseo.ws.message.outbound", + args: { + envelopeType: "session", + messageType: "fetch_agents_request", + requestId: "diagnostic-request", + connectionAttempt: "1", + }, + }), + expect.objectContaining({ + name: "paseo.ws.message.inbound", + args: { + envelopeType: "session", + messageType: "fetch_agents_response", + requestId: "diagnostic-request", + connectionAttempt: "1", + }, + }), + expect.objectContaining({ + name: "paseo.ws.dispatch", + args: { + messageType: "fetch_agents_response", + requestId: "diagnostic-request", + connectionAttempt: "1", + }, + }), + ]), + ); + const stack: string[] = []; + const parentage: [string, string | undefined][] = []; + for (const record of recorder.records) { + if (record.phase === "begin") { + parentage.push([record.name, stack.at(-1)]); + stack.push(record.name); + } else { + expect(stack.length).toBeGreaterThan(0); + stack.pop(); + } + } + expect(stack).toEqual([]); + expect(parentage).toContainEqual(["paseo.ws.frame.outbound", "paseo.ws.message.outbound"]); + expect(parentage).toContainEqual(["paseo.ws.dispatch", "paseo.ws.frame.inbound"]); + expect(JSON.stringify(recorder.records)).not.toContain("trace_unit_test"); }); test("does not infer browser automation capabilities from Electron runtime", async () => { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 30d0db6cc6..aa4624338e 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1147,19 +1147,34 @@ function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): voi throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES); } -function describeInboundTransportFrame( - frame: unknown, - rawBytes: Uint8Array | null, -): Record { +function describeInboundTransportFrame(frame: unknown): Record { if (typeof frame === "string") { - return { kind: "text", size: String(frame.length) }; + return { kind: "text", size: String(frame.length), sizeUnit: "utf16-code-units" }; } - if (rawBytes) { - return { kind: "binary", size: String(rawBytes.byteLength) }; + if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) { + return { kind: "binary", size: String(frame.byteLength), sizeUnit: "bytes" }; } return { kind: "unknown", size: "0" }; } +function describeRequestIdentity(value: unknown): Record { + if (!value || typeof value !== "object") return {}; + const message = "message" in value ? value.message : value; + if (!message || typeof message !== "object") return {}; + if ("requestId" in message && typeof message.requestId === "string") + return { requestId: message.requestId }; + if ( + "payload" in message && + message.payload && + typeof message.payload === "object" && + "requestId" in message.payload && + typeof message.payload.requestId === "string" + ) { + return { requestId: message.payload.requestId }; + } + return {}; +} + function hashForLog(value: string): string { let hash = 0; for (let index = 0; index < value.length; index += 1) { @@ -1277,6 +1292,7 @@ export class DaemonClient { private connectTimeout: ReturnType | null = null; private pendingGenericTransportErrorTimeout: ReturnType | null = null; private reconnectAttempt = 0; + private traceConnectionAttempt = 0; private shouldReconnect = true; private connectPromise: Promise | null = null; private connectResolve: (() => void) | null = null; @@ -1429,6 +1445,7 @@ export class DaemonClient { headers: selected.headers, ...(selected.protocols ? { protocols: selected.protocols } : {}), }); + this.traceConnectionAttempt += 1; this.transport = transport; this.lastServerInfoMessage = null; this.authFailureReasonValue = null; @@ -1457,6 +1474,7 @@ export class DaemonClient { this.transportCleanup = [ transport.onOpen(() => { + this.traceInstant("paseo.connection.open"); if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; @@ -1465,6 +1483,11 @@ export class DaemonClient { void this.sendHelloMessage(); }), transport.onClose((event) => { + this.traceInstant("paseo.connection.close", { + code: String( + event && typeof event === "object" && "code" in event ? event.code : "unknown", + ), + }); this.resetConnectTimeout(); if (this.pendingGenericTransportErrorTimeout) { clearTimeout(this.pendingGenericTransportErrorTimeout); @@ -1484,6 +1507,7 @@ export class DaemonClient { }); }), transport.onError((event) => { + this.traceInstant("paseo.connection.error"); this.resetConnectTimeout(); const reason = describeTransportError(event); const isGeneric = reason === "Transport error"; @@ -1755,7 +1779,7 @@ export class DaemonClient { if (!trace?.isEnabled()) { return false; } - trace.beginSection(name, args); + trace.beginSection(name, { ...args, connectionAttempt: String(this.traceConnectionAttempt) }); return true; } @@ -1770,12 +1794,26 @@ export class DaemonClient { this.endTraceSection(isOpen); } + private beginSessionDispatchTrace(message: SessionOutboundMessage): boolean { + if (!this.config.trace?.isEnabled()) return false; + const fields = { messageType: message.type, ...describeRequestIdentity(message) }; + this.traceInstant("paseo.ws.message.inbound", { envelopeType: "session", ...fields }); + return this.beginTraceSection("paseo.ws.dispatch", fields); + } + private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void { - this.traceInstant("paseo.ws.message.outbound", { - envelopeType, - messageType, - }); - this.sendTransportFrame(JSON.stringify(message)); + const isOpen = + this.config.trace?.isEnabled() === true && + this.beginTraceSection("paseo.ws.message.outbound", { + envelopeType, + messageType, + ...describeRequestIdentity(message), + }); + try { + this.sendTransportFrame(JSON.stringify(message)); + } finally { + this.endTraceSection(isOpen); + } } private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void { @@ -1787,6 +1825,7 @@ export class DaemonClient { const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", { kind: typeof frame === "string" ? "text" : "binary", size: String(getTransportFrameSize(frame)), + sizeUnit: typeof frame === "string" ? "utf16-code-units" : "bytes", }); try { this.transport.send(frame); @@ -6394,12 +6433,13 @@ export class DaemonClient { return; } - const rawBytes = asUint8Array(rawData); const isOpen = this.beginTraceSection( "paseo.ws.frame.inbound", - describeInboundTransportFrame(rawData, rawBytes), + describeInboundTransportFrame(rawData), ); try { + // Record arrival before conversion: encoding a large text frame is JS work too. + const rawBytes = asUint8Array(rawData); if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) { return; } @@ -6419,17 +6459,26 @@ export class DaemonClient { let parsedJson: unknown; const parseTraceOpen = this.beginTraceSection("paseo.ws.json.parse", { size: String(bytes), + sizeUnit: rawBytesLength === undefined ? "utf16-code-units" : "bytes", }); try { parsedJson = JSON.parse(payload); } catch { + this.traceInstant("paseo.ws.json.invalid"); return; } finally { this.endTraceSection(parseTraceOpen); } - const parsed = validateWSOutboundMessage(parsedJson); + const validationTrace = this.beginTraceSection("paseo.ws.validate"); + let parsed: ReturnType; + try { + parsed = validateWSOutboundMessage(parsedJson); + } finally { + this.endTraceSection(validationTrace); + } if (!parsed.success) { + this.traceInstant("paseo.ws.validation.failed"); const responseIdentity = extractCorrelatedResponseIdentity(parsedJson); const envelopeType = parsedJson != null && @@ -6462,6 +6511,7 @@ export class DaemonClient { } if (parsed.data.type === "hello.rejected") { + this.traceInstant("paseo.connection.rejected", { reason: parsed.data.reason }); const reasonMessage = { password_required: "Password required", incorrect_password: "Incorrect password", @@ -6473,11 +6523,12 @@ export class DaemonClient { return; } - this.traceInstant("paseo.ws.message.inbound", { - envelopeType: "session", - messageType: parsed.data.message.type, - }); - this.handleSessionMessage(parsed.data.message); + const dispatchTrace = this.beginSessionDispatchTrace(parsed.data.message); + try { + this.handleSessionMessage(parsed.data.message); + } finally { + this.endTraceSection(dispatchTrace); + } const msgType = parsed.data.message.type; this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs); if (parsed.data.message.type === "agent_stream") { @@ -6492,6 +6543,7 @@ export class DaemonClient { envelopeType: "binary", messageType: "file", opcode: String(fileFrame.opcode), + requestId: fileFrame.requestId, }); this.consecutiveLivenessFailures = 0; this.handleFileTransferFrame(fileFrame); @@ -6507,6 +6559,7 @@ export class DaemonClient { envelopeType: "binary", messageType: "terminal", opcode: String(frame.opcode), + slot: String(frame.slot), }); this.consecutiveLivenessFailures = 0; const binaryStartMs = perfNow(); @@ -6625,6 +6678,12 @@ export class DaemonClient { next.status === "disconnected" && typeof next.reason === "string" ? next.reason : null; const reason = metadata?.reason ?? reasonFromNext; const reasonCode = metadata?.reasonCode ?? toReasonCode(reason); + this.traceInstant("paseo.connection.state", { + from: previous.status, + to: next.status, + event: metadata?.event ?? "STATE_UPDATE", + reasonCode: reasonCode ?? "none", + }); this.logger.debug( { serverId: this.logServerId, @@ -6724,6 +6783,10 @@ export class DaemonClient { const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS; const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS; const delay = Math.min(baseDelay * 2 ** attempt, maxDelay); + this.traceInstant("paseo.connection.reconnect", { + attempt: String(attempt), + delayMs: String(delay), + }); this.reconnectAttempt = attempt + 1; this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null; diff --git a/scripts/build-apk.mjs b/scripts/build-apk.mjs new file mode 100644 index 0000000000..964e6eacf2 --- /dev/null +++ b/scripts/build-apk.mjs @@ -0,0 +1,52 @@ +import { spawnSync, execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, rmSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const target = process.argv[2] ?? "universal"; +const architectures = { + universal: "armeabi-v7a,arm64-v8a,x86,x86_64", + arm64: "arm64-v8a", +}[target]; +if (!architectures) { + throw new Error(`Unknown APK target: ${target}. Use universal or arm64.`); +} + +const root = fileURLToPath(new URL("..", import.meta.url)); +const app = path.join(root, "packages/app"); +const env = { + ...process.env, + APP_VARIANT: "production", + PASEO_WATCHDOG_BUILD: "1", + PASEO_DIAGNOSTIC_BUILD_TIME: new Date().toISOString(), + PASEO_DIAGNOSTIC_REVISION: + execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8" }).trim() + + (execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).trim() + ? "-dirty" + : ""), +}; +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, env, stdio: "inherit" }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} +run("npm", ["run", "build:app-deps"], root); +run("npx", ["expo", "prebuild", "--platform", "android", "--no-install"], app); +// Gradle does not track Metro's environment flags. Always regenerate the JS artifact +// so a previous ordinary release cannot silently bypass the profiling renderer. +rmSync(path.join(app, "android/app/build/generated/assets"), { recursive: true, force: true }); +run( + "./gradlew", + [ + "assembleRelease", + "--no-daemon", + "--max-workers=2", + "-Dorg.gradle.parallel=false", + `-PreactNativeArchitectures=${architectures}`, + ], + path.join(app, "android"), +); +const destination = path.join(root, `artifacts/paseo-dogfood-${target}.apk`); +mkdirSync(path.dirname(destination), { recursive: true }); +copyFileSync(path.join(app, "android/app/build/outputs/apk/release/app-release.apk"), destination); +console.log(`\nDiagnostic release APK: ${destination}\nLogs: Downloads/Paseo diagnostics\n`); From a891155b826f9cecea422694d9e50a1c738dd3e1 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:55:06 +0000 Subject: [PATCH 05/62] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 2d9ebc92ff..7c3e526100 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-A5l+geXBbMYZS5lUkFA/+bOhQdM9zkmsYP1dh/Z3Yaw= +sha256-Et1ofRqkqFoGETnbeqXuy2RerFT0kkk5F+VwDXak+fk= From fbd90cb88dff1f6fd1078332ff6df6defd701204 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 14:16:24 +0700 Subject: [PATCH 06/62] Keep setup execution and output owned by the workspace (#7473) * perf(workspaces): stop duplicating setup output in progress snapshots Keep the required combined log field empty for setup clients, which display per-command output. Preserve concatenation only for legacy agent bootstrap timeline cards whose released renderers still read it directly. * refactor(workspaces): own setup exclusively at the workspace boundary Preserve keyed modern agent worktree creation while retiring legacy placement that selected an agent-owned setup lifecycle. Keep output in per-command workspace snapshots and retain empty strings required by existing wire schemas. * fix(server): avoid duplicate cwd in worktree provisioning request * test(e2e): keep developer toasts out of concurrent app assertions Concurrent layout measurements could invoke the same Playwright locator handler before its first dismissal finished, leaving checks waiting for a removed button. Hide only the Expo toast host through test styling while retaining console error capture. --- docs/agent-lifecycle.md | 2 + .../e2e/browser/sidebar-nav-settings.spec.ts | 23 + .../workspace-scripts-menu-resize.spec.ts | 5 +- .../browser/workspace-setup-streaming.spec.ts | 31 +- .../e2e/support/helpers/workspace-setup.ts | 4 + .../app/src/components/tool-call-details.tsx | 56 +- packages/app/src/panels/setup-panel.tsx | 46 +- .../src/utils/tool-call-detail-state.test.ts | 13 + .../app/src/utils/tool-call-detail-state.ts | 9 +- packages/client/src/daemon-client.test.ts | 226 ++---- packages/client/src/daemon-client.ts | 9 +- .../protocol/src/messages.wire-compat.test.ts | 51 ++ .../src/server/agent/agent-mcp.e2e.test.ts | 29 +- .../agent/create-agent-lifecycle-dispatch.ts | 43 +- .../server/agent/create-agent/create.test.ts | 45 -- .../src/server/agent/create-agent/create.ts | 148 +--- .../src/server/agent/mcp-server.test.ts | 747 +----------------- .../src/server/agent/timeline-append.ts | 38 - .../src/server/agent/tools/paseo-tools.ts | 228 +----- .../archive-if-safe.test.ts | 2 +- .../auto-archive-on-merge/behavior.test.ts | 2 +- .../src/server/creation/creation.e2e.test.ts | 180 ++++- ...ve-unrelated-github-merge.real.e2e.test.ts | 4 +- .../daemon-e2e/checkout-pr-merge.e2e.test.ts | 2 +- .../daemon-e2e/checkout-ship.e2e.test.ts | 4 +- .../daemon-e2e/git-operations.e2e.test.ts | 215 +---- .../src/server/paseo-worktree-service.test.ts | 64 +- .../src/server/paseo-worktree-service.ts | 2 +- ...ate-agent-worktree-autoarchive.e2e.test.ts | 68 +- packages/server/src/server/session.test.ts | 1 - packages/server/src/server/session.ts | 94 +-- .../src/server/session.workspaces.test.ts | 4 +- .../session/checkout/checkout-session.test.ts | 2 - .../workspace-recovery-service.test.ts | 5 +- .../workspace-recovery-service.ts | 2 +- .../server/src/server/wire-compat.test.ts | 2 +- ...orkspace-archive-record-scoped.e2e.test.ts | 2 +- .../server/workspace-archive-service.test.ts | 2 +- ...rkspace-create-worktree-source.e2e.test.ts | 95 ++- .../server/worktree-bootstrap.posix.test.ts | 325 +------- .../src/server/worktree-bootstrap.test.ts | 176 ++--- .../server/src/server/worktree-bootstrap.ts | 397 +--------- .../src/server/worktree-core.posix.test.ts | 33 +- packages/server/src/server/worktree-core.ts | 2 - .../src/server/worktree-session.test.ts | 361 +-------- .../server/src/server/worktree-session.ts | 384 +-------- .../server/src/server/worktree/commands.ts | 7 +- .../server/src/utils/checkout-git.test.ts | 4 +- .../utils/worktree-shell-selection.test.ts | 1 - .../server/src/utils/worktree.posix.test.ts | 97 +-- packages/server/src/utils/worktree.test.ts | 6 +- packages/server/src/utils/worktree.ts | 30 +- 52 files changed, 869 insertions(+), 3459 deletions(-) delete mode 100644 packages/server/src/server/agent/timeline-append.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 378a0070ea..b49e8309db 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -71,6 +71,8 @@ normal root agent immediately, and closing its still-open tab archives it. Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested. +Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility. + Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. `notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index a17480b740..177ddd563e 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "../support/fixtures"; +import type { Page } from "@playwright/test"; import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow"; import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { @@ -31,6 +32,28 @@ import { setSidebarNavItemVisible, } from "../support/helpers/sidebar-nav-settings"; +async function showDeveloperToast(page: Page): Promise { + await page.evaluate(() => { + document.getElementById("error-toast")?.remove(); + const toast = document.createElement("div"); + toast.id = "error-toast"; + toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white"; + // Expo renders its unnamed dismiss button in an open shadow root. + const button = document.createElement("button"); + button.style.cssText = "width:24px;height:24px"; + button.addEventListener("click", () => toast.remove()); + toast.attachShadow({ mode: "open" }).appendChild(button); + document.body.appendChild(toast); + }); +} + +test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => { + await gotoAppShell(page); + await showDeveloperToast(page); + await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]); + await expect(page.locator("#error-toast")).toBeHidden(); +}); + test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => { test.setTimeout(120_000); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); diff --git a/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts index 17c4d3dd45..969f330520 100644 --- a/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts +++ b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from "../support/fixtures"; import { createTempGitRepo } from "../support/helpers/workspace"; import { connectWorkspaceSetupClient, + setupCommandOutput, createWorkspaceThroughDaemon, openWorkspaceScriptsMenu, seedProjectForWorkspaceSetup, @@ -33,7 +34,9 @@ test("scripts menu resizes when a service row grows after launch", async ({ page await seedProjectForWorkspaceSetup(client, repo.path); const completed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"), + (payload) => + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts index 227e2c8bc1..18a9704ddf 100644 --- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -18,6 +18,7 @@ import { } from "../support/helpers/terminal-perf"; import { connectWorkspaceSetupClient, + setupCommandOutput, createWorkspaceThroughDaemon, expectSetupPanel, openHomeWithProject, @@ -92,7 +93,8 @@ test.describe("Workspace setup streaming", () => { const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, @@ -170,16 +172,19 @@ test.describe("Workspace setup streaming", () => { await seedProjectForWorkspaceSetup(client, repo.path); const initialRunning = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "running" && payload.detail.log === "", + (payload) => payload.status === "running" && payload.detail.commands.length === 0, ); const runningWithOutput = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "running" && payload.detail.log.includes("starting setup"), + (payload) => + payload.status === "running" && + setupCommandOutput(payload.detail).includes("starting setup"), ); const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); await createWorkspaceThroughDaemon(client, { @@ -192,8 +197,8 @@ test.describe("Workspace setup streaming", () => { const completedPayload = await completed; expect(initialPayload.detail.log).toBe(""); - expect(runningPayload.detail.log).toContain("starting setup"); - expect(completedPayload.detail.log).toContain("setup complete"); + expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup"); + expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete"); expect(completedPayload.error).toBeNull(); } finally { await client.close(); @@ -215,7 +220,9 @@ test.describe("Workspace setup streaming", () => { await seedProjectForWorkspaceSetup(client, repo.path); const failed = waitForWorkspaceSetupProgress( client, - (payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"), + (payload) => + payload.status === "failed" && + setupCommandOutput(payload.detail).includes("setup failed"), ); const workspace = await createWorkspaceThroughDaemon(client, { @@ -224,8 +231,8 @@ test.describe("Workspace setup streaming", () => { }); const failedPayload = await failed; - expect(failedPayload.detail.log).toContain("starting setup"); - expect(failedPayload.detail.log).toContain("setup failed"); + expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup"); + expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed"); expect(failedPayload.error).toMatch(/failed/i); await openHomeWithProject(page, repo.path); @@ -331,7 +338,8 @@ test.describe("Workspace setup streaming", () => { const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const workspace = await createWorkspaceThroughDaemon(client, { cwd: repo.path, @@ -377,7 +385,8 @@ test.describe("Workspace setup streaming", () => { const completed = waitForWorkspaceSetupProgress( client, (payload) => - payload.status === "completed" && payload.detail.log.includes("setup complete"), + payload.status === "completed" && + setupCommandOutput(payload.detail).includes("setup complete"), ); const result = await client.createPaseoWorktree({ diff --git a/packages/app/e2e/support/helpers/workspace-setup.ts b/packages/app/e2e/support/helpers/workspace-setup.ts index dd1bc779bd..eda1d8d023 100644 --- a/packages/app/e2e/support/helpers/workspace-setup.ts +++ b/packages/app/e2e/support/helpers/workspace-setup.ts @@ -376,3 +376,7 @@ export function observeSetupPanelTraffic(page: Page) { }, }; } + +export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string { + return detail.commands.map((command) => command.log).join("\n"); +} diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index d67eb15f26..a10bd7b0c6 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; import { View, Text, @@ -186,48 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) { ); } -interface WorktreeSetupDetailProps { - log: string; - branchName: string; - worktreePath: string; - ds: DetailStyles; -} - -function WorktreeSetupDetailSection({ - log, - branchName, - worktreePath, - ds, -}: WorktreeSetupDetailProps) { - const setupLog = log.replace(/^\n+/, ""); - const hasLog = setupLog.length > 0; - return ( - - - - - - - {hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`} - - - - - - - ); -} - function resolveSubAgentFallbackHeader( subAgentType: string | null | undefined, description: string | null | undefined, @@ -678,17 +636,7 @@ function buildDetailSections( , ]; } - if (detail.type === "worktree_setup") { - return [ - , - ]; - } + if (detail.type === "worktree_setup") return []; if (detail.type === "sub_agent") { return [ ; manuallyCollapsed: Set; snapshotError: string | null | undefined; } function buildCommandRowState(args: BuildCommandRowPropsArgs) { - const { command, autoExpandIndex, log, expandedIndices, manuallyCollapsed, snapshotError } = args; + const { command, autoExpandIndex, expandedIndices, manuallyCollapsed, snapshotError } = args; const isExpanded = expandedIndices.has(command.index); const hasError = command.status === "failed" && Boolean(snapshotError); - const commandLog = resolveCommandLog(command, autoExpandIndex, log); + const commandLog = command.log; const hasLog = commandLog.trim().length > 0; const isExpandable = command.status !== "running" || hasLog || hasError; const isAutoExpanded = command.index === autoExpandIndex && !manuallyCollapsed.has(command.index); @@ -285,9 +272,7 @@ function SetupPanel() { ); const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS; - const log = snapshot?.detail.log ?? ""; - const hasNoSetupCommands = - snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0; + const hasNoSetupCommands = snapshot?.status === "completed" && commands.length === 0; const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0); const { expandedIndices, manuallyCollapsed, toggleExpanded } = useExpandedSetupCommands(); @@ -323,7 +308,6 @@ function SetupPanel() { workspaceId={target.workspaceId} snapshot={snapshot} commands={commands} - log={log} isWaiting={isWaiting} hasNoSetupCommands={hasNoSetupCommands} autoExpandIndex={autoExpandIndex} @@ -341,7 +325,6 @@ interface SetupPanelBodyProps { workspaceId: string; snapshot: WorkspaceSetupSnapshot | null; commands: SetupCommand[]; - log: string; isWaiting: boolean; hasNoSetupCommands: boolean; autoExpandIndex: number | null; @@ -352,7 +335,7 @@ interface SetupPanelBodyProps { function SetupPanelBody(props: SetupPanelBodyProps) { const { t } = useTranslation(); - const { snapshot, commands, log } = props; + const { snapshot, commands } = props; if (snapshot?.status === "blocked" && snapshot.blockedSource) { return ( ); })} - ); @@ -523,25 +504,6 @@ function SetupCommandChevron({ showDetail }: { showDetail: boolean }) { ); } -function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) { - const { t } = useTranslation(); - if (commands.length !== 0 || log.trim().length === 0) return null; - return ( - - - {log} - - - ); -} - function TopLevelSetupError({ snapshotError, commands, diff --git a/packages/app/src/utils/tool-call-detail-state.test.ts b/packages/app/src/utils/tool-call-detail-state.test.ts index 1cf7d624dc..ddfed7c803 100644 --- a/packages/app/src/utils/tool-call-detail-state.test.ts +++ b/packages/app/src/utils/tool-call-detail-state.test.ts @@ -77,3 +77,16 @@ describe("tool-call detail state", () => { assert.strictEqual(hasMeaningfulToolCallDetail(detail), true); }); }); + +it("keeps historical setup rows readable as history without a chat output view", () => { + assert.strictEqual( + hasMeaningfulToolCallDetail({ + type: "worktree_setup", + branchName: "old", + worktreePath: "/workspace", + log: "historical output", + commands: [], + }), + false, + ); +}); diff --git a/packages/app/src/utils/tool-call-detail-state.ts b/packages/app/src/utils/tool-call-detail-state.ts index de9e151427..e2d983d8cb 100644 --- a/packages/app/src/utils/tool-call-detail-state.ts +++ b/packages/app/src/utils/tool-call-detail-state.ts @@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract, -): boolean { - return Boolean(detail.branchName || detail.worktreePath || detail.log); -} - function hasMeaningfulSubAgentDetail( detail: Extract, ): boolean { @@ -81,7 +75,7 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined): case "fetch": return hasMeaningfulFetchDetail(detail); case "worktree_setup": - return hasMeaningfulWorktreeSetupDetail(detail); + return false; case "sub_agent": return hasMeaningfulSubAgentDetail(detail); case "plain_text": @@ -98,6 +92,7 @@ export function isPendingToolCallDetail(params: { status: "executing" | "running" | "completed" | "failed" | "canceled"; error: unknown; }): boolean { + if (params.detail?.type === "worktree_setup") return false; const isRunning = params.status === "running" || params.status === "executing"; return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail); } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 7f183ccfa4..a172af0380 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3372,62 +3372,6 @@ test("sends create_agent_request with workspace and caller identity", async () = await expect(createPromise).rejects.toThrow("compat test sentinel"); }); -test("sends worktree target and autoArchive in create_agent_request", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const createPromise = client.createAgent({ - provider: "codex", - cwd: "/tmp/project", - worktree: { - mode: "branch-off", - newBranch: "agent-lifecycle-dispatch", - base: "main", - }, - autoArchive: true, - }); - - expect(mock.sent).toHaveLength(1); - const request = parseSentFrame(mock.sent[0]); - expect(request).toEqual( - expect.objectContaining({ - type: "create_agent_request", - worktree: { - mode: "branch-off", - newBranch: "agent-lifecycle-dispatch", - base: "main", - }, - autoArchive: true, - }), - ); - - mock.triggerMessage( - wrapSessionMessage({ - type: "status", - payload: { - status: "agent_create_failed", - requestId: request.requestId, - error: "worktree auto archive sentinel", - }, - }), - ); - - await expect(createPromise).rejects.toThrow("worktree auto archive sentinel"); -}); - test("sends internal in create_agent_request when the host advertises internal agents", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -3646,120 +3590,6 @@ test("sends structured attachments with create_agent_request", async () => { await expect(createPromise).rejects.toThrow("attachment test sentinel"); }); -test("sends worktree base-ref fields in create_agent_request git options", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const createPromise = client.createAgent({ - provider: "codex", - cwd: "/tmp/project", - requestId: "req-agent-ref", - git: { - createWorktree: true, - worktreeSlug: "review-pr-123", - refName: "feature/worktree-base-ref", - action: "checkout", - githubPrNumber: 123, - }, - }); - - expect(mock.sent).toHaveLength(1); - const request = parseSentFrame(mock.sent[0]); - expect(request.git).toEqual({ - createWorktree: true, - worktreeSlug: "review-pr-123", - refName: "feature/worktree-base-ref", - action: "checkout", - githubPrNumber: 123, - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "status", - payload: { - status: "agent_create_failed", - requestId: request.requestId, - error: "git ref fields sentinel", - }, - }), - ); - - await expect(createPromise).rejects.toThrow("git ref fields sentinel"); -}); - -test("omitting create_agent_request worktree base-ref fields preserves legacy wire shape", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const createPromise = client.createAgent({ - provider: "codex", - cwd: "/tmp/project", - requestId: "req-agent-legacy", - git: { - createWorktree: true, - worktreeSlug: "feature-a", - }, - }); - - expect(assertStr(mock.sent[0])).toBe( - JSON.stringify({ - type: "session", - message: { - type: "create_agent_request", - config: { - provider: "codex", - cwd: "/tmp/project", - }, - git: { - createWorktree: true, - worktreeSlug: "feature-a", - }, - labels: {}, - requestId: "req-agent-legacy", - }, - }), - ); - - mock.triggerMessage( - wrapSessionMessage({ - type: "status", - payload: { - status: "agent_create_failed", - requestId: "req-agent-legacy", - error: "legacy git shape sentinel", - }, - }), - ); - - await expect(createPromise).rejects.toThrow("legacy git shape sentinel"); -}); - test("sends structured first-agent context attachments with create_paseo_worktree_request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -7881,3 +7711,59 @@ test("measures an outbound message in UTF-8 bytes against the daemon's message l expect(mock.sent).toHaveLength(1); expect(client.getConnectionState().status).toBe("connected"); }); + +test("sends worktree target and autoArchive in create_agent_request", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const createPromise = client.createAgent({ + provider: "codex", + cwd: "/tmp/project", + worktree: { + mode: "branch-off", + newBranch: "agent-lifecycle-dispatch", + base: "main", + }, + autoArchive: true, + }); + + expect(mock.sent).toHaveLength(1); + const request = parseSentFrame(mock.sent[0]); + expect(request).toEqual( + expect.objectContaining({ + type: "create_agent_request", + worktree: { + mode: "branch-off", + newBranch: "agent-lifecycle-dispatch", + base: "main", + }, + autoArchive: true, + }), + ); + + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: request.requestId, + error: "worktree auto archive sentinel", + }, + }), + ); + + await expect(createPromise).rejects.toThrow("worktree auto archive sentinel"); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index aa4624338e..29990cb428 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -459,12 +459,12 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { outputSchema?: Record; images?: CreateAgentRequestMessage["images"]; attachments?: CreateAgentRequestMessage["attachments"]; + /** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */ git?: GitSetupOptions; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; background?: CreateAgentRequestMessage["background"]; - // COMPAT(createAgentWorktree): low-level old callers may still send the - // create-agent worktree field. Added in v0.2.0; remove after 2027-01-17. + /** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */ worktreeName?: string; requestId?: string; labels?: Record; @@ -2963,6 +2963,11 @@ export class DaemonClient { } async createAgent(options: CreateAgentRequestOptions): Promise { + if (options.git !== undefined || options.worktreeName !== undefined) { + throw new Error( + "Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) or create a workspace first and pass workspaceId.", + ); + } if (options.background !== undefined) this.requireBackgroundWorkspaces(); const result = await this.creations.createAgent({ ...options, diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index 6903aa2cd1..d3b3ce26df 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -474,3 +474,54 @@ test("usage entries accept older failures and additive successful snapshots", () }).success, ).toBe(false); }); + +// Frozen setup detail shape from v0.8.0: log is required, command output is optional. +const ReleasedSetupDetailSchema = z.object({ + type: z.literal("worktree_setup"), + worktreePath: z.string(), + branchName: z.string(), + log: z.string(), + commands: z.array( + z.object({ + index: z.number().int().positive(), + command: z.string(), + cwd: z.string(), + log: z.string().optional().default(""), + status: z.enum(["running", "completed", "failed"]), + exitCode: z.number().nullable(), + durationMs: z.number().nonnegative().optional(), + }), + ), + truncated: z.boolean().optional(), +}); + +test("released setup clients parse empty combined logs and retain command output", () => { + const detail = { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "feature", + log: "", + commands: [ + { + index: 1, + command: "echo ready", + cwd: "/workspace", + log: "ready\n", + status: "completed", + exitCode: 0, + }, + ], + }; + const parsed = ReleasedSetupDetailSchema.parse(JSON.parse(JSON.stringify(detail))); + expect(parsed).toEqual(detail); + expect(parsed.commands[0]?.log).toBe("ready\n"); + const { log: _log, ...withoutLegacyField } = detail; + expect(ReleasedSetupDetailSchema.safeParse(withoutLegacyField).success).toBe(false); + expect( + WorkspaceSetupSnapshotSchema.parse({ + status: "completed", + detail: { ...detail, log: "old daemon combined output" }, + error: null, + }).detail.log, + ).toBe("old daemon combined output"); +}); diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index cf9711a9df..b123fa30c3 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -787,7 +787,7 @@ describe("agent MCP end-to-end (offline)", () => { } }, 30_000); - test("create_agent with worktree is async and boots terminals only after setup success", async () => { + test("workspace creation owns async setup and terminal bootstrap before agent attachment", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-")); @@ -843,21 +843,30 @@ describe("agent MCP end-to-end (offline)", () => { stdio: "pipe", }); - const result = await withTimeout({ + const workspaceResult = await withTimeout({ promise: client.callTool({ - name: "create_agent", + name: "create_workspace", args: { - cwd: repoRoot, - title: "MCP worktree setup terminals", - provider: "claude/claude-test-model", - mode: "bypassPermissions", - initialPrompt: "say done and stop", - worktreeName: "mcp-worktree-setup-test", + isolation: "worktree", + path: repoRoot, + worktreeSlug: "mcp-worktree-setup-test", baseBranch: "main", }, }), timeoutMs: 2500, - label: "create_agent should not block on setup", + label: "create_workspace should not block on setup", + }); + const workspace = getStructuredContent(workspaceResult); + if (typeof workspace?.workspaceId !== "string") throw new Error("Expected created workspace"); + const result = await client.callTool({ + name: "create_agent", + args: { + workspaceId: workspace.workspaceId, + title: "MCP worktree setup terminals", + provider: "claude/claude-test-model", + settings: { modeId: "bypassPermissions" }, + initialPrompt: "say done and stop", + }, }); const payload = getStructuredContent(result); diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 554bb9869e..2cbf71313b 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -1,3 +1,4 @@ +import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js"; import { randomUUID } from "node:crypto"; import type pino from "pino"; @@ -7,11 +8,13 @@ import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-se import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, - CreatePaseoWorktreeSetupContinuationInput, - NormalizedGitOptions, } from "../worktree-session.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; -import type { FirstAgentContext, SessionOutboundMessage } from "../messages.js"; +import type { + FirstAgentContext, + CreateAgentWorktreeTarget, + SessionOutboundMessage, +} from "../messages.js"; import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; @@ -57,33 +60,39 @@ export class CreateAgentLifecycleDispatch { async createWorktreeForRequest(input: { cwd: string; - target: NormalizedGitOptions | null; + target: CreateAgentWorktreeTarget | undefined; firstAgentContext: FirstAgentContext; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; background?: boolean; callerWorkspaceId?: string; }): Promise { const target = input.target; - if (!target?.createWorktree) return null; + if (!target) return null; + const placement: CreatePaseoWorktreeInput = { cwd: input.cwd }; + switch (target.mode) { + case "branch-off": + placement.action = "branch-off"; + placement.worktreeSlug = target.newBranch; + placement.refName = target.base; + break; + case "checkout-branch": + placement.action = "checkout"; + placement.refName = target.branch; + break; + case "checkout-pr": + placement.githubPrNumber = target.prNumber; + break; + } + const base = target.mode === "branch-off" ? target.base : undefined; return this.dependencies.createPaseoWorktreeWorkflow( { - cwd: input.cwd, + ...placement, background: input.background, callerWorkspaceId: input.callerWorkspaceId, firstAgentContext: input.firstAgentContext, - worktreeSlug: target.worktreeSlug, - refName: target.refName, - action: target.action, - checkoutSource: target.checkoutSource, - githubPrNumber: target.githubPrNumber, - runSetup: false, paseoHome: this.dependencies.paseoHome, worktreesRoot: this.dependencies.worktreesRoot, }, - { - ...(target.baseBranch ? { resolveDefaultBranch: async () => target.baseBranch! } : {}), - setupContinuation: input.setupContinuation, - }, + base ? { resolveDefaultBranch: async () => base } : undefined, ); } diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index f12309da2a..5d2c9fddc1 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -11,7 +11,6 @@ import { AgentStorage } from "../agent-storage.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { createAgentCommand } from "./create.js"; import type { ManagedAgent } from "../agent-manager.js"; -import type { AgentSessionConfig } from "../agent-sdk-types.js"; const logger = createTestLogger(); @@ -53,7 +52,6 @@ function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: strin workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd }, repoRoot: args.repoRoot, created: true, - setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} }, }) as unknown as CreatePaseoWorktreeWorkflowResult; } @@ -88,7 +86,6 @@ test("session create forwards clientMessageId to the initial prompt run options" labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }); expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", { @@ -125,7 +122,6 @@ test("session create validates the requested mode against the provider's modes", labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }), ).rejects.toThrow("Invalid mode 'plan'"); @@ -169,7 +165,6 @@ test("session create applies the resolved mode from the provider create config", labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }); expect(createAgent).toHaveBeenCalledWith( @@ -247,7 +242,6 @@ test("session create stamps the requested workspaceId when no worktree setup run labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }, ); @@ -258,41 +252,6 @@ test("session create stamps the requested workspaceId when no worktree setup run } }); -test("session create stamps the new worktree's workspaceId when a setup continuation runs", async () => { - const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - const agentManager = createRealAgentManager(storage); - - try { - const { snapshot } = await createAgentCommand( - { - agentManager, - agentStorage: storage, - logger, - providerSnapshotManager: createProviderSnapshotManagerStub().manager, - }, - { - kind: "session", - config: { provider: "codex", cwd: workdir }, - workspaceId: "ws-source", - labels: {}, - provisionalTitle: null, - firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ - sessionConfig: config, - setupContinuation: { kind: "agent", startAfterAgentCreate: () => {} }, - createdWorkspaceId: "ws-new-worktree", - }), - }, - ); - - const stored = await storage.get(snapshot.id); - expect(stored?.workspaceId).toBe("ws-new-worktree"); - } finally { - await removeRealAgentManagerWorkdir({ agentManager, storage, workdir }); - } -}); - test("mcp create stamps the new worktree's workspaceId, not the parent's", async () => { const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-")); const storage = new AgentStorage(join(workdir, "agents"), logger); @@ -309,7 +268,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }, ); @@ -416,7 +374,6 @@ test("session create keeps the prompt title after the initial prompt settles", a labels: {}, provisionalTitle: title, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }, ); @@ -454,7 +411,6 @@ test("session create keeps an explicit title after the initial prompt settles", labels: {}, provisionalTitle: title, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config) => ({ sessionConfig: config }), }, ); @@ -492,7 +448,6 @@ test("session create with an internal config skips provider session persistence" labels: {}, provisionalTitle: null, firstAgentContext: { attachments: [] }, - buildSessionConfig: async (config: AgentSessionConfig) => ({ sessionConfig: config }), }; await createAgentCommand(dependencies, { diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index b67935a0a8..f9fe10bd09 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -6,12 +6,10 @@ import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js"; import { expandUserPath, resolvePathFromBase } from "../../path-utils.js"; import { toWorktreeRequestError } from "../../worktree-errors.js"; import type { - AgentWorktreeSetupContinuation, - CreatePaseoWorktreeSetupContinuationInput, CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, } from "../../worktree-session.js"; -import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js"; +import type { AgentAttachment, FirstAgentContext } from "../../messages.js"; import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js"; import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentStorage } from "../agent-storage.js"; @@ -22,20 +20,8 @@ import { resolveCreateAgentTitles } from "../create-agent-title.js"; import { buildAgentPrompt } from "../prompt-attachments.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js"; -import { - appendTimelineItemIfAgentKnown, - emitLiveTimelineItemIfAgentKnown, -} from "../timeline-append.js"; import { resolveCreateAgentIntent } from "./intent.js"; -export interface CreateAgentSessionWorktreeResult { - sessionConfig: AgentSessionConfig; - setupContinuation?: AgentWorktreeSetupContinuation; - // Set when this build created a fresh worktree workspace. The agent must be - // stamped with it so workspaceId-scoped archive can find the agent later. - createdWorkspaceId?: string; -} - export interface CreateAgentCommandDependencies { agentManager: AgentManager; agentStorage: AgentStorage; @@ -61,24 +47,16 @@ export interface CreateAgentFromSessionInput { agentId?: string; config: AgentSessionConfig; workspaceId: string; - worktreeName?: string; initialPrompt?: string; source?: AgentPromptSource; clientMessageId?: string; outputSchema?: Record; images?: Array<{ data: string; mimeType: string }>; attachments?: AgentAttachment[]; - git?: GitSetupOptions; labels: Record; env?: Record; provisionalTitle: string | null; firstAgentContext: FirstAgentContext; - buildSessionConfig: ( - config: AgentSessionConfig, - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, - firstAgentContext?: FirstAgentContext, - ) => Promise; } export interface CreateAgentFromMcpInput { @@ -111,6 +89,7 @@ export interface CreateAgentFromMcpInput { allowCustomCwd?: boolean; childAgentDefaultLabels?: Record; } | null; + worktree?: { worktreeName?: string; branchName?: string; @@ -167,7 +146,6 @@ interface ResolvedCreateAgent { prompt?: AgentPromptInput; source?: AgentPromptSource; runOptions?: AgentRunOptions; - setupContinuation?: AgentWorktreeSetupContinuation; promptFailure: CreateAgentPromptFailureMode; promptLogger?: Logger; createdWorktree?: CreatePaseoWorktreeWorkflowResult; @@ -200,7 +178,7 @@ export async function createAgentCommand( input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && - (resolved.prompt !== undefined || resolved.setupContinuation !== undefined) + resolved.prompt !== undefined ? setupFinishNotification({ agentManager: dependencies.agentManager, agentStorage: dependencies.agentStorage, @@ -212,9 +190,6 @@ export async function createAgentCommand( }) : undefined; - resolved.setupContinuation?.startAfterAgentCreate({ - agentId: snapshot.id, - }); if (resolved.prompt !== undefined) { const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => { stopNotification?.(); @@ -239,29 +214,7 @@ async function resolveSessionCreateAgent( input: CreateAgentFromSessionInput, ): Promise { const trimmedPrompt = input.initialPrompt?.trim(); - const { - sessionConfig: builtSessionConfig, - setupContinuation, - createdWorkspaceId, - } = await input.buildSessionConfig( - input.config, - input.git, - input.worktreeName, - input.firstAgentContext, - ); - // Validate the requested mode against the provider's modes for the resolved - // cwd. The app remembers mode preferences globally, so a saved mode can be - // stale for a workspace whose provider config no longer defines it — reject - // it here instead of letting the provider fail mid-turn. - // - // This runs after buildSessionConfig, which may already have created a - // worktree and/or workspace record — cwd (required to resolve modes) is - // only known once that step completes. If validation throws, any - // worktree/workspace buildSessionConfig created is the caller's - // responsibility to clean up (session.ts's handleCreateAgentRequest does - // this for the worktree path via cleanupCreatedWorktreeAfterFailedAgentCreate; - // this is a pre-existing gap for directory-only workspace creates, not - // introduced by this validation). + const builtSessionConfig = input.config; const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({ cwd: builtSessionConfig.cwd, provider: builtSessionConfig.provider, @@ -285,7 +238,7 @@ async function resolveSessionCreateAgent( ...(clientMessageId ? { clientMessageId } : {}), } : undefined; - const workspaceId = setupContinuation ? createdWorkspaceId : input.workspaceId; + const workspaceId = input.workspaceId; return { config: sessionConfig, @@ -297,15 +250,11 @@ async function resolveSessionCreateAgent( // Paseo never persists an internal agent, so nothing could resume its // provider session either. Same as the daemon's own ephemeral helpers. ...(sessionConfig.internal ? { persistSession: false } : {}), - // A legacy git/worktreeName worktree creates a fresh workspace, so the - // agent belongs to that workspace, not the source one. createdWorkspaceId - // is the freshly created worktree's workspace. workspaceId: requireResolvedWorkspaceId(workspaceId), }, prompt: hasPromptContent ? prompt : undefined, source: input.source, runOptions, - setupContinuation, // Registration commits creation. First-turn failures belong to the created agent. promptFailure: "return-error", promptLogger: dependencies.logger.child({ @@ -324,18 +273,16 @@ async function resolveMcpCreateAgent( ? requireParentAgent(dependencies.agentManager, input.callerAgentId) : null; const cwd = resolveMcpInitialCwd(input, parentAgent); - const { resolvedCwd, setupContinuation, createdWorkspaceId, createdWorktree } = - await resolveMcpCwd({ - dependencies, - cwd, - worktree: input.worktree, - initialPrompt: input.initialPrompt ?? "", - callerWorkspaceId: parentAgent?.workspaceId, - }); + const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({ + dependencies, + cwd, + worktree: input.worktree, + initialPrompt: input.initialPrompt ?? "", + callerWorkspaceId: parentAgent?.workspaceId, + }); if (createdWorktree) input.onWorktreeCreated?.(createdWorktree); - const intent = await resolveCreateAgentIntent({ - explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId, + explicitWorkspaceId: createdWorkspaceId ?? input.workspaceId, caller: parentAgent ? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId } : null, @@ -384,7 +331,6 @@ async function resolveMcpCreateAgent( source: input.callerAgentId ? { kind: "agent-message", agentId: input.callerAgentId } : undefined, - setupContinuation, createdWorktree, promptFailure: input.promptFailure ?? "log", }; @@ -536,6 +482,28 @@ function resolveChildAgentCwd(params: { return resolvePathFromBase(params.parentCwd, requestedCwd); } +interface CreateMcpWorktreeOptions { + input: CreatePaseoWorktreeInput; + createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined; + resolveDefaultBranch?: (repoRoot: string) => Promise; +} + +async function createMcpWorktree( + options: CreateMcpWorktreeOptions, +): Promise { + try { + if (!options.createPaseoWorktree) { + throw new Error("Paseo worktree service is not configured"); + } + return await options.createPaseoWorktree( + options.input, + options.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } : {}, + ); + } catch (error) { + throw toWorktreeRequestError(error); + } +} + async function resolveMcpCwd(params: { dependencies: CreateAgentCommandDependencies; cwd: string; @@ -544,7 +512,6 @@ async function resolveMcpCwd(params: { worktree: CreateAgentFromMcpInput["worktree"]; }): Promise<{ resolvedCwd: string; - setupContinuation?: AgentWorktreeSetupContinuation; createdWorkspaceId?: string; createdWorktree?: CreatePaseoWorktreeWorkflowResult; }> { @@ -578,59 +545,16 @@ async function resolveMcpCwd(params: { action: worktree.action, githubPrNumber: worktree.githubPrNumber, firstAgentContext: { prompt: params.initialPrompt }, - runSetup: false, + paseoHome: dependencies.paseoHome, worktreesRoot: dependencies.worktreesRoot, }, createPaseoWorktree: dependencies.createPaseoWorktree, resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, - setupContinuation: { - kind: "agent", - terminalManager: dependencies.terminalManager ?? null, - appendTimelineItem: ({ agentId, item }) => - appendTimelineItemIfAgentKnown({ - agentManager: dependencies.agentManager, - agentId, - item, - }), - emitLiveTimelineItem: ({ agentId, item }) => - emitLiveTimelineItemIfAgentKnown({ - agentManager: dependencies.agentManager, - agentId, - item, - }), - logger: dependencies.logger, - }, }); return { resolvedCwd: createdWorktree.workspace.cwd, - setupContinuation: createdWorktree.setupContinuation, createdWorkspaceId: createdWorktree.workspace.workspaceId, createdWorktree, }; } - -interface CreateMcpWorktreeOptions { - input: CreatePaseoWorktreeInput; - createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined; - resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; -} - -async function createMcpWorktree( - options: CreateMcpWorktreeOptions, -): Promise { - try { - if (!options.createPaseoWorktree) { - throw new Error("Paseo worktree service is not configured"); - } - return await options.createPaseoWorktree(options.input, { - ...(options.resolveDefaultBranch - ? { resolveDefaultBranch: options.resolveDefaultBranch } - : {}), - ...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}), - }); - } catch (error) { - throw toWorktreeRequestError(error); - } -} diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 6e093a18a4..e774820dee 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -72,7 +72,6 @@ import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol import type { DaemonConfigStore } from "../daemon-config-store.js"; import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js"; import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js"; -import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js"; import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; const REPO_CWD = resolvePath("/tmp/repo"); @@ -180,20 +179,6 @@ async function waitForWorkspaceTitle( }); } -async function waitForWorkspaceBranch( - workspaceRecords: Map, - workspaceId: string, - branch: string, -): Promise { - await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.branch).toBe(branch), { - timeout: 5_000, - }); -} - -async function waitForUnexpectedWorkspaceNamingSideEffects(): Promise { - await new Promise((resolve) => setTimeout(resolve, 25)); -} - async function removeTempDir(path: string): Promise { await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } @@ -693,7 +678,6 @@ function createPaseoWorktreeForMcpTest(options: { createdWorkspaceIds?: string[]; workspaceRecords?: Map; generateWorkspaceName?: () => Promise; - setupContinuations?: Array<"workspace" | "agent" | undefined>; startedAgentSetupIds?: string[]; }): CreatePaseoWorktreeWorkflowFn { const projects = new Map(); @@ -790,7 +774,6 @@ function createPaseoWorktreeForMcpTest(options: { }); return async (input, serviceOptions) => { - options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind); const result = await createPaseoWorktreeWorkflow( { paseoHome: options.paseoHome, @@ -824,17 +807,6 @@ function createPaseoWorktreeForMcpTest(options: { ); options.broadcasts.push(result.workspace.workspaceId); options.createdWorkspaceIds?.push(result.workspace.workspaceId); - if (serviceOptions?.setupContinuation?.kind === "agent") { - return { - ...result, - setupContinuation: { - kind: "agent", - startAfterAgentCreate: ({ agentId }) => { - options.startedAgentSetupIds?.push(agentId); - }, - }, - }; - } return result; }; } @@ -1466,16 +1438,6 @@ describe("create_agent MCP tool", () => { relationship: { kind: "detached" as const }, workspace: { kind: "create" as const, source: { kind: "directory" as const, path } }, }); - const detachedWorktreeWorkspace = ( - cwd: string, - target: - | { kind: "branch-off"; worktreeSlug?: string; branchName?: string; baseBranch?: string } - | { kind: "checkout-branch"; branch: string } - | { kind: "checkout-pr"; githubPrNumber: number }, - ) => ({ - relationship: { kind: "detached" as const }, - workspace: { kind: "create" as const, source: { kind: "worktree" as const, cwd, target } }, - }); const subagentCurrentWorkspace = (cwd?: string) => ({ relationship: { kind: "subagent" as const }, workspace: { kind: "current" as const, ...(cwd ? { cwd } : {}) }, @@ -1902,29 +1864,6 @@ describe("create_agent MCP tool", () => { ).rejects.toThrow("Unrecognized key"); }); - it("accepts worktree workspace intent in create_agent input validation", async () => { - const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - logger, - }); - const tool = registeredTool(server, "create_agent"); - - const parsed = await tool.inputSchema.safeParseAsync({ - ...detachedWorktreeWorkspace(existingCwd, { - kind: "checkout-pr", - githubPrNumber: 42, - }), - title: "Short title", - provider: "codex/gpt-5.4", - initialPrompt: "test", - }); - - expect(parsed.success).toBe(true); - }); - it("exposes workspace tools instead of worktree tools", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ @@ -2082,444 +2021,6 @@ describe("create_agent MCP tool", () => { ); }); - it("registers and broadcasts a workspace when create_agent creates a worktree", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-worktree-")); - const repoDir = join(tempDir, "repo"); - const paseoHome = join(tempDir, ".paseo"); - const broadcasts: string[] = []; - const createdWorkspaceIds: string[] = []; - const setupContinuations: Array<"workspace" | "agent" | undefined> = []; - const startedAgentSetupIds: string[] = []; - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-with-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "Worktree agent" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ - paseoHome, - broadcasts, - createdWorkspaceIds, - setupContinuations, - startedAgentSetupIds, - }), - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "branch-off", - worktreeSlug: "agent-worktree", - branchName: "feature/agent-worktree", - baseBranch: "main", - }), - title: "Worktree agent", - provider: "codex/gpt-5.4", - initialPrompt: "Do work", - }); - - expect(broadcasts).toHaveLength(1); - expect(createdWorkspaceIds).toHaveLength(1); - expect(broadcasts[0]).toBe(createdWorkspaceIds[0]); - expect(setupContinuations).toEqual(["agent"]); - expect(startedAgentSetupIds).toEqual(["agent-with-worktree"]); - const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const branchName = execFileSync("git", ["branch", "--show-current"], { - cwd: agentCwd, - stdio: "pipe", - }) - .toString() - .trim(); - expect(branchName).toBe("feature/agent-worktree"); - // The agent is stamped with the freshly created worktree's workspaceId so - // workspaceId-scoped archive can find and tear it down later. - expect(spies.agentManager.createAgent).toHaveBeenCalledWith( - expect.objectContaining({ - cwd: expect.stringContaining("agent-worktree"), - }), - undefined, - { workspaceId: createdWorkspaceIds[0] }, - ); - } finally { - await removeTempDir(tempDir); - } - }); - - it("creates a create_agent branch-off worktree without invoking the legacy metadata branch rename", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-name-context-")); - const repoDir = join(tempDir, "repo"); - const paseoHome = join(tempDir, ".paseo"); - const broadcasts: string[] = []; - const workspaceGitService = { - getSnapshot: vi.fn(async () => { - throw new Error("agent metadata branch rename should not run"); - }), - }; - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-auto-named-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "Worktree agent" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), - workspaceGitService: workspaceGitService as unknown as Pick< - WorkspaceGitService, - "getSnapshot" | "listWorktrees" - >, - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "branch-off", - baseBranch: "main", - }), - title: "Worktree agent", - provider: "codex/gpt-5.4", - initialPrompt: "Fix workspace creation naming", - }); - - const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const initialBranch = execFileSync("git", ["branch", "--show-current"], { - cwd: agentCwd, - stdio: "pipe", - }) - .toString() - .trim(); - expect(initialBranch).not.toBe(""); - expect(initialBranch).not.toBe("main"); - await waitForUnexpectedWorkspaceNamingSideEffects(); - expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(broadcasts).toHaveLength(1); - } finally { - await removeTempDir(tempDir); - } - }); - - it("auto-titles and renames an agent-created branch-off worktree from the initial prompt", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-auto-title-")); - const repoDir = join(tempDir, "repo"); - const paseoHome = join(tempDir, ".paseo"); - const broadcasts: string[] = []; - const createdWorkspaceIds: string[] = []; - const workspaceRecords = new Map(); - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-auto-titled-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "Agent title" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ - paseoHome, - broadcasts, - createdWorkspaceIds, - workspaceRecords, - generateWorkspaceName: async () => ({ - title: "Workspace Auto Title Flow", - branch: "workspace-auto-title-flow", - }), - }), - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "branch-off", - branchName: "feat/placeholder-auto-title", - baseBranch: "main", - }), - title: "Agent title", - provider: "codex/gpt-5.4", - initialPrompt: "Build a workspace auto title flow", - }); - const workspaceId = z.string().parse(createdWorkspaceIds[0]); - await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow"); - - const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const workspace = workspaceRecords.get(workspaceId); - const branchName = execFileSync("git", ["branch", "--show-current"], { - cwd: agentCwd, - stdio: "pipe", - }) - .toString() - .trim(); - const metadata = readPaseoWorktreeMetadata(agentCwd); - - expect(metadata).toMatchObject({ - version: 2, - firstAgentBranchAutoName: { - status: "attempted", - placeholderBranchName: "feat/placeholder-auto-title", - }, - }); - expect(branchName).toBe("workspace-auto-title-flow"); - expect(workspace).toMatchObject({ - title: "Workspace Auto Title Flow", - branch: "workspace-auto-title-flow", - }); - } finally { - await removeTempDir(tempDir); - } - }); - - it("keeps a manual workspace title when agent-created worktree naming finishes later", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-manual-title-")); - const repoDir = join(tempDir, "repo"); - const paseoHome = join(tempDir, ".paseo"); - const broadcasts: string[] = []; - const createdWorkspaceIds: string[] = []; - const workspaceRecords = new Map(); - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-manual-title-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "Agent title" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ - paseoHome, - broadcasts, - createdWorkspaceIds, - workspaceRecords, - generateWorkspaceName: async () => ({ - title: "Generated Manual Race Title", - branch: "generated-manual-race-title", - }), - }), - workspaceRegistry: { - get: async (workspaceId) => workspaceRecords.get(workspaceId) ?? null, - upsert: async (record) => { - workspaceRecords.set(record.workspaceId, record); - }, - }, - emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => { - broadcasts.push(...workspaceIds); - }, - logger, - }); - const createAgentTool = registeredTool(server, "create_agent"); - await createAgentTool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "branch-off", - branchName: "feat/manual-title-placeholder", - baseBranch: "main", - }), - title: "Agent title", - provider: "codex/gpt-5.4", - initialPrompt: "Keep the manually renamed workspace title", - }); - const renameWorkspaceTool = registeredTool(server, "rename_workspace"); - await renameWorkspaceTool.handler({ - workspaceId: createdWorkspaceIds[0], - title: "Manual Workspace Title", - }); - const workspaceId = z.string().parse(createdWorkspaceIds[0]); - await waitForWorkspaceBranch(workspaceRecords, workspaceId, "generated-manual-race-title"); - - const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const workspace = workspaceRecords.get(workspaceId); - const branchName = execFileSync("git", ["branch", "--show-current"], { - cwd: agentCwd, - stdio: "pipe", - }) - .toString() - .trim(); - - expect(branchName).toBe("generated-manual-race-title"); - expect(workspace).toMatchObject({ - title: "Manual Workspace Title", - branch: "generated-manual-race-title", - }); - } finally { - await removeTempDir(tempDir); - } - }); - - it("uses create_agent title for the agent while still auto-titling the worktree workspace", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-title-workspace-title-")); - const repoDir = join(tempDir, "repo"); - const paseoHome = join(tempDir, ".paseo"); - const broadcasts: string[] = []; - const createdWorkspaceIds: string[] = []; - const workspaceRecords = new Map(); - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-explicit-title-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "Explicit Agent Title" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ - paseoHome, - broadcasts, - createdWorkspaceIds, - workspaceRecords, - generateWorkspaceName: async () => ({ - title: "Generated Workspace Title", - branch: "generated-workspace-title", - }), - }), - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "branch-off", - branchName: "feat/agent-title-placeholder", - baseBranch: "main", - }), - title: "Explicit Agent Title", - provider: "codex/gpt-5.4", - initialPrompt: "Generate the workspace title anyway", - }); - const workspaceId = z.string().parse(createdWorkspaceIds[0]); - await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title"); - - const workspace = workspaceRecords.get(workspaceId); - expect(spies.agentManager.createAgent).toHaveBeenCalledWith( - expect.objectContaining({ - title: "Explicit Agent Title", - }), - undefined, - { workspaceId }, - ); - expect(workspace).toMatchObject({ - title: "Generated Workspace Title", - branch: "generated-workspace-title", - }); - } finally { - await removeTempDir(tempDir); - } - }); - it("auto-titles an agent-created directory workspace from the initial prompt", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-directory-auto-title-")); @@ -2630,221 +2131,12 @@ describe("create_agent MCP tool", () => { } }); - it("auto-titles without renaming a create_agent checkout worktree from the initial prompt", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-")); - const repoDir = join(tempDir, "repo"); - const paseoHome = join(tempDir, ".paseo"); - const broadcasts: string[] = []; - const createdWorkspaceIds: string[] = []; - const workspaceRecords = new Map(); - let generateCalls = 0; - const workspaceGitService = { - getSnapshot: vi.fn(async () => { - throw new Error("agent metadata branch rename should not run"); - }), - }; - - try { - execFileSync("git", ["init", repoDir], { stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@example.com"], { - cwd: repoDir, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["config", "commit.gpgsign", "false"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "README.md"), "hello\n"); - execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["checkout", "-b", "existing-feature"], { - cwd: repoDir, - stdio: "pipe", - }); - await writeFile(join(repoDir, "feature.txt"), "feature\n"); - execFileSync("git", ["add", "feature.txt"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["commit", "-m", "feature"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); - - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-checkout-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "Checkout agent" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - paseoHome, - createPaseoWorktree: createPaseoWorktreeForMcpTest({ - paseoHome, - broadcasts, - createdWorkspaceIds, - workspaceRecords, - generateWorkspaceName: async () => { - generateCalls += 1; - return { - title: "Generated Checkout Workspace Title", - branch: "generated-checkout-workspace-title", - }; - }, - }), - workspaceGitService: workspaceGitService as unknown as Pick< - WorkspaceGitService, - "getSnapshot" | "listWorktrees" - >, - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(repoDir, { - kind: "checkout-branch", - branch: "existing-feature", - }), - title: "Checkout agent", - provider: "codex/gpt-5.4", - initialPrompt: "Rename this checkout from the prompt", - }); - - const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); - const workspaceId = z.string().parse(createdWorkspaceIds[0]); - await waitForWorkspaceTitle( - workspaceRecords, - workspaceId, - "Generated Checkout Workspace Title", - ); - expect( - execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" }) - .toString() - .trim(), - ).toBe("existing-feature"); - expect(workspaceRecords.get(workspaceId)).toMatchObject({ - title: "Generated Checkout Workspace Title", - branch: "existing-feature", - }); - expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({ - version: 1, - baseRefName: "main", - }); - expect(generateCalls).toBe(1); - expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(broadcasts).toEqual([workspaceId, workspaceId]); - } finally { - await removeTempDir(tempDir); - } - }); - - it("passes create_agent GitHub PR worktrees through workspace creation without metadata branch rename", async () => { - const { agentManager, agentStorage, spies } = createTestDeps(); - const startedAgentSetupIds: string[] = []; - const createPaseoWorktree = vi.fn( - async ( - input: CreatePaseoWorktreeInput, - options?: Parameters[1], - ) => ({ - worktree: { - branchName: "pr-123", - worktreePath: "/tmp/worktrees/pr-123", - }, - intent: { - kind: "checkout-github-pr" as const, - githubPrNumber: input.githubPrNumber ?? 123, - headRef: "pr-123", - baseRefName: "main", - }, - workspace: { - workspaceId: "ws-pr-123", - projectId: REPO_CWD, - cwd: "/tmp/worktrees/pr-123", - kind: "worktree" as const, - displayName: "pr-123", - createdAt: "2026-04-30T00:00:00.000Z", - updatedAt: "2026-04-30T00:00:00.000Z", - archivedAt: null, - }, - repoRoot: REPO_CWD, - created: true, - ...(options?.setupContinuation?.kind === "agent" - ? { - setupContinuation: { - kind: "agent" as const, - startAfterAgentCreate: ({ agentId }: { agentId: string }) => { - startedAgentSetupIds.push(agentId); - }, - }, - } - : {}), - }), - ); - const workspaceGitService = { - getSnapshot: vi.fn(async () => { - throw new Error("agent metadata branch rename should not run"); - }), - }; - spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({ - id: "agent-pr-worktree", - cwd: config.cwd, - lifecycle: "idle", - currentModeId: null, - availableModes: [], - config: { title: "PR agent" }, - })); - - const server = await createAgentMcpServer({ - agentManager, - agentStorage, - providerSnapshotManager: createOpenCodeManager().manager, - createPaseoWorktree, - workspaceGitService: workspaceGitService as unknown as Pick< - WorkspaceGitService, - "getSnapshot" | "listWorktrees" - >, - logger, - }); - const tool = registeredTool(server, "create_agent"); - await tool.handler({ - ...detachedWorktreeWorkspace(REPO_CWD, { - kind: "checkout-pr", - githubPrNumber: 123, - }), - title: "PR agent", - provider: "codex/gpt-5.4", - initialPrompt: "Rename this PR branch from prompt", - }); - - expect(createPaseoWorktree).toHaveBeenCalledWith( - expect.objectContaining({ - githubPrNumber: 123, - firstAgentContext: { prompt: "Rename this PR branch from prompt" }, - }), - expect.objectContaining({ - setupContinuation: expect.objectContaining({ kind: "agent" }), - }), - ); - expect(startedAgentSetupIds).toEqual(["agent-pr-worktree"]); - expect(spies.agentManager.createAgent).toHaveBeenCalledWith( - expect.objectContaining({ cwd: "/tmp/worktrees/pr-123" }), - undefined, - { workspaceId: "ws-pr-123" }, - ); - await waitForUnexpectedWorkspaceNamingSideEffects(); - expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - }); - it("creates a worktree-isolated workspace", async () => { const { agentManager, agentStorage } = createTestDeps(); const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-")); const repoDir = join(tempDir, "repo"); const paseoHome = join(tempDir, ".paseo"); const broadcasts: string[] = []; - const setupContinuations: Array<"workspace" | "agent" | undefined> = []; try { execFileSync("git", ["init", repoDir], { stdio: "pipe" }); @@ -2875,7 +2167,6 @@ describe("create_agent MCP tool", () => { createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts, - setupContinuations, }), workspaceGitService: workspaceGitService as unknown as Pick< WorkspaceGitService, @@ -2896,7 +2187,6 @@ describe("create_agent MCP tool", () => { expect(response.structuredContent.cwd).toContain("tool-worktree"); expect(response.structuredContent.workspaceId).toBe(broadcasts[0]); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(setupContinuations).toEqual([undefined]); expect(broadcasts).toHaveLength(1); expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/); } finally { @@ -7378,3 +6668,40 @@ describe("agent snapshot MCP serialization", () => { expect(content).not.toContain("first answer"); }); }); + +it("rejects hidden agent-worktree placement and directs callers to workspace creation", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger: createTestLogger(), + }); + const tool = registeredTool(server, "create_agent"); + await expect( + tool.handler({ + provider: "codex/gpt-5.4", + title: "Worktree agent", + initialPrompt: "test", + relationship: { kind: "detached" }, + workspace: { + kind: "create", + source: { + kind: "worktree", + cwd: process.cwd(), + target: { kind: "branch-off", worktreeSlug: "legacy" }, + }, + }, + }), + ).rejects.toThrow("create_workspace"); + await expect( + tool.handler({ + provider: "codex/gpt-5.4", + title: "Worktree agent", + initialPrompt: "test", + cwd: process.cwd(), + worktreeName: "legacy", + baseBranch: "main", + }), + ).rejects.toThrow("create_workspace"); +}); diff --git a/packages/server/src/server/agent/timeline-append.ts b/packages/server/src/server/agent/timeline-append.ts deleted file mode 100644 index e97fce65c6..0000000000 --- a/packages/server/src/server/agent/timeline-append.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AgentManager } from "./agent-manager.js"; -import type { AgentTimelineItem } from "./agent-sdk-types.js"; - -export interface AppendTimelineItemIfAgentKnownOptions { - agentManager: AgentManager; - agentId: string; - item: AgentTimelineItem; -} - -export async function appendTimelineItemIfAgentKnown( - options: AppendTimelineItemIfAgentKnownOptions, -): Promise { - try { - await options.agentManager.appendTimelineItem(options.agentId, options.item); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("Unknown agent")) { - return false; - } - throw error; - } -} - -export async function emitLiveTimelineItemIfAgentKnown( - options: AppendTimelineItemIfAgentKnownOptions, -): Promise { - try { - await options.agentManager.emitLiveTimelineItem(options.agentId, options.item); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("Unknown agent")) { - return false; - } - throw error; - } -} diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 30f28426b1..a5117b36b1 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -33,7 +33,7 @@ import { requireActiveWorkspaceForArchive, type ArchiveDependencies, } from "../../workspace-archive-service.js"; -import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js"; +import { createAgentCommand } from "../create-agent/create.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { FirstAgentContext } from "../../messages.js"; import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence"; @@ -1030,43 +1030,6 @@ function createToolCatalog( .strict() .describe("Create a root agent that does not appear in this agent's subagent track."), ]); - const AgentCreateWorktreeTargetInputSchema = z.discriminatedUnion("kind", [ - z - .object({ - kind: z.literal("branch-off"), - worktreeSlug: z - .string() - .min(1) - .optional() - .describe("Optional worktree slug/path label. Omit to let Paseo generate one."), - branchName: z - .string() - .min(1) - .optional() - .describe("Optional git branch name. Defaults to the worktree slug."), - baseBranch: z - .string() - .min(1) - .optional() - .describe("Optional base branch. Defaults to the repository default branch."), - }) - .strict() - .describe("Create a new branch in a new Paseo worktree."), - z - .object({ - kind: z.literal("checkout-branch"), - branch: z.string().min(1).describe("Existing branch to check out."), - }) - .strict() - .describe("Check out an existing branch in a new Paseo worktree."), - z - .object({ - kind: z.literal("checkout-pr"), - githubPrNumber: z.number().int().positive().describe("GitHub pull request number."), - }) - .strict() - .describe("Check out a GitHub pull request in a new Paseo worktree."), - ]); const AgentWorkspaceInputSchema = z.discriminatedUnion("kind", [ z .object({ @@ -1099,16 +1062,6 @@ function createToolCatalog( .describe("Optional directory path. Defaults to the caller's cwd."), }) .strict(), - z - .object({ - kind: z.literal("worktree"), - cwd: z - .string() - .optional() - .describe("Optional source repository. Defaults to the caller's cwd."), - target: AgentCreateWorktreeTargetInputSchema, - }) - .strict(), ]), }) .strict() @@ -1195,32 +1148,6 @@ function createToolCatalog( .record(z.string(), z.unknown()) .optional() .describe("Legacy feature values. Prefer settings.features."), - worktreeName: z - .string() - .min(1) - .optional() - .describe("Legacy worktree slug. Prefer workspace.source.target.worktreeSlug."), - branchName: z - .string() - .min(1) - .optional() - .describe("Legacy branch name. Prefer workspace.source.target.branchName."), - baseBranch: z - .string() - .min(1) - .optional() - .describe("Legacy base branch. Prefer workspace.source.target.baseBranch."), - refName: z - .string() - .min(1) - .optional() - .describe("Legacy branch/ref to check out. Prefer workspace.source.target.branch."), - githubPrNumber: z - .number() - .int() - .positive() - .optional() - .describe("Legacy GitHub PR number. Prefer workspace.source.target.githubPrNumber."), }; const createAgentInputSchema = z .object(scope === "agent" ? agentToAgentInputSchema : canonicalTopLevelInputSchema) @@ -1560,7 +1487,7 @@ function createToolCatalog( }, async (args: unknown) => { const resolvedArgs = await resolveCreateAgentToolArgs(args); - const { parsedArgs, worktree } = resolvedArgs; + const { parsedArgs } = resolvedArgs; let notifyOnFinish: boolean; if (resolvedArgs.kind === "agent-scoped") { notifyOnFinish = parsedArgs.notifyOnFinish; @@ -1600,7 +1527,6 @@ function createToolCatalog( detached: resolvedArgs.detached, callerAgentId, callerContext, - worktree, }, ); @@ -1634,7 +1560,6 @@ function createToolCatalog( detached: boolean; cwd: string | undefined; workspaceId: string | undefined; - worktree: CreateAgentFromMcpInput["worktree"]; } | { kind: "top-level"; @@ -1642,16 +1567,38 @@ function createToolCatalog( detached: boolean; cwd: string | undefined; workspaceId: string | undefined; - worktree: CreateAgentFromMcpInput["worktree"]; }; async function resolveCreateAgentToolArgs(args: unknown): Promise { + const placement = args as { + worktreeName?: unknown; + branchName?: unknown; + baseBranch?: unknown; + refName?: unknown; + githubPrNumber?: unknown; + workspace?: { source?: { kind?: unknown } }; + }; + if ( + placement && + (placement.workspace?.source?.kind === "worktree" || + [ + placement.worktreeName, + placement.branchName, + placement.baseBranch, + placement.refName, + placement.githubPrNumber, + ].some((value) => value !== undefined)) + ) { + throw new PaseoToolInputError( + "Agent worktree placement was removed. Use create_workspace first, then create_agent with workspaceId.", + ); + } if (callerAgentId) { if (hasLegacyCreateAgentPlacement(args)) { // COMPAT(nestedCreateAgentPlacement): accept the old relationship/workspace shape without // advertising it to models. Added in v0.2.0; remove after 2027-01-17. const parsed = parseToolArguments(legacyAgentToAgentCreateAgentArgsSchema, args); - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, { + const { cwd, workspaceId } = await resolveCreateAgentWorkspace(parsed.workspace, { prompt: parsed.initialPrompt, }); return { @@ -1660,7 +1607,6 @@ function createToolCatalog( detached: parsed.relationship.kind === "detached", cwd, workspaceId, - worktree, }; } const parsed = parseToolArguments(agentToAgentCreateAgentArgsSchema, args); @@ -1673,7 +1619,6 @@ function createToolCatalog( detached: false, cwd, workspaceId, - worktree: undefined, }; } if (hasLegacyCreateAgentPlacement(args)) { @@ -1689,17 +1634,15 @@ function createToolCatalog( if (!parsedArgs.workspace) { throw new PaseoToolInputError("Legacy create_agent placement could not be resolved"); } - const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace( - parsedArgs.workspace, - { prompt: parsedArgs.initialPrompt }, - ); + const { cwd, workspaceId } = await resolveCreateAgentWorkspace(parsedArgs.workspace, { + prompt: parsedArgs.initialPrompt, + }); return { kind: "top-level", parsedArgs, detached: true, cwd, workspaceId, - worktree, }; } const parsedArgs = parseToolArguments(canonicalTopLevelCreateAgentArgsSchema, args); @@ -1713,7 +1656,6 @@ function createToolCatalog( detached: false, cwd, workspaceId, - worktree: undefined, }; } @@ -1770,18 +1712,7 @@ function createToolCatalog( function normalizeTopLevelCreateAgentArgs( args: LegacyTopLevelCreateAgentArgs, ): LegacyTopLevelCreateAgentArgs { - const { - cwd, - mode, - thinking, - features, - worktreeName, - branchName, - baseBranch, - refName, - githubPrNumber, - ...canonicalCandidate - } = args; + const { cwd, mode, thinking, features, ...canonicalCandidate } = args; const settings = { ...canonicalCandidate.settings, ...(mode ? { modeId: mode } : {}), @@ -1804,29 +1735,10 @@ function createToolCatalog( throw new PaseoToolInputError("cwd is required for legacy top-level create_agent calls"); } - const legacyWorktreeTarget = resolveLegacyCreateAgentWorktreeTarget({ - worktreeName, - branchName, - baseBranch, - refName, - githubPrNumber, - }); - const workspace = legacyWorktreeTarget - ? { - kind: "create" as const, - source: { - kind: "worktree" as const, - cwd, - target: legacyWorktreeTarget, - }, - } - : { - kind: "create" as const, - source: { - kind: "directory" as const, - path: cwd, - }, - }; + const workspace = { + kind: "create" as const, + source: { kind: "directory" as const, path: cwd }, + }; return parseToolArguments(legacyTopLevelCreateAgentArgsSchema, { ...canonicalCandidate, @@ -1836,39 +1748,6 @@ function createToolCatalog( }); } - function resolveLegacyCreateAgentWorktreeTarget(input: { - worktreeName?: string; - branchName?: string; - baseBranch?: string; - refName?: string; - githubPrNumber?: number; - }): z.infer | null { - if (input.githubPrNumber !== undefined) { - return { - kind: "checkout-pr", - githubPrNumber: input.githubPrNumber, - }; - } - - if (input.refName) { - return { - kind: "checkout-branch", - branch: input.refName, - }; - } - - if (input.worktreeName || input.branchName || input.baseBranch) { - return { - kind: "branch-off", - worktreeSlug: input.worktreeName, - branchName: input.branchName, - baseBranch: input.baseBranch, - }; - } - - return null; - } - async function resolveCreateAgentWorkspace( workspace: | LegacyAgentToAgentCreateAgentArgs["workspace"] @@ -1877,7 +1756,6 @@ function createToolCatalog( ): Promise<{ cwd: string | undefined; workspaceId: string | undefined; - worktree: CreateAgentFromMcpInput["worktree"]; }> { if (workspace.kind === "current") { if (!callerAgentId) { @@ -1890,7 +1768,6 @@ function createToolCatalog( return { cwd: workspace.cwd, workspaceId: callerAgent.workspaceId, - worktree: undefined, }; } @@ -1916,7 +1793,6 @@ function createToolCatalog( return { cwd, workspaceId: workspace.workspaceId, - worktree: undefined, }; } @@ -1930,42 +1806,12 @@ function createToolCatalog( workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { callerWorkspaceId: resolveCallerAgent()?.workspaceId, }), - worktree: undefined, }; } - const cwd = resolveScopedCwd(workspace.source.cwd, { required: true }); - return { - cwd, - workspaceId: undefined, - worktree: resolveCreateAgentWorktree(workspace.source.target), - }; - } - - function resolveCreateAgentWorktree( - target: z.infer, - ): NonNullable { - switch (target.kind) { - case "branch-off": - return { - action: "branch-off", - worktreeName: target.worktreeSlug, - branchName: target.branchName, - baseBranch: target.baseBranch, - }; - case "checkout-branch": - return { - action: "checkout", - refName: target.branch, - }; - case "checkout-pr": - return { - action: "checkout", - githubPrNumber: target.githubPrNumber, - }; - default: - throw new Error("unreachable"); - } + throw new PaseoToolInputError( + "Use create_workspace first, then create_agent with workspaceId.", + ); } const PROMPTED_AGENT_NOTIFICATION_GUIDANCE = diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index cc0bf08250..f050237620 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -200,7 +200,7 @@ async function createPaseoOwnedWorktree( baseBranch: "main", branchName: worktreeSlug, }, - runSetup: false, + paseoHome, }); } diff --git a/packages/server/src/server/auto-archive-on-merge/behavior.test.ts b/packages/server/src/server/auto-archive-on-merge/behavior.test.ts index c0e20e36f2..218ae4aee3 100644 --- a/packages/server/src/server/auto-archive-on-merge/behavior.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/behavior.test.ts @@ -48,7 +48,7 @@ async function createWorkspaceJourney() { cwd: repoDir, worktreeSlug: "workspace", source: { kind: "branch-off", baseBranch: "main", branchName: "workspace" }, - runSetup: false, + paseoHome, }); const workspaceId = "workspace-under-test"; diff --git a/packages/server/src/server/creation/creation.e2e.test.ts b/packages/server/src/server/creation/creation.e2e.test.ts index f4d35bd3f3..48cecaa4ac 100644 --- a/packages/server/src/server/creation/creation.e2e.test.ts +++ b/packages/server/src/server/creation/creation.e2e.test.ts @@ -4,7 +4,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import { WebSocket } from "ws"; import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test, vi } from "vitest"; @@ -395,3 +395,181 @@ test.each(["agent", "workspace"] as const)( }, 60000, ); + +test("combined worktree and agent creation owns setup on the workspace and replays one identity", async () => { + const directory = await mkdtemp(join(tmpdir(), "workspace-owned-setup-")); + const daemon = await createCreationDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await initializeSetupRepository(directory); + await client.connect(); + const input = { + idempotencyKey: "combined-workspace-setup", + source: { kind: "worktree" as const, cwd: directory, worktreeSlug: "combined-setup" }, + agent: { config: { provider: "codex", cwd: directory } }, + }; + const first = await client.createWorkspace(input); + const replay = await client.createWorkspace(input); + expect(first.error).toBeNull(); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(first.workspace?.id); + expect(replay.agent?.id).toBe(first.agent?.id); + if (!first.workspace || !first.agent) throw new Error("Expected combined creation resources"); + const observation = client.observeWorkspaceSetup(first.workspace.id); + await observation.ready; + await expect + .poll(async () => (await client.fetchWorkspaceSetupStatus(first.workspace!.id)).snapshot, { + timeout: 10000, + }) + .toMatchObject({ + status: "completed", + detail: { + log: "", + commands: [ + expect.objectContaining({ + log: expect.stringContaining("workspace setup output\n"), + status: "completed", + }), + ], + }, + }); + await observation.release(); + expect(await readFile(join(first.workspace.workspaceDirectory!, "setup-runs"), "utf8")).toBe( + "once\n", + ); + const timeline = await client.fetchAgentTimeline(first.agent.id); + expect( + timeline.entries.map((entry) => entry.item).filter((item) => item.type === "tool_call"), + ).toEqual([]); + expect(first.agent.workspaceId).toBe(first.workspace.id); + const conflict = await client.createWorkspace({ + ...input, + source: { ...input.source, worktreeSlug: "different-intent" }, + }); + expect(conflict.error).toBe("workspace_request_key_conflict"); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } +}, 60000); + +test.each(["create_agent_request", "agent.create.request"] as const)( + "%s rejects agent worktree placement at the wire boundary", + async (type) => { + const directory = await mkdtemp(join(tmpdir(), "agent-worktree-rejected-")); + const daemon = await createCreationDaemon(); + const peer = await connectCreationPeer(daemon.port); + try { + for (const placement of [{ git: { createWorktree: true } }, { worktreeName: "legacy" }]) { + const response = await peer.request({ + type, + requestId: randomUUID(), + config: { provider: "codex", cwd: directory }, + ...placement, + }); + if (response.type !== "agent.create.response" && response.type !== "status") + throw new Error("Expected agent creation response"); + const error = response.payload.error; + expect(error).toContain("createWorkspace"); + } + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } + }, +); + +async function initializeSetupRepository(directory: string): Promise { + execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" }); + await writeFile( + join(directory, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [ + "node -e \"require('fs').appendFileSync('setup-runs', 'once\\n'); console.log('workspace setup output')\"", + ], + }, + }), + ); + execFileSync("git", ["add", "paseo.json"], { cwd: directory }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "setup fixture", + ], + { cwd: directory, stdio: "pipe" }, + ); +} + +test("Hub-shaped keyed agent worktree creation uses workspace setup and preserves resource identity", async () => { + const directory = await mkdtemp(join(tmpdir(), "keyed-agent-workspace-setup-")); + const daemon = await createCreationDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const peer = await connectCreationPeer(daemon.port); + try { + await initializeSetupRepository(directory); + await client.connect(); + const input = { + type: "create_agent_request" as const, + idempotencyKey: "keyed-agent-worktree", + config: { provider: "codex", cwd: directory }, + worktree: { mode: "branch-off" as const, newBranch: "keyed-agent-setup", base: "main" }, + }; + const first = await peer.request({ ...input, requestId: "first" }); + const replay = await peer.request({ ...input, requestId: "replay" }); + if ( + first.type !== "status" || + first.payload.status !== "agent_created" || + replay.type !== "status" || + replay.payload.status !== "agent_created" + ) + throw new Error("Expected agent creation responses"); + expect(replay.payload.agent.id).toBe(first.payload.agent.id); + expect(replay.payload.agent.workspaceId).toBe(first.payload.agent.workspaceId); + const agent = first.payload.agent; + if (!agent.workspaceId) throw new Error("Expected agent workspace ownership"); + await expect + .poll(async () => (await client.fetchWorkspaceSetupStatus(agent.workspaceId!)).snapshot, { + timeout: 10000, + }) + .toMatchObject({ + status: "completed", + detail: { + log: "", + commands: [ + expect.objectContaining({ log: expect.stringContaining("workspace setup output\n") }), + ], + }, + }); + expect(await readFile(join(agent.cwd, "setup-runs"), "utf8")).toBe("once\n"); + expect( + (await client.fetchAgentTimeline(agent.id)).entries + .map((entry) => entry.item) + .filter((item) => item.type === "tool_call"), + ).toEqual([]); + const conflict = await peer.request({ + ...input, + requestId: "conflict", + worktree: { ...input.worktree, newBranch: "different-intent" }, + }); + expect(conflict).toMatchObject({ + type: "status", + payload: { status: "agent_create_failed", error: "agent_request_key_conflict" }, + }); + } finally { + peer.close(); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } +}, 60000); diff --git a/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts index 67d39b3134..76ed83b3b2 100644 --- a/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts @@ -106,14 +106,14 @@ githubTest( cwd: repository, worktreeSlug: "merged-feature", source: { kind: "branch-off", baseBranch: "main", branchName: "merged-feature" }, - runSetup: false, + paseoHome: context.daemon.paseoHome, }); const unrelated = await createWorktree({ cwd: repository, worktreeSlug: "never-pushed-unrelated", source: { kind: "branch-off", baseBranch: "main", branchName: "never-pushed-unrelated" }, - runSetup: false, + paseoHome: context.daemon.paseoHome, }); const mergedWorkspace = await context.client.createWorkspace({ diff --git a/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts index d686783835..d7f09182f9 100644 --- a/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-pr-merge.e2e.test.ts @@ -215,7 +215,7 @@ describe("daemon checkout PR merge loop", () => { baseBranch: "main", branchName: "merge-pr-squash", }, - runSetup: true, + paseoHome: ctx.daemon.paseoHome, }); worktreePath = worktree.worktreePath; diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index 9fdc90635d..a9becb6473 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -20,7 +20,7 @@ interface LegacyCreateWorktreeTestOptions { cwd: string; baseBranch: string; worktreeSlug: string; - runSetup?: boolean; + paseoHome?: string; } @@ -39,7 +39,7 @@ function createLegacyWorktreeForTest( baseBranch: options.baseBranch, branchName: options.branchName, }, - runSetup: options.runSetup ?? true, + paseoHome: options.paseoHome, }); } diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index be225f171d..2c49f7f79f 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -12,74 +12,14 @@ import { execFileSync } from "node:child_process"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; -import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; import { withTimeout } from "../../utils/promise-timeout.js"; import { deriveWorktreeProjectHash } from "../../utils/worktree.js"; import { isPlatform } from "../../test-utils/platform.js"; -import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; -import type { SessionOutboundMessage } from "../messages.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } -function findTimelineToolCall( - messages: SessionOutboundMessage[], - agentId: string, - predicate: (item: AgentTimelineItem) => boolean, -): AgentTimelineItem | null { - for (let i = messages.length - 1; i >= 0; i -= 1) { - const msg = messages[i]; - if (msg?.type !== "agent_stream") { - continue; - } - if (msg.payload.agentId !== agentId) { - continue; - } - const event = msg.payload.event as { type?: string; item?: AgentTimelineItem }; - if (event?.type !== "timeline") { - continue; - } - const item = event.item as AgentTimelineItem; - if (item?.type === "tool_call" && predicate(item)) { - return item; - } - } - return null; -} - -async function waitForTimelineToolCall( - messages: SessionOutboundMessage[], - agentId: string, - predicate: (item: AgentTimelineItem) => boolean, - timeoutMs = 10000, -): Promise> { - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - const existing = findTimelineToolCall(messages, agentId, predicate); - if (existing && existing.type === "tool_call") { - return existing; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - const recentToolCalls: Array<{ name: string; status?: string; callId?: string }> = []; - for (let i = messages.length - 1; i >= 0 && recentToolCalls.length < 10; i -= 1) { - const msg = messages[i]; - if (msg?.type !== "agent_stream") continue; - if (msg.payload.agentId !== agentId) continue; - const event = msg.payload.event as { type?: string; item?: AgentTimelineItem }; - if (event?.type !== "timeline") continue; - const item = event.item as AgentTimelineItem; - if (item?.type !== "tool_call") continue; - recentToolCalls.push({ name: item.name, status: item.status, callId: item.callId }); - } - throw new Error( - `Timed out waiting for timeline tool_call (${agentId}). Recent tool_calls: ${JSON.stringify( - recentToolCalls, - )}`, - ); -} - async function waitForPathExists(options: { targetPath: string; timeoutMs: number; @@ -121,43 +61,17 @@ async function withShell(shell: string, run: () => Promise): Promise { } } -interface WorktreeTerminalBootstrapEntry { - name: string | null; - command: string; - status: "started" | "failed"; - terminalId: string | null; - error: string | null; -} - -function getWorktreeTerminalBootstrapEntries( - item: Extract, -): WorktreeTerminalBootstrapEntry[] | null { - const detail = item.detail; - if (!detail || detail.type !== "unknown" || !detail.output) { - return null; - } - const output = detail.output as Record; - const terminals = output.terminals; - if (!Array.isArray(terminals)) { - return null; - } - return terminals as WorktreeTerminalBootstrapEntry[]; -} - // Use gpt-5.4-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; let ctx: DaemonTestContext; -let collector: MessageCollector; beforeEach(async () => { ctx = await createDaemonTestContext(); - collector = createMessageCollector(ctx.client); }); afterEach(async () => { - collector.unsubscribe(); await ctx.cleanup(); }, 60000); @@ -423,7 +337,7 @@ test("returns isGit false for non-git directory", async () => { rmSync(cwd, { recursive: true, force: true }); }, 60000); // 1 minute timeout -test("runs paseo.json setup asynchronously and reports status via timeline tool_call", async () => { +test("runs paseo.json setup asynchronously and reports workspace status", async () => { const repoRoot = tmpCwd(); const { execSync } = await import("child_process"); @@ -461,13 +375,7 @@ test("runs paseo.json setup asynchronously and reports status via timeline tool_ thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: repoRoot, title: "Async Worktree Setup Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-test", - worktreeSlug: "async-setup-test", - }, + worktree: { mode: "branch-off", newBranch: "async-setup-test", base: "main" }, }), timeoutMs: 2500, label: "createAgent should not block on setup", @@ -478,19 +386,11 @@ test("runs paseo.json setup asynchronously and reports status via timeline tool_ writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); - const completed = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "completed", - 20000, - ); - - expect(completed.callId).toBeTruthy(); - expect(completed.detail.type).toBe("worktree_setup"); - if (completed.detail.type === "worktree_setup") { - expect(completed.detail.commands.length).toBeGreaterThan(0); - expect(completed.detail.log.length).toBeGreaterThan(0); - } + const completed = await waitForWorkspaceSetup(agent.workspaceId!, "completed"); + expect(completed.detail.log).toBe(""); + expect(completed.detail.commands).toEqual([ + expect.objectContaining({ status: "completed", exitCode: 0 }), + ]); expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true); await ctx.client.deleteAgent(agent.id); @@ -549,13 +449,7 @@ test("bootstraps configured worktree terminals after setup succeeds", async () = thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: repoRoot, title: "Async Worktree Setup + Terminals Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-terminals-test", - worktreeSlug: "async-setup-terminals-test", - }, + worktree: { mode: "branch-off", newBranch: "async-setup-terminals-test", base: "main" }, }), timeoutMs: 2500, label: "createAgent should not block on setup", @@ -568,25 +462,12 @@ test("bootstraps configured worktree terminals after setup succeeds", async () = writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); - await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "completed", - 20000, - ); - const terminalsBootstrapToolCall = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_terminals" && item.status === "completed", - 30000, - ); - const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries(terminalsBootstrapToolCall); - expect(bootstrappedTerminals).toBeTruthy(); - expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2); - const failedBootstraps = - bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? []; - expect(failedBootstraps).toEqual([]); - + await waitForWorkspaceSetup(agent.workspaceId!, "completed"); + await expect + .poll(async () => (await ctx.client.listTerminals(agent.cwd)).terminals.length, { + timeout: 30000, + }) + .toBe(2); const list = await ctx.client.listTerminals(agent.cwd); expect(list.error).toBeUndefined(); expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true); @@ -639,7 +520,7 @@ test("bootstraps configured worktree terminals after setup succeeds", async () = }); }, 60000); -test("reports failures via timeline tool_call without deleting the created worktree", async () => { +test("reports workspace setup failures without deleting the created worktree", async () => { const repoRoot = tmpCwd(); const { execSync } = await import("child_process"); @@ -687,13 +568,7 @@ test("reports failures via timeline tool_call without deleting the created workt thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: repoRoot, title: "Async Worktree Setup Failure Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-failure-test", - worktreeSlug: "async-setup-failure-test", - }, + worktree: { mode: "branch-off", newBranch: "async-setup-failure-test", base: "main" }, }), timeoutMs: 2500, label: "createAgent should not block on failing setup", @@ -702,23 +577,7 @@ test("reports failures via timeline tool_call without deleting the created workt expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); expect(existsSync(agent.cwd)).toBe(true); - const started = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "running", - 10000, - ); - - const failed = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => - item.name === "paseo_worktree_setup" && - item.callId === started.callId && - item.status === "failed", - 20000, - ); - + const failed = await waitForWorkspaceSetup(agent.workspaceId!, "failed"); expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true); expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false); @@ -726,7 +585,8 @@ test("reports failures via timeline tool_call without deleting the created workt if (failed.detail.type === "worktree_setup") { expect(Array.isArray(failed.detail.commands)).toBe(true); expect(failed.detail.commands[0]?.exitCode).toBe(7); - expect(failed.detail.log).toContain("Exit 7"); + expect(failed.detail.log).toBe(""); + expect(failed.detail.commands[0]?.log).toContain("boom"); } await ctx.client.deleteAgent(agent.id); @@ -756,13 +616,7 @@ test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", as thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Worktree Agent Test", - git: { - createWorktree: true, - createNewBranch: true, - newBranchName: "worktree-test", - worktreeSlug: "worktree-test", - baseBranch: "main", - }, + worktree: { mode: "branch-off", newBranch: "worktree-test", base: "main" }, }); expect(agent.id).toBeTruthy(); @@ -776,7 +630,7 @@ test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", as rmSync(cwd, { recursive: true, force: true }); }, 60000); -test("archiving a worktree shuts down its terminals but leaves the worktree on disk", async () => { +test("archiving a worktree shuts down terminals and removes its final workspace backing", async () => { const repoRoot = tmpCwd(); const { execSync } = await import("child_process"); @@ -823,13 +677,7 @@ test("archiving a worktree shuts down its terminals but leaves the worktree on d thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: repoRoot, title: "Worktree Archive Cleanup Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "archive-cleanup-test", - worktreeSlug: "archive-cleanup-test", - }, + worktree: { mode: "branch-off", newBranch: "archive-cleanup-test", base: "main" }, }), timeoutMs: 2500, label: "createAgent should not block on setup", @@ -858,14 +706,23 @@ test("archiving a worktree shuts down its terminals but leaves the worktree on d expect(archive.success).toBe(true); expect(archive.removedAgents).toContain(agent.id); - // Archiving tears down the workspace's terminals but never deletes the - // worktree from disk and never runs teardown commands — on-disk removal is a - // separate, explicit step. - expect(existsSync(agent.cwd)).toBe(true); - expect(existsSync(teardownMarkerPath)).toBe(false); + // The final workspace reference owns teardown and backing-worktree removal. + expect(existsSync(agent.cwd)).toBe(false); + expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd); const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); expect(afterArchiveDirectories).not.toContain(agent.cwd); rmSync(repoRoot, { recursive: true, force: true }); }, 60000); + +async function waitForWorkspaceSetup(workspaceId: string, status: "completed" | "failed") { + await expect + .poll(async () => (await ctx.client.fetchWorkspaceSetupStatus(workspaceId)).snapshot?.status, { + timeout: 20000, + }) + .toBe(status); + const snapshot = (await ctx.client.fetchWorkspaceSetupStatus(workspaceId)).snapshot; + if (!snapshot) throw new Error("Expected workspace setup snapshot"); + return snapshot; +} diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index a5120aa5ff..0e0ccf34f7 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -59,7 +59,7 @@ test("creates a worktree and registers it in the source workspace project withou cwd: repoDir, worktreeSlug: "feature-one", title: "Feature One", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -114,7 +114,7 @@ test("refreshes a source project that became Git while creating a worktree", asy { cwd: repoDir, worktreeSlug: "project-became-git", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -148,7 +148,7 @@ test("repairs a legacy source workspace whose project record is missing", async { cwd: repoDir, worktreeSlug: "repaired-source", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -192,7 +192,7 @@ test("uses an equivalent source workspace path when creating a worktree", async { cwd: sourceDir, worktreeSlug: "equivalent-source", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -221,7 +221,7 @@ test("creates a worktree workspace at the selected project subdirectory", async cwd: sourceDir, projectId: project.projectId, worktreeSlug: "selected-subdirectory", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -249,7 +249,7 @@ test("seeds an uncommitted exact-project config into the mapped worktree directo { cwd: sourceDir, worktreeSlug: "seed-nested-config", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -277,7 +277,7 @@ test("does not overwrite a committed exact-project config with source checkout e { cwd: sourceDir, worktreeSlug: "preserve-nested-config", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -314,7 +314,7 @@ test("removes a new worktree when its ref does not contain the selected project action: "checkout", refName: "without-subproject", worktreeSlug: "missing-subproject", - runSetup: false, + paseoHome, }, deps, @@ -345,7 +345,7 @@ test("removes a new worktree when workspace persistence fails", async () => { cwd: repoDir, projectId: "missing-project", worktreeSlug: "persistence-failure", - runSetup: false, + paseoHome, }, createDeps(), @@ -373,7 +373,7 @@ test("maps a nested cwd from an existing Paseo worktree into the next worktree", { cwd: repoDir, worktreeSlug: "source-worktree", - runSetup: false, + paseoHome, }, deps, @@ -384,7 +384,7 @@ test("maps a nested cwd from an existing Paseo worktree into the next worktree", { cwd: sourceCwd, worktreeSlug: "nested-worktree", - runSetup: false, + paseoHome, }, deps, @@ -408,7 +408,7 @@ test("rejects source checkout planning before creating a worktree", async () => { cwd: repoDir, worktreeSlug: "must-not-create", - runSetup: false, + paseoHome, }, deps, @@ -443,7 +443,7 @@ test("registers a new worktree in the existing root project after the main check cwd: repoDir, projectId: sourceProject.projectId, worktreeSlug: "second-worktree", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -472,7 +472,7 @@ test("an explicit project FK remains unchanged when its worktree comes from anot cwd: repoDir, projectId: project.projectId, worktreeSlug: "attached-worktree", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -502,7 +502,7 @@ test.skipIf(isPlatform("win32"))( { cwd: repoDir, worktreeSlug: "reuse-me", - runSetup: false, + paseoHome, }, firstDeps, @@ -518,7 +518,7 @@ test.skipIf(isPlatform("win32"))( { cwd: repoDir, worktreeSlug: "reuse-me", - runSetup: false, + paseoHome, }, deps, @@ -541,7 +541,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex { cwd: repoDir, worktreeSlug: "dazzling-yak", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -610,7 +610,7 @@ test("falls back to a numeric suffix when the desired branch name already exists { cwd: repoDir, worktreeSlug: "dazzling-yak", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -647,7 +647,7 @@ test("renames the branch even when the app supplies a random placeholder slug", cwd: repoDir, worktreeSlug: "dazzling-yak", firstAgentContext: { prompt: "Investigate the failing login flow" }, - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -695,7 +695,7 @@ test("renames the branch from a github_pr attachment when no prompt is supplied" }, ], }, - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -740,7 +740,7 @@ test("leaves the branch alone when generated branch text is invalid", async () = cwd: repoDir, worktreeSlug: "dazzling-yak", firstAgentContext: { prompt: "Name this branch" }, - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -785,7 +785,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename cwd: repoDir, action: "checkout", refName: "dev", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -825,7 +825,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren cwd: repoDir, action: "checkout", githubPrNumber: 123, - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, createDeps(), @@ -862,7 +862,7 @@ test("does not mutate registries or broadcast when core worktree creation fails" { cwd: tempDir, worktreeSlug: "not-git", - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }, deps, @@ -888,7 +888,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "restore-me", source: { kind: "checkout-branch", branchName: "restore-me" }, - runSetup: false, + paseoHome, }); expect(existsSync(created.worktreePath)).toBe(true); @@ -903,7 +903,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "restore-me", source: { kind: "checkout-branch", branchName: "restore-me" }, - runSetup: false, + paseoHome, }); @@ -947,7 +947,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "restore-me", source: { kind: "checkout-branch", branchName: "restore-me" }, - runSetup: false, + paseoHome, }); expect(existsSync(created.worktreePath)).toBe(true); @@ -970,7 +970,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "restore-me", source: { kind: "checkout-branch", branchName: "restore-me" }, - runSetup: false, + paseoHome, }), ).rejects.toThrow("missing but already registered worktree"); @@ -982,7 +982,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "restore-me", source: { kind: "checkout-branch", branchName: "restore-me" }, - runSetup: false, + paseoHome, }); @@ -1010,7 +1010,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "gone-branch", source: { kind: "checkout-branch", branchName: "gone-branch" }, - runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), }), ).rejects.toMatchObject({ name: "UnknownBranchError" }); @@ -1029,7 +1029,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "busy-branch", source: { kind: "checkout-branch", branchName: "busy-branch" }, - runSetup: false, + paseoHome, }); expect(existsSync(first.worktreePath)).toBe(true); @@ -1038,7 +1038,7 @@ test.skipIf(isPlatform("win32"))( cwd: repoDir, worktreeSlug: "busy-branch-again", source: { kind: "checkout-branch", branchName: "busy-branch" }, - runSetup: false, + paseoHome, }); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 94e82d5e95..61e00723d0 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -135,7 +135,7 @@ async function createPaseoWorktreeWithPriority( { cwd: createdWorktree.repoRoot, worktreePath: createdWorktree.worktree.worktreePath, - ...(input.runSetup === false ? { teardownCwds: [] } : {}), + teardownCwds: [], paseoHome: input.paseoHome, worktreesBaseRoot: input.worktreesRoot, }, diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index fb23daa6f0..34d1483023 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -411,7 +411,7 @@ test("create_agent_request rejects legacy git options before creating a worktree }, initialPrompt: "Say done.", }), - ).rejects.toThrow("worktree cannot be combined with git options"); + ).rejects.toThrow("createWorkspace"); await expectActiveAgentListEmpty(); await expectWorktreeListEmpty(repoDir); @@ -447,72 +447,6 @@ function storedAgentIds(): string[] { .map((entry) => path.basename(entry, ".json")); } -test.each(["git", "worktreeName"] as const)( - "legacy %s worktree creation resolves final workspace visibility without intermediate records", - async (placement) => { - const cwd = createGitRepo(); - const config = { ...getFullAccessConfig("codex"), cwd }; - const backgroundCaller = await ctx.client.createAgent({ config, background: true }); - const visibleCaller = await ctx.client.createAgent({ config }); - const cases = [ - { name: "review", background: true, expected: true }, - { name: "review-child", callerAgentId: backgroundCaller.id, expected: true }, - { - name: "visible-child", - callerAgentId: backgroundCaller.id, - background: false, - expected: false, - }, - { name: "hidden-child", callerAgentId: visibleCaller.id, background: true, expected: true }, - { name: "human-default", expected: false }, - ]; - const orderedCases = - placement === "worktreeName" ? [cases[1]!, cases[0]!, ...cases.slice(2)] : cases; - for (const entry of orderedCases) { - const before = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }); - const child = await ctx.client.createAgent({ - config, - ...(entry.callerAgentId ? { callerAgentId: entry.callerAgentId } : {}), - ...(entry.background !== undefined ? { background: entry.background } : {}), - ...(placement === "git" - ? { - git: { - createWorktree: true, - createNewBranch: true, - newBranchName: entry.name, - baseBranch: "main", - }, - } - : { worktreeName: entry.name }), - }); - const inclusive = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }); - const created = inclusive.entries.filter( - (workspace) => !before.entries.some((existing) => existing.id === workspace.id), - ); - expect(created).toHaveLength(1); - expect(created[0]).toMatchObject({ - id: child.workspaceId, - workspaceDirectory: child.cwd, - background: entry.expected, - workspaceKind: "worktree", - }); - expect(child.cwd).not.toBe(cwd); - expect(child.labels?.["paseo.parent-agent-id"]).toBe(entry.callerAgentId); - const defaultAgents = await ctx.client.fetchAgents(); - const allAgents = await ctx.client.fetchAgents({ filter: { includeBackground: true } }); - expect(defaultAgents.entries.some((item) => item.agent.id === child.id)).toBe( - !entry.expected, - ); - expect(allAgents.entries.some((item) => item.agent.id === child.id)).toBe(true); - const defaultWorkspaces = await ctx.client.fetchWorkspaces(); - expect( - defaultWorkspaces.entries.some((workspace) => workspace.id === child.workspaceId), - ).toBe(!entry.expected); - } - }, - 30_000, -); - test("background workspaces keep durable agents addressable through restart and archive", async () => { const cwd = createGitRepo(); const config = { ...getFullAccessConfig("codex"), cwd }; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index e114d8b26e..5b85d12a2b 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -4694,7 +4694,6 @@ describe("session paseo worktree creation handling", () => { await asSessionInternals(session).createPaseoWorktree({ cwd: "/tmp/repo", worktreeSlug: "new-worktree", - runSetup: false, }); expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 87949594aa..f2e5e28bf2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -29,7 +29,6 @@ import { type SessionInboundMessage, type SessionOutboundMessage, type ScriptStatusUpdateMessage, - type GitSetupOptions, type StartWorkspaceScriptRequest, type WorkspaceScriptListRequest, type WorkspaceScriptStartRequest, @@ -122,10 +121,6 @@ import { updateAgentCommand, } from "./agent/lifecycle-command.js"; import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js"; -import { - appendTimelineItemIfAgentKnown, - emitLiveTimelineItemIfAgentKnown, -} from "./agent/timeline-append.js"; import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js"; import { parsePluginClientId } from "./plugins/plugin-session-identity.js"; import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; @@ -249,10 +244,7 @@ import { } from "./paseo-worktree-service.js"; import { WorkspaceAutoName } from "./workspace-auto-name.js"; import { - buildAgentSessionConfig as buildWorktreeAgentSessionConfig, - normalizeAgentCreationGitOptions, createPaseoWorktreeWorkflow as createWorktreeWorkflow, - type CreatePaseoWorktreeSetupContinuationInput, type CreatePaseoWorktreeWorkflowResult, handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest, handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest, @@ -4293,17 +4285,19 @@ export class Session { } } - private validatePublicAgentWorkspaceIntent( - request: CreateAgentRequestMessage, - createsWorktree: boolean, - ): void { + private validatePublicAgentWorkspaceIntent(request: CreateAgentRequestMessage): void { + if (request.git !== undefined || request.worktreeName !== undefined) { + throw new Error( + "Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) to create a workspace with an agent, or create the workspace first and pass workspaceId.", + ); + } if (request.internal) throw new Error( "Public internal agents were removed; create a background workspace instead.", ); if ( request.background !== undefined && - (request.workspaceId || (request.callerAgentId && !createsWorktree)) + (request.workspaceId || (request.callerAgentId && !request.worktree)) ) { throw new Error( "background is workspace creation intent; configure the selected workspace at creation.", @@ -4322,7 +4316,6 @@ export class Session { initialPrompt, clientMessageId, outputSchema, - git, worktree, autoArchive, images, @@ -4340,14 +4333,11 @@ export class Session { let createdAgentId: string | null = null; try { const requestedCwd = resolve(config.cwd); - const needsRequestedDirectory = - Boolean(worktreeName || git || worktree) || (!msg.workspaceId && !msg.callerAgentId); + const needsRequestedDirectory = Boolean(worktree) || (!msg.workspaceId && !msg.callerAgentId); if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) { throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`); } - // Resolve all supported worktree input forms before provisioning workspace ownership. - const gitIntent = normalizeAgentCreationGitOptions(git, worktreeName, worktree); - this.validatePublicAgentWorkspaceIntent(msg, gitIntent?.createWorktree === true); + this.validatePublicAgentWorkspaceIntent(msg); const trimmedPrompt = initialPrompt?.trim(); const { provisionalTitle } = resolveCreateAgentTitles({ configTitle: config.title, @@ -4361,9 +4351,8 @@ export class Session { const workspacePromptTitle = resolveFirstAgentPromptTitle(firstAgentContext); const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({ cwd: config.cwd, - target: gitIntent, + target: worktree, firstAgentContext, - setupContinuation: worktree ? undefined : this.agentWorktreeSetupContinuation(), background: msg.background, callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId), }); @@ -4396,7 +4385,6 @@ export class Session { agentId, config: resolvedIntent.config, workspaceId: resolvedIntent.intent.workspaceId, - worktreeName, initialPrompt, source: msg.callerAgentId ? { kind: "agent-message", agentId: msg.callerAgentId } @@ -4405,19 +4393,10 @@ export class Session { outputSchema, images, attachments, - git, labels: resolvedIntent.intent.labels, env, provisionalTitle, firstAgentContext, - buildSessionConfig: async (sessionConfig, gitOptions, legacyWorktreeName, ctx) => - createdWorktree - ? { - sessionConfig, - setupContinuation: createdWorktree.setupContinuation, - createdWorkspaceId: createdWorktree.workspace.workspaceId, - } - : this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx), }, ); createdAgentId = snapshot.id; @@ -4887,58 +4866,6 @@ export class Session { } } - private agentWorktreeSetupContinuation(): CreatePaseoWorktreeSetupContinuationInput { - return { - kind: "agent", - terminalManager: this.terminalManager, - appendTimelineItem: ({ agentId, item }) => - appendTimelineItemIfAgentKnown({ - agentManager: this.agentManager, - agentId, - item, - }), - emitLiveTimelineItem: ({ agentId, item }) => - emitLiveTimelineItemIfAgentKnown({ - agentManager: this.agentManager, - agentId, - item, - }), - logger: this.sessionLogger, - }; - } - - private async buildAgentSessionConfig( - config: AgentSessionConfig, - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, - firstAgentContext?: FirstAgentContext, - ): Promise<{ - sessionConfig: AgentSessionConfig; - setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"]; - createdWorkspaceId?: string; - }> { - return buildWorktreeAgentSessionConfig( - { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - sessionLogger: this.sessionLogger, - workspaceGitService: this.workspaceGitService, - createPaseoWorktree: (input, serviceOptions) => - this.createPaseoWorktreeWorkflow(input, { - ...serviceOptions, - setupContinuation: this.agentWorktreeSetupContinuation(), - }), - checkoutExistingBranch: (cwd, branch) => - this.gitMutation.checkoutExistingBranch(cwd, branch), - createBranchFromBase: (params) => this.gitMutation.createBranchFromBase(params), - }, - config, - gitOptions, - legacyWorktreeName, - firstAgentContext, - ); - } - private isPathWithinRoot(rootPath: string, candidatePath: string): boolean { const resolvedRoot = resolve(rootPath); const resolvedCandidate = resolve(candidatePath); @@ -7400,7 +7327,6 @@ export class Session { input: CreatePaseoWorktreeInput, options?: { resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; }, ): Promise { return createWorktreeWorkflow( diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index fafcbc182a..89bc5a43e3 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -5704,7 +5704,7 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () = cwd: repoDir, worktreeSlug: "keep", source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, + paseoHome, worktreesRoot, }); @@ -5952,7 +5952,7 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t baseBranch: "main", branchName: "worktree-kind-archive", }, - runSetup: false, + paseoHome, }); diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index bc65b01d87..aaa79e3da1 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -1746,7 +1746,6 @@ it("creates a PR from a restored exact base using the host metadata and a forge paseoHome, worktreeSlug: "restored-pr", source: { kind: "branch-off", baseBranch: baseRef, branchName: "restored-pr" }, - runSetup: false, }); writeFileSync(join(created.worktreePath, "feature.txt"), "feature\n"); git(created.worktreePath, "add", "."); @@ -1757,7 +1756,6 @@ it("creates a PR from a restored exact base using the host metadata and a forge paseoHome, worktreeSlug: "restored-pr", source: { kind: "restore", branchName: created.branchName, baseRef }, - runSetup: false, }); const requests: Array[0]> = []; const service: ForgeService = { diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 964f305eb8..f738a0b0d6 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -153,7 +153,7 @@ describe("workspace recovery", () => { cwd: repoDir, worktreeSlug: "mixed-project", source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, + paseoHome, worktreesRoot, }); @@ -209,7 +209,7 @@ describe("workspace recovery", () => { cwd: repoDir, worktreeSlug: "without-subproject", source: { kind: "checkout-branch", branchName: branch }, - runSetup: false, + paseoHome, worktreesRoot, }); @@ -303,7 +303,6 @@ async function createBaseRecoveryFixture(baseBranch: string | null) { paseoHome, worktreeSlug: "base-recovery", source: { kind: "branch-off", branchName: "feature", baseBranch: "main" }, - runSetup: false, }); writeFileSync(join(created.worktreePath, "feature.txt"), "feature\n"); execFileSync("git", ["add", "."], { cwd: created.worktreePath }); diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index 8b0e9a853a..1a715dd349 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -184,7 +184,7 @@ export function createWorkspaceRecoveryService(deps: { cwd: sourceRepoRoot, worktreeSlug: basename(previousWorktreePath), source, - runSetup: false, + paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot, }); diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 22c8661544..a7b36f521d 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -550,7 +550,7 @@ describe("wire compatibility", () => { refName: undefined, action: undefined, githubPrNumber: undefined, - runSetup: false, + paseoHome: "/tmp/paseo-home", }); }); diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 87cb758ac5..df229f9221 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -329,7 +329,7 @@ test("archiving an unregistered worktree in a bare repo clears the main checkout cwd: mainCheckout, source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" }, worktreeSlug: "cached-bare-worktree", - runSetup: false, + paseoHome: ctx.daemon.paseoHome, }); expect(await activeWorkspaceIds()).toEqual(new Set()); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index d1cfb8ada7..a1a901c4d6 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -110,7 +110,7 @@ async function createPaseoOwnedWorktree( baseBranch: "main", branchName: worktreeSlug, }, - runSetup: false, + paseoHome, }); } diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 80208fe63d..fc9efe1684 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -604,12 +604,18 @@ async function expectMissingLocalMergeTarget({ client, cwd }: RestorableWorkspac ).toContain("No local merge target is recorded"); } +function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string { + return detail.commands.map((command) => command.log).join("\n"); +} + // Setup output is opt-in even for multiple sockets sharing one logical client. test("setup details follow one workspace/source and restore after reconnect with minimal directory status", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: true }); const { repoDir, tempRoot } = createGitRepoWithBranch(); const clients: SocketDaemonClient[] = []; const sockets = new Map(); + const setupWireTypes = new Set(); + const setupWireDetails: Array<{ log: string; commands: Array<{ log: string }> }> = []; const observations: Array<{ release(): Promise }> = []; const workspaces: Array<{ id: string; workspaceDirectory?: string }> = []; const mcp = new Client({ name: "setup-test", version: "1.0.0" }); @@ -653,6 +659,24 @@ const timer = setInterval(() => { reconnect: { baseDelayMs: 10, maxDelayMs: 10 }, webSocketFactory: (url) => { const socket = new WebSocket(url); + socket.on("message", (data, isBinary) => { + if (isBinary) return; + const frame = JSON.parse(data.toString()); + const message = frame.message ?? frame; + if (message.type === "workspace_setup_progress") { + setupWireTypes.add(message.type); + setupWireDetails.push(message.payload.detail); + } + if ( + message.type === "workspace_setup_status_response" || + message.type === "workspace.setup.subscribe.response" + ) { + if (message.payload.snapshot) { + setupWireTypes.add(message.type); + setupWireDetails.push(message.payload.snapshot.detail); + } + } + }); sockets.set(clientId, [...(sockets.get(clientId) ?? []), socket]); return socket as unknown as WebSocketLike; }, @@ -683,7 +707,10 @@ const timer = setInterval(() => { }) .toMatchObject({ status: "running", - detail: { log: expect.stringContaining("setup started") }, + detail: { + log: "", + commands: [expect.objectContaining({ log: expect.stringContaining("setup started") })], + }, }); }; for (const worktreeSlug of ["noisy-a", "noisy-b", "noisy-c"]) { @@ -712,7 +739,10 @@ const timer = setInterval(() => { const initial = await details.ready; expect(initial.snapshot).toMatchObject({ status: "running", - detail: { log: expect.stringContaining("setup started") }, + detail: { + log: "", + commands: [expect.objectContaining({ log: expect.stringContaining("setup started") })], + }, }); await expect .poll( @@ -745,7 +775,7 @@ const timer = setInterval(() => { viewerMessages.filter( (message) => message.type === "workspace_setup_progress" && - message.payload.detail.log.includes("wave-0:"), + setupCommandOutput(message.payload.detail).includes("wave-0:"), ).length, ) .toBeGreaterThan(0); @@ -776,10 +806,12 @@ const timer = setInterval(() => { .filter( (message) => message.type === "workspace_setup_progress" && - message.payload.detail.log.includes("wave-0:"), + setupCommandOutput(message.payload.detail).includes("wave-0:"), ) .map((message) => - message.type === "workspace_setup_progress" ? message.payload.detail.log : "", + message.type === "workspace_setup_progress" + ? setupCommandOutput(message.payload.detail) + : "", ); expect(waveLogs.length).toBe(new Set(waveLogs).size); const restoredSnapshots: unknown[] = []; @@ -792,7 +824,12 @@ const timer = setInterval(() => { await expect.poll(() => restoredSnapshots.length, { timeout: 10000 }).toBe(2); expect(details.subscriptionId).not.toBe(beforeReconnect); expect(restoredSnapshots[1]).toMatchObject({ - snapshot: { detail: { log: expect.stringContaining("wave-0:") } }, + snapshot: { + detail: { + log: "", + commands: [expect.objectContaining({ log: expect.stringContaining("wave-0:") })], + }, + }, }); const subscriptionId = details.subscriptionId; await details.release(); @@ -804,7 +841,8 @@ const timer = setInterval(() => { // Wait until the daemon has consumed the output before checking wire silence. await expect .poll( - async () => (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.log, + async () => + (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.commands[0]?.log, { timeout: 30000, }, @@ -815,8 +853,22 @@ const timer = setInterval(() => { ); const resumed = viewer.observeWorkspaceSetup(workspace.id); observations.push(resumed); - expect((await resumed.ready).snapshot?.detail.log).toContain("wave-1:"); + expect((await resumed.ready).snapshot?.detail.commands[0]?.log).toContain("wave-1:"); expect(resumed.subscriptionId).not.toBe(subscriptionId); + expect(setupWireTypes).toEqual( + new Set([ + "workspace_setup_progress", + "workspace_setup_status_response", + "workspace.setup.subscribe.response", + ]), + ); + expect(setupWireDetails.length).toBeGreaterThan(0); + expect(setupWireDetails.every((detail) => detail.log === "")).toBe(true); + expect( + setupWireDetails.some((detail) => + detail.commands.some((command) => command.log.includes("wave-1:")), + ), + ).toBe(true); writeFileSync(path.join(workspaces[1]!.workspaceDirectory!, "finish.txt"), "fail"); await expect .poll( @@ -849,3 +901,30 @@ const timer = setInterval(() => { await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 180000); + +test("agent creation rejects legacy worktree intent without provisioning or executing setup", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await expect( + client.createAgent({ + provider: "codex", + cwd: repoDir, + git: { + createWorktree: true, + createNewBranch: true, + baseBranch: "main", + newBranchName: "legacy-setup", + worktreeSlug: "legacy-setup", + }, + }), + ).rejects.toThrow("createWorkspace"); + expect((await client.fetchWorkspaces()).entries).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/worktree-bootstrap.posix.test.ts b/packages/server/src/server/worktree-bootstrap.posix.test.ts index 41de520d09..36ee6af24f 100644 --- a/packages/server/src/server/worktree-bootstrap.posix.test.ts +++ b/packages/server/src/server/worktree-bootstrap.posix.test.ts @@ -13,31 +13,13 @@ import { } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; -import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js"; +import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { ScriptRouteStore } from "./script-proxy.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { isPlatform } from "../test-utils/platform.js"; -import { - createWorktree as createWorktreePrimitive, - type WorktreeConfig, -} from "../utils/worktree.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; -interface CreateAgentWorktreeTestOptions { - cwd: string; - branchName: string; - baseBranch: string; - worktreeSlug: string; - paseoHome?: string; -} - -interface CreateAgentWorktreeTestResult { - worktree: WorktreeConfig; - shouldBootstrap: boolean; -} - async function cleanupTerminalManager(terminalManager: TerminalManager): Promise { const terminalsByCwd = await Promise.all( terminalManager.listDirectories().map((cwd) => terminalManager.getTerminals(cwd)), @@ -54,28 +36,10 @@ function killTerminal(terminalManager: TerminalManager, terminal: TerminalSessio }); } -async function createBootstrapWorktreeForTest( - options: CreateAgentWorktreeTestOptions, -): Promise { - const worktree = await createWorktreePrimitive({ - cwd: options.cwd, - worktreeSlug: options.worktreeSlug, - source: { - kind: "branch-off", - baseBranch: options.baseBranch, - branchName: options.branchName, - }, - runSetup: false, - paseoHome: options.paseoHome, - }); - return { worktree, shouldBootstrap: true }; -} - describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { - describe("runAsyncWorktreeBootstrap", () => { + describe("workspace scripts", () => { let tempDir: string; let repoDir: string; - let paseoHome: string; let realTerminalManagers: TerminalManager[]; async function waitForPathExists(targetPath: string, timeoutMs = 10000): Promise { @@ -108,7 +72,6 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { realTerminalManagers = []; tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-"))); repoDir = join(tempDir, "repo"); - paseoHome = join(tempDir, "paseo-home"); mkdirSync(repoDir, { recursive: true }); execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" }); @@ -129,290 +92,6 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { await Promise.all(realTerminalManagers.map(cleanupTerminalManager)); rmSync(tempDir, { recursive: true, force: true }); }); - it("streams running setup updates live and persists only a final setup timeline row", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'], - }, - }), - ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], { - cwd: repoDir, - stdio: "pipe", - }); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-streaming-setup", - baseBranch: "main", - worktreeSlug: "feature-streaming-setup", - paseoHome, - }); - - const persisted: AgentTimelineItem[] = []; - const live: AgentTimelineItem[] = []; - - await runAsyncWorktreeBootstrap({ - agentId: "agent-test", - workspaceId: "ws-agent-test", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: null, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async (item: AgentTimelineItem) => { - live.push(item); - return true; - }, - }); - - const liveSetupItems = live.filter( - (item) => - item.type === "tool_call" && - item.name === "paseo_worktree_setup" && - item.status === "running", - ); - expect(liveSetupItems.length).toBeGreaterThan(0); - - const persistedSetupItems = persisted.filter( - (item) => item.type === "tool_call" && item.name === "paseo_worktree_setup", - ); - expect(persistedSetupItems).toHaveLength(1); - expect(persistedSetupItems[0]?.type).toBe("tool_call"); - if (persistedSetupItems[0]?.type === "tool_call") { - expect(persistedSetupItems[0].status).toBe("completed"); - expect(persistedSetupItems[0].detail.type).toBe("worktree_setup"); - - if (persistedSetupItems[0].detail.type === "worktree_setup") { - expect(persistedSetupItems[0].detail.log).toContain( - '==> [1/2] Running: echo "line-one"; echo "line-two" 1>&2', - ); - expect(persistedSetupItems[0].detail.log).toContain("line-one"); - expect(persistedSetupItems[0].detail.log).toContain("line-two"); - expect(persistedSetupItems[0].detail.log).toContain( - '==> [2/2] Running: echo "line-three"', - ); - expect(persistedSetupItems[0].detail.log).toContain("line-three"); - expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/); - expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/); - - expect(persistedSetupItems[0].detail.commands).toHaveLength(2); - expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({ - index: 1, - command: 'echo "line-one"; echo "line-two" 1>&2', - log: expect.stringContaining("line-one"), - status: "completed", - exitCode: 0, - }); - expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two"); - expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({ - index: 2, - command: 'echo "line-three"', - log: "line-three\n", - status: "completed", - exitCode: 0, - }); - expect(typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number").toBe( - true, - ); - expect(typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number").toBe( - true, - ); - } - } - - const liveCallIds = new Set( - liveSetupItems - .filter( - (item): item is Extract => - item.type === "tool_call", - ) - .map((item) => item.callId), - ); - expect(liveCallIds.size).toBe(1); - if (persistedSetupItems[0]?.type === "tool_call") { - expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true); - } - }); - - it("keeps only the final carriage-return-updated content in command logs", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [ - `node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`, - ], - }, - }), - ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); - execFileSync( - "git", - ["-c", "commit.gpgsign=false", "commit", "-m", "add carriage return setup"], - { - cwd: repoDir, - stdio: "pipe", - }, - ); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-carriage-return", - baseBranch: "main", - worktreeSlug: "feature-carriage-return", - paseoHome, - }); - - const persisted: AgentTimelineItem[] = []; - await runAsyncWorktreeBootstrap({ - agentId: "agent-carriage-return", - workspaceId: "ws-carriage-return", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: null, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async () => true, - }); - - const persistedSetupItem = persisted.find( - (item): item is Extract => - item.type === "tool_call" && item.name === "paseo_worktree_setup", - ); - expect(persistedSetupItem?.detail.type).toBe("worktree_setup"); - if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") { - throw new Error("Expected worktree_setup tool detail"); - } - - expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n"); - expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n"); - expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n"); - expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n"); - }); - - it("shares the same worktree runtime port across setup and bootstrap terminals", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: ['echo "$PASEO_WORKTREE_PORT" > setup-port.txt'], - terminals: [ - { - name: "Port Terminal", - command: "true", - }, - ], - }, - }), - ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); - execFileSync( - "git", - ["-c", "commit.gpgsign=false", "commit", "-m", "add port setup and terminals"], - { - cwd: repoDir, - stdio: "pipe", - }, - ); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-shared-runtime-port", - baseBranch: "main", - worktreeSlug: "feature-shared-runtime-port", - paseoHome, - }); - - const registeredEnvs: Array<{ cwd: string; env: Record }> = []; - const createTerminalEnvs: Record[] = []; - const createTerminalWorkspaceIds: Array = []; - const persisted: AgentTimelineItem[] = []; - await runAsyncWorktreeBootstrap({ - agentId: "agent-shared-runtime-port", - workspaceId: "ws-shared-runtime-port", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: { - async getTerminals() { - return []; - }, - async createTerminal(options) { - createTerminalEnvs.push(options.env ?? {}); - createTerminalWorkspaceIds.push(options.workspaceId); - return { - id: "term-1", - name: options.name ?? "Terminal", - cwd: options.cwd, - send: () => {}, - subscribe: () => () => {}, - onExit: () => () => {}, - onCommandFinished: () => () => {}, - onTitleChange: () => () => {}, - getSize: () => ({ rows: 1, cols: 1 }), - getTitle: () => undefined, - getExitInfo: () => null, - getState: () => ({ - rows: 1, - cols: 1, - grid: [[{ char: "$" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }), - kill: () => {}, - killAndWait: async () => {}, - }; - }, - registerCwdEnv(options) { - registeredEnvs.push({ cwd: options.cwd, env: options.env }); - }, - getTerminal() { - return undefined; - }, - killTerminal() {}, - async killTerminalAndWait() {}, - listDirectories() { - return []; - }, - killAll() {}, - subscribeTerminalsChanged() { - return () => {}; - }, - }, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async () => true, - }); - - const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt"); - await waitForPathExists(setupPortPath); - - const setupPort = readFileSync(setupPortPath, "utf8").trim(); - expect(setupPort.length).toBeGreaterThan(0); - expect(registeredEnvs).toHaveLength(1); - expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath); - expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort); - expect(createTerminalEnvs.length).toBeGreaterThan(0); - expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort); - expect(createTerminalWorkspaceIds).toEqual(["ws-shared-runtime-port"]); - - const terminalToolCall = persisted.find( - (item): item is Extract => - item.type === "tool_call" && - item.name === "paseo_worktree_terminals" && - item.status === "completed", - ); - expect(terminalToolCall?.status).toBe("completed"); - }); it("injects real peer service env into terminal-backed services", async () => { writeFileSync( diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 5e5ac1762e..96da6e648b 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -3,9 +3,11 @@ import { execFileSync } from "child_process"; import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; - -import type { AgentTimelineItem } from "./agent/agent-sdk-types.js"; -import { runAsyncWorktreeBootstrap, spawnWorkspaceScript } from "./worktree-bootstrap.js"; +import { + buildWorktreeSetupDetail, + runWorktreeAutoTerminals, + spawnWorkspaceScript, +} from "./worktree-bootstrap.js"; import { ensureWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ScriptRouteStore } from "./script-proxy.js"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; @@ -13,6 +15,7 @@ import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js import { createWorktree as createWorktreePrimitive, type WorktreeConfig, + resolveWorktreeRuntimeEnv, } from "../utils/worktree.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; @@ -57,13 +60,58 @@ async function createBootstrapWorktreeForTest( baseBranch: options.baseBranch, branchName: options.branchName, }, - runSetup: false, + paseoHome: options.paseoHome, }); return { worktree, shouldBootstrap: true }; } -describe("runAsyncWorktreeBootstrap", () => { +describe("workspace setup detail", () => { + it("serializes output only in command logs while retaining the legacy string field", () => { + const detail = buildWorktreeSetupDetail({ + worktree: { worktreePath: "/workspace", branchName: "feature" }, + results: [ + { + command: "echo ready", + cwd: "/workspace", + stdout: "ready\n", + stderr: "", + exitCode: 0, + durationMs: 10, + }, + ], + }); + const serialized = JSON.stringify(detail); + expect(JSON.parse(serialized)).toMatchObject({ + log: "", + commands: [{ log: "ready\n", status: "completed", exitCode: 0 }], + }); + expect(serialized.split("ready\\n")).toHaveLength(2); + }); + it("retains bounded per-command output and truncation without an aggregate copy", () => { + const detail = buildWorktreeSetupDetail({ + worktree: { worktreePath: "/workspace", branchName: "feature" }, + results: [ + { + command: "large-output", + cwd: "/workspace", + stdout: "prefix-" + "x".repeat(100000) + "-suffix", + stderr: "", + exitCode: 0, + durationMs: 10, + }, + ], + }); + expect(detail.log).toBe(""); + expect(detail.truncated).toBe(true); + expect(detail.commands[0]?.log).toContain("prefix-"); + expect(detail.commands[0]?.log).toContain("-suffix"); + expect(detail.commands[0]?.log).toContain("......"); + expect(Buffer.byteLength(detail.commands[0]!.log)).toBeLessThanOrEqual(64 * 1024); + }); +}); + +describe("runWorktreeAutoTerminals", () => { let tempDir: string; let repoDir: string; let paseoHome: string; @@ -92,116 +140,6 @@ describe("runAsyncWorktreeBootstrap", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("does not fail setup when live timeline emission throws", async () => { - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: ['echo "ok"'], - }, - }), - ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add setup"], { - cwd: repoDir, - stdio: "pipe", - }); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-live-failure", - baseBranch: "main", - worktreeSlug: "feature-live-failure", - paseoHome, - }); - - const persisted: AgentTimelineItem[] = []; - await expect( - runAsyncWorktreeBootstrap({ - agentId: "agent-live-failure", - workspaceId: "ws-live-failure", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: null, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async () => { - throw new Error("live emit failed"); - }, - }), - ).resolves.toBeUndefined(); - - const persistedSetupItems = persisted.filter( - (item) => item.type === "tool_call" && item.name === "paseo_worktree_setup", - ); - expect(persistedSetupItems).toHaveLength(1); - if (persistedSetupItems[0]?.type === "tool_call") { - expect(persistedSetupItems[0].status).toBe("completed"); - } - }); - - it("truncates each command output to 64kb in the middle", async () => { - const largeOutputCommand = - "node -e \"process.stdout.write('prefix-'); process.stdout.write('x'.repeat(70000)); process.stdout.write('-suffix')\""; - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [largeOutputCommand], - }, - }), - ); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add large output setup"], { - cwd: repoDir, - stdio: "pipe", - }); - - const worktreeBootstrap = await createBootstrapWorktreeForTest({ - cwd: repoDir, - branchName: "feature-large-output", - baseBranch: "main", - worktreeSlug: "feature-large-output", - paseoHome, - }); - - const persisted: AgentTimelineItem[] = []; - await runAsyncWorktreeBootstrap({ - agentId: "agent-large-output", - workspaceId: "ws-large-output", - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: null, - appendTimelineItem: async (item) => { - persisted.push(item); - return true; - }, - emitLiveTimelineItem: async () => true, - }); - - const persistedSetupItem = persisted.find( - (item): item is Extract => - item.type === "tool_call" && item.name === "paseo_worktree_setup", - ); - expect(persistedSetupItem).toBeDefined(); - expect(persistedSetupItem?.detail.type).toBe("worktree_setup"); - if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") { - throw new Error("Expected worktree_setup tool detail"); - } - - expect(persistedSetupItem.detail.truncated).toBe(true); - expect(persistedSetupItem.detail.log).toContain("prefix-"); - expect(persistedSetupItem.detail.log).toContain("-suffix"); - expect(persistedSetupItem.detail.log).toContain("......"); - expect(persistedSetupItem.detail.commands[0]?.log).toContain("prefix-"); - expect(persistedSetupItem.detail.commands[0]?.log).toContain("-suffix"); - expect(persistedSetupItem.detail.commands[0]?.log).toContain( - "......", - ); - }); - it("waits for terminal output before sending bootstrap commands", async () => { writeFileSync( join(repoDir, "paseo.json"), @@ -238,11 +176,11 @@ describe("runAsyncWorktreeBootstrap", () => { let sendAt = 0; let outputListener: ((chunk: { data: string }) => void) | null = null; - await runAsyncWorktreeBootstrap({ - agentId: "agent-terminal-readiness", + await runWorktreeAutoTerminals({ workspaceId: "ws-terminal-readiness", worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, + workspaceCwd: worktreeBootstrap.worktree.worktreePath, + runtimeEnv: await resolveWorktreeRuntimeEnv(worktreeBootstrap.worktree), terminalManager: { async getTerminals() { return []; @@ -296,8 +234,6 @@ describe("runAsyncWorktreeBootstrap", () => { return () => {}; }, }, - appendTimelineItem: async () => true, - emitLiveTimelineItem: async () => true, }); expect(readyAt).toBeGreaterThan(0); diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 4bd49b1c2d..6e138e8ab4 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -1,4 +1,3 @@ -import { v4 as uuidv4 } from "uuid"; import type { Logger } from "pino"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; @@ -9,9 +8,7 @@ import { paseoConfigParseError, processCarriageReturns, readPaseoConfig, - resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, - WorktreeSetupError, type WorktreeConfig, type WorktreeSetupCommandResult, type WorktreeRuntimeEnv, @@ -19,7 +16,7 @@ import { import type { ServiceProxySubsystem } from "./service-proxy.js"; import { allocateWorkspaceServicePort } from "./workspace-service-port-allocator.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; -import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js"; +import type { ToolCallDetail } from "./agent/agent-sdk-types.js"; import { assertNoServiceEnvNameCollisions, buildWorkspaceServiceEnv, @@ -32,28 +29,6 @@ import { } from "./workspace-service-port-registry.js"; import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; -export interface WorktreeBootstrapTerminalResult { - name: string | null; - command: string; - status: "started" | "failed"; - terminalId: string | null; - error: string | null; -} - -export interface RunAsyncWorktreeBootstrapOptions { - agentId: string; - // Workspace the bootstrapped terminals belong to. Stamping it lets - // workspaceId-scoped archive tear these terminals down. - workspaceId: string; - worktree: WorktreeConfig; - workspaceCwd?: string; - shouldBootstrap?: boolean; - terminalManager: TerminalManager | null; - appendTimelineItem: (item: AgentTimelineItem) => Promise; - emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise; - logger?: Logger; -} - const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024; const WORKTREE_SETUP_TRUNCATION_MARKER = "\n......\n"; const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500; @@ -170,10 +145,6 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula }; } -function formatDurationMs(durationMs: number): string { - return `${(durationMs / 1000).toFixed(2)}s`; -} - function commandStatusFromResult( result: WorktreeSetupCommandResult, ): "running" | "completed" | "failed" { @@ -183,46 +154,6 @@ function commandStatusFromResult( return result.exitCode === 0 ? "completed" : "failed"; } -function buildWorktreeSetupLog(input: { - results: WorktreeSetupCommandResult[]; - outputAccumulatorsByIndex?: Map; -}): { log: string; truncated: boolean } { - const { results, outputAccumulatorsByIndex } = input; - if (results.length === 0) { - return { - log: "", - truncated: false, - }; - } - - const lines: string[] = []; - let anyTruncated = false; - const total = results.length; - for (const [index, result] of results.entries()) { - lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`); - const output = buildWorktreeSetupCommandLog({ - index: index + 1, - result, - outputAccumulatorsByIndex, - }); - if (output.log.length > 0) { - lines.push(output.log.replace(/\n$/, "")); - } - if (output.truncated) { - anyTruncated = true; - } - if (result.exitCode !== null) { - lines.push( - `<== [${index + 1}/${total}] Exit ${result.exitCode} in ${formatDurationMs(result.durationMs)}`, - ); - } - } - return { - log: lines.join("\n"), - truncated: anyTruncated, - }; -} - function buildWorktreeSetupCommandLog(input: { index: number; result: WorktreeSetupCommandResult; @@ -325,124 +256,15 @@ export function buildWorktreeSetupDetail(input: { ...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}), }; }); - const renderedLog = buildWorktreeSetupLog({ - results: input.results, - outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, - }); - return { type: "worktree_setup", worktreePath: input.worktree.worktreePath, branchName: input.worktree.branchName, - log: renderedLog.log, + // COMPAT(workspaceSetupCombinedLog): added in v0.12.0, remove after 2027-04-20 once legacy wire schemas no longer require log. + // Setup clients render commands[].log; retain the required wire field without duplicating output. + log: "", commands, - ...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}), - }; -} - -function buildSetupTimelineItem(input: { - callId: string; - status: "running" | "completed" | "failed"; - worktree: WorktreeConfig; - results: WorktreeSetupCommandResult[]; - outputAccumulatorsByIndex?: Map; - errorMessage: string | null; -}): AgentTimelineItem { - const detail = buildWorktreeSetupDetail({ - worktree: input.worktree, - results: input.results, - outputAccumulatorsByIndex: input.outputAccumulatorsByIndex, - }); - - if (input.status === "running") { - return { - type: "tool_call", - name: "paseo_worktree_setup", - callId: input.callId, - status: "running", - detail, - error: null, - }; - } - - if (input.status === "completed") { - return { - type: "tool_call", - name: "paseo_worktree_setup", - callId: input.callId, - status: "completed", - detail, - error: null, - }; - } - - return { - type: "tool_call", - name: "paseo_worktree_setup", - callId: input.callId, - status: "failed", - detail, - error: { message: input.errorMessage ?? "Worktree setup failed" }, - }; -} - -function buildTerminalTimelineItem(input: { - callId: string; - status: "running" | "completed" | "failed"; - worktree: WorktreeConfig; - results: WorktreeBootstrapTerminalResult[]; - errorMessage: string | null; -}): AgentTimelineItem { - const detailInput = { - worktreePath: input.worktree.worktreePath, - branchName: input.worktree.branchName, - }; - const detailOutput = { - worktreePath: input.worktree.worktreePath, - terminals: input.results, - }; - - if (input.status === "running") { - return { - type: "tool_call", - name: "paseo_worktree_terminals", - callId: input.callId, - status: "running", - detail: { - type: "unknown", - input: detailInput, - output: null, - }, - error: null, - }; - } - - if (input.status === "completed") { - return { - type: "tool_call", - name: "paseo_worktree_terminals", - callId: input.callId, - status: "completed", - detail: { - type: "unknown", - input: detailInput, - output: detailOutput, - }, - error: null, - }; - } - - return { - type: "tool_call", - name: "paseo_worktree_terminals", - callId: input.callId, - status: "failed", - detail: { - type: "unknown", - input: detailInput, - output: detailOutput, - }, - error: { message: input.errorMessage ?? "Worktree terminal bootstrap failed" }, + ...(anyCommandTruncated ? { truncated: true } : {}), }; } @@ -502,93 +324,6 @@ function terminalHasOutput(state: ReturnType): bool return false; } -async function runWorktreeTerminalBootstrap( - options: RunAsyncWorktreeBootstrapOptions, - runtimeEnv: WorktreeRuntimeEnv, -): Promise { - const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath; - const terminalSpecs = getWorktreeTerminalSpecs(workspaceCwd); - if (terminalSpecs.length === 0) { - return; - } - - const callId = uuidv4(); - const started = await options.appendTimelineItem( - buildTerminalTimelineItem({ - callId, - status: "running", - worktree: options.worktree, - results: [], - errorMessage: null, - }), - ); - if (!started) { - return; - } - - if (!options.terminalManager) { - await options.appendTimelineItem( - buildTerminalTimelineItem({ - callId, - status: "failed", - worktree: options.worktree, - results: [], - errorMessage: "Terminal manager not available", - }), - ); - return; - } - - const terminalManager = options.terminalManager; - const results = await Promise.all( - terminalSpecs.map(async (spec): Promise => { - try { - const terminal = await terminalManager.createTerminal({ - cwd: workspaceCwd, - name: spec.name, - env: runtimeEnv, - workspaceId: options.workspaceId, - }); - await waitForTerminalBootstrapReadiness(terminal); - terminal.send({ - type: "input", - data: `${spec.command}\r`, - }); - return { - name: terminal.name ?? spec.name ?? null, - command: spec.command, - status: "started", - terminalId: terminal.id, - error: null, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - options.logger?.warn( - { agentId: options.agentId, command: spec.command, err: error }, - "Failed to bootstrap worktree terminal", - ); - return { - name: spec.name ?? null, - command: spec.command, - status: "failed", - terminalId: null, - error: message, - }; - } - }), - ); - - await options.appendTimelineItem( - buildTerminalTimelineItem({ - callId, - status: "completed", - worktree: options.worktree, - results, - errorMessage: null, - }), - ); -} - export async function runWorktreeAutoTerminals(options: { workspaceId: string; worktree: WorktreeConfig; @@ -597,117 +332,27 @@ export async function runWorktreeAutoTerminals(options: { terminalManager: TerminalManager | null; logger?: Logger; }): Promise { - await runWorktreeTerminalBootstrap( - { - agentId: options.workspaceId, - workspaceId: options.workspaceId, - worktree: options.worktree, - workspaceCwd: options.workspaceCwd, - terminalManager: options.terminalManager, - appendTimelineItem: async () => true, - logger: options.logger, - }, - options.runtimeEnv, - ); -} - -export async function runAsyncWorktreeBootstrap( - options: RunAsyncWorktreeBootstrapOptions, -): Promise { - if (options.shouldBootstrap === false) { - return; - } - - const setupCallId = uuidv4(); - let setupResults: WorktreeSetupCommandResult[] = []; - let runtimeEnv: WorktreeRuntimeEnv | null = null; - const emitLiveTimelineItem = options.emitLiveTimelineItem; - const progressAccumulator = createWorktreeSetupProgressAccumulator(); - const workspaceCwd = options.workspaceCwd ?? options.worktree.worktreePath; - let liveEmitQueue = Promise.resolve(); - - const queueLiveRunningEmit = () => { - if (!emitLiveTimelineItem) { - return; - } - const runningResults = getWorktreeSetupProgressResults(progressAccumulator); - liveEmitQueue = liveEmitQueue.then(async () => { + if (!options.terminalManager) return; + const terminalManager = options.terminalManager; + await Promise.all( + getWorktreeTerminalSpecs(options.workspaceCwd).map(async (spec) => { try { - await emitLiveTimelineItem( - buildSetupTimelineItem({ - callId: setupCallId, - status: "running", - worktree: options.worktree, - results: runningResults, - outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, - errorMessage: null, - }), - ); + const terminal = await terminalManager.createTerminal({ + cwd: options.workspaceCwd, + name: spec.name, + env: options.runtimeEnv, + workspaceId: options.workspaceId, + }); + await waitForTerminalBootstrapReadiness(terminal); + terminal.send({ type: "input", data: `${spec.command}\r` }); } catch (error) { options.logger?.warn( - { err: error, agentId: options.agentId }, - "Failed to emit live worktree setup timeline update", + { workspaceId: options.workspaceId, command: spec.command, err: error }, + "Failed to bootstrap worktree terminal", ); } - return; - }); - }; - - try { - runtimeEnv = await resolveWorktreeRuntimeEnv({ - worktreePath: options.worktree.worktreePath, - branchName: options.worktree.branchName, - }); - options.terminalManager?.registerCwdEnv({ - cwd: workspaceCwd, - env: runtimeEnv, - }); - - setupResults = await runWorktreeSetupCommands({ - worktreePath: workspaceCwd, - branchName: options.worktree.branchName, - cleanupOnFailure: false, - runtimeEnv, - onEvent: (event) => { - applyWorktreeSetupProgressEvent(progressAccumulator, event); - queueLiveRunningEmit(); - }, - }); - await liveEmitQueue; - - const completed = await options.appendTimelineItem( - buildSetupTimelineItem({ - callId: setupCallId, - status: "completed", - worktree: options.worktree, - results: setupResults, - outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, - errorMessage: null, - }), - ); - if (!completed) { - return; - } - } catch (error) { - if (error instanceof WorktreeSetupError) { - setupResults = error.results; - } - await liveEmitQueue; - const message = error instanceof Error ? error.message : String(error); - await options.appendTimelineItem( - buildSetupTimelineItem({ - callId: setupCallId, - status: "failed", - worktree: options.worktree, - results: setupResults, - outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex, - errorMessage: message, - }), - ); - return; - } - - await runWorktreeTerminalBootstrap(options, runtimeEnv); + }), + ); } // --------------------------------------------------------------------------- diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index d5f85ae364..9d02d0a2ec 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -405,7 +405,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { cwd: repoDir, worktreeSlug: "legacy-rpc", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -431,7 +430,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "branch-off", refName: "main", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -452,7 +450,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { { cwd: repoDir, paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -475,7 +472,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 123, refName: "feature/review-pr", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -502,7 +498,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 123, refName: "feature/review-pr", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -520,7 +515,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { cwd: repoDir, worktreeSlug: "mcp-standalone", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -547,7 +541,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "branch-off", refName: "dev", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -576,7 +569,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", refName: "dev", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -604,7 +596,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 123, paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -656,7 +647,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -694,7 +684,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 14, paseoHome, - runSetup: false, }, createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }), ); @@ -746,7 +735,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 14, paseoHome, - runSetup: false, }, createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }), ); @@ -796,7 +784,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 14, paseoHome, - runSetup: false, }, createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }), ); @@ -841,7 +828,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 14, paseoHome, - runSetup: false, }, createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }), ); @@ -852,7 +838,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 14, paseoHome, - runSetup: false, }, createCoreDeps({ forge: { forge: "gitlab", service: gitlab } }), ); @@ -910,7 +895,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -955,7 +939,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -997,7 +980,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1008,7 +990,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1118,7 +1099,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1129,7 +1109,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", githubPrNumber: 1790, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1179,7 +1158,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 526, refName: "main", paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1285,7 +1263,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 526, refName: "main", paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1383,7 +1360,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 526, refName: "main", paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1395,7 +1371,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 526, refName: "main", paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); @@ -1417,7 +1392,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { action: "checkout", refName: "missing-branch", paseoHome, - runSetup: false, }, createCoreDeps(), ), @@ -1433,7 +1407,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { cwd: repoDir, worktreeSlug: "agent-worktree", paseoHome, - runSetup: false, }, createCoreDeps(), ); @@ -1452,11 +1425,11 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { const deps = createCoreDeps(); const first = await createCoreWorktree( - { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false }, + { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome }, deps, ); const second = await createCoreWorktree( - { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome, runSetup: false }, + { cwd: repoDir, worktreeSlug: "reused-worktree", paseoHome }, deps, ); @@ -1475,7 +1448,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { githubPrNumber: 123, refName: "feature/review-pr", paseoHome, - runSetup: false, }; const first = await createCoreWorktree(input, deps); @@ -1515,7 +1487,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { worktreeSlug: "stubbed-github", githubPrNumber: 123, paseoHome, - runSetup: false, }, createCoreDeps({ github }), ); diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index c66e91c5dd..b0310d6e1f 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -28,7 +28,6 @@ export interface CreateWorktreeCoreInput { firstAgentContext?: FirstAgentContext; paseoHome?: string; worktreesRoot?: string; - runSetup?: boolean; } export interface CreateWorktreeCoreDeps { @@ -120,7 +119,6 @@ async function createWorktreeCoreWithPriority( cwd: repoRoot, worktreeSlug: normalizedSlug, source: intent, - runSetup: input.runSetup ?? true, paseoHome: input.paseoHome, worktreesRoot: input.worktreesRoot, }), diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index b146c59f31..d462a2605a 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -15,11 +15,9 @@ import pino, { type Logger } from "pino"; import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js"; import { - buildAgentSessionConfig, createPaseoWorktreeWorkflow, handlePaseoWorktreeArchiveRequest, handlePaseoWorktreeListRequest, - resolveGitCreateBaseBranch, runWorktreeSetupInBackground, handleCreatePaseoWorktreeRequest, handleWorkspaceSetupStatusRequest, @@ -57,7 +55,7 @@ interface LegacyCreateWorktreeTestOptions { cwd: string; baseBranch: string; worktreeSlug: string; - runSetup?: boolean; + paseoHome?: string; } @@ -76,7 +74,7 @@ function createLegacyWorktreeForTest( baseBranch: options.baseBranch, branchName: options.branchName, }, - runSetup: options.runSetup ?? true, + paseoHome: options.paseoHome, }); } @@ -122,7 +120,6 @@ function createWorkflowForRequestTest(options: { onScriptsChanged: null, }, input, - { setupContinuation: { kind: "workspace" } }, ).then((result) => { options.onSetupStarted?.({ requestCwd: input.cwd, @@ -428,30 +425,6 @@ describe("handlePaseoWorktreeListRequest", () => { }); }); -describe("resolveGitCreateBaseBranch", () => { - test("resolves the default branch through the workspace git service", async () => { - const { tempDir, repoDir } = createGitRepo(); - const cwd = path.join(repoDir, "packages", "app"); - const workspaceGitService = { - resolveDefaultBranch: vi.fn().mockResolvedValue("main"), - getSnapshot: vi.fn(async () => { - throw new Error("getSnapshot should not be used for default-branch resolution"); - }), - }; - - try { - await expect( - resolveGitCreateBaseBranch(cwd, workspaceGitService as unknown as WorkspaceGitService), - ).resolves.toBe("main"); - - expect(workspaceGitService.resolveDefaultBranch).toHaveBeenCalledWith(cwd); - expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); -}); - describe("create-agent worktree setup boundary", () => { test("blocked worktrees keep their workspace but skip setup and automatic terminals", async () => { const { tempDir, repoDir } = createGitRepo(); @@ -469,7 +442,7 @@ describe("create-agent worktree setup boundary", () => { ); try { - const result = await createPaseoWorktreeWorkflow( + await createPaseoWorktreeWorkflow( { paseoHome, createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), @@ -494,19 +467,9 @@ describe("create-agent worktree setup boundary", () => { getDaemonTcpHost: null, onScriptsChanged: null, }, - { cwd: repoDir, worktreeSlug: "blocked-fork", runSetup: false, paseoHome }, - { - setupContinuation: { - kind: "agent", - terminalManager: createTerminalManagerStub().manager, - appendTimelineItem: async () => true, - logger: createLogger(), - }, - }, + { cwd: repoDir, worktreeSlug: "blocked-fork", paseoHome }, ); - expect(result.setupContinuation?.kind).toBe("agent"); - result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-fork" }); expect(existsSync(setupMarker)).toBe(false); expect(emitted).toContainEqual( expect.objectContaining({ @@ -518,79 +481,6 @@ describe("create-agent worktree setup boundary", () => { rmSync(tempDir, { recursive: true, force: true }); } }); - - test("agent setup continuation starts setup for the created agent timeline", async () => { - const { tempDir, repoDir } = createGitRepo(); - const paseoHome = path.join(tempDir, ".paseo"); - const appendedItems: Array<{ name: string; status: string }> = []; - const liveItems: Array<{ name: string; status: string }> = []; - const workspaceSetupEvents: SessionOutboundMessage[] = []; - - try { - const result = await createPaseoWorktreeWorkflow( - { - paseoHome, - createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), - warmWorkspaceGitData: async () => {}, - autoNameWorkspaceBranchForFirstAgent: () => {}, - emitWorkspaceUpdateForWorkspaceId: async () => {}, - cacheWorkspaceSetupSnapshot: () => {}, - emit: (message) => workspaceSetupEvents.push(message), - sessionLogger: createLogger(), - terminalManager: null, - serviceProxy: null, - scriptRuntimeStore: null, - getDaemonTcpPort: null, - getDaemonTcpHost: null, - onScriptsChanged: null, - }, - { - cwd: repoDir, - worktreeSlug: "agent-setup-after-create", - runSetup: false, - paseoHome, - }, - { - setupContinuation: { - kind: "agent", - terminalManager: createTerminalManagerStub().manager, - appendTimelineItem: async ({ agentId, item }) => { - expect(agentId).toBe("agent-after-create"); - if (item.type !== "tool_call") { - throw new Error(`Expected tool call timeline item, got ${item.type}`); - } - appendedItems.push({ name: item.name, status: item.status }); - return true; - }, - emitLiveTimelineItem: async ({ agentId, item }) => { - expect(agentId).toBe("agent-after-create"); - if (item.type !== "tool_call") { - throw new Error(`Expected tool call timeline item, got ${item.type}`); - } - liveItems.push({ name: item.name, status: item.status }); - return true; - }, - logger: createLogger(), - }, - }, - ); - - expect(result.setupContinuation?.kind).toBe("agent"); - expect(workspaceSetupEvents).toEqual([]); - - result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-after-create" }); - - await vi.waitFor(() => { - expect(appendedItems).toContainEqual({ - name: "paseo_worktree_setup", - status: "completed", - }); - }); - expect(liveItems).toEqual([]); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); }); function createAgentStorageStub(): Pick { @@ -698,7 +588,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "feature-subdirectory-setup", - runSetup: false, + paseoHome, }); const workspaceCwd = path.join(createdWorktree.worktreePath, "packages", "app"); @@ -746,7 +636,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "feature-no-setup", - runSetup: false, + paseoHome, }); const worktreePath = createdWorktree.worktreePath; @@ -842,7 +732,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "broken-feature", - runSetup: false, + paseoHome, }); const worktreePath = createdWorktree.worktreePath; @@ -916,7 +806,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "feature-running-setup", - runSetup: false, + paseoHome, }); const worktreePath = createdWorktree.worktreePath; @@ -983,7 +873,7 @@ describe("runWorktreeSetupInBackground", () => { const setupOutputMessage = runningMessages.find((message) => message.payload.detail.commands[0]?.log.includes("phase-one"), ); - expect(setupOutputMessage?.payload.detail.log).toContain("phase-one"); + expect(setupOutputMessage?.payload.detail.log).toBe(""); expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({ index: 1, command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"", @@ -1001,7 +891,7 @@ describe("runWorktreeSetupInBackground", () => { branchName: "feature-running-setup", }, }); - expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two"); + expect(progressMessages.at(-1)?.payload.detail.log).toBe(""); expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({ index: 1, command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"", @@ -1037,7 +927,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "reused-worktree", - runSetup: false, + paseoHome, }); @@ -1125,7 +1015,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "feature-service-failure", - runSetup: false, + paseoHome, }); const worktreePath = createdWorktree.worktreePath; @@ -1208,7 +1098,7 @@ describe("runWorktreeSetupInBackground", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "feature-socket-mode", - runSetup: false, + paseoHome, }); const worktreePath = createdWorktree.worktreePath; @@ -1508,221 +1398,6 @@ describe("handleCreatePaseoWorktreeRequest", () => { expect(readme).toContain("review branch"); }); - test("buildAgentSessionConfig checks out the GitHub PR branch for agent worktrees", async () => { - const { tempDir, repoDir } = createGitHubPrRemoteRepo(); - cleanupPaths.push(tempDir); - const events: string[] = []; - - const result = await buildAgentSessionConfig( - { - paseoHome: path.join(tempDir, ".paseo"), - sessionLogger: createLogger(), - workspaceGitService: { - resolveRepoRoot: vi.fn(async () => repoDir), - resolveDefaultBranch: vi.fn(async () => "main"), - } as unknown as WorkspaceGitService, - createPaseoWorktree: createPaseoWorktreeForTest({ - paseoHome: path.join(tempDir, ".paseo"), - events, - }), - checkoutExistingBranch: async () => { - throw new Error("should not checkout existing branch"); - }, - createBranchFromBase: async () => { - throw new Error("should not create a new branch from base"); - }, - }, - { - provider: "codex", - cwd: repoDir, - }, - { - createWorktree: true, - worktreeSlug: "agent-review-pr-123", - action: "checkout", - githubPrNumber: 123, - refName: "feature/review-pr", - }, - ); - - expect(result.sessionConfig.cwd).toContain("agent-review-pr-123"); - expect(events.some((event) => event.startsWith("workspace:"))).toBe(true); - - const branch = execFileSync("git", ["branch", "--show-current"], { - cwd: result.sessionConfig.cwd, - stdio: "pipe", - }) - .toString() - .trim(); - expect(branch).toBe("feature/review-pr"); - }); - - test("buildAgentSessionConfig uses the normalized new branch name as the worktree slug fallback", async () => { - const { tempDir, repoDir } = createGitRepo(); - cleanupPaths.push(tempDir); - const paseoHome = path.join(tempDir, ".paseo"); - - const result = await buildAgentSessionConfig( - { - paseoHome, - sessionLogger: createLogger(), - workspaceGitService: { - resolveRepoRoot: vi.fn(async () => repoDir), - resolveDefaultBranch: vi.fn(async () => "main"), - } as unknown as WorkspaceGitService, - createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }), - checkoutExistingBranch: async () => { - throw new Error("should not checkout existing branch"); - }, - createBranchFromBase: async () => { - throw new Error("should not create a branch outside the worktree service"); - }, - }, - { - provider: "codex", - cwd: repoDir, - }, - { - createWorktree: true, - createNewBranch: true, - newBranchName: "feature-x", - }, - ); - - expect(path.basename(result.sessionConfig.cwd)).toBe("feature-x"); - }); - - test("buildAgentSessionConfig passes prompt and attachment context into worktree creation", async () => { - const createPaseoWorktree = vi.fn(async () => ({ - worktree: { - branchName: "fix-attached-pr-context", - worktreePath: "/tmp/worktrees/fix-attached-pr-context", - }, - intent: { - kind: "branch-off" as const, - baseBranch: "main", - branchName: "fix-attached-pr-context", - }, - workspace: { - workspaceId: "ws-fix-attached-pr-context", - projectId: "/tmp/repo", - cwd: "/tmp/worktrees/fix-attached-pr-context/packages/app", - kind: "worktree" as const, - displayName: "fix-attached-pr-context", - createdAt: "2026-04-30T00:00:00.000Z", - updatedAt: "2026-04-30T00:00:00.000Z", - archivedAt: null, - }, - repoRoot: "/tmp/repo", - created: true, - })); - const firstAgentContext = { - prompt: "Create a worktree name from this prompt", - attachments: [ - { - type: "github_pr" as const, - mimeType: "application/github-pr", - number: 123, - title: "Fix worktree naming", - url: "https://github.com/getpaseo/paseo/pull/123", - baseRefName: "main", - headRefName: "fix/worktree-naming", - }, - ], - }; - - const result = await buildAgentSessionConfig( - { - sessionLogger: createLogger(), - workspaceGitService: { - resolveDefaultBranch: vi.fn(async () => "main"), - } as unknown as WorkspaceGitService, - createPaseoWorktree, - checkoutExistingBranch: async () => { - throw new Error("should not checkout existing branch"); - }, - createBranchFromBase: async () => { - throw new Error("should not create a branch outside the worktree service"); - }, - }, - { - provider: "codex", - cwd: "/tmp/repo", - }, - { - createWorktree: true, - action: "branch-off", - }, - undefined, - firstAgentContext, - ); - - expect(createPaseoWorktree).toHaveBeenCalledWith( - expect.objectContaining({ - firstAgentContext, - }), - expect.anything(), - ); - expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context/packages/app"); - }); - - test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => { - const invalidate = vi.fn(); - const createBranchFromBase = vi.fn(async () => {}); - const checkoutExistingBranch = vi.fn(async () => ({ source: "local" as const })); - const createPaseoWorktree = vi.fn(async () => { - throw new Error("should not create worktree"); - }); - - await buildAgentSessionConfig( - { - sessionLogger: createLogger(), - createPaseoWorktree, - checkoutExistingBranch, - createBranchFromBase, - workspaceGitService: { invalidateForge: invalidate } as unknown as WorkspaceGitService, - }, - { - provider: "codex", - cwd: "/tmp/repo", - }, - { - createNewBranch: true, - baseBranch: "main", - newBranchName: "feature-x", - }, - ); - - expect(createBranchFromBase).toHaveBeenCalledWith({ - cwd: "/tmp/repo", - baseBranch: "main", - newBranchName: "feature-x", - }); - expect(invalidate).toHaveBeenCalledWith("/tmp/repo"); - - invalidate.mockClear(); - - await buildAgentSessionConfig( - { - sessionLogger: createLogger(), - createPaseoWorktree, - checkoutExistingBranch, - createBranchFromBase, - workspaceGitService: { invalidateForge: invalidate } as unknown as WorkspaceGitService, - }, - { - provider: "codex", - cwd: "/tmp/repo", - }, - { - baseBranch: "release", - }, - ); - - expect(checkoutExistingBranch).toHaveBeenCalledWith("/tmp/repo", "release"); - expect(invalidate).toHaveBeenCalledWith("/tmp/repo"); - }); - test("createPaseoWorktreeForTest forwards the default branch resolver for branch-off intents", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -1734,7 +1409,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { cwd: repoDir, worktreeSlug: "resolver-feature", action: "branch-off", - runSetup: false, + paseoHome, }, { resolveDefaultBranch }, @@ -1996,7 +1671,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "archive-worktree-scope", - runSetup: false, + paseoHome, }); const sharedCwd = created.worktreePath; @@ -2072,7 +1747,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "archive-default-scope", - runSetup: false, + paseoHome, }); const workspaceId = "ws-default-scope"; @@ -2146,7 +1821,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "archive-default-scope-sibling", - runSetup: false, + paseoHome, }); const sharedCwd = created.worktreePath; @@ -2223,7 +1898,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "archive-delete-flag", - runSetup: false, + paseoHome, }); const sharedCwd = created.worktreePath; diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 02b1625228..4ba1010bd3 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -1,12 +1,7 @@ import type { Logger } from "pino"; import { basename } from "node:path"; - -import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; import { - type GitSetupOptions, - type CreateAgentWorktreeTarget, type FirstAgentContext, - type ChangeRequestCheckoutSource, type SessionInboundMessage, type SessionOutboundMessage, type WorkspaceSetupSnapshot, @@ -15,7 +10,6 @@ import { import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; import { - runAsyncWorktreeBootstrap, runWorktreeAutoTerminals, applyWorktreeSetupProgressEvent, buildWorktreeSetupDetail, @@ -25,14 +19,10 @@ import { import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { ServiceProxySubsystem } from "./service-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; -import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js"; -import { expandTilde } from "../utils/path.js"; import { getWorktreeSetupCommands, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, - slugify, - validateBranchSlug, type WorktreeConfig, type WorktreeSetupCommandResult, WorktreeSetupError, @@ -57,49 +47,7 @@ import { const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/; -export interface NormalizedGitOptions { - baseBranch?: string; - createNewBranch: boolean; - newBranchName?: string; - createWorktree: boolean; - worktreeSlug?: string; - requestedWorktreeSlug?: string; - refName?: string; - action?: "branch-off" | "checkout"; - checkoutSource?: ChangeRequestCheckoutSource; - githubPrNumber?: number; -} - type EmitSessionMessage = (message: SessionOutboundMessage) => void; -type AgentWorktreeSetupTimelineItem = Parameters< - typeof runAsyncWorktreeBootstrap ->[0]["appendTimelineItem"] extends (item: infer Item) => unknown - ? Item - : never; -type AgentWorktreeSetupTimelineWriter = (input: { - agentId: string; - item: AgentWorktreeSetupTimelineItem; -}) => Promise; - -interface BuildAgentSessionConfigDependencies { - paseoHome?: string; - worktreesRoot?: string; - sessionLogger: Logger; - workspaceGitService?: WorkspaceGitService; - createPaseoWorktree: ( - input: CreatePaseoWorktreeInput, - options?: { - resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; - }, - ) => Promise; - checkoutExistingBranch: (cwd: string, branch: string) => Promise; - createBranchFromBase: (params: { - cwd: string; - baseBranch: string; - newBranchName: string; - }) => Promise; -} interface CreatePaseoWorktreeInBackgroundDependencies { paseoHome?: string; @@ -133,32 +81,12 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB assertWorkspaceAutomationAllowed?: (workspaceId: string) => Promise; } -interface AgentWorktreeSetupContinuationInput { - kind: "agent"; - terminalManager: TerminalManager | null; - appendTimelineItem: AgentWorktreeSetupTimelineWriter; - emitLiveTimelineItem: AgentWorktreeSetupTimelineWriter; - logger: Logger; -} - -export type CreatePaseoWorktreeSetupContinuationInput = - | { kind: "workspace" } - | AgentWorktreeSetupContinuationInput; - -export interface AgentWorktreeSetupContinuation { - kind: "agent"; - startAfterAgentCreate: (input: { agentId: string }) => void; -} - -export type CreatePaseoWorktreeWorkflowResult = CreatePaseoWorktreeResult & { - setupContinuation?: AgentWorktreeSetupContinuation; -}; +export type CreatePaseoWorktreeWorkflowResult = CreatePaseoWorktreeResult; export type CreatePaseoWorktreeWorkflowFn = ( input: CreatePaseoWorktreeInput, options?: { resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; }, ) => Promise; @@ -204,245 +132,12 @@ function normalizeFirstAgentContext( return undefined; } -export async function buildAgentSessionConfig( - dependencies: BuildAgentSessionConfigDependencies, - config: AgentSessionConfig, - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, - firstAgentContext?: FirstAgentContext, -): Promise<{ - sessionConfig: AgentSessionConfig; - setupContinuation?: AgentWorktreeSetupContinuation; - createdWorkspaceId?: string; -}> { - let cwd = expandTilde(config.cwd); - const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName); - let setupContinuation: AgentWorktreeSetupContinuation | undefined; - let createdWorkspaceId: string | undefined; - - if (!normalized) { - return { - sessionConfig: { - ...config, - cwd, - }, - }; - } - - if (normalized.createWorktree) { - dependencies.sessionLogger.info( - { worktreeSlug: normalized.requestedWorktreeSlug }, - "Creating worktree through createWorktreeCore", - ); - - const createdWorktree = await dependencies.createPaseoWorktree( - { - cwd, - worktreeSlug: normalized.worktreeSlug, - refName: normalized.refName, - action: normalized.action, - checkoutSource: normalized.checkoutSource, - githubPrNumber: normalized.githubPrNumber, - firstAgentContext, - runSetup: false, - paseoHome: dependencies.paseoHome, - worktreesRoot: dependencies.worktreesRoot, - }, - { - resolveDefaultBranch: normalized.baseBranch - ? async () => normalized.baseBranch! - : (repoRoot) => - resolveGitCreateBaseBranch( - repoRoot, - dependencies.workspaceGitService, - dependencies.paseoHome, - ), - }, - ); - cwd = createdWorktree.workspace.cwd; - setupContinuation = createdWorktree.setupContinuation; - createdWorkspaceId = createdWorktree.workspace.workspaceId; - } else if (normalized.createNewBranch) { - const baseBranch = - normalized.baseBranch ?? - (await resolveGitCreateBaseBranch( - cwd, - dependencies.workspaceGitService, - dependencies.paseoHome, - )); - await dependencies.createBranchFromBase({ - cwd, - baseBranch, - newBranchName: normalized.newBranchName!, - }); - dependencies.workspaceGitService?.invalidateForge(cwd); - } else if (normalized.baseBranch) { - await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch); - dependencies.workspaceGitService?.invalidateForge(cwd); - } - - return { - sessionConfig: { - ...config, - cwd, - }, - setupContinuation, - createdWorkspaceId, - }; -} - -interface ValidateNormalizedGitOptionsInput { - baseBranch: string | undefined; - createNewBranch: boolean; - normalizedBranchName: string | undefined; - normalizedWorktreeSlug: string | undefined; -} - -function validateNormalizedGitOptions(input: ValidateNormalizedGitOptionsInput): void { - if (input.baseBranch) { - assertSafeGitRef(input.baseBranch, "base branch"); - } - - if (input.createNewBranch) { - if (!input.normalizedBranchName) { - throw new Error("New branch name is required"); - } - const validation = validateBranchSlug(input.normalizedBranchName); - if (!validation.valid) { - throw new Error(`Invalid branch name: ${validation.error}`); - } - } - - if (input.normalizedWorktreeSlug) { - const validation = validateBranchSlug(input.normalizedWorktreeSlug); - if (!validation.valid) { - throw new Error(`Invalid worktree name: ${validation.error}`); - } - } -} - -export function normalizeAgentCreationGitOptions( - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, - worktree?: CreateAgentWorktreeTarget, -): NormalizedGitOptions | null { - if (worktree && gitOptions) { - throw new Error("create_agent_request worktree cannot be combined with git options"); - } - if (worktree) { - switch (worktree.mode) { - case "branch-off": - gitOptions = { - createWorktree: true, - createNewBranch: true, - newBranchName: worktree.newBranch, - baseBranch: worktree.base, - action: "branch-off", - refName: worktree.base, - }; - break; - case "checkout-branch": - gitOptions = { createWorktree: true, action: "checkout", refName: worktree.branch }; - break; - case "checkout-pr": - gitOptions = { - createWorktree: true, - action: "checkout", - githubPrNumber: worktree.prNumber, - }; - break; - } - } - return normalizeGitOptions(gitOptions, legacyWorktreeName); -} - -function normalizeGitOptions( - gitOptions?: GitSetupOptions, - legacyWorktreeName?: string, -): NormalizedGitOptions | null { - const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName - ? { - createWorktree: true, - createNewBranch: true, - newBranchName: legacyWorktreeName, - worktreeSlug: legacyWorktreeName, - } - : undefined; - - const merged = gitOptions ?? fallbackOptions; - if (!merged) { - return null; - } - - const baseBranch = merged.baseBranch?.trim() || undefined; - const createWorktree = Boolean(merged.createWorktree); - const createNewBranch = Boolean(merged.createNewBranch); - const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined; - const requestedWorktreeSlug = merged.worktreeSlug ? slugify(merged.worktreeSlug) : undefined; - const normalizedWorktreeSlug = requestedWorktreeSlug ?? normalizedBranchName; - const refName = merged.refName?.trim() || undefined; - const action = merged.action; - // COMPAT(githubPrNumber): legacy GitHub checkout input retained when - // checkoutSource shipped in v0.2.0-beta.1. Remove after 2027-01-17 once the - // supported client floor is >= v0.2.0. - const checkoutSource = - merged.checkoutSource ?? - (merged.githubPrNumber - ? ({ kind: "change_request", forge: "github", number: merged.githubPrNumber } as const) - : undefined); - const githubPrNumber = merged.githubPrNumber; - - if ( - !createWorktree && - !createNewBranch && - !baseBranch && - !refName && - !action && - !checkoutSource && - !githubPrNumber - ) { - return null; - } - - validateNormalizedGitOptions({ - baseBranch, - createNewBranch, - normalizedBranchName, - normalizedWorktreeSlug, - }); - - return { - baseBranch, - createNewBranch, - newBranchName: normalizedBranchName, - createWorktree, - worktreeSlug: normalizedWorktreeSlug, - requestedWorktreeSlug, - refName, - action, - checkoutSource, - githubPrNumber, - }; -} - export function assertSafeGitRef(ref: string, label: string): void { if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) { throw new Error(`Invalid ${label}: ${ref}`); } } -export async function resolveGitCreateBaseBranch( - cwd: string, - workspaceGitService?: WorkspaceGitService, - _paseoHome?: string, -): Promise { - if (!workspaceGitService) { - throw new Error("WorkspaceGitService is required to resolve the repository root"); - } - - return workspaceGitService.resolveDefaultBranch(cwd); -} - export async function handlePaseoWorktreeListRequest( dependencies: { emit: EmitSessionMessage; @@ -648,13 +343,12 @@ export async function createPaseoWorktreeWorkflow( input: CreatePaseoWorktreeInput, options?: { resolveDefaultBranch?: (repoRoot: string) => Promise; - setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; }, ): Promise { const createdWorktree = await dependencies.createPaseoWorktree( { ...input, - runSetup: false, + paseoHome: input.paseoHome ?? dependencies.paseoHome, worktreesRoot: input.worktreesRoot ?? dependencies.worktreesRoot, }, @@ -664,7 +358,6 @@ export async function createPaseoWorktreeWorkflow( ); const slug = basename(createdWorktree.worktree.worktreePath); const workspace = createdWorktree.workspace; - const setupContinuation = options?.setupContinuation ?? { kind: "workspace" }; try { await dependencies.assertWorkspaceAutomationAllowed?.(workspace.workspaceId); @@ -681,15 +374,6 @@ export async function createPaseoWorktreeWorkflow( type: "workspace_setup_progress", payload: { workspaceId: workspace.workspaceId, ...snapshot }, }); - if (setupContinuation.kind === "agent") { - return { - ...createdWorktree, - setupContinuation: { - kind: "agent", - startAfterAgentCreate: () => undefined, - }, - }; - } return createdWorktree; } @@ -706,53 +390,28 @@ export async function createPaseoWorktreeWorkflow( "Failed to warm workspace git data after creating worktree", ); }); - if (setupContinuation.kind === "workspace") { - const runSetup = (signal: AbortSignal) => - runWorktreeSetupInBackground( - dependencies, - { - requestCwd: input.cwd, - repoRoot: createdWorktree.repoRoot, - workspaceId: workspace.workspaceId, - worktree: createdWorktree.worktree, - shouldBootstrap: createdWorktree.created, - slug, - worktreePath: createdWorktree.worktree.worktreePath, - workspaceCwd: workspace.cwd, - }, - signal, - ); - if (dependencies.startWorkspaceSetup) { - dependencies.startWorkspaceSetup(workspace.workspaceId, runSetup); - } else { - void runSetup(new AbortController().signal); - } + const runSetup = (signal: AbortSignal) => + runWorktreeSetupInBackground( + dependencies, + { + requestCwd: input.cwd, + repoRoot: createdWorktree.repoRoot, + workspaceId: workspace.workspaceId, + worktree: createdWorktree.worktree, + shouldBootstrap: createdWorktree.created, + slug, + worktreePath: createdWorktree.worktree.worktreePath, + workspaceCwd: workspace.cwd, + }, + signal, + ); + if (dependencies.startWorkspaceSetup) { + dependencies.startWorkspaceSetup(workspace.workspaceId, runSetup); + } else { + void runSetup(new AbortController().signal); } }, 0); - if (setupContinuation.kind === "agent") { - return { - ...createdWorktree, - setupContinuation: { - kind: "agent", - startAfterAgentCreate: ({ agentId }) => { - void runAsyncWorktreeBootstrap({ - agentId, - workspaceId: workspace.workspaceId, - worktree: createdWorktree.worktree, - workspaceCwd: workspace.cwd, - shouldBootstrap: createdWorktree.created, - terminalManager: setupContinuation.terminalManager, - appendTimelineItem: (item) => setupContinuation.appendTimelineItem({ agentId, item }), - emitLiveTimelineItem: (item) => - setupContinuation.emitLiveTimelineItem({ agentId, item }), - logger: setupContinuation.logger, - }); - }, - }, - }; - } - return createdWorktree; } @@ -911,7 +570,6 @@ export async function runWorktreeSetupInBackground( setupResults = await runWorktreeSetupCommands({ worktreePath: workspaceCwd, branchName: worktree.branchName, - cleanupOnFailure: false, repoRootPath: options.repoRoot, runtimeEnv, signal, diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index 4f76cb1887..0aab346984 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -45,10 +45,7 @@ export interface CreatePaseoWorktreeCommandDependencies< createPaseoWorktreeWorkflow?: CreatePaseoWorktreeWorkflow; } -export type CreatePaseoWorktreeCommandInput = Omit< - CreatePaseoWorktreeInput, - "paseoHome" | "runSetup" -> & { +export type CreatePaseoWorktreeCommandInput = Omit & { paseoHome?: string; worktreesRoot?: string; }; @@ -75,7 +72,7 @@ export async function createPaseoWorktreeCommand { await runWorktreeSetupCommands({ worktreePath, branchName: "main", - cleanupOnFailure: false, runtimeEnv: { PASEO_SOURCE_CHECKOUT_PATH: worktreePath, PASEO_ROOT_PATH: worktreePath, diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 2b9181bb77..a550995fb3 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -58,7 +58,7 @@ interface LegacyCreateWorktreeTestOptions { cwd: string; baseBranch: string; worktreeSlug: string; - runSetup?: boolean; + paseoHome?: string; worktreesRoot?: string; } @@ -78,7 +78,7 @@ function createLegacyWorktreeForTest( baseBranch: options.baseBranch, branchName: options.branchName, }, - runSetup: options.runSetup ?? true, + paseoHome: options.paseoHome, worktreesRoot: options.worktreesRoot, }); @@ -239,7 +239,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "my-feature", source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" }, - runSetup: true, + paseoHome, }); @@ -282,7 +282,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "existing-feature", source: { kind: "checkout-branch", branchName: "feature/existing" }, - runSetup: false, + paseoHome, }); const status = await getCheckoutStatus(result.worktreePath, { paseoHome }); @@ -304,7 +304,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "dev-worktree", source: { kind: "checkout-branch", branchName: "dev" }, - runSetup: true, + paseoHome, }); @@ -346,7 +346,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "stale-default", source: { kind: "checkout-branch", branchName: "feature/stale-default" }, - runSetup: false, + paseoHome, }); const diff = await getCheckoutDiff(created.worktreePath, { mode: "base" }, { paseoHome }); @@ -365,7 +365,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "feature-x-worktree", source: { kind: "checkout-branch", branchName: "feature-x" }, - runSetup: true, + paseoHome, }); @@ -382,7 +382,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "release-worktree", source: { kind: "checkout-branch", branchName: "release/1.1.15" }, - runSetup: true, + paseoHome, }); @@ -400,7 +400,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "dev-worktree", source: { kind: "checkout-branch", branchName: "main" }, - runSetup: true, + paseoHome, }); @@ -412,7 +412,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); - it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => { + it("fetches a GitHub PR branch and writes metadata without executing setup", async () => { const remoteDir = join(tempDir, "remote.git"); const remoteCloneDir = join(tempDir, "remote-clone"); execFileSync("git", ["clone", "--bare", repoDir, remoteDir]); @@ -446,12 +446,12 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { headRef: "user/feature", baseRefName: "main", }, - runSetup: true, + paseoHome, }); expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n"); - expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe("setup ran\n"); + expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false); const currentBranch = execFileSync("git", ["branch", "--show-current"], { cwd: result.worktreePath, }) @@ -494,7 +494,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { headRef: "Feature.X", baseRefName: "main", }, - runSetup: true, + paseoHome, }); @@ -540,7 +540,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "refs/heads/main", worktreeSlug: "prefer-local-feature", - runSetup: false, + paseoHome, }); const originResult = await createLegacyWorktreeForTest({ @@ -548,7 +548,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "refs/remotes/origin/main", worktreeSlug: "prefer-origin-feature", - runSetup: false, + paseoHome, }); @@ -598,7 +598,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "refs/remotes/upstream/main", worktreeSlug: "fork-base-feature", - runSetup: false, + paseoHome, }); @@ -689,7 +689,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "refs/remotes/upstream/release+hotfix", worktreeSlug: "release-hotfix-feature", - runSetup: false, + paseoHome, }); @@ -717,7 +717,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "prefer-local-fallback-feature", - runSetup: false, + paseoHome, }); @@ -731,7 +731,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "does-not-exist", worktreeSlug: "missing-base-feature", - runSetup: false, + paseoHome, }), ).rejects.toThrow("Base branch not found: does-not-exist"); @@ -755,7 +755,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "invalid-existing-branch", source: { kind: "checkout-branch", branchName: "bad..name" }, - runSetup: true, + paseoHome, }); } catch (error) { @@ -773,7 +773,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "invalid-option-like-branch", source: { kind: "checkout-branch", branchName: "-bad" }, - runSetup: true, + paseoHome, }); } catch (error) { @@ -853,6 +853,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(existsSync(result.worktreePath)).toBe(true); + await runWorktreeSetupCommands({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); // Verify setup ran and env vars were available const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8"); expect(setupLog).toContain(`source=${repoDir}`); @@ -886,6 +890,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { paseoHome, }); + await runWorktreeSetupCommands({ + worktreePath: result.worktreePath, + branchName: result.branchName, + }); expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([ 'greeting="hello from string setup"\necho "$greeting" > setup.log', ]); @@ -926,7 +934,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { await runWorktreeSetupCommands({ worktreePath: repoDir, branchName: "main", - cleanupOnFailure: false, runtimeEnv: { PASEO_SOURCE_CHECKOUT_PATH: repoDir, PASEO_ROOT_PATH: repoDir, @@ -1002,7 +1009,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ]); }); - it("does not run setup commands when runSetup=false", async () => { + it("only provisions the worktree without executing setup", async () => { const paseoConfig = { worktree: { setup: ['echo "setup ran" > setup.log'], @@ -1019,7 +1026,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "no-setup-test", - runSetup: false, + paseoHome, }); @@ -1043,7 +1050,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { const results = await runWorktreeSetupCommands({ worktreePath: repoDir, branchName: "main", - cleanupOnFailure: false, onEvent: (event) => { progressEvents.push(event); }, @@ -1061,7 +1067,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "runtime-env-port-reuse", - runSetup: false, + paseoHome, }); @@ -1083,7 +1089,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, baseBranch: "main", worktreeSlug: "runtime-env-port-conflict", - runSetup: false, + paseoHome, }); @@ -1117,35 +1123,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); - it("cleans up worktree if setup command fails", async () => { - // Create paseo.json with failing setup command - const paseoConfig = { - worktree: { - setup: ["exit 1"], - }, - }; - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { - cwd: repoDir, - }); - - const expectedWorktreePath = join(paseoHome, "worktrees", "test-repo", "fail-test"); - - await expect( - createLegacyWorktreeForTest({ - branchName: "main", - cwd: repoDir, - baseBranch: "main", - worktreeSlug: "fail-test", - paseoHome, - }), - ).rejects.toThrow("Worktree setup command failed"); - - // Verify worktree was cleaned up - expect(existsSync(expectedWorktreePath)).toBe(false); - }); - it("reads worktree terminal specs from paseo.json with optional name", async () => { const paseoConfig = { worktree: { @@ -1278,7 +1255,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "seed-uncommitted", source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" }, - runSetup: false, + paseoHome, }); @@ -1324,7 +1301,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { baseBranch: "origin/main", branchName: "feature/upstream-config", }, - runSetup: false, + paseoHome, }); @@ -1364,7 +1341,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { baseBranch: "symlink-config", branchName: "feature/symlink-config", }, - runSetup: false, + paseoHome, }); @@ -1385,7 +1362,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: repoDir, worktreeSlug: "no-config", source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" }, - runSetup: false, + paseoHome, }); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 82505dc800..4d6f50bba6 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -31,7 +31,7 @@ interface LegacyCreateWorktreeTestOptions { cwd: string; baseBranch: string; worktreeSlug: string; - runSetup?: boolean; + paseoHome?: string; } @@ -50,7 +50,7 @@ function createLegacyWorktreeForTest( baseBranch: options.baseBranch, branchName: options.branchName, }, - runSetup: options.runSetup ?? true, + paseoHome: options.paseoHome, }); } @@ -132,7 +132,7 @@ describe("paseo worktree manager", () => { cwd: repoDir, paseoHome, worktreeSlug: "fork-review", - runSetup: false, + source: { kind: "checkout-change-request", forge: "gitea", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 39b64d8137..ca921fb219 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,13 +1,6 @@ import { execFile } from "child_process"; import { promisify } from "util"; -import { - constants as fsConstants, - existsSync, - mkdirSync, - realpathSync, - rmSync, - statSync, -} from "fs"; +import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs"; import { copyFile, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; @@ -217,7 +210,6 @@ export interface CreateWorktreeOptions { cwd: string; worktreeSlug: string; source: WorktreeSource; - runSetup: boolean; paseoHome?: string; worktreesRoot?: string; } @@ -643,7 +635,6 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise< export async function runWorktreeSetupCommands(options: { worktreePath: string; branchName: string; - cleanupOnFailure: boolean; repoRootPath?: string; runtimeEnv?: WorktreeRuntimeEnv; signal?: AbortSignal; @@ -683,16 +674,6 @@ export async function runWorktreeSetupCommands(options: { results.push(result); if (result.exitCode !== 0) { - if (options.cleanupOnFailure) { - try { - await runGitCommand(["worktree", "remove", options.worktreePath, "--force"], { - cwd: options.worktreePath, - timeout: 120_000, - }); - } catch { - rmSync(options.worktreePath, { recursive: true, force: true }); - } - } throw new WorktreeSetupError( `Worktree setup command failed: ${cmd}\n${result.stderr}`.trim(), results, @@ -1221,7 +1202,6 @@ export const createWorktree = async ({ cwd, source, worktreeSlug, - runSetup, paseoHome, worktreesRoot, }: CreateWorktreeOptions): Promise => { @@ -1269,14 +1249,6 @@ export const createWorktree = async ({ await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); - if (runSetup) { - await runWorktreeSetupCommands({ - worktreePath, - branchName: sourcePlan.branchName, - cleanupOnFailure: true, - }); - } - return { branchName: sourcePlan.branchName, worktreePath, From 5d70bf064548e22c4bcedbc4c701fb0f8fac94c0 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:17:26 +0200 Subject: [PATCH 07/62] fix(android): restore image picker after Activity recreation (#7546) Backport expo/expo#49634 to SDK 54 so retained launchers register against the current Activity after their previous owner is destroyed. Cover repeated recreation and replacement-before-destroy ordering with native regression tests, and document the native rebuild requirement. Co-authored-by: luke Co-authored-by: Ido Yana Co-authored-by: RoundRedDot Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- docs/android.md | 16 +++ patches/expo-modules-core+57.0.23.patch | 144 ++++++++++++++++++++++++ scripts/postinstall-patches.mjs | 6 + 3 files changed, 166 insertions(+) create mode 100644 patches/expo-modules-core+57.0.23.patch diff --git a/docs/android.md b/docs/android.md index 9b83240c52..5a59eedaa4 100644 --- a/docs/android.md +++ b/docs/android.md @@ -61,6 +61,22 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted ## Local build + install +The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`. +It backports +[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing +font size or another unhandled configuration can otherwise disable image selection +until the app process restarts. Rebuild the native app after changing this patch; +a Metro reload cannot update it. Remove the patch when the installed +`expo-modules-core` includes the fix. + +Run its native regression tests from `packages/app/android` after prebuild: + +```bash +./gradlew :expo-modules-core:testDebugUnitTest \ + --tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest \ + --no-daemon --max-workers=1 -Dorg.gradle.parallel=false +``` + From repo root: ```bash diff --git a/patches/expo-modules-core+57.0.23.patch b/patches/expo-modules-core+57.0.23.patch new file mode 100644 index 0000000000..bfcc8932a5 --- /dev/null +++ b/patches/expo-modules-core+57.0.23.patch @@ -0,0 +1,144 @@ +diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt +index a5b971e..7ef1b59 100644 +--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt ++++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt +@@ -29,6 +29,7 @@ import expo.modules.kotlin.safeGetParcelable + import expo.modules.kotlin.safeGetParcelableExtra + import java.io.Serializable + import java.util.Random ++import java.util.concurrent.CountDownLatch + + private const val TAG = "ActivityResultRegistry" + +@@ -222,6 +223,7 @@ class AppContextActivityResultRegistry( + return object : AppContextActivityResultLauncher() { + override fun launch(input: I, callback: ActivityResultCallback) { + val requestCode = keyToRequestCode[key] ++ ?: reregister(key, contract, fallbackCallback) + ?: throw IllegalStateException("Attempting to launch an unregistered ActivityResultLauncher with contract $contract and input $input. You must ensure the ActivityResultLauncher is registered before calling launch()") + + keyToCallbacksAndContract[key] = CallbacksAndContract(fallbackCallback, callback, contract) +@@ -240,6 +242,39 @@ class AppContextActivityResultRegistry( + } + } + ++ /** ++ * A registration is bound to one Activity's lifecycle and dropped by its ON_DESTROY, ++ * while launchers outlive the Activity inside the AppContext. Registers [key] again ++ * against the live Activity and returns its request code. register() adds a lifecycle ++ * observer, which androidx requires on the main thread; launch() runs on the module's ++ * IO dispatcher, so the call is marshalled there. ++ */ ++ private fun reregister( ++ key: String, ++ contract: AppContextActivityResultContract, ++ fallbackCallback: AppContextActivityResultFallbackCallback ++ ): Int? { ++ val liveActivity = activity ++ if (Looper.myLooper() == Looper.getMainLooper()) { ++ register(key, liveActivity, contract, fallbackCallback) ++ } else { ++ val registered = CountDownLatch(1) ++ var failure: Throwable? = null ++ Handler(Looper.getMainLooper()).post { ++ try { ++ register(key, liveActivity, contract, fallbackCallback) ++ } catch (e: Throwable) { ++ failure = e ++ } finally { ++ registered.countDown() ++ } ++ } ++ registered.await() ++ failure?.let { throw it } ++ } ++ return keyToRequestCode[key] ++ } ++ + /** + * Persist the state of the registry. + */ +diff --git a/node_modules/expo-modules-core/android/src/test/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistryRecreationTest.kt b/node_modules/expo-modules-core/android/src/test/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistryRecreationTest.kt +new file mode 100644 +index 0000000..89a8eb1 +--- /dev/null ++++ b/node_modules/expo-modules-core/android/src/test/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistryRecreationTest.kt +@@ -0,0 +1,78 @@ ++package expo.modules.kotlin.activityresult ++ ++import android.app.Activity ++import android.content.Context ++import android.content.Intent ++import androidx.appcompat.app.AppCompatActivity ++import expo.modules.kotlin.providers.CurrentActivityProvider ++import org.junit.Assert.assertEquals ++import org.junit.Assert.assertTrue ++import org.junit.Test ++import org.junit.runner.RunWith ++import org.robolectric.Robolectric ++import org.robolectric.RobolectricTestRunner ++import org.robolectric.Shadows.shadowOf ++import org.robolectric.annotation.Config ++ ++@RunWith(RobolectricTestRunner::class) ++@Config(sdk = [33], manifest = Config.NONE) ++class AppContextActivityResultRegistryRecreationTest { ++ private class ActivityProvider(override var currentActivity: Activity?) : CurrentActivityProvider ++ ++ private val contract = object : AppContextActivityResultContract { ++ override fun createIntent(context: Context, input: String) = Intent("test.PICK_IMAGE") ++ override fun parseResult(input: String, resultCode: Int, intent: Intent?) = "$input:$resultCode" ++ } ++ ++ private fun createActivity() = Robolectric.buildActivity(AppCompatActivity::class.java).apply { ++ get().setTheme(androidx.appcompat.R.style.Theme_AppCompat) ++ }.setup() ++ ++ @Test ++ fun retainedLauncherDeliversResultsAfterRepeatedActivityRecreation() { ++ var controller = createActivity() ++ val provider = ActivityProvider(controller.get()) ++ val registry = AppContextActivityResultRegistry(provider) ++ val fallbackResults = mutableListOf() ++ val launcher = registry.register("image", controller.get(), contract) { _, result -> ++ fallbackResults.add(result) ++ } ++ val results = mutableListOf() ++ ++ repeat(3) { iteration -> ++ controller.pause().stop().destroy() ++ controller = createActivity() ++ provider.currentActivity = controller.get() ++ ++ launcher.launch("image-$iteration") { results.add(it) } ++ val started = shadowOf(controller.get()).nextStartedActivityForResult ++ assertEquals("test.PICK_IMAGE", started.intent.action) ++ assertTrue(registry.dispatchResult(started.requestCode, Activity.RESULT_OK, null)) ++ } ++ ++ assertEquals(listOf("image-0:-1", "image-1:-1", "image-2:-1"), results) ++ assertEquals(emptyList(), fallbackResults) ++ controller.pause().stop().destroy() ++ } ++ ++ @Test ++ fun retainedLauncherUsesReplacementThatResumesBeforePreviousActivityIsDestroyed() { ++ val previous = createActivity() ++ val provider = ActivityProvider(previous.get()) ++ val registry = AppContextActivityResultRegistry(provider) ++ val results = mutableListOf() ++ val launcher = registry.register("image", previous.get(), contract) { _, result -> ++ results.add("fallback:$result") ++ } ++ previous.pause() ++ val replacement = createActivity() ++ provider.currentActivity = replacement.get() ++ previous.stop().destroy() ++ ++ launcher.launch("replacement") { results.add(it) } ++ val started = shadowOf(replacement.get()).nextStartedActivityForResult ++ assertTrue(registry.dispatchResult(started.requestCode, Activity.RESULT_OK, null)) ++ assertEquals(listOf("replacement:-1"), results) ++ replacement.pause().stop().destroy() ++ } ++} diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 638c62de55..d82af94788 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -8,6 +8,12 @@ import { join, relative } from "node:path"; // workspace root live in their workspace's own node_modules, and patch-package resolves // the patch's node_modules/... paths relative to its working directory. const patchedPackages = [ + // Backport for expo-modules-core 57.0.23: https://github.com/expo/expo/pull/49634. + // Remove when the installed Expo SDK includes the Activity recreation fix. + { + nodeModulesPath: "node_modules/expo-modules-core", + patchPrefix: "expo-modules-core+", + }, { nodeModulesPath: "packages/app/node_modules/react-native-enriched-markdown", patchPrefix: "react-native-enriched-markdown+", From 91dca5702b10e9320ca5b71f22f3026bfaacae99 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 16:10:47 +0700 Subject: [PATCH 08/62] Connect saved hosts and delegate agents across daemons (#7370) * feat(hosts): connect to saved hosts through a daemon * fix(hosts): validate safe targets and verify encrypted relay routes * test(hosts): include relay coverage in existing CI contracts * fix(cli): preserve validated addresses in daemon command hints * fix(hosts): share daemon links across isolated routed sessions Multiplex logical sessions over the monitored daemon connection and wrap original binary bytes in routing headers. Admit channels under their authenticated parent and current grants; preserve scoped delivery and physical send completion. * fix(hosts): bound encrypted overload and initial acquisition Check encrypted wire capacity before the async channel converts a resource limit into a physical connection error. Bound initial routed acquisition with the existing client timeout, then enable reconnect after admission. * fix(hosts): keep compact row actions clear of host labels Give compact connected-host rows separate text and action widths so long names and producer labels cannot compete with primary controls. * refactor(hosts): remove management settings UI from phase one Keep connected-host management in the CLI and shared SDK while preserving app saved connections and plugin access. * test(hosts): control physical write backpressure across platforms Hold real TCP writes until assertions finish instead of assuming a paused receiver exhausts every platform receive buffer. Preserve physical send settlement, byte identity, capacity and sibling checks. * refactor(hosts): use the ordinary client contract over channel transports Keep queuing, permission codes and reconnection independent of transport selection. Track submitted request outcomes generically while retaining connection error codes and structured causes. * test(e2e): keep transient developer toasts from stalling checks Concurrent locator handlers can wait for a dismiss button another handler removed. Dismiss existing toast DOM at document lifetime, including its open shadow root, without intercepting app actions. * Create, prompt, and monitor agents across connected hosts (#7507) * feat(hosts): delegate agents across connected hosts Resolve MCP host operations through in-process commands for self and the existing routed client transport for other destinations. Keep ordinary parent labels, app sessions and idle finish notifications. * test(app): normalize subagent fixtures through snapshots * docs(protocol): clarify same-host parent labels * fix(hosts): resolve subagents through destination sessions A child address identifies its destination regardless of the connection used. Reuse the existing host candidate selection for discovered routes and direct connections, and keep navigation and row actions independent of the parent transport. * fix(hosts): keep routed destinations distinct during discovery A routed candidate names only the origin daemon. Match it within the named destination profile so two destinations discovered through the same origin retain separate identities and connections, including existing saved routes. * feat(hosts): prompt and query agents on selected hosts Extend the existing MCP host operation boundary with prompt, status, activity and list operations. Local calls retain their handlers and notification owner; selected destinations use ordinary client RPCs and the existing idle finish wait. * test(e2e): preserve toast coverage with CSS hiding * docs(hosts): translate connected host references --- CLAUDE.md | 1 + docs/architecture.md | 3 + docs/connected-hosts.md | 89 + docs/permissions.md | 4 + package-lock.json | 1 + packages/app/e2e/browser/developer-ui.spec.ts | 58 + .../e2e/browser/subagent-origin-pane.spec.ts | 197 ++- packages/app/e2e/support/helpers/subagents.ts | 155 +- .../src/desktop/daemon/local-credential.ts | 1 + packages/app/src/hooks/use-archive-agent.ts | 5 + packages/app/src/panels/agent-tracks.tsx | 16 +- packages/app/src/runtime/host-runtime.test.ts | 117 +- packages/app/src/runtime/host-runtime.ts | 117 +- .../app/src/screens/settings/host-page.tsx | 1 + .../workspace-subagents-integration.test.ts | 18 +- .../src/subagents/archive-finished.test.ts | 27 +- .../app/src/subagents/archive-finished.ts | 24 +- packages/app/src/subagents/index.ts | 4 +- .../app/src/subagents/opens-in-sheet.test.tsx | 12 +- packages/app/src/subagents/select.test.ts | 54 +- packages/app/src/subagents/select.ts | 47 +- .../src/subagents/track-presentation.test.ts | 1 + packages/app/src/subagents/track.tsx | 37 +- .../app/src/subagents/use-archive-finished.ts | 13 +- .../app/src/subagents/use-archive-subagent.ts | 12 +- .../app/src/subagents/use-detach-subagent.ts | 12 +- .../app/src/types/host-connection.test.ts | 51 + packages/app/src/types/host-connection.ts | 19 +- packages/app/src/utils/agent-snapshots.ts | 5 +- .../app/src/utils/test-daemon-connection.ts | 2 + packages/cli/src/cli.ts | 3 + packages/cli/src/commands/host/index.ts | 116 ++ packages/cli/src/ssh/ssh-tunnel.ts | 92 +- packages/cli/src/utils/client.ssh.test.ts | 61 +- packages/cli/src/utils/client.ts | 204 +-- packages/cli/src/utils/daemon-target.test.ts | 63 +- packages/cli/src/utils/daemon-target.ts | 28 +- packages/client/package.json | 9 + .../src/daemon-client-relay-e2ee-transport.ts | 12 +- packages/client/src/daemon-client.test.ts | 133 +- packages/client/src/daemon-client.ts | 267 ++- packages/client/src/hosts/index.ts | 208 +++ packages/client/src/index.test.ts | 36 + packages/client/src/index.ts | 60 +- packages/client/src/node/buffering.ts | 25 + packages/client/src/node/connector.test.ts | 153 ++ packages/client/src/node/index.ts | 195 +++ packages/client/src/node/ssh-tunnel.ts | 90 + packages/protocol/package.json | 4 + packages/protocol/src/addresses.ts | 5 + packages/protocol/src/agent-labels.ts | 22 + .../protocol/src/binary-frames/demux.test.ts | 38 + packages/protocol/src/binary-frames/demux.ts | 6 + .../protocol/src/binary-frames/host-route.ts | 33 + packages/protocol/src/binary-frames/index.ts | 1 + packages/protocol/src/host-target.test.ts | 76 + packages/protocol/src/host-target.ts | 137 ++ packages/protocol/src/hosts.ts | 123 ++ packages/protocol/src/messages.ts | 47 + .../src/paseo-tool-presentation/catalog.ts | 1 + packages/server/package.json | 2 +- .../src/server/agent/activity-curator.ts | 20 +- .../src/server/agent/agent-projections.ts | 61 + .../server/src/server/agent/agent-prompt.ts | 109 +- .../src/server/agent/host-operations/index.ts | 393 +++++ .../src/server/agent/tools/paseo-tools.ts | 241 ++- .../authorization/operation-permissions.ts | 17 + packages/server/src/server/bootstrap.ts | 5 + .../server/connected-hosts/hosts.e2e.test.ts | 1482 +++++++++++++++++ .../src/server/connected-hosts/index.ts | 539 ++++++ .../server/src/server/connected-hosts/link.ts | 210 +++ .../src/server/connected-hosts/session.ts | 252 +++ .../daemon-e2e/relay-transport.e2e.test.ts | 268 ++- packages/server/src/server/session.test.ts | 39 + packages/server/src/server/session.ts | 100 +- .../session/owned-subscriptions/index.test.ts | 37 + .../session/owned-subscriptions/index.ts | 27 +- .../websocket-server.relay-reconnect.test.ts | 28 +- .../server/src/server/websocket-server.ts | 170 +- public-docs/cli.md | 25 + public-docs/cli.zh-CN.md | 24 + public-docs/plugins/reference.md | 20 + public-docs/plugins/reference.zh-CN.md | 19 + public-docs/sdk/reference.md | 60 + public-docs/sdk/reference.zh-CN.md | 54 + scripts/ci-workflow.test.mjs | 2 +- 86 files changed, 6970 insertions(+), 585 deletions(-) create mode 100644 docs/connected-hosts.md create mode 100644 packages/app/e2e/browser/developer-ui.spec.ts create mode 100644 packages/cli/src/commands/host/index.ts create mode 100644 packages/client/src/hosts/index.ts create mode 100644 packages/client/src/node/buffering.ts create mode 100644 packages/client/src/node/connector.test.ts create mode 100644 packages/client/src/node/index.ts create mode 100644 packages/client/src/node/ssh-tunnel.ts create mode 100644 packages/protocol/src/addresses.ts create mode 100644 packages/protocol/src/binary-frames/host-route.ts create mode 100644 packages/protocol/src/host-target.test.ts create mode 100644 packages/protocol/src/host-target.ts create mode 100644 packages/protocol/src/hosts.ts create mode 100644 packages/server/src/server/agent/host-operations/index.ts create mode 100644 packages/server/src/server/connected-hosts/hosts.e2e.test.ts create mode 100644 packages/server/src/server/connected-hosts/index.ts create mode 100644 packages/server/src/server/connected-hosts/link.ts create mode 100644 packages/server/src/server/connected-hosts/session.ts diff --git a/CLAUDE.md b/CLAUDE.md index 809d6668fc..841a4c477e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging | | [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules | +| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries | | [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | diff --git a/docs/architecture.md b/docs/architecture.md index 4ed25ba1c4..c98f3825af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a Your code never leaves your machine. Paseo is local-first. +Daemon-owned outbound connections and routed API scope ownership are described in +[Connected hosts](connected-hosts.md). + ## System overview ``` diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md new file mode 100644 index 0000000000..1844456007 --- /dev/null +++ b/docs/connected-hosts.md @@ -0,0 +1,89 @@ +# Connected hosts + +A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list. +Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable +`registrationId` created before B is reachable. + +## Ownership and authority + +Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe +target description. Replacement inputs never come back through a read API. Keep this file private +when collecting diagnostics or backing up daemon state. + +The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry, +including a user's disabled state. Changed input requires an explicit update. Display labels and +access can be changed by users with `tunnel.manage`; connection and metadata changes require the +producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again. +Disabling retains the opt-out. + +Each enabled registration owns one physical daemon link, shared by status monitoring and every +routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the +admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual +physical link principal, then intersects forwarded caller grants with that link's current authority. +Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with +current authority. A routed API cannot select a second hop, and saving a host grants no reverse access. + +A producer that registers a host through a routed API is qualified on B as +`route:::`, with each component percent-encoded. This namespace belongs to the authenticated link +principal. An ordinary direct B consumer does not become that producer by supplying a source key. +The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal. +Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature. + +## Delegating agents + +Agent MCP discovers destination IDs with `list_hosts`. `create_workspace`, `list_workspaces`, +`create_agent`, `send_agent_prompt`, `get_agent_status`, `get_agent_activity`, and `list_agents` +accept `serverId`; omission selects the calling daemon. Remote creation requires a +destination workspace and explicit provider/model. Caller filesystem defaults apply only on the +calling daemon. Prompting a remote child acknowledges acceptance and uses the existing idle wait for +finish notifications. Remote agent lists have no caller-directory default; pass `cwd` to filter them. + +The parent’s Subagents track shows qualified children across connected hosts. Opening a child uses +the ordinary conversation on the destination's session. The runtime selects among that host's +connection candidates, including direct connections and routes discovered through another daemon. +Adding a direct connection to a discovered host adds another candidate. If no candidate connects, +the destination workspace keeps its existing disconnected or reconnecting behavior. + +Finish notifications use the existing idle wait, including a pending initial run or an already idle +child. They name the server and agent. A daemon restart or route loss ends an outstanding notification +wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain +no additional turn attribution or delivery guarantee. + +## Connections and recovery + +The shared Node connector owns the target grammar and each connection attempt, including a fresh +SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns +physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery +status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate +registrations remain inspectable; a changed destination requires explicit identity replacement. + +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. + +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 +through the usual owned-subscription contract. The ordinary client queues new requests while connecting +within its existing deadline and rejects requests while disconnected. Connection loss clears old +waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError` +(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination +before retrying a mutation. +Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent +creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission +RPC codes pass through unchanged, and connection/recovery behavior matches direct clients. + +Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8 +channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to +base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed +payloads reserve space for the routing header inside that limit. Relay encryption retains its own +existing frame limit. + +Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical +output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary +frame size. Awaitable output completes when the physical send completes. An overflowing channel +closes without closing siblings or the saved link. Routed sessions release resources on disconnect; +subscription demand is rebuilt by its owning client on reconnect. + +See the [SDK reference](../public-docs/sdk/reference.md#clienthosts), +[CLI reference](../public-docs/cli.md#connected-hosts), and +[plugin reference](../public-docs/plugins/reference.md#connected-hosts). diff --git a/docs/permissions.md b/docs/permissions.md index 93a70d6fb4..e2bf7c971f 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -31,6 +31,10 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce. +Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement +requires the producer or `access.manage`. Routed operations retain both caller and link authority, +see [Connected hosts](connected-hosts.md#ownership-and-authority). + Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal. Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence. diff --git a/package-lock.json b/package-lock.json index f4bbd3e6ea..c35ff101b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38178,6 +38178,7 @@ "dependencies": { "@getpaseo/protocol": "0.12.0", "@getpaseo/relay": "0.12.0", + "ws": "^8.20.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/app/e2e/browser/developer-ui.spec.ts b/packages/app/e2e/browser/developer-ui.spec.ts new file mode 100644 index 0000000000..c835d2a952 --- /dev/null +++ b/packages/app/e2e/browser/developer-ui.spec.ts @@ -0,0 +1,58 @@ +import { metroTest as test, expect } from "../support/fixtures"; +import { hideDeveloperToasts } from "../support/developer-ui.mjs"; +import type { Page } from "@playwright/test"; + +async function showTransientDeveloperToast(page: Page, shadow: boolean) { + await page.evaluate((useShadow) => { + const toast = document.createElement("div"); + toast.id = "error-toast"; + document.body.append(toast); + const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast; + const button = document.createElement("button"); + button.style.cssText = "width:32px;height:32px"; + button.addEventListener("click", () => button.remove()); + root.append(button); + }, shadow); +} + +async function openHiddenDeveloperToast(page: Page) { + await page.setContent(""); + await showTransientDeveloperToast(page, false); + await hideDeveloperToasts(page); +} + +async function navigateWithHiddenDeveloperToasts(page: Page) { + await hideDeveloperToasts(page); + await page.goto("data:text/html,"); + await showTransientDeveloperToast(page, true); +} + +async function inspectLayoutWithHiddenToast(page: Page) { + const action = page.getByRole("button", { name: "Continue", exact: true }); + const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox())); + for (const box of boxes) expect(box).not.toBeNull(); + await expect(page.locator("#error-toast")).toBeHidden(); + await action.click(); +} + +test("a hidden developer toast does not stall concurrent layout checks", async ({ page }) => { + test.setTimeout(10000); + await openHiddenDeveloperToast(page); + await inspectLayoutWithHiddenToast(page); +}); + +test("developer toast hiding survives navigation and Expo shadow-root updates", async ({ + page, +}) => { + test.setTimeout(10000); + await navigateWithHiddenDeveloperToasts(page); + await inspectLayoutWithHiddenToast(page); + await page.evaluate(() => { + const root = document.getElementById("error-toast")!.shadowRoot!; + const replacement = document.createElement("button"); + replacement.style.cssText = "width:32px;height:32px"; + replacement.addEventListener("click", () => replacement.remove()); + root.append(replacement); + }); + await inspectLayoutWithHiddenToast(page); +}); diff --git a/packages/app/e2e/browser/subagent-origin-pane.spec.ts b/packages/app/e2e/browser/subagent-origin-pane.spec.ts index 47a0120da6..30b19e5099 100644 --- a/packages/app/e2e/browser/subagent-origin-pane.spec.ts +++ b/packages/app/e2e/browser/subagent-origin-pane.spec.ts @@ -1,10 +1,19 @@ +import { buildSeededHost } from "../support/helpers/daemon-registry"; +import { submitMessage } from "../support/helpers/composer"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents"; +import { + delegateFleetThroughMcp, + expectManagedSubagentArchived, + openSubagentsTrack, + seedParentWithSubagent, +} from "../support/helpers/subagents"; + +test.use({ e2eInjectPaseoTools: true }); const SETTINGS_KEY = "@paseo:app-settings"; @@ -147,3 +156,189 @@ test("a preferred chat file link still opens beside its right-pane agent", async await workspace.cleanup(); } }); + +test("MCP delegates to self and B and opens both qualified children in ordinary conversations", async ({ + page, +}) => { + const fleet = await delegateFleetThroughMcp(); + let appSockets = 0; + let routeOpens = 0; + const activeDaemonSockets = new Set(); + const observationRequests: Record = {}; + page.on("websocket", (socket) => { + appSockets++; + if (new URL(socket.url()).pathname === "/ws") { + activeDaemonSockets.add(socket); + socket.on("close", () => activeDaemonSockets.delete(socket)); + } + socket.on("framesent", ({ payload }) => { + if (typeof payload !== "string" || !payload.startsWith("{")) return; + if (String(payload).includes('"hosts.route.open.request"')) routeOpens++; + const outer = JSON.parse(String(payload)); + const request = outer.type === "session" ? outer.message : null; + if ( + request?.type && + (request.subscribe !== undefined || + request.observe === true || + request.type.endsWith("set_subscription.request")) + ) + observationRequests[request.type] = (observationRequests[request.type] ?? 0) + 1; + }); + }); + try { + await openAgentRoute(page, { + workspaceId: fleet.workspace.workspaceId, + agentId: fleet.parent.id, + }); + await openSubagentsTrack(page); + await expect(page.getByTestId(`subagents-track-row-${fleet.local.agentId}`)).toContainText( + "MCP child on A", + ); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText( + "MCP child on B", + ); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText( + "Fleet B", + ); + await page.getByTestId(`subagents-track-row-${fleet.local.agentId}`).click(); + await expect(page.getByTestId(`workspace-tab-agent_${fleet.local.agentId}`)).toBeVisible(); + await submitMessage(page, "LOCAL_UI_TARGET"); + await expect(page.getByText("LOCAL_UI_TARGET", { exact: true })).toBeVisible(); + expect((await fleet.b.fetchAgent(fleet.local.agentId))?.agent.lastUserMessageAt).toBeNull(); + await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click(); + await openSubagentsTrack(page); + await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click(); + await expect(page).toHaveURL((url) => + url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`), + ); + await submitMessage(page, "REMOTE_UI_TARGET"); + await expect(page.getByText("REMOTE_UI_TARGET", { exact: true })).toBeVisible(); + expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull(); + await fleet.expectCompletion(fleet.local); + await fleet.expectCompletion(fleet.remote); + await openAgentRoute(page, { + workspaceId: fleet.workspace.workspaceId, + agentId: fleet.parent.id, + }); + await expect( + page.getByRole("button", { name: /Agent finished.*MCP child on A/ }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /Agent finished.*MCP child on B/ }), + ).toBeVisible(); + const owners = await page.evaluate(() => { + const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as { + getHosts(): Array<{ serverId: string }>; + getSnapshot( + serverId: string, + ): { connectionStatus: string; clientGeneration: number } | null; + }; + return runtime.getHosts().map((host) => { + const snapshot = runtime.getSnapshot(host.serverId); + return { + serverId: host.serverId, + connectionStatus: snapshot?.connectionStatus, + clientGeneration: snapshot?.clientGeneration, + }; + }); + }); + console.log("Fleet ordinary route counts", { + appSockets, + activeDaemonSockets: activeDaemonSockets.size, + routeOpens, + owners, + observationRequests, + }); + } finally { + await fleet.close(); + } +}); + +async function openFleetParentWithDirectB( + page: Page, + fleet: Awaited>, +) { + await page.goto("/", { waitUntil: "commit" }); + const host = buildSeededHost({ + serverId: fleet.remote.serverId, + endpoint: `127.0.0.1:${fleet.destination.port}`, + label: "Fleet B", + nowIso: new Date().toISOString(), + }); + await page.evaluate((extraHost) => { + localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost])); + }, host); + await openAgentRoute(page, { + workspaceId: fleet.workspace.workspaceId, + agentId: fleet.parent.id, + }); + await openSubagentsTrack(page); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText( + "MCP child on B", + ); +} + +async function openFleetChild( + page: Page, + fleet: Awaited>, +) { + await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click(); + await expect(page).toHaveURL( + (url) => url.pathname === `/h/${fleet.remote.serverId}/workspace/${fleet.remote.workspaceId}`, + ); +} + +test("a directly connected B opens and archives its MCP child from A's track", async ({ page }) => { + const fleet = await delegateFleetThroughMcp(); + try { + await openFleetParentWithDirectB(page, fleet); + await openFleetChild(page, fleet); + await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible(); + await submitMessage(page, "DIRECT_B_TARGET"); + await expect(page.getByText("DIRECT_B_TARGET", { exact: true })).toBeVisible(); + expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull(); + await openFleetParentWithDirectB(page, fleet); + await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).hover(); + page.once("dialog", (dialog) => { + void dialog.accept(); + }); + await page.getByRole("button", { name: "Archive MCP child on B", exact: true }).click(); + await expectManagedSubagentArchived({ client: fleet.b }, fleet.remote.agentId); + await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toHaveCount(0); + expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.archivedAt).toBeNull(); + await expect(page.getByText(/already connected through another route/)).toHaveCount(0); + } finally { + await fleet.close(); + } +}); + +test("an unavailable B child opens its cached workspace with existing reconnecting behavior", async ({ + page, +}) => { + const fleet = await delegateFleetThroughMcp(); + try { + await openFleetParentWithDirectB(page, fleet); + await fleet.destination.close(); + await expect + .poll(() => + page.evaluate((serverId) => { + const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as { + getSnapshot(id: string): { serverId: string; connectionStatus: string } | null; + }; + return runtime.getSnapshot(serverId); + }, fleet.remote.serverId), + ) + .toMatchObject({ + serverId: fleet.remote.serverId, + connectionStatus: expect.stringMatching(/^(offline|connecting|error)$/), + }); + await openFleetChild(page, fleet); + await expect(page.getByTestId("workspace-header-title").filter({ visible: true })).toHaveText( + "B workspace", + ); + await expect(page.getByRole("button", { name: "MCP child on B", exact: true })).toBeVisible(); + await expect(page.getByText(/already connected through another route/)).toHaveCount(0); + } finally { + await fleet.close(); + } +}); diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts index f3a5ca7c71..3354bf3dd8 100644 --- a/packages/app/e2e/support/helpers/subagents.ts +++ b/packages/app/e2e/support/helpers/subagents.ts @@ -1,3 +1,7 @@ +import { connectDaemonClient } from "./daemon-client-loader"; +import { startIsolatedHostDaemon } from "./isolated-host-daemon"; +import { seedWorkspace } from "./seed-client"; +import { getE2EDaemonPort } from "./daemon-port"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { expect, type Page } from "@playwright/test"; import { daemonWsRoutePattern } from "./daemon-port"; @@ -166,7 +170,7 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis } export async function expectManagedSubagentUnarchived( - workspace: Pick, + workspace: { client: Pick }, childId: string, ): Promise { await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({ @@ -175,7 +179,7 @@ export async function expectManagedSubagentUnarchived( } export async function expectManagedSubagentArchived( - workspace: Pick, + workspace: { client: Pick }, childId: string, ): Promise { await expect @@ -320,3 +324,150 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom await expect(detachButton).toBeVisible({ timeout: 30_000 }); await detachButton.click(); } + +/** The primary children come from the real agent MCP endpoint on A. */ +export async function delegateFleetThroughMcp() { + const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); + const { StreamableHTTPClientTransport } = + await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); + const { z } = await import("zod"); + type DaemonClient = import("@getpaseo/client/internal/daemon-client").DaemonClient; + const workspace = await seedWorkspace({ repoPrefix: "fleet-mcp-" }); + const destination = await startIsolatedHostDaemon(`fleet_${crypto.randomUUID()}`); + const a = await connectDaemonClient({ clientIdPrefix: "fleet-seed-A" }); + const b = await connectDaemonClient({ + clientIdPrefix: "fleet-observe-B", + port: destination.port, + }); + const serverId = a.getLastServerInfoMessage()!.serverId; + const mcp = new Client({ name: "fleet-parent", version: "1" }); + const close = async () => { + await mcp.close(); + await Promise.allSettled([a.close(), b.close()]); + await destination.close(); + await workspace.cleanup(); + }; + const call = async (name: string, args: Record = {}) => { + const response = await mcp.callTool({ name, arguments: args }); + expect(response, name).not.toMatchObject({ isError: true }); + return z.record(z.string(), z.unknown()).parse(response.structuredContent); + }; + try { + const parent = await a.createAgent({ + provider: "mock", + model: "e2e-fast-stream", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Fleet parent", + modeId: "load-test", + }); + const registration = await a.addHost({ + connection: `127.0.0.1:${destination.port}`, + label: "Fleet B", + }); + await expect + .poll( + async () => + (await a.listHosts()).hosts.find( + (row) => row.registrationId === registration.registrationId, + )?.status, + ) + .toBe("online"); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + expect(await call("list_hosts")).toMatchObject({ + hosts: expect.arrayContaining([ + expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }), + ]), + }); + const target = await call("create_workspace", { + serverId: destination.serverId, + isolation: "local", + path: workspace.repoPath, + title: "B workspace", + }); + expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({ + workspaces: expect.arrayContaining([ + expect.objectContaining({ workspaceId: target.workspaceId }), + ]), + }); + const createChild = async (targetServerId: string, workspaceId: string, title: string) => { + const result = await call("create_agent", { + serverId: targetServerId, + workspaceId, + title, + provider: "mock/e2e-fast-stream", + settings: { modeId: "load-test" }, + initialPrompt: `Finish ${title}`, + }); + return { + serverId: targetServerId, + agentId: z.string().parse(result.agentId), + workspaceId, + title, + }; + }; + const local = await createChild(serverId, workspace.workspaceId, "MCP child on A"); + const remote = await createChild( + destination.serverId, + z.string().parse(target.workspaceId), + "MCP child on B", + ); + // Existing SDK reservation creates unrelated equal-ID fixtures; primary children remain MCP-created. + await b.createAgent({ + agentId: local.agentId, + provider: "mock", + model: "e2e-fast-stream", + cwd: workspace.repoPath, + workspaceId: String(target.workspaceId), + title: "Unrelated on B", + modeId: "load-test", + }); + await a.createAgent({ + agentId: remote.agentId, + provider: "mock", + model: "e2e-fast-stream", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Unrelated on A", + modeId: "load-test", + }); + return { + workspace, + destination, + parent, + serverId, + local, + remote, + a, + b, + close, + async expectCompletion(child: typeof local) { + await expect + .poll(async () => { + const timeline = await a.fetchAgentTimeline(parent.id, { + direction: "tail", + limit: 100, + }); + return timeline.entries + .flatMap(({ item }) => + item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [], + ) + .filter( + (message) => + message.event === "finished" && + message.sender.id === child.agentId && + message.text.includes(`Server: ${child.serverId}`), + ).length; + }) + .toBe(1); + }, + }; + } catch (error) { + await close(); + throw error; + } +} diff --git a/packages/app/src/desktop/daemon/local-credential.ts b/packages/app/src/desktop/daemon/local-credential.ts index 005abdc21f..78093c8e63 100644 --- a/packages/app/src/desktop/daemon/local-credential.ts +++ b/packages/app/src/desktop/daemon/local-credential.ts @@ -10,6 +10,7 @@ function connectionListen(connection: HostConnection): string | null { case "directPipe": return `pipe://${connection.path}`; case "remoteSsh": + case "routed": case "relay": return null; } diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 92d8e4d2d6..e3ecf685b2 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -367,6 +367,11 @@ function useArchiveAgentPendingQuery() { }); } +export function usePendingArchiveAgentKeys(): ReadonlySet { + const pendingQuery = useArchiveAgentPendingQuery(); + return useMemo(() => new Set(Object.keys(pendingQuery.data ?? {})), [pendingQuery.data]); +} + export function usePendingArchiveAgentIds(serverId: string): ReadonlySet { const pendingQuery = useArchiveAgentPendingQuery(); return useMemo( diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index e67a004b45..3fc42030a7 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { memo, useCallback, type ReactElement } from "react"; import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill"; import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; @@ -56,8 +57,8 @@ export const AgentTracks = memo(function AgentTracks({ const canDetachSubagents = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, ); - const archiveSubagent = useArchiveSubagent({ serverId }); - const detachSubagent = useDetachSubagent({ serverId }); + const archiveSubagent = useArchiveSubagent(); + const detachSubagent = useDetachSubagent(); const openSubagentTarget = useCallback( (target: WorkspaceTabTarget) => { openPreferredWorkspaceTarget({ @@ -71,12 +72,15 @@ export const AgentTracks = memo(function AgentTracks({ [openInSidePane, tabId, workspaceKey], ); const handleOpenSubagent = useCallback( - (subagentId: string) => { - const session = useSessionStore.getState().sessions[serverId]; + ({ serverId: targetServerId, agentId: subagentId }: AgentAddress) => { + const session = useSessionStore.getState().sessions[targetServerId]; const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId); // A subagent in another workspace lives in that workspace's tabs. - if (agent?.workspaceId && agent.workspaceId !== workspaceId) { - navigateToAgent({ serverId, agentId: subagentId }); + if ( + targetServerId !== serverId || + (agent?.workspaceId && agent.workspaceId !== workspaceId) + ) { + navigateToAgent({ serverId: targetServerId, agentId: subagentId }); return; } openSubagentTarget({ kind: "agent", agentId: subagentId }); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 61285eb2c6..1425dc3560 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -11,6 +11,7 @@ import type { FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client"; +import type { HostDirectory } from "@getpaseo/protocol/hosts"; import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; @@ -157,10 +158,24 @@ class FakeDaemonClient { } } + get isConnected(): boolean { + return this.state.status === "connected"; + } + getConnectionState(): ConnectionState { return this.state; } + public connectedHosts = false; + public hostDirectory: Promise = Promise.resolve({ + self: { serverId: "srv_test", label: "test" }, + hosts: [], + }); + + observeHosts() { + return subscriptionFixture(this.hostDirectory, () => () => {}); + } + public ownedSubscriptions = true; private daemonVersion: string | null = "0.8.0"; @@ -171,7 +186,10 @@ class FakeDaemonClient { serverId: "srv_test", hostname: "test", version: this.daemonVersion, - features: { ownedSubscriptions: this.ownedSubscriptions }, + features: { + ownedSubscriptions: this.ownedSubscriptions, + connectedHosts: this.connectedHosts, + }, }; } @@ -1658,6 +1676,103 @@ describe("HostRuntimeController", () => { }); describe("HostRuntimeStore", () => { + it.each([false, true])( + "keeps B and C distinct with routed and direct candidates (B direct first: %s)", + async (directFirst) => { + const directory = new Deferred(); + const origin = new FakeDaemonClient(); + origin.setConnectionState({ status: "connected" }); + origin.connectedHosts = true; + origin.hostDirectory = directory.promise; + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: ({ host }) => + (host.serverId === "srv_a" + ? origin + : new FakeDaemonClient()) as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: (host.serverId === "srv_a" + ? origin + : makeConnectedProbeClient(5)) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label, + }), + getClientId: async () => "cid_fleet_candidates", + }, + }); + const addDirectB = () => + store.upsertDirectConnection({ + serverId: "srv_b", + endpoint: "b:6767", + label: "My B", + }); + try { + if (directFirst) await addDirectB(); + await store.upsertDirectConnection({ + serverId: "srv_c", + endpoint: "c:6767", + label: "My C", + }); + await store.upsertDirectConnection({ serverId: "srv_a", endpoint: "a:6767" }); + await waitForHostOnline(store, "srv_a"); + directory.resolve({ + self: { serverId: "srv_a", label: "A" }, + hosts: ["b", "c"].map((target) => ({ + registrationId: `reg_${target}`, + serverId: `srv_${target}`, + label: `Fleet ${target.toUpperCase()}`, + source: { id: "user", key: target }, + metadata: {}, + enabled: true, + status: "online" as const, + targetDescription: `${target}:6767`, + lastOnlineAt: null, + nextRetryAt: null, + error: null, + })), + }); + await vi.waitFor(() => + expect(store.getHosts()).toContainEqual( + expect.objectContaining({ + serverId: "srv_b", + connections: expect.arrayContaining([ + { id: "via:srv_a", type: "routed", originServerId: "srv_a" }, + ]), + }), + ), + ); + await waitForHostOnline(store, "srv_b"); + if (!directFirst) await addDirectB(); + const targets = store.getHosts().filter((host) => host.serverId === "srv_b"); + expect(targets).toHaveLength(1); + expect(targets[0]?.connections).toEqual( + expect.arrayContaining([ + { id: "via:srv_a", type: "routed", originServerId: "srv_a" }, + { id: "direct:b:6767", type: "directTcp", endpoint: "b:6767", useTls: false }, + ]), + ); + expect(targets[0]?.connections).toHaveLength(2); + expect(targets[0]?.label).toBe(directFirst ? "My B" : "Fleet B"); + expect(store.getSnapshot("srv_b")?.connectionStatus).toBe("online"); + expect(store.getHosts()).toHaveLength(3); + const targetC = store.getHosts().find((host) => host.serverId === "srv_c"); + expect(targetC).toMatchObject({ + serverId: "srv_c", + label: "My C", + connections: [ + { id: "direct:c:6767", type: "directTcp", endpoint: "c:6767", useTls: false }, + { id: "via:srv_a", type: "routed", originServerId: "srv_a" }, + ], + }); + await waitForHostOnline(store, "srv_c"); + expect(store.getSnapshot("srv_c")?.connectionStatus).toBe("online"); + } finally { + store.syncHosts([]); + } + }, + ); + it.each(["active", "inactive", "background"] as const)( "keeps reconnect enabled through inactive/background and resumes immediately (mounted %s)", async (currentState) => { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 85d077ee0a..152e3331e5 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,4 +1,7 @@ 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 { useSyncExternalStore, useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; @@ -114,6 +117,7 @@ export type ConnectionLinkImport = | { status: "cancelled" }; export type ActiveConnection = + | { type: "routed"; endpoint: string; display: string } | { type: "directTcp"; endpoint: string; display: string } | { type: "directSocket"; endpoint: string; display: "socket" } | { type: "directPipe"; endpoint: string; display: "pipe" } @@ -235,6 +239,12 @@ const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3; const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000; function toActiveConnection(connection: HostConnection): ActiveConnection { + if (connection.type === "routed") + return { + type: "routed", + endpoint: connection.originServerId, + display: `via ${connection.originServerId}`, + }; if (connection.type === "directSocket") { return { type: "directSocket", @@ -519,7 +529,9 @@ function probeIntervalForConnection( return PROBE_MAX_BACKOFF_MS; } -function createDefaultDeps(): HostRuntimeControllerDeps { +function createDefaultDeps( + resolveOrigin: (serverId: string) => DaemonClient | null = () => null, +): HostRuntimeControllerDeps { const browserHostAvailable = typeof getDesktopHost()?.browser?.executeAutomationCommand === "function"; const browserAutomationCapabilities = browserHostAvailable @@ -550,6 +562,15 @@ function createDefaultDeps(): HostRuntimeControllerDeps { ...(host.password ? { password: host.password } : {}), localCredential: () => readDesktopManagedLocalCredential(connection), } satisfies Omit; + if (connection.type === "routed") { + const origin = resolveOrigin(connection.originServerId); + if (!origin) throw new Error(`Origin host ${connection.originServerId} is unavailable`); + return new DaemonClient({ + ...base, + url: "ws://routed-host/ws", + transportFactory: createHostChannelTransport(origin, host.serverId, clientId, null), + }); + } if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ ...base, @@ -598,15 +619,40 @@ function createDefaultDeps(): HostRuntimeControllerDeps { }, }); }, - connectToDaemon: ({ host, connection, timeoutMs }) => - connectToDaemon(connection, { + connectToDaemon: async ({ host, connection, timeoutMs }) => { + if (connection.type === "routed") { + const origin = resolveOrigin(connection.originServerId); + if (!origin?.isConnected) + throw new Error(`Origin host ${connection.originServerId} is unavailable`); + const client = new DaemonClient({ + url: "ws://routed-host/ws", + clientId: crypto.randomUUID(), + transportFactory: createHostChannelTransport( + origin, + host.serverId, + crypto.randomUUID(), + null, + ), + reconnect: { enabled: false }, + appVersion: resolveAppVersion() ?? undefined, + }); + try { + await client.connect(); + return { client, serverId: host.serverId, hostname: null }; + } catch (error) { + await client.close(); + throw error; + } + } + return connectToDaemon(connection, { ...(host.serverId ? { serverId: host.serverId } : {}), ...(host.password ? { password: host.password } : {}), localCredential: () => readDesktopManagedLocalCredential(connection), ...(timeoutMs !== undefined ? { timeoutMs } : {}), capabilities: appCapabilities, trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), - }), + }); + }, getClientId: () => getOrCreateClientId(), mountClientHandlers: ({ client, host }) => { const unmountServerData = mountServerDataPushRouter({ @@ -1495,6 +1541,11 @@ interface AgentDirectoryRefreshInput { export class HostRuntimeStore { private controllers = new Map(); + private hostObservations = new Map< + string, + { client: DaemonClient; observation: OwnedSubscription; unsubscribe: () => void } + >(); + private fleetDirectoryDemand = new Map void>(); private serverListeners = new Map void>>(); private globalListeners = new Set<() => void>(); private hostListListeners = new Set<() => void>(); @@ -1525,7 +1576,7 @@ export class HostRuntimeStore { replicaRowStore?: ReplicaRowStore; revokePushNotifications?: typeof revokePushNotifications; }) { - this.deps = input?.deps ?? createDefaultDeps(); + this.deps = input?.deps ?? createDefaultDeps((serverId) => this.getClient(serverId)); this.storage = input?.storage ?? AsyncStorage; this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore()); this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications; @@ -2265,6 +2316,11 @@ export class HostRuntimeStore { if (nextIds.has(serverId)) { continue; } + this.hostObservations.get(serverId)?.unsubscribe(); + void this.hostObservations.get(serverId)?.observation.release(); + this.hostObservations.delete(serverId); + this.fleetDirectoryDemand.get(serverId)?.(); + this.fleetDirectoryDemand.delete(serverId); this.controllers.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); this.connectionStatusStartedAtByServer.delete(serverId); @@ -2321,6 +2377,7 @@ export class HostRuntimeStore { const snapshot = controller.getSnapshot(); this.syncSessionReplica(snapshot.serverId, snapshot); this.syncDirectoryConnection(snapshot.serverId); + this.syncHostDiscovery(snapshot.serverId); this.emit(snapshot.serverId); }); void controller @@ -2339,6 +2396,56 @@ export class HostRuntimeStore { } } + private syncHostDiscovery(serverId: string): void { + const client = this.getClient(serverId); + const previous = this.hostObservations.get(serverId); + if (previous?.client === client) return; + previous?.unsubscribe(); + void previous?.observation.release(); + this.hostObservations.delete(serverId); + const host = this.hosts.find((candidate) => candidate.serverId === serverId); + if ( + !client?.isConnected || + !client.getLastServerInfoMessage()?.features?.connectedHosts || + host?.connections.some((connection) => connection.type === "routed") + ) + return; + const observation = client.observeHosts(); + const apply = (directory: HostDirectory) => { + if (this.hostObservations.get(serverId)?.client !== client) return; + for (const target of directory.hosts) { + if (!target.serverId || target.serverId === serverId) continue; + const next = upsertHostConnectionInProfiles({ + profiles: this.hosts, + serverId: target.serverId, + label: target.label ?? undefined, + connection: { + id: `via:${serverId}`, + type: "routed", + originServerId: serverId, + }, + }); + if (next !== this.hosts) { + this.setHostsAndSync(next); + void this.persistHosts(); + } + if (!this.fleetDirectoryDemand.has(target.serverId)) + this.fleetDirectoryDemand.set( + target.serverId, + this.acquireDirectoryDemand(target.serverId), + ); + } + }; + const unsubscribe = observation.subscribe({ + snapshot: apply, + update: (message) => { + if (message.type === "hosts.changed") apply(message.payload.directory); + }, + }); + this.hostObservations.set(serverId, { client, observation, unsubscribe }); + void observation.ready.then(apply).catch(() => undefined); + } + private syncSessionReplica(serverId: string, snapshot: HostRuntimeSnapshot): void { if (!snapshot.client) { return; diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 6b308547a1..c70c4941cb 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -106,6 +106,7 @@ const removeProfileIcon = ; function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string { + if (connection.type === "routed") return `Via ${connection.originServerId}`; if (connection.type === "relay") { return `${t("settings.host.badges.relay")} (${connection.relayEndpoint})`; } diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 5f04a39bd9..ff2c8b2f4b 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -1,4 +1,5 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildWorkspaceTabSnapshot, @@ -10,6 +11,7 @@ import { selectSubagentsForParent } from "@/subagents/select"; import { findPaneById, findPaneContainingTab } from "./workspace-layout-actions"; import { useWorkspaceLayoutStore } from "./workspace-layout-store"; import { useSessionStore, type Agent } from "./session-store"; +import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; vi.mock("@react-native-async-storage/async-storage", () => { const storage = new Map(); @@ -73,7 +75,11 @@ const AGENT_DEFAULTS: Agent = { }; function makeAgent(input: Partial & Pick): Agent { - return { ...AGENT_DEFAULTS, ...input }; + const agent = { ...AGENT_DEFAULTS, ...input }; + return { + ...normalizeAgentSnapshot(projectAgentSnapshot(agent), SERVER_ID), + projectPlacement: agent.projectPlacement, + }; } function initializeAgents(agents: Agent[]): void { @@ -142,7 +148,7 @@ describe("workspace subagents integration", () => { const child = makeAgent({ id: "child-agent", - parentAgentId: "parent-agent", + labels: parentAddressLabels({ serverId: SERVER_ID, agentId: "parent-agent" }), title: "Child agent", }); const parent = makeAgent({ @@ -186,7 +192,7 @@ describe("workspace subagents integration", () => { }); const child = makeAgent({ id: "child-agent", - parentAgentId: "parent-agent", + labels: parentAddressLabels({ serverId: SERVER_ID, agentId: "parent-agent" }), title: "Child agent", }); @@ -205,7 +211,7 @@ describe("workspace subagents integration", () => { ).map((row) => row.id), ).toEqual(["child-agent"]); - appendAgent({ ...child, parentAgentId: null, labels: {} }); + appendAgent(makeAgent({ ...child, labels: {} })); reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual([ @@ -238,7 +244,7 @@ describe("workspace subagents integration", () => { }); const child = makeAgent({ id: "child-agent", - parentAgentId: parent.id, + labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }), title: "Cross-workspace child", }); @@ -271,7 +277,7 @@ describe("workspace subagents integration", () => { }); const child = makeAgent({ id: "child-agent", - parentAgentId: parent.id, + labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }), title: "Child agent", }); diff --git a/packages/app/src/subagents/archive-finished.test.ts b/packages/app/src/subagents/archive-finished.test.ts index 5c1615d96c..8ad93f990f 100644 --- a/packages/app/src/subagents/archive-finished.test.ts +++ b/packages/app/src/subagents/archive-finished.test.ts @@ -5,6 +5,7 @@ import type { PaseoSubagentRow, ProviderSubagentRow } from "./select"; function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow { return { kind: "paseo", + address: { serverId: "server-1", agentId: id }, id, provider: "codex", title: id, @@ -76,8 +77,8 @@ describe("createArchiveFinishedSubagents", () => { [provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: (id) => { + getManagedSubagent: ({ agentId: id }) => current.get(id), + archiveManagedSubagent: ({ agentId: id }) => { events.push(`archive:${id}`); return id === "first" ? first.promise : second.promise; }, @@ -130,8 +131,8 @@ describe("createArchiveFinishedSubagents", () => { const calls: string[] = []; const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: (id) => { + getManagedSubagent: ({ agentId: id }) => current.get(id), + archiveManagedSubagent: ({ agentId: id }) => { calls.push(id); const response = responses.shift(); if (!response) throw new Error("Unexpected archive"); @@ -204,8 +205,8 @@ describe("createArchiveFinishedSubagents", () => { ]); const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: async (id) => { + getManagedSubagent: ({ agentId: id }) => current.get(id), + archiveManagedSubagent: async ({ agentId: id }) => { throw id === "first" ? firstError : secondError; }, dismissProviderSubagents: () => undefined, @@ -230,7 +231,7 @@ describe("createArchiveFinishedSubagents", () => { const current = new Map([["failed", managed("failed")]]); const archive = createArchiveFinishedSubagents([paseo("failed")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), + getManagedSubagent: ({ agentId: id }) => current.get(id), archiveManagedSubagent: async () => { current.set("failed", managed("failed", "running")); throw new Error("archive failed"); @@ -256,7 +257,7 @@ describe("createArchiveFinishedSubagents", () => { const current = new Map([["failed", managed("failed")]]); const archive = createArchiveFinishedSubagents([paseo("failed")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), + getManagedSubagent: ({ agentId: id }) => current.get(id), archiveManagedSubagent: async () => { current.set("failed", managed("failed", "initializing")); throw new Error("archive failed"); @@ -297,7 +298,7 @@ describe("createArchiveFinishedSubagents", () => { const current = new Map([["failed", managed("failed")]]); const archive = createArchiveFinishedSubagents([paseo("failed")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), + getManagedSubagent: ({ agentId: id }) => current.get(id), archiveManagedSubagent: async () => { updateSnapshot(current); throw error; @@ -325,8 +326,8 @@ describe("createArchiveFinishedSubagents", () => { const calls: string[] = []; const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: async (id) => { + getManagedSubagent: ({ agentId: id }) => current.get(id), + archiveManagedSubagent: async ({ agentId: id }) => { calls.push(id); if (calls.length === 1) throw firstError; if (calls.length === 2) { @@ -384,8 +385,8 @@ describe("createArchiveFinishedSubagents", () => { const archived: string[] = []; const archive = createArchiveFinishedSubagents(rows, { parentAgentId: "parent", - getManagedSubagent: (id) => current.get(id), - archiveManagedSubagent: async (id) => { + getManagedSubagent: ({ agentId: id }) => current.get(id), + archiveManagedSubagent: async ({ agentId: id }) => { archived.push(id); current.set("resumed", managed("resumed", "running")); }, diff --git a/packages/app/src/subagents/archive-finished.ts b/packages/app/src/subagents/archive-finished.ts index 0f3c685591..c79c3af6f9 100644 --- a/packages/app/src/subagents/archive-finished.ts +++ b/packages/app/src/subagents/archive-finished.ts @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import type { Agent } from "@/stores/session-store"; import type { SubagentRow } from "./select"; @@ -22,8 +23,8 @@ export type ManagedSubagentSnapshot = Pick ManagedSubagentSnapshot | undefined; - archiveManagedSubagent: (id: string) => Promise; + getManagedSubagent: (address: AgentAddress) => ManagedSubagentSnapshot | undefined; + archiveManagedSubagent: (address: AgentAddress) => Promise; dismissProviderSubagents: (ids: string[]) => void; } @@ -55,12 +56,12 @@ function eligibleSignature(rows: readonly SubagentRow[]): string { return identitySignature(rowIdentities(rows)); } -function managedRowIdentity(id: string): string { - return `paseo:${id}`; +function managedRowIdentity(address: AgentAddress): string { + return `paseo:${address.serverId}:${address.agentId}`; } function rowIdentity(row: SubagentRow): string { - return row.kind === "paseo" ? managedRowIdentity(row.id) : `provider:${row.id}`; + return row.kind === "paseo" ? managedRowIdentity(row.address) : `provider:${row.id}`; } function rowIdentities(rows: readonly SubagentRow[]): Set { @@ -169,7 +170,7 @@ async function runArchiveFinished( reportProgress: (completedCount: number) => void, ): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set }> { const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id); - const paseoIds = rows.filter((row) => row.kind === "paseo").map((row) => row.id); + const paseoAddresses = rows.filter((row) => row.kind === "paseo").map((row) => row.address); let completedCount = 0; const outcome = emptyOutcome(); const retryableFailureIds = new Set(); @@ -181,15 +182,16 @@ async function runArchiveFinished( reportProgress(completedCount); } - for (const id of paseoIds) { - if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) { + for (const address of paseoAddresses) { + const id = address.agentId; + if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parentAgentId)) { try { - await deps.archiveManagedSubagent(id); + await deps.archiveManagedSubagent(address); outcome.archivedPaseoIds.push(id); } catch (error) { outcome.failures.push({ id, error }); - if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) { - retryableFailureIds.add(managedRowIdentity(id)); + if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parentAgentId)) { + retryableFailureIds.add(managedRowIdentity(address)); } } } else { diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index 9c6227a34f..6c539931e1 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -1,7 +1,7 @@ export type { SubagentRow } from "./select"; export { selectSubagentsForParent, useSubagentsForParent } from "./select"; -export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent"; -export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent"; +export { useArchiveSubagent } from "./use-archive-subagent"; +export { useDetachSubagent } from "./use-detach-subagent"; export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy"; export { isWorkspaceRootAgent } from "./workspace-root-policy"; export { diff --git a/packages/app/src/subagents/opens-in-sheet.test.tsx b/packages/app/src/subagents/opens-in-sheet.test.tsx index 0792091135..c9b915f787 100644 --- a/packages/app/src/subagents/opens-in-sheet.test.tsx +++ b/packages/app/src/subagents/opens-in-sheet.test.tsx @@ -2,9 +2,11 @@ * @vitest-environment jsdom */ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; import { act, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { useSubagentOpensInSheet } from "./opens-in-sheet"; @@ -77,7 +79,15 @@ if (!workspaceKey) throw new Error("workspace key"); function seedParentWithChild(): SubagentRow { const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" }; - const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" }; + const child = normalizeAgentSnapshot( + projectAgentSnapshot({ + ...AGENT_DEFAULTS, + id: "child", + title: "Child", + labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }), + }), + SERVER_ID, + ); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); useSessionStore.getState().setAgents( SERVER_ID, diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 38a905183d..c9d7ca4096 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -53,7 +53,14 @@ const AGENT_DEFAULTS: Agent = { }; function makeAgent(input: Partial & Pick): Agent { - return { ...AGENT_DEFAULTS, ...input }; + return { + ...AGENT_DEFAULTS, + ...input, + labels: { + ...(input.parentAgentId ? { "paseo.parent-agent-id": input.parentAgentId } : {}), + ...input.labels, + }, + }; } function setAgents(agents: Agent[]): void { @@ -73,6 +80,41 @@ afterEach(() => { }); describe("selectSubagentsForParent", () => { + it("keeps equal child IDs on different hosts qualified to the complete parent address", () => { + const parentId = "shared-parent"; + const childId = "shared-child"; + const local = makeAgent({ + id: childId, + parentAgentId: parentId, + labels: { "paseo.parent-agent-id": parentId, "paseo.parent-server-id": SERVER_ID }, + }); + const remote = makeAgent({ + id: childId, + serverId: "server-2", + parentAgentId: null, + labels: { "paseo.parent-agent-id": parentId, "paseo.parent-server-id": SERVER_ID }, + }); + setAgents([local]); + useSessionStore.getState().initializeSession("server-2", null as unknown as DaemonClient); + useSessionStore.getState().setAgents("server-2", new Map([[childId, remote]])); + try { + const rows = selectSubagentsForParent( + useSessionStore.getState(), + { serverId: SERVER_ID, parentAgentId: parentId }, + EMPTY_PENDING_ARCHIVE_IDS, + ); + expect(rows).toHaveLength(2); + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ address: { serverId: SERVER_ID, agentId: childId } }), + expect.objectContaining({ address: { serverId: "server-2", agentId: childId } }), + ]), + ); + } finally { + useSessionStore.getState().clearSession("server-2"); + } + }); + it("hides cached provider children when the host does not support them", () => { useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { kind: "upsert", @@ -315,6 +357,7 @@ describe("selectSubagentsForParent", () => { expect(rows).toEqual([ { kind: "paseo", + address: { serverId: SERVER_ID, agentId: "child" }, id: "child", provider: "claude", title: "Review child", @@ -327,6 +370,7 @@ describe("selectSubagentsForParent", () => { }, ]); expect(Object.keys(rows[0] ?? {}).sort()).toEqual([ + "address", "createdAt", "description", "id", @@ -371,7 +415,7 @@ describe("selectSubagentsForParent", () => { setAgents([ makeAgent({ id: "parent-a" }), makeAgent({ id: "parent-b" }), - { ...child, parentAgentId: "parent-b" }, + { ...child, parentAgentId: "parent-b", labels: { "paseo.parent-agent-id": "parent-b" } }, ]); expect( @@ -409,7 +453,7 @@ describe("selectSubagentsForParent", () => { serverId: SERVER_ID, parentAgentId: "parent", }, - new Set(["child-b"]), + new Set([`${SERVER_ID}:child-b`]), ); expect(rows.map((row) => row.id)).toEqual(["child-a"]); @@ -424,7 +468,7 @@ describe("selectSubagentsForParent", () => { serverId: SERVER_ID, parentAgentId: "parent", }, - new Set(["child"]), + new Set([`${SERVER_ID}:child`]), ); expect(rows).toEqual([]); @@ -541,7 +585,7 @@ describe("createSubagentsForParentSelector", () => { const rows = createSubagentsForParentSelector( params, - new Set(["child-b"]), + new Set([`${SERVER_ID}:child-b`]), )(useSessionStore.getState()); expect(rows.map((row) => row.id)).toEqual(["child-a"]); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index 164a4d1280..29f76e34d4 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -1,5 +1,7 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels"; import { useEffect, useMemo } from "react"; -import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent"; +import { usePendingArchiveAgentKeys } from "@/hooks/use-archive-agent"; import equal from "fast-deep-equal"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSessionStore, type Agent } from "@/stores/session-store"; @@ -8,6 +10,7 @@ import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messa export interface PaseoSubagentRow { kind: "paseo"; + address: AgentAddress; id: Agent["id"]; provider: Agent["provider"]; title: Agent["title"]; @@ -55,6 +58,7 @@ const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = []; function toSubagentRow(agent: Agent): SubagentRow { return { kind: "paseo", + address: { serverId: agent.serverId, agentId: agent.id }, id: agent.id, provider: agent.provider, title: agent.title, @@ -72,21 +76,19 @@ export function selectSubagentsForParent( params: SelectSubagentsParams, pendingArchiveIds: ReadonlySet, ): SubagentRow[] { - const agents = state.sessions[params.serverId]?.agents; - if (!agents || agents.size === 0) { - return EMPTY_SUBAGENT_ROWS; - } - const rows: SubagentRow[] = []; - for (const agent of agents.values()) { - if ( - agent.archivedAt || - pendingArchiveIds.has(agent.id) || - agent.parentAgentId !== params.parentAgentId - ) { - continue; + for (const [serverId, session] of Object.entries(state.sessions)) { + for (const agent of session.agents.values()) { + const parent = getParentAddressFromLabels(agent.labels, serverId); + if ( + agent.archivedAt || + pendingArchiveIds.has(`${serverId}:${agent.id}`) || + parent?.serverId !== params.serverId || + parent.agentId !== params.parentAgentId + ) + continue; + rows.push(toSubagentRow(agent)); } - rows.push(toSubagentRow(agent)); } if (rows.length === 0) { @@ -104,12 +106,21 @@ export function createSubagentsForParentSelector( params: SelectSubagentsParams, pendingArchiveIds: ReadonlySet, ): (state: SessionStoreSnapshot) => SubagentRow[] { - let previousAgents: Map | undefined; + let previousAgents: Array<[string, Map]> = []; let previousRows: SubagentRow[] | null = null; return (state) => { - const agents = state.sessions[params.serverId]?.agents; - if (previousRows && agents === previousAgents) { + const agents = Object.entries(state.sessions).map( + ([serverId, session]) => [serverId, session.agents] as [string, Map], + ); + if ( + previousRows && + agents.length === previousAgents.length && + agents.every( + ([serverId, map], index) => + previousAgents[index]?.[0] === serverId && previousAgents[index]?.[1] === map, + ) + ) { return previousRows; } @@ -159,7 +170,7 @@ export function selectProviderSubagentsForParent( export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] { const { serverId, parentAgentId } = params; - const pendingArchiveIds = usePendingArchiveAgentIds(serverId); + const pendingArchiveIds = usePendingArchiveAgentKeys(); const selectPaseoRows = useMemo( () => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds), [serverId, parentAgentId, pendingArchiveIds], diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index df009583be..0397795541 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -13,6 +13,7 @@ function row( ): PaseoSubagentRow { return { kind: "paseo", + address: overrides.address ?? { serverId: "server-1", agentId: overrides.id }, id: overrides.id, provider: overrides.provider ?? "codex", title: overrides.title ?? `Agent ${overrides.id}`, diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 43f659c534..d759da431e 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -1,3 +1,5 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import { useHosts } from "@/runtime/host-runtime"; import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; @@ -35,17 +37,17 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ export interface SubagentsTrackProps { serverId: string; rows: SubagentRow[]; - onOpenSubagent: (id: string) => void; + onOpenSubagent: (address: AgentAddress) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; /** * Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on * close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list. */ opensInSheet: (row: SubagentRow) => boolean; - onArchiveSubagent: (id: string) => void; + onArchiveSubagent: (address: AgentAddress) => void; onArchiveFinished?: () => void; archiveFinishedStatus?: ArchiveFinishedStatus; - onDetachSubagent?: (id: string) => void; + onDetachSubagent?: (address: AgentAddress) => void; } const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" }; @@ -105,7 +107,7 @@ export function SubagentsTrack({ ) : null} {rows.map((row) => ( void; + onOpenSubagent: (address: AgentAddress) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; opensInSheet: boolean; - onArchiveSubagent: (id: string) => void; - onDetachSubagent?: (id: string) => void; + onArchiveSubagent: (address: AgentAddress) => void; + onDetachSubagent?: (address: AgentAddress) => void; } function SubagentsTrackRow({ @@ -198,22 +200,24 @@ function SubagentsTrackRow({ }: SubagentsTrackRowProps): ReactElement { const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); - const presentation = useRowPresentation(row, serverId); + const rowServerId = row.kind === "paseo" ? row.address.serverId : serverId; + const hostLabel = useHosts().find((host) => host.serverId === rowServerId)?.label ?? rowServerId; + const presentation = useRowPresentation(row, rowServerId); const displayLabel = presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; const handlePress = useCallback(() => { if (row.kind === "provider") { onOpenProviderSubagent(row.parentAgentId, row.id); } else { - onOpenSubagent(row.id); + onOpenSubagent(row.address); } }, [onOpenProviderSubagent, onOpenSubagent, row]); const handleArchivePress = useCallback(() => { - onArchiveSubagent(row.id); - }, [onArchiveSubagent, row.id]); + if (row.kind === "paseo") onArchiveSubagent(row.address); + }, [onArchiveSubagent, row]); const handleDetachPress = useCallback(() => { - onDetachSubagent?.(row.id); - }, [onDetachSubagent, row.id]); + if (row.kind === "paseo") onDetachSubagent?.(row.address); + }, [onDetachSubagent, row]); const actionsAlwaysVisible = isNative || isCompact; const renderRow = useCallback( @@ -225,7 +229,10 @@ function SubagentsTrackRow({ {displayLabel} {row.kind === "paseo" ? ( - + <> + + + ) : ( )} @@ -253,6 +260,8 @@ function SubagentsTrackRow({ row.kind, row.id, serverId, + rowServerId, + hostLabel, ], ); diff --git a/packages/app/src/subagents/use-archive-finished.ts b/packages/app/src/subagents/use-archive-finished.ts index e7653f054e..0152dc9ba3 100644 --- a/packages/app/src/subagents/use-archive-finished.ts +++ b/packages/app/src/subagents/use-archive-finished.ts @@ -1,3 +1,4 @@ +import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels"; import { useEffect, useMemo, useSyncExternalStore } from "react"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useSessionStore } from "@/stores/session-store"; @@ -32,8 +33,16 @@ export function useArchiveFinishedSubagents({ () => createArchiveFinishedSubagents([], { parentAgentId, - getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id), - archiveManagedSubagent: (id) => archiveAgent({ serverId, agentId: id }), + getManagedSubagent: (address) => { + const agent = useSessionStore + .getState() + .sessions[address.serverId]?.agents.get(address.agentId); + if (!agent) return undefined; + const parent = getParentAddressFromLabels(agent.labels, address.serverId); + if (parent?.serverId !== serverId || parent.agentId !== parentAgentId) return undefined; + return { ...agent, parentAgentId: parent.agentId }; + }, + archiveManagedSubagent: archiveAgent, dismissProviderSubagents: (ids) => { useProviderSubagentStore.getState().hideFromTrack(serverId, parentAgentId, ids); }, diff --git a/packages/app/src/subagents/use-archive-subagent.ts b/packages/app/src/subagents/use-archive-subagent.ts index b00f9bb871..e8189c1830 100644 --- a/packages/app/src/subagents/use-archive-subagent.ts +++ b/packages/app/src/subagents/use-archive-subagent.ts @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; @@ -13,17 +14,12 @@ export type { ResolveArchiveSubagentDialogInput, } from "./archive-subagent"; -export interface UseArchiveSubagentInput { - serverId: string; -} - -export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void { +export function useArchiveSubagent(): (address: AgentAddress) => void { const { archiveAgent } = useArchiveAgent(); - const { serverId } = input; const toast = useToast(); return useCallback( - (subagentId: string) => { + ({ serverId, agentId: subagentId }: AgentAddress) => { void requestArchiveSubagent( { serverId, subagentId }, { @@ -37,6 +33,6 @@ export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: }, ); }, - [archiveAgent, serverId, toast], + [archiveAgent, toast], ); } diff --git a/packages/app/src/subagents/use-detach-subagent.ts b/packages/app/src/subagents/use-detach-subagent.ts index 36b2f2a560..120aedb872 100644 --- a/packages/app/src/subagents/use-detach-subagent.ts +++ b/packages/app/src/subagents/use-detach-subagent.ts @@ -1,3 +1,4 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; import { i18n } from "@/i18n/i18next"; @@ -14,16 +15,11 @@ export type { ResolveDetachSubagentDialogInput, } from "./detach-subagent"; -export interface UseDetachSubagentInput { - serverId: string; -} - -export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: string) => void { - const { serverId } = input; +export function useDetachSubagent(): (address: AgentAddress) => void { const toast = useToast(); return useCallback( - (subagentId: string) => { + ({ serverId, agentId: subagentId }: AgentAddress) => { void requestDetachSubagent( { serverId, subagentId }, { @@ -46,6 +42,6 @@ export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: s }, ); }, - [serverId, toast], + [toast], ); } diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index a476c32089..0f0590bb9e 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -205,6 +205,57 @@ describe("upsertHostConnectionInProfiles", () => { path: "/tmp/paseo.sock", }; + it.each([false, true])( + "keeps destinations distinct when a persisted route shares an origin (C already saved: %s)", + (savedC) => { + const route: HostConnection = { id: "via:srv_a", type: "routed", originServerId: "srv_a" }; + const directB: HostConnection = { + id: "direct:b:6767", + type: "directTcp", + endpoint: "b:6767", + }; + const directC: HostConnection = { + id: "direct:c:6767", + type: "directTcp", + endpoint: "c:6767", + }; + const b: HostProfile = { + ...makeHost("srv_b"), + label: "My B", + connections: [route, directB], + preferredConnectionId: route.id, + }; + const c: HostProfile = { + ...makeHost("srv_c"), + label: "My C", + connections: [directC], + preferredConnectionId: directC.id, + }; + const profiles = upsertHostConnectionInProfiles({ + profiles: savedC ? [b, c] : [b], + serverId: "srv_c", + label: "Fleet C", + connection: route, + }); + expect(profiles).toHaveLength(2); + expect(profiles[0]).toEqual(b); + expect(profiles[1]).toMatchObject({ + serverId: "srv_c", + label: savedC ? "My C" : "Fleet C", + connections: savedC ? [directC, route] : [route], + preferredConnectionId: savedC ? directC.id : route.id, + }); + expect( + upsertHostConnectionInProfiles({ + profiles, + serverId: "srv_b", + label: "Fleet B", + connection: route, + }), + ).toBe(profiles); + }, + ); + it("gives a newly discovered host the default appearance", () => { const [profile] = upsertHostConnectionInProfiles({ profiles: [], diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 0962c02c7c..fb3150a168 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -51,7 +51,14 @@ export interface RelayHostConnection { daemonPublicKeyB64: string; } +export interface RoutedHostConnection { + id: string; + type: "routed"; + originServerId: string; +} + export type HostConnection = + | RoutedHostConnection | DirectTcpHostConnection | DirectSocketHostConnection | DirectPipeHostConnection @@ -128,6 +135,8 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool return false; } + if (left.type === "routed" && right.type === "routed") + return left.originServerId === right.originServerId; if (left.type === "directTcp" && right.type === "directTcp") { return left.endpoint === right.endpoint && (left.useTls ?? false) === (right.useTls ?? false); } @@ -236,9 +245,11 @@ export function upsertHostConnectionInProfiles(input: { const matchingIndexes = existing.reduce((matches, daemon, index) => { if ( daemon.serverId === serverId || - daemon.connections.some((existingConnection) => - hostConnectionEquals(existingConnection, normalizedConnection), - ) + // A routed candidate names its origin; its destination belongs to the host profile. + (normalizedConnection.type !== "routed" && + daemon.connections.some((existingConnection) => + hostConnectionEquals(existingConnection, normalizedConnection), + )) ) { matches.push(index); } @@ -399,6 +410,7 @@ export function createRemoteSshHostConnection(input: { } const StoredHostConnectionSchema = z.discriminatedUnion("type", [ + z.strictObject({ id: z.string(), type: z.literal("routed"), originServerId: z.string().min(1) }), z.strictObject({ id: z.string().optional(), type: z.literal("directTcp"), @@ -446,6 +458,7 @@ export const StoredHostRegistrySchema = z.array(StoredHostProfileSchema); type StoredHostConnection = z.infer; function normalizeStoredConnection(connection: StoredHostConnection): HostConnection | null { + if (connection.type === "routed") return connection; if (connection.type === "directTcp") { try { const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint)); diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index c1c70c640e..13a7bcd14b 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,6 +1,6 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; -import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; +import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels"; import { TURN_LIVENESS_IDLE, type ActiveTurnIdentity, @@ -98,7 +98,8 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: ? new Date(snapshot.attentionTimestamp) : null; const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null; - const parentAgentId = getParentAgentIdFromLabels(snapshot.labels); + const parent = getParentAddressFromLabels(snapshot.labels, serverId); + const parentAgentId = parent?.serverId === serverId ? parent.agentId : null; // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. // Old daemons expose only status. Normalize that legacy signal once so the rest // of the app consumes one activity shape. diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index b882b1f30a..5942b45621 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -146,6 +146,8 @@ export async function buildClientConfig( | "buildDesktopTransportUrl" > = defaultDaemonConnectionDependencies, ): Promise { + if (connection.type === "routed") + throw new Error("Routed host connections are resolved by HostRuntime"); const clientId = await deps.getClientId(); const desktopTransportFactory = deps.createDesktopTransportFactory(); const base = { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index dceb3f372d..1385ebb2d5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,3 +1,4 @@ +import { createHostCommand } from "./commands/host/index.js"; import { pairCommand } from "./commands/daemon/pair.js"; import { Command, Option } from "commander"; import { createAgentCommand } from "./commands/agent/index.js"; @@ -58,6 +59,8 @@ export function createCli(): Command { .option("--no-color", "disable colored output"); addDaemonHostOption(program); + program.addCommand(createHostCommand()); + // Primary agent commands (top-level) addJsonAndDaemonHostOptions(addLsOptions(program.command("ls"))).action(withOutput(runLsCommand)); diff --git a/packages/cli/src/commands/host/index.ts b/packages/cli/src/commands/host/index.ts new file mode 100644 index 0000000000..ddd6a0e21e --- /dev/null +++ b/packages/cli/src/commands/host/index.ts @@ -0,0 +1,116 @@ +import { Command } from "commander"; +import type { HostSummary, HostUpdate } from "@getpaseo/client"; +import { + withOutput, + type CommandOptions, + type OutputSchema, + type CommandResult, +} from "../../output/index.js"; +import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js"; +import { connectToDaemon } from "../../utils/client.js"; + +const schema: OutputSchema = { + idField: "registrationId", + columns: [ + { header: "ID", field: "registrationId" }, + { header: "LABEL", field: "label" }, + { header: "SERVER", field: "serverId" }, + { header: "ENABLED", field: "enabled" }, + { header: "STATUS", field: "status" }, + { header: "SOURCE", field: (row) => `${row.source.id}/${row.source.key}` }, + ], +}; +function metadata(values: string[] = []): Record { + return Object.fromEntries( + values.map((entry) => { + const split = entry.indexOf("="); + if (split < 1) throw { code: "invalid_metadata", message: "Metadata must use key=value" }; + return [entry.slice(0, split), entry.slice(split + 1)]; + }), + ); +} +interface Options extends CommandOptions { + label?: string; + meta?: string[]; + removeMeta?: string[]; + connection?: string; + replaceIdentity?: boolean; +} +async function run( + action: string, + id: string | undefined, + options: Options, +): Promise> { + const client = await connectToDaemon({ target: options.daemonTarget }); + try { + if (action === "add") + return { + type: "single", + data: await client.addHost({ + connection: id!, + label: options.label, + metadata: metadata(options.meta), + }), + schema, + }; + if (action === "ls") return { type: "list", data: (await client.listHosts()).hosts, schema }; + const host = (await client.listHosts()).hosts.find((entry) => entry.registrationId === id); + if (!host) throw { code: "host_unknown", message: `Unknown registration ${id}` }; + if (action === "inspect") + return { + type: "single", + data: host, + schema: { ...schema, renderHuman: () => JSON.stringify(host, null, 2) }, + }; + if (action === "rm") { + await client.removeHost(id!); + return { type: "single", data: host, schema }; + } + const patch: HostUpdate = {}; + if (action === "enable" || action === "disable") patch.enabled = action === "enable"; + if (options.label !== undefined) patch.label = options.label || null; + if (options.connection !== undefined) patch.connection = options.connection; + if (options.replaceIdentity) patch.replaceIdentity = true; + if (options.meta?.length || options.removeMeta?.length) { + patch.metadata = { ...host.metadata, ...metadata(options.meta) }; + for (const key of options.removeMeta ?? []) delete patch.metadata[key]; + } + return { type: "single", data: await client.updateHost(id!, patch), schema }; + } finally { + await client.close(); + } +} +export function createHostCommand(): Command { + const host = new Command("host").description("Manage hosts connected to the selected daemon"); + addJsonAndDaemonHostOptions( + host.command("ls").description("List saved hosts, including disabled and failed entries"), + ).action(withOutput((options: Options, _command: Command) => run("ls", undefined, options))); + addJsonAndDaemonHostOptions( + host + .command("add") + .argument("") + .option("--label ") + .option("--meta ", "Set metadata", collectMultiple, []), + ).action( + withOutput((connection: string, options: Options, _command: Command) => + run("add", connection, options), + ), + ); + addJsonAndDaemonHostOptions( + host + .command("update") + .argument("") + .option("--label ") + .option("--connection ") + .option("--replace-identity", "Accept a new destination identity") + .option("--meta ", "Replace supplied metadata keys", collectMultiple, []) + .option("--remove-meta ", "Remove a metadata key", collectMultiple, []), + ).action( + withOutput((id: string, options: Options, _command: Command) => run("update", id, options)), + ); + for (const action of ["inspect", "enable", "disable", "rm"]) + addJsonAndDaemonHostOptions(host.command(action).argument("")).action( + withOutput((id: string, options: Options, _command: Command) => run(action, id, options)), + ); + return host; +} diff --git a/packages/cli/src/ssh/ssh-tunnel.ts b/packages/cli/src/ssh/ssh-tunnel.ts index 33900d0baa..0d25c2e9b8 100644 --- a/packages/cli/src/ssh/ssh-tunnel.ts +++ b/packages/cli/src/ssh/ssh-tunnel.ts @@ -1,90 +1,2 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { createServer, type Server, type Socket } from "node:net"; -import { buildSshTunnelArgs, type SshTransportTarget } from "@getpaseo/protocol/ssh-transport"; - -const SSH_STDERR_LIMIT = 8192; - -export interface SshTunnel { - endpoint: string; - close(): void; - failureDetail(): string | null; -} - -function formatSshFailure( - stderr: string, - code: number | null, - signal: NodeJS.Signals | null, -): string { - const detail = stderr.trim(); - if (detail) return detail; - if (signal) return `ssh exited with signal ${signal}`; - return `ssh exited with code ${code ?? "unknown"}`; -} - -export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null { - return failure ?? (stderr.trim() || null); -} - -export function createSshTunnel(target: SshTransportTarget): Promise { - let server: Server | null = null; - let socket: Socket | null = null; - let child: ChildProcessWithoutNullStreams | null = null; - let stderr = ""; - let failure: string | null = null; - - function close(): void { - server?.close(); - server = null; - socket?.destroy(); - socket = null; - if (child && !child.killed) child.kill(); - child = null; - } - - return new Promise((resolve, reject) => { - server = createServer((acceptedSocket) => { - socket = acceptedSocket; - server?.close(); - server = null; - - child = spawn("ssh", buildSshTunnelArgs(target), { - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }); - child.stderr.on("data", (chunk: Buffer | string) => { - stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT); - }); - child.on("error", (error) => { - failure = error.message; - acceptedSocket.destroy(error); - }); - child.on("exit", (code, signal) => { - if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal); - acceptedSocket.destroy(failure ? new Error(failure) : undefined); - }); - acceptedSocket.on("error", () => undefined); - acceptedSocket.on("close", () => { - if (child && !child.killed) child.kill(); - }); - acceptedSocket.pipe(child.stdin); - child.stdout.pipe(acceptedSocket); - }); - server.once("error", (error) => { - close(); - reject(error); - }); - server.listen(0, "127.0.0.1", () => { - const address = server?.address(); - if (!address || typeof address === "string") { - close(); - reject(new Error("Failed to allocate the SSH tunnel port")); - return; - } - resolve({ - endpoint: `127.0.0.1:${address.port}`, - close, - failureDetail: () => resolveSshFailureDetail(failure, stderr), - }); - }); - }); -} +export { createSshTunnel, resolveSshFailureDetail } from "@getpaseo/client/node"; +export type { SshTunnel } from "@getpaseo/client/node"; diff --git a/packages/cli/src/utils/client.ssh.test.ts b/packages/cli/src/utils/client.ssh.test.ts index 19b720a893..a4c08501f4 100644 --- a/packages/cli/src/utils/client.ssh.test.ts +++ b/packages/cli/src/utils/client.ssh.test.ts @@ -1,42 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { resolveSshFailureDetail } from "../ssh/ssh-tunnel.js"; -import { connectToDaemon } from "./client.js"; - -const mocks = vi.hoisted(() => ({ - configs: [] as Array>, - createSshTunnel: vi.fn(), -})); - -vi.mock("@getpaseo/client/internal/daemon-client", () => ({ - DaemonClient: class { - lastError = null; - - constructor(config: Record) { - mocks.configs.push(config); - } - - async connect() {} - async close() {} - }, -})); - -vi.mock("../ssh/ssh-tunnel.js", async (importOriginal) => ({ - ...(await importOriginal()), - createSshTunnel: mocks.createSshTunnel, -})); -vi.mock("./client-id.js", () => ({ getOrCreateCliClientId: async () => "cli-test-id" })); +import { parseHostTarget } from "@getpaseo/client/node"; describe("CLI SSH transport", () => { - beforeEach(() => { - mocks.configs.length = 0; - mocks.createSshTunnel.mockReset(); - mocks.createSshTunnel.mockResolvedValue({ - endpoint: "127.0.0.1:4567", - close: vi.fn(), - failureDetail: () => null, - }); - }); - it("surfaces SSH stderr before the child exit event settles", () => { expect(resolveSshFailureDetail(null, "Host key verification failed.\n")).toBe( "Host key verification failed.", @@ -45,21 +11,14 @@ describe("CLI SSH transport", () => { "ssh exited with code 255", ); }); - - it("routes an SSH host through a local tunnel", async () => { - await connectToDaemon({ - target: { kind: "endpoint", host: "ssh://deploy@build-box:2222?daemonPort=7777" }, - }); - - expect(mocks.createSshTunnel).toHaveBeenCalledWith({ - host: "deploy@build-box", - sshPort: 2222, - daemonPort: 7777, - }); - expect(mocks.configs[0]).toMatchObject({ - url: "ws://127.0.0.1:4567/ws", - clientId: "cli-test-id", - clientType: "cli", + it("uses the shared SSH target grammar and excludes reusable credentials from discovery", () => { + expect(parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777")).toEqual({ + kind: "ssh", + ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 }, + description: "ssh://build-box:2222 (daemon port 7777)", }); + expect(() => parseHostTarget("ssh://deploy:secret@build-box")).toThrow( + "Invalid host connection", + ); }); }); diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 92d91ea944..27d1a4dbcb 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,3 +1,4 @@ +import { createHostClient, parseHostTarget } from "@getpaseo/client/node"; import { waitForDaemonReady, resolvePaseoHome, @@ -8,21 +9,12 @@ import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js"; export type { DaemonTarget } from "./daemon-target.js"; import { buildDaemonWebSocketUrl, - buildRelayWebSocketUrl, normalizeHostPort, parseConnectionUri, - shouldUseTlsForDefaultHostedRelay, } from "@getpaseo/protocol/daemon-endpoints"; -import { - parseConnectionOfferFromUrl, - type ConnectionOffer, -} from "@getpaseo/protocol/connection-offer"; -import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport"; -import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client"; -import { WebSocket } from "ws"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { getOrCreateCliClientId } from "./client-id.js"; import { resolveCliVersion } from "../version.js"; -import { createSshTunnel } from "../ssh/ssh-tunnel.js"; export interface ConnectOptions { target: DaemonTarget; @@ -50,15 +42,15 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er if (error && typeof error === "object" && "message" in error) message = String(error.message); if (options.target.kind === "endpoint") message = message.replaceAll(options.target.host, describeDaemonTarget(options.target)); - if (message.startsWith("Cannot connect to daemon at ")) - return error as { code: string; message: string; details: string }; let code = "DAEMON_UNREACHABLE"; if (typeof error === "object" && error !== null && "code" in error) code = String(error.code); else if (message === "Password required") code = "AUTH_REQUIRED"; else if (message === "Incorrect password") code = "AUTH_FAILED"; return { code, - message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`, + message: message.startsWith("Cannot connect to daemon at ") + ? message + : `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`, details: describeConnectionRemedy(code, options.target), }; } @@ -170,11 +162,8 @@ export function resolveDaemonTarget(host: string): TransportTarget { } export function resolveDaemonPassword(host: string): string | undefined { - const trimmed = host.trim(); - if (trimmed.startsWith("tcp://")) { - const fromUri = parseConnectionUri(trimmed).password; - if (fromUri) return fromUri; - } + const fromUri = parseHostTarget(host).password; + if (fromUri) return fromUri; const fromEnv = process.env.PASEO_PASSWORD; return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; } @@ -189,117 +178,9 @@ export function resolveDaemonCredential( return token ? { kind: "localCredential", token } : null; } -/** - * Create a WebSocket factory that works in Node.js - */ -function createNodeWebSocketFactory() { - return ( - url: string, - options?: { headers?: Record; protocols?: string[]; socketPath?: string }, - ): WebSocketLike => { - return new WebSocket(url, options?.protocols, { - headers: options?.headers, - ...(options?.socketPath ? { socketPath: options.socketPath } : {}), - }) as unknown as WebSocketLike; - }; -} - -/** - * Create and connect a daemon client - * Returns the connected client or throws if connection fails - */ -async function tryConnectHost( - host: string, - credential: ReturnType, - home: string, - clientId: string, - timeout: number, - nodeWebSocketFactory: ReturnType, -): Promise<{ client: DaemonClient } | { error: unknown }> { - const target = resolveDaemonTarget(host); - const client = new DaemonClient({ - url: target.url, - clientId, - clientType: "cli", - appVersion: resolveCliVersion(), - ...(credential?.kind === "password" ? { password: credential.password } : {}), - ...(credential?.kind === "localCredential" - ? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined } - : {}), - connectTimeoutMs: timeout, - webSocketFactory: ( - url: string, - config?: { headers?: Record; protocols?: string[] }, - ) => - nodeWebSocketFactory(url, { - headers: config?.headers, - protocols: config?.protocols, - ...(target.type === "ipc" ? { socketPath: target.socketPath } : {}), - }), - reconnect: { enabled: false }, - }); - - try { - await client.connect(); - return { client }; - } catch (error) { - await client.close().catch(() => {}); - return { error }; - } -} - -async function connectViaRelayOffer( - offer: ConnectionOffer, - clientId: string, - timeout: number, - nodeWebSocketFactory: ReturnType, -): Promise { - const url = buildRelayWebSocketUrl({ - endpoint: offer.relay.endpoint, - serverId: offer.serverId, - role: "client", - useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint), - }); - - const client = new DaemonClient({ - url, - clientId, - clientType: "cli", - appVersion: resolveCliVersion(), - connectTimeoutMs: timeout, - webSocketFactory: ( - target: string, - config?: { headers?: Record; protocols?: string[] }, - ) => nodeWebSocketFactory(target, { headers: config?.headers, protocols: config?.protocols }), - e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 }, - reconnect: { enabled: false }, - }); - - try { - await client.connect(); - return client; - } catch (error) { - await client.close().catch(() => {}); - const message = error instanceof Error ? error.message : String(error); - const lastError = client.lastError ? ` (${client.lastError})` : ""; - throw new Error(`Failed to connect via relay offer: ${message}${lastError}`, { cause: error }); - } -} - -function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null { - if (!host) return null; - try { - return parseConnectionOfferFromUrl(host); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid pairing offer URL: ${message}`, { cause: error }); - } -} - async function connectSelectedDaemon(options: ConnectOptions): Promise { const timeout = options.timeout ?? DEFAULT_TIMEOUT; - const deadline = Date.now() + timeout; - const explicitHost = + const host = options.target.kind === "endpoint" ? options.target.host : ( @@ -309,58 +190,25 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise { - try { - await close(); - } finally { - tunnel.close(); - } - }; - return result.client; - } - - const failure = tunnel.failureDetail(); - tunnel.close(); - if (failure) throw new Error(`SSH connection failed: ${failure}`, { cause: result.error }); - throw result.error; + const credential = resolveDaemonCredential(host, home); + const client = createHostClient(host, { + clientId: await getOrCreateCliClientId(home), + clientType: "cli", + appVersion: resolveCliVersion(), + ...(credential?.kind === "password" ? { password: credential.password } : {}), + ...(credential?.kind === "localCredential" + ? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined } + : {}), + connectTimeoutMs: timeout, + reconnect: { enabled: false }, + }); + try { + await client.connect(); + return client; + } catch (error) { + await client.close(); + throw error; } - const offer = parseHostOfferOrNull(explicitHost); - if (offer) { - return connectViaRelayOffer( - offer, - clientId, - Math.max(1, deadline - Date.now()), - nodeWebSocketFactory, - ); - } - - const result = await tryConnectHost( - explicitHost, - resolveDaemonCredential(explicitHost, home), - home, - clientId, - Math.max(1, deadline - Date.now()), - nodeWebSocketFactory, - ); - if ("client" in result) return result.client; - throw result.error; } export async function connectToDaemon(options: ConnectOptions): Promise { diff --git a/packages/cli/src/utils/daemon-target.test.ts b/packages/cli/src/utils/daemon-target.test.ts index fc989ab476..966f23b071 100644 --- a/packages/cli/src/utils/daemon-target.test.ts +++ b/packages/cli/src/utils/daemon-target.test.ts @@ -1,6 +1,11 @@ +import { parseHostTarget } from "@getpaseo/protocol/host-target"; import { test, expect } from "vitest"; import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js"; -import { resolveDaemonCredential, resolveClientPaseoHome } from "./client.js"; +import { + resolveDaemonCredential, + resolveClientPaseoHome, + buildDaemonConnectionCommandError, +} from "./client.js"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -44,6 +49,31 @@ test("endpoint descriptions redact pairing material and credentials", () => { ).not.toContain("private"); }); +test("a pairing offer without a URL prefix still redacts its key", () => { + const offer = { + v: 2, + serverId: "srv_example", + daemonPublicKeyB64: "example-secret", + relay: { endpoint: "localhost:6767", useTls: false }, + }; + const host = `#offer=${Buffer.from(JSON.stringify(offer)).toString("base64url")}`; + expect(describeDaemonTarget({ kind: "endpoint", host })).toBe( + "relay://localhost:6767/srv_example", + ); +}); + +test("malformed endpoint diagnostics never expose the supplied credential", () => { + const target = { kind: "endpoint" as const, host: "alice:example-secret@localhost:6767" }; + expect(describeDaemonTarget(target)).not.toContain("example-secret"); + for (const message of [ + `Invalid connection ${target.host}`, + `Cannot connect to daemon at ${target.host}`, + ]) { + const diagnostic = buildDaemonConnectionCommandError({ target, error: new Error(message) }); + expect(JSON.stringify(diagnostic)).not.toContain("example-secret"); + } +}); + test("CLI selects an explicit password before a matching local credential and never sends the latter remotely", async () => { const home = await mkdtemp(join(tmpdir(), "paseo-cli-credential-")); const previousPassword = process.env.PASEO_PASSWORD; @@ -67,3 +97,34 @@ test("CLI selects an explicit password before a matching local credential and ne await rm(home, { recursive: true, force: true }); } }); + +test.each(["/tmp/paseo.sock", "\\\\.\\pipe\\paseo", "localhost:6767", "[::1]:6767", "6767"])( + "validated bare target %s keeps its spelling in command hints", + (host) => { + expect(describeDaemonTarget({ kind: "endpoint", host })).toBe(host); + }, +); + +test("validated URI command hints preserve their transport address without credentials", () => { + const ssh = describeDaemonTarget({ + kind: "endpoint", + host: "ssh://deploy@build-box:2222?daemonPort=7777&password=example-secret", + }); + expect(ssh).not.toContain("example-secret"); + expect(parseHostTarget(ssh)).toMatchObject({ + kind: "ssh", + ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 }, + }); + const ws = describeDaemonTarget({ + kind: "endpoint", + host: "wss://example.test/custom-ws?authorization=example-secret", + }); + expect(ws).not.toContain("example-secret"); + expect(new URL(ws).pathname).toBe("/custom-ws"); + expect( + describeDaemonTarget({ + kind: "endpoint", + host: "tcp://localhost:6767?daemonPort=example-secret&ssl=example-secret", + }), + ).not.toContain("example-secret"); +}); diff --git a/packages/cli/src/utils/daemon-target.ts b/packages/cli/src/utils/daemon-target.ts index cfe8db8a15..0caba4e98f 100644 --- a/packages/cli/src/utils/daemon-target.ts +++ b/packages/cli/src/utils/daemon-target.ts @@ -1,3 +1,4 @@ +import { parseHostTarget } from "@getpaseo/protocol/host-target"; import { resolvePaseoHome } from "@getpaseo/server/daemon-control"; export type DaemonTarget = { kind: "instance"; home: string } | { kind: "endpoint"; host: string }; @@ -39,13 +40,26 @@ export function selectDaemonTarget( export function describeDaemonTarget(target: DaemonTarget): string { if (target.kind === "instance") return `home ${target.home}`; try { - const url = new URL(target.host); - if (url.password) url.password = "REDACTED"; - for (const key of url.searchParams.keys()) - if (/password|token|secret/i.test(key)) url.searchParams.set(key, "REDACTED"); - if (url.hash) url.hash = "REDACTED"; - return url.toString(); + const input = target.host.trim(); + const parsed = parseHostTarget(input); + // Discovery descriptions are display text; command hints must preserve the address syntax. + if (parsed.kind === "ssh" || (!parsed.offer && !parsed.socketPath && input.includes("://"))) { + const url = new URL(input); + if (decodeURIComponent(url.username).includes(":")) url.username = "REDACTED"; + for (const key of new Set(url.searchParams.keys())) { + const routingOption = + (parsed.kind === "ssh" && key === "daemonPort") || + (key === "ssl" && + url.searchParams.getAll(key).every((value) => value === "true" || value === "false")); + if (!routingOption) url.searchParams.set(key, "REDACTED"); + } + return url.toString(); + } + // Pairing offers must always use safe display text, including prefix-free fragments. + return parsed.kind === "socket" && !parsed.offer && !input.includes("://") + ? input + : parsed.description; } catch { - return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED"); + return ""; } } diff --git a/packages/client/package.json b/packages/client/package.json index 07ba43b332..d3f5cabb63 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -24,6 +24,14 @@ "./internal/daemon-client-websocket-transport": { "types": "./dist/daemon-client-websocket-transport.d.ts", "default": "./dist/daemon-client-websocket-transport.js" + }, + "./node": { + "types": "./dist/node/index.d.ts", + "default": "./dist/node/index.js" + }, + "./internal/hosts": { + "types": "./dist/hosts/index.d.ts", + "default": "./dist/hosts/index.js" } }, "publishConfig": { @@ -41,6 +49,7 @@ "dependencies": { "@getpaseo/protocol": "0.12.0", "@getpaseo/relay": "0.12.0", + "ws": "^8.20.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index e25779a7bb..237699e600 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -24,10 +24,16 @@ export function createRelayE2eeTransportFactory(args: { baseFactory: DaemonTransportFactory; daemonPublicKeyB64: string; logger: TransportLogger; + assertOutboundCapacity?: (wireBytes: number) => void; }): DaemonTransportFactory { return ({ url }) => { const base = args.baseFactory({ url }); - return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger); + return createEncryptedTransport( + base, + args.daemonPublicKeyB64, + args.logger, + args.assertOutboundCapacity, + ); }; } @@ -42,6 +48,7 @@ export function createEncryptedTransport( base: DaemonTransport, daemonPublicKeyB64: string, logger: TransportLogger, + assertOutboundCapacity: (wireBytes: number) => void = () => {}, ): DaemonTransport { let channel: EncryptedChannel | null = null; let opened = false; @@ -148,6 +155,9 @@ export function createEncryptedTransport( if (wireBytes > MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) { throw messageTooLargeError(wireBytes, MAX_RELAY_WEBSOCKET_MESSAGE_BYTES); } + // Encryption and its transport handoff are synchronous up to send's first await. + // Reject capacity failures here so a routed sender loses only its own channel. + assertOutboundCapacity(wireBytes); void channel.send(payload).catch((error) => { emitError(error); }); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index a172af0380..6fef9fea40 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3,6 +3,7 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; import { z } from "zod"; import { DaemonClient, + DaemonConnectionError, supportsUsageReports, type DaemonClientTrace, type CreateAgentRequestOptions, @@ -910,9 +911,12 @@ test("keeps relay upgrade credentials out of the socket request", async () => { }, }); clients.push(client); - void client.connect(); + const connecting = client.connect(); + void connecting.catch(() => {}); await vi.waitFor(() => expect(requests).toHaveLength(1)); expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" }); + await client.close(); + await expect(connecting).rejects.toThrow("Daemon client closed"); }); test("refuses relay password auth without an encrypted hello", async () => { @@ -7767,3 +7771,130 @@ test("sends worktree target and autoArchive in create_agent_request", async () = await expect(createPromise).rejects.toThrow("worktree auto archive sentinel"); }); + +test("ordinary lifecycle queues new calls, distinguishes submitted outcomes, and never resends lost requests", async () => { + const transports = [createMockTransport(), createMockTransport(), createMockTransport()]; + let attempt = 0; + const client = new DaemonClient({ + url: "ws://transport", + clientId: "ordinary-lifecycle", + reconnect: { enabled: false }, + transportFactory: () => transports[attempt++]!.transport, + }); + clients.push(client); + const first = transports[0]!; + const connecting = client.connect(); + const initial = client.getDaemonConfig("initial").catch((error: unknown) => error); + expect(first.sent).toEqual([]); + first.triggerOpen(); + await connecting; + expect(parseSentFrame(first.sent[0]).requestId).toBe("initial"); + first.triggerMessage( + wrapSessionMessage({ + type: "rpc_error", + payload: { requestId: "initial", error: "Denied", code: "access_denied" }, + }), + ); + expect(await initial).toMatchObject({ code: "access_denied", serverId: "srv_test_1" }); + const submitted = client + .patchDaemonConfig({ pluginsEnabled: false }, "submitted") + .catch((error: unknown) => error); + first.triggerClose({ code: 1001, reason: "physical loss" }); + const unknown = await submitted; + expect(unknown).toBeInstanceOf(DaemonConnectionError); + expect(unknown).toMatchObject({ + code: "DAEMON_CONNECTION_LOST", + outcome: "unknown", + serverId: "srv_test_1", + }); + + expect(() => client.ensureConnected()).not.toThrow(); + const second = transports[1]!; + const neverSent = client + .patchDaemonConfig({ pluginsEnabled: true }, "never-sent") + .catch((error: unknown) => error); + expect(second.sent).toEqual([]); + second.triggerClose({ code: 1001, reason: "handshake lost" }); + const unavailable = await neverSent; + expect(unavailable).toBeInstanceOf(DaemonConnectionError); + expect(unavailable).toMatchObject({ code: "DAEMON_CONNECTION_LOST", serverId: "srv_test_1" }); + expect((unavailable as DaemonConnectionError).outcome).toBeUndefined(); + + client.ensureConnected(); + const third = transports[2]!; + const recovery = client.getDaemonConfig("recovery").catch((error: unknown) => error); + expect(third.sent).toEqual([]); + third.triggerOpen(); + expect(third.sent.map(parseSentFrame).map((frame) => frame.requestId)).toEqual(["recovery"]); + third.triggerMessage( + wrapSessionMessage({ + type: "rpc_error", + payload: { requestId: "recovery", error: "Denied", code: "access_denied" }, + }), + ); + expect(await recovery).toMatchObject({ code: "access_denied" }); +}); + +test.each([false, true])( + "synchronous send rejection leaves no unknown outcome after close (close during handoff: %s)", + async (closeDuringSend) => { + const first = createMockTransport(); + const second = createMockTransport(); + const rejected = Object.assign(new Error("Transport rejected this send"), { + code: "resource_limit", + }); + const originalSend = first.transport.send; + first.transport.send = (data) => { + if (typeof data === "string" && data.includes("set_daemon_config_request")) { + if (closeDuringSend) first.triggerClose({ code: 1001, reason: "closed during send" }); + throw rejected; + } + originalSend(data); + }; + let attempt = 0; + const client = new DaemonClient({ + url: "ws://transport", + clientId: "rejected-send", + reconnect: { enabled: false }, + transportFactory: () => [first, second][attempt++]!.transport, + }); + clients.push(client); + const connected = client.connect(); + first.triggerOpen(); + await connected; + await expect(client.patchDaemonConfig({ pluginsEnabled: false }, "rejected")).rejects.toBe( + rejected, + ); + first.triggerClose({ code: 1001, reason: "lost connection" }); + client.ensureConnected(); + const queued = client.getDaemonConfig("not-submitted").catch((error: unknown) => error); + second.triggerClose({ code: 1001, reason: "lost handshake" }); + const failure = (await queued) as DaemonConnectionError; + expect(failure).toBeInstanceOf(DaemonConnectionError); + expect(failure.outcome).toBeUndefined(); + expect((rejected as Error & { outcome?: string }).outcome).toBeUndefined(); + }, +); + +test("initial acquisition preserves structured transport failures and ordinary offline errors retain their cause", async () => { + const transport = createMockTransport(); + const failure = Object.assign(new Error("Destination identity changed"), { + code: "server_id_mismatch", + serverId: "srv_expected", + }); + const client = new DaemonClient({ + url: "ws://transport", + clientId: "structured-acquisition", + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connecting = client.connect(); + const rejected = expect(connecting).rejects.toBe(failure); + transport.triggerError(failure); + await rejected; + await expect(client.getDaemonConfig()).rejects.toMatchObject({ + code: "DAEMON_CONNECTION_LOST", + cause: failure, + }); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 29990cb428..0741b08147 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,4 +1,11 @@ +import { + HOST_ROUTE_OPCODE, + encodeHostRouteFrame, + decodeHostRouteFrame, + type HostRouteFrame, +} from "@getpaseo/protocol/binary-frames/index"; import { messageTooLargeError } from "./daemon-client-transport-utils.js"; +import type { HostAdd, HostUpdate, HostDirectory } from "@getpaseo/protocol/hosts"; import { legacyUsageIcon } from "./legacy-usage-icons.js"; import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; @@ -333,7 +340,7 @@ export type ConnectionState = | { status: "idle" } | { status: "connecting"; attempt: number } | { status: "connected" } - | { status: "disconnected"; reason?: string } + | { status: "disconnected"; reason?: string; nextRetryAt?: string; failureCode?: string } | { status: "disposed" }; export type DaemonEvent = @@ -391,6 +398,7 @@ export interface DaemonClientConfig { /** Deliver compact bodies/hash references to a caller-owned snapshot cache. * The default keeps public SDK snapshot entries expanded. */ providerSnapshots?: "wire"; + sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[]; url: string; clientId: string; clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub"; @@ -938,6 +946,7 @@ interface Waiter { reject(error: Error): void; timeoutHandle: ReturnType | null; requestId?: string; + submitted: boolean; } interface WaitHandle { @@ -995,11 +1004,16 @@ type CorrelatedResponsePayload = CorrelatedResponsePayloads[TType]; export class DaemonConnectionError extends Error { + readonly serverId?: string; + readonly outcome?: "unknown"; constructor( message: string, readonly code: "DAEMON_CONNECTION_LOST" | "DAEMON_REQUEST_TIMEOUT" = "DAEMON_CONNECTION_LOST", + context?: { serverId?: string; outcome?: "unknown"; cause?: Error }, ) { - super(message); + super(message, context?.cause ? { cause: context.cause } : undefined); + this.serverId = context?.serverId; + this.outcome = context?.outcome; this.name = "DaemonConnectionError"; } } @@ -1008,8 +1022,16 @@ class DaemonRpcError extends Error { readonly requestId: string; readonly requestType?: string; readonly code?: string; + readonly serverId?: string; + readonly userMessage: string; - constructor(params: { requestId: string; error: string; requestType?: string; code?: string }) { + constructor(params: { + requestId: string; + error: string; + requestType?: string; + code?: string; + serverId?: string; + }) { const parts = [params.error]; if (params.requestType) parts.push(`requestType=${params.requestType}`); if (params.code) parts.push(`code=${params.code}`); @@ -1018,6 +1040,8 @@ class DaemonRpcError extends Error { this.requestId = params.requestId; this.requestType = params.requestType; this.code = params.code; + this.serverId = params.serverId; + this.userMessage = params.error; } } @@ -1298,6 +1322,8 @@ export class DaemonClient { private connectResolve: (() => void) | null = null; private connectReject: ((error: Error) => void) | null = null; private lastErrorValue: string | null = null; + private destinationServerId: string | undefined; + private connectionFailure: Error | undefined; private authFailureReasonValue: DaemonAuthFailureReason | null = null; private connectionState: ConnectionState = { status: "idle" }; private readonly terminalStreams = new TerminalStreamRouter(); @@ -1502,6 +1528,7 @@ export class DaemonClient { } this.scheduleReconnect({ reason, + failure: event instanceof Error ? event : undefined, event: "TRANSPORT_CLOSE", reasonCode: "transport_closed", }); @@ -1526,6 +1553,7 @@ export class DaemonClient { this.lastErrorValue = reason; this.scheduleReconnect({ reason, + failure: event instanceof Error ? event : undefined, event: "TRANSPORT_ERROR", reasonCode: "transport_error", }); @@ -1542,6 +1570,7 @@ export class DaemonClient { this.lastErrorValue = reason; this.scheduleReconnect({ reason, + failure: event instanceof Error ? event : undefined, event: "TRANSPORT_ERROR", reasonCode: "transport_error", }); @@ -1592,9 +1621,7 @@ export class DaemonClient { return; } this.shouldReconnect = false; - this.connectPromise = null; - this.connectResolve = null; - this.connectReject = null; + this.rejectConnect(new Error("Daemon client closed")); if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; @@ -1637,7 +1664,9 @@ export class DaemonClient { this.attemptConnect(); return; } - void this.connect(); + void this.connect().catch((error: unknown) => + this.logger.debug({ err: error }, "Connection attempt ended"), + ); } private verifyConnection(): void { @@ -1662,6 +1691,119 @@ export class DaemonClient { }); } + requireConnectedHosts(): void { + if (!this.isConnected) + throw Object.assign(new Error("The managing daemon is offline"), { + code: "host_offline", + serverId: this.destinationServerId, + }); + if (this.lastServerInfoMessage?.features?.connectedHosts !== true) + throw Object.assign(new Error("Update the managing daemon to use connected hosts"), { + code: "unsupported_capability", + }); + } + async listHosts(): Promise { + this.requireConnectedHosts(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.list.request" }, + responseType: "hosts.list.response", + }) + ).directory; + } + observeHosts(): OwnedSubscription { + this.requireConnectedHosts(); + return this.owned.observe(async (accept) => { + const result = await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.list.request", observe: true }, + responseType: "hosts.list.response", + }); + if (!result.subscriptionId) throw new Error("Host omitted subscription identity"); + const snapshot = { ...result.directory, subscriptionId: result.subscriptionId }; + accept(snapshot); + return snapshot; + }); + } + async addHost(input: HostAdd) { + this.requireConnectedHosts(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.add.request", input }, + responseType: "hosts.add.response", + }) + ).host; + } + async updateHost(registrationId: string, patch: HostUpdate) { + this.requireConnectedHosts(); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.update.request", registrationId, patch }, + responseType: "hosts.update.response", + }) + ).host; + } + async removeHost(registrationId: string): Promise { + this.requireConnectedHosts(); + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.remove.request", registrationId }, + responseType: "hosts.remove.response", + }); + } + async openHostRoute(serverId: string, scopeId: string): Promise { + this.requireConnectedHosts(); + try { + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.route.open.request", serverId, scopeId }, + responseType: "hosts.route.open.response", + }) + ).routeId; + } catch (error) { + throw Object.assign(error instanceof Error ? error : new Error("Host route failed"), { + serverId, + }); + } + } + async openHostLink(channelId: string): Promise { + this.requireConnectedHosts(); + if (this.getLastServerInfoMessage()?.features?.hostMultiplexing !== true) + throw Object.assign(new Error("Update the destination daemon"), { + code: "unsupported_capability", + }); + return ( + await this.sendCorrelatedSessionRequest({ + message: { type: "hosts.link.open.request", channelId }, + responseType: "hosts.link.open.response", + }) + ).routeId; + } + sendHostFrame(routeId: string, data: string | Uint8Array): void { + if (typeof data === "string") { + this.sendSessionMessageStrict({ + type: "hosts.route.frame.request", + routeId, + data, + binary: false, + }); + } else { + if (!this.isConnected || !this.transport) throw this.unavailableError(); + this.sendTransportFrame(encodeHostRouteFrame(routeId, data)); + } + } + private readonly hostFrameListeners = new Set<(frame: HostRouteFrame) => void>(); + subscribeHostFrames(handler: (frame: HostRouteFrame) => void): () => void { + this.hostFrameListeners.add(handler); + return () => this.hostFrameListeners.delete(handler); + } + cancelHostRoute(scopeId: string): void { + if (this.isConnected && this.transport) + this.sendSessionMessageStrict({ type: "hosts.route.close.request", scopeId }); + } + closeHostRoute(routeId: string): void { + if (this.isConnected && this.transport) + this.sendSessionMessageStrict({ type: "hosts.route.close.request", routeId }); + } + getConnectionState(): ConnectionState { return this.connectionState; } @@ -1801,7 +1943,12 @@ export class DaemonClient { return this.beginTraceSection("paseo.ws.dispatch", fields); } - private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void { + private sendJsonMessage( + envelopeType: string, + messageType: string, + message: unknown, + submitted?: () => void, + ): void { const isOpen = this.config.trace?.isEnabled() === true && this.beginTraceSection("paseo.ws.message.outbound", { @@ -1810,15 +1957,27 @@ export class DaemonClient { ...describeRequestIdentity(message), }); try { - this.sendTransportFrame(JSON.stringify(message)); + this.sendTransportFrame(JSON.stringify(message), submitted); } finally { this.endTraceSection(isOpen); } } - private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void { + private sendSessionPayload(payload: SessionInboundMessage): void { + this.sendJsonMessage("session", payload.type, { type: "session", message: payload }, () => { + if (!("requestId" in payload) || typeof payload.requestId !== "string") return; + for (const waiter of this.waiters) { + if (waiter.requestId === payload.requestId) waiter.submitted = true; + } + }); + } + + private sendTransportFrame( + frame: string | Uint8Array | ArrayBuffer, + submitted?: () => void, + ): void { if (!this.transport) { - throw new DaemonConnectionError("Transport not connected"); + throw this.unavailableError(); } // The daemon closes the connection on an oversized message, and creations resubmit on reconnect. assertTransportFrameFits(frame); @@ -1828,6 +1987,7 @@ export class DaemonClient { sizeUnit: typeof frame === "string" ? "utf16-code-units" : "bytes", }); try { + submitted?.(); this.transport.send(frame); } finally { this.endTraceSection(isOpen); @@ -1848,7 +2008,7 @@ export class DaemonClient { } const payload = SessionInboundMessageSchema.parse(message); try { - this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); + this.sendSessionPayload(payload); } catch (error) { if (this.config.suppressSendErrors) { return; @@ -1887,7 +2047,7 @@ export class DaemonClient { private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise { return this.sendWhenConnected(() => { const payload = SessionInboundMessageSchema.parse(message); - this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); + this.sendSessionPayload(payload); }); } @@ -1927,7 +2087,7 @@ export class DaemonClient { } // Not connected and not connecting - fail immediately - return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`)); + return Promise.reject(this.unavailableError()); } /** @@ -1981,6 +2141,7 @@ export class DaemonClient { kind: "error", error: new DaemonRpcError({ requestId: msg.payload.requestId, + serverId: this.destinationServerId, error: msg.payload.error, requestType: msg.payload.requestType, code: msg.payload.code, @@ -2092,13 +2253,20 @@ export class DaemonClient { }); } + private unavailableError(): DaemonConnectionError { + return new DaemonConnectionError("Transport not connected", "DAEMON_CONNECTION_LOST", { + serverId: this.destinationServerId, + cause: this.connectionFailure, + }); + } + private sendSessionMessageStrict(message: SessionInboundMessage): void { if (!this.transport || this.connectionState.status !== "connected") { - throw new DaemonConnectionError("Transport not connected"); + throw this.unavailableError(); } const payload = SessionInboundMessageSchema.parse(message); try { - this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); + this.sendSessionPayload(payload); } catch (error) { throw error instanceof Error ? error : new Error(String(error)); } @@ -2913,7 +3081,9 @@ export class DaemonClient { // ============================================================================ private readonly creations = new CreationClient({ - supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true, + supports: (feature) => { + return this.lastServerInfoMessage?.features?.[feature] === true; + }, requestId: () => this.createRequestId(), request: (kind, input) => kind === "workspace" @@ -6356,6 +6526,9 @@ export class DaemonClient { const auth = this.helloAuth; this.sendJsonMessage("hello", "hello", { type: "hello", + ...(this.config.sessionPermissions + ? { sessionPermissions: this.config.sessionPermissions } + : {}), clientId: this.config.clientId, clientType: this.config.clientType ?? "cli", protocolVersion: 1, @@ -6542,6 +6715,16 @@ export class DaemonClient { } private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean { + const hostFrame = decodeHostRouteFrame(rawBytes); + if (rawBytes[0] === HOST_ROUTE_OPCODE && !hostFrame) { + this.disposeTransport(1008, "Invalid host routing frame"); + this.scheduleReconnect({ reason: "Invalid host routing frame", event: "INVALID_HOST_FRAME" }); + return true; + } + if (hostFrame) { + for (const handler of this.hostFrameListeners) handler(hostFrame); + return true; + } const fileFrame = decodeFileTransferFrame(rawBytes); if (fileFrame) { this.traceInstant("paseo.ws.message.inbound", { @@ -6721,6 +6904,7 @@ export class DaemonClient { } private scheduleReconnect(input?: { + failure?: Error; reason?: string; event?: string; reasonCode?: string; @@ -6741,9 +6925,18 @@ export class DaemonClient { // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. - this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost")); - this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost")); - this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost")); + const connectionError = new DaemonConnectionError( + reason ?? "Connection lost", + "DAEMON_CONNECTION_LOST", + { + serverId: this.destinationServerId, + cause: input?.failure, + }, + ); + this.connectionFailure = input?.failure; + this.clearWaiters(connectionError, true); + this.rejectPendingSendQueue(connectionError); + this.rejectPingProbe(connectionError); this.terminalStreams.clearSlots(); this.lastServerInfoMessage = null; @@ -6758,7 +6951,7 @@ export class DaemonClient { this.rejectConnect( this.authFailureReasonValue ? new DaemonAuthenticationError(this.authFailureReasonValue) - : new Error(reason ?? "Transport disconnected before connect"), + : (input?.failure ?? new Error(reason ?? "Transport disconnected before connect")), ); return; } @@ -6768,11 +6961,26 @@ export class DaemonClient { private emitDisconnectedStateForReconnect( reason: string | undefined, - input: { reason?: string; event?: string; reasonCode?: string } | undefined, + input: { reason?: string; event?: string; reasonCode?: string; failure?: Error } | undefined, ): void { this.updateConnectionState( { status: "disconnected", + ...(input?.failure && "code" in input.failure && typeof input.failure.code === "string" + ? { failureCode: input.failure.code } + : {}), + ...(this.shouldReconnect && this.config.reconnect?.enabled !== false + ? { + nextRetryAt: new Date( + Date.now() + + Math.min( + (this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) * + 2 ** this.reconnectAttempt, + this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, + ), + ).toISOString(), + } + : {}), ...(reason ? { reason } : {}), }, { @@ -6870,6 +7078,8 @@ export class DaemonClient { const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload); if (serverInfo) { this.lastServerInfoMessage = serverInfo; + this.destinationServerId = serverInfo.serverId; + this.connectionFailure = undefined; if (this.connectionState.status === "connecting") { this.resetConnectTimeout(); this.reconnectAttempt = 0; @@ -6953,12 +7163,20 @@ export class DaemonClient { } } - private clearWaiters(error: Error): void { + private clearWaiters(error: Error, connectionLost = false): void { for (const waiter of Array.from(this.waiters)) { if (waiter.timeoutHandle) { clearTimeout(waiter.timeoutHandle); } - waiter.reject(error); + waiter.reject( + connectionLost && waiter.submitted && error instanceof DaemonConnectionError + ? new DaemonConnectionError(error.message, error.code, { + serverId: error.serverId, + outcome: "unknown", + cause: error.cause instanceof Error ? error.cause : undefined, + }) + : error, + ); } this.waiters.clear(); } @@ -7065,6 +7283,7 @@ export class DaemonClient { reject: wrappedReject, timeoutHandle, requestId: options?.requestId, + submitted: false, }; this.waiters.add(waiter); }); diff --git a/packages/client/src/hosts/index.ts b/packages/client/src/hosts/index.ts new file mode 100644 index 0000000000..1baa9ebe43 --- /dev/null +++ b/packages/client/src/hosts/index.ts @@ -0,0 +1,208 @@ +import { safeRandomId } from "../daemon-client-transport-utils.js"; +import { DaemonClient } from "../daemon-client.js"; +import type { DaemonTransportFactory } from "../daemon-client-transport-types.js"; +import type { OwnedSubscription } from "../connection/index.js"; +import type { PaseoApi, createPaseoApi } from "../index.js"; +import type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts"; + +export interface PaseoHostActions { + list(): Promise; + observe(): OwnedSubscription; + add(input: HostAdd): Promise; + update(registrationId: string, patch: HostUpdate): Promise; + remove(registrationId: string): Promise; + client(serverId: string): Promise; +} + +export function createHostChannelTransport( + origin: DaemonClient, + serverId: string, + scopeId: string, + initialRouteId: string | null, + link = false, +): DaemonTransportFactory { + let first: string | null = initialRouteId; + return () => { + const opens = new Set<() => void>(); + const closes = new Set<(event?: unknown) => void>(); + const messages = new Set<(data: unknown, binary: boolean) => void>(); + const errors = new Set<(error: unknown) => void>(); + let routeId: string | null = null; + let closed = false; + const cleanups: (() => void)[] = []; + const close = (error?: unknown) => { + if (closed) return; + closed = true; + if (error instanceof Error) Object.assign(error, { serverId }); + for (const cleanup of cleanups) cleanup(); + try { + if (routeId) origin.closeHostRoute(routeId); + else origin.cancelHostRoute(scopeId); + } catch { + /* Transport already closed. */ + } + if (error) for (const handler of errors) handler(error); + for (const handler of closes) handler(error); + }; + cleanups.push( + origin.subscribeRawMessages((message) => { + if (message.type === "hosts.route.frame" && message.payload.subscriptionId === routeId) { + for (const handler of messages) handler(message.payload.data, false); + } + if (message.type === "hosts.route.closed" && message.payload.subscriptionId === routeId) + close( + Object.assign(new Error(message.payload.message), { + code: message.payload.code, + serverId, + }), + ); + }), + ); + cleanups.push( + origin.subscribeHostFrames((frame) => { + if (frame.routeId === routeId) for (const handler of messages) handler(frame.payload, true); + }), + ); + cleanups.push( + origin.subscribeConnectionStatus((connectionState) => { + if (connectionState.status !== "connected") close(); + }), + ); + let opening: Promise; + if (first) opening = Promise.resolve(first); + else opening = link ? origin.openHostLink(scopeId) : origin.openHostRoute(serverId, scopeId); + first = null; + void opening + .then((id) => { + if (closed) { + origin.closeHostRoute(id); + return; + } + routeId = id; + for (const handler of opens) handler(); + return undefined; + }) + .catch(close); + return { + send(data) { + if (closed || !routeId) + throw Object.assign(new Error(`Host ${serverId} is offline`), { + code: "host_offline", + serverId, + }); + origin.sendHostFrame( + routeId, + typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data), + ); + }, + close: () => close(), + onOpen(handler) { + opens.add(handler); + return () => opens.delete(handler); + }, + onClose(handler) { + closes.add(handler); + return () => closes.delete(handler); + }, + onError(handler) { + errors.add(handler); + return () => errors.delete(handler); + }, + onMessage(handler) { + messages.add(handler); + return () => messages.delete(handler); + }, + }; + }; +} + +export function createHostActions( + origin: DaemonClient, + owner: { + own }>(create: () => T): T; + signal: AbortSignal; + api: typeof createPaseoApi; + }, +): PaseoHostActions { + const assertActive = () => { + if (owner.signal.aborted) throw new Error("Paseo API is disposed"); + }; + return { + list: () => { + assertActive(); + return origin.listHosts(); + }, + observe: () => owner.own(() => origin.observeHosts()), + add: (input) => { + assertActive(); + return origin.addHost(input); + }, + update: (id, patch) => { + assertActive(); + return origin.updateHost(id, patch); + }, + remove: (id) => { + assertActive(); + return origin.removeHost(id); + }, + async client(serverId) { + assertActive(); + origin.requireConnectedHosts(); + if (origin.getLastServerInfoMessage()?.serverId === serverId) { + const api = owner.api(origin, { signal: owner.signal }); + const scope = owner.own(() => ({ release: () => api.dispose() })); + return { ...api, dispose: () => scope.release() }; + } + const scopeId = safeRandomId(); + const cancel = () => origin.cancelHostRoute(scopeId); + owner.signal.addEventListener("abort", cancel, { once: true }); + let routeId: string; + try { + routeId = await origin.openHostRoute(serverId, scopeId); + } catch (error) { + throw Object.assign(error instanceof Error ? error : new Error(String(error)), { + serverId, + }); + } finally { + owner.signal.removeEventListener("abort", cancel); + } + if (owner.signal.aborted) { + origin.closeHostRoute(routeId); + assertActive(); + } + const client = new DaemonClient({ + url: "ws://routed-host/ws", + clientId: scopeId, + transportFactory: createHostChannelTransport(origin, serverId, scopeId, routeId), + // Initial acquisition has a deadline; established scopes restore demand on loss. + reconnect: { enabled: false }, + }); + const api = owner.api(client, { signal: owner.signal }); + const scope = owner.own(() => ({ + release: async () => { + try { + await api.dispose(); + } finally { + await client.close(); + } + }, + })); + try { + await client.connect(); + client.setReconnectEnabled(true); + assertActive(); + return { ...api, dispose: () => scope.release() }; + } catch (error) { + const failure = Object.assign( + error instanceof Error ? error : new Error("Host handshake failed"), + { + code: error instanceof Error && "code" in error ? error.code : "host_offline", + serverId, + }, + ); + await scope.release(); + throw failure; + } + }, + }; +} diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index ce6d4a5156..a7bb17121c 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -267,6 +267,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own "agents", "config", "dispose", + "hosts", "observeEvents", "projects", "providers", @@ -1734,3 +1735,38 @@ test("canceled timeline handles and captured state are collectible while their A ); expect(result.stdout).toContain('"phase":"API alive"'); }, 20000); + +test("qualified parents preserve foreign identity without resolving an equal local agent ID", async () => { + const { client, ws } = await connectClient(); + const localParent = client.agents.ref("same-parent"); + expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" }); + const creating = client.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/repo/sdk", + parent: { serverId: "srv_parent_elsewhere", agentId: "same-parent" }, + labels: { task: "preserved" }, + }); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).not.toHaveProperty("callerAgentId"); + expect(request).toMatchObject({ + labels: { + task: "preserved", + "paseo.parent-agent-id": "same-parent", + "paseo.parent-server-id": "srv_parent_elsewhere", + }, + }); + ws.message( + sessionMessage({ + type: "status", + payload: { + status: "agent_created", + requestId: request.requestId, + agentId: "agent_sdk", + agent: createAgent(), + }, + }), + ); + await creating; + await client.close(); + expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 058ec158ef..455d377634 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,9 @@ +import type { AgentAddress } from "@getpaseo/protocol/addresses"; +import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; +export type { AgentAddress } from "@getpaseo/protocol/addresses"; +import { createHostActions, type PaseoHostActions } from "./hosts/index.js"; +export type { PaseoHostActions } from "./hosts/index.js"; +export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts"; import type { OwnedSubscription } from "./connection/index.js"; export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js"; import type { DaemonClientConfig } from "./daemon-client.js"; @@ -246,7 +252,7 @@ export interface PaseoAgentCreateOptions { onEvent?: (snapshot: CreationSnapshot) => void; config: PaseoAgentConfig; cwd: string; - parent?: string | PaseoAgentHandle; + parent?: string | AgentAddress | PaseoAgentHandle; title?: PaseoAgentSessionConfig["title"]; env?: CreateAgentRequestMessage["env"]; prompt?: string; @@ -340,6 +346,7 @@ export interface PaseoAgentTimelineHandle { export interface PaseoAgentHandle { readonly id: string; + readonly address: AgentAddress; /** * `workspaceId` through `archivedAt` mirror the last snapshot this handle * observed. A handle from `ref()` reads `null` for all of them until @@ -484,6 +491,7 @@ export interface PaseoConfigActions { } export interface PaseoApi { + readonly hosts: PaseoHostActions; dispose(): Promise; observeEvents: DaemonClient["observeEvents"]; readonly terminals: PaseoTerminalActions; @@ -526,10 +534,12 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient { function toDaemonAgentCreateOptions( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, + serverId?: string, ): CreateAgentRequestOptions { const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; const { provider, model } = parseProviderModel(providerModel); + const relationship = parent ? encodeParent(parent, serverId) : {}; return { ...requestOptions, config: { @@ -541,7 +551,12 @@ function toDaemonAgentCreateOptions( ...(providerOptions !== undefined ? { providerOptions } : {}), }, ...(placement ? { workspaceId: placement.workspaceId } : {}), - ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), + ...(parent + ? { + ...relationship, + labels: { ...requestOptions.labels, ...relationship.labels }, + } + : {}), ...(prompt !== undefined ? { initialPrompt: prompt } : {}), }; } @@ -588,7 +603,13 @@ export function createPaseoApi( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, ) => { - const agent = await daemonClient.createAgent(toDaemonAgentCreateOptions(options, placement)); + const agent = await daemonClient.createAgent( + toDaemonAgentCreateOptions( + options, + placement, + daemonClient.getLastServerInfoMessage()?.serverId, + ), + ); return createAgentHandle(agent); }; const terminals = createTerminalActions(daemonClient, async (workspaceId) => { @@ -695,6 +716,7 @@ export function createPaseoApi( return { dispose, + hosts: createHostActions(daemonClient, { own, signal: lifetime.signal, api: createPaseoApi }), observeEvents, terminals, projects: { @@ -712,7 +734,18 @@ export function createPaseoApi( openWorkspace(daemonClient, createWorkspaceHandle, input, requestId), create: async ({ requestId, agent, ...options }) => { const result = await daemonClient.createWorkspace( - { ...options, ...(agent ? { agent: toDaemonAgentCreateOptions(agent) } : {}) }, + { + ...options, + ...(agent + ? { + agent: toDaemonAgentCreateOptions( + agent, + undefined, + daemonClient.getLastServerInfoMessage()?.serverId, + ), + } + : {}), + }, requestId, ); if (result.error || !result.workspace) { @@ -865,8 +898,14 @@ function createAgentHandleFactory( const id = typeof agent === "string" ? agent : agent.id; let current = typeof agent === "string" ? null : agent; + let serverId = daemonClient.getLastServerInfoMessage()?.serverId; const handle: PaseoAgentHandle = { id, + get address() { + serverId ??= daemonClient.getLastServerInfoMessage()?.serverId; + if (!serverId) throw new Error("Connect to a host before obtaining an agent address"); + return { serverId, agentId: id }; + }, timeline: { append: (item) => daemonClient.appendAgentTimelineItem(id, item), refetch: async (options) => { @@ -1015,8 +1054,16 @@ function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string { return typeof workspace === "string" ? workspace : workspace.id; } -function resolveAgentId(agent: string | PaseoAgentHandle): string { - return typeof agent === "string" ? agent : agent.id; +function encodeParent( + parent: string | AgentAddress | PaseoAgentHandle, + serverId?: string, +): Pick { + if (typeof parent === "string") return { callerAgentId: parent }; + const address = "address" in parent ? parent.address : parent; + if (!serverId) throw new Error("Connect to a host before creating a child"); + return address.serverId === serverId + ? { callerAgentId: address.agentId, labels: parentAddressLabels(address) } + : { labels: parentAddressLabels(address) }; } function parseProviderModel(selection: string): { provider: string; model: string } { @@ -1034,6 +1081,7 @@ function listProviderUsage( daemonClient: DaemonClient, options?: PaseoProviderUsageOptions, ): Promise { + daemonClient.ensureConnected(); // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-02-28 once daemon floor >= v0.1.98. if (daemonClient.getLastServerInfoMessage()?.features?.providerUsageList !== true) { return Promise.reject(new Error("Update the host to list provider usage.")); diff --git a/packages/client/src/node/buffering.ts b/packages/client/src/node/buffering.ts new file mode 100644 index 0000000000..1dbf5be05f --- /dev/null +++ b/packages/client/src/node/buffering.ts @@ -0,0 +1,25 @@ +/** Counts bytes until the socket's send callback, shared by all links owned by a daemon. */ +export class HostTransportBudget { + private used = 0; + constructor(readonly limit = 256 * 1024 * 1024) {} + get bufferedBytes(): number { + return this.used; + } + assertCapacity(bytes: number): void { + if (bytes > this.limit - this.used) + throw Object.assign(new Error("Connected host buffer limit reached"), { + code: "resource_limit", + }); + } + reserve(bytes: number): () => void { + this.assertCapacity(bytes); + this.used += bytes; + let released = false; + return () => { + if (!released) { + released = true; + this.used -= bytes; + } + }; + } +} diff --git a/packages/client/src/node/connector.test.ts b/packages/client/src/node/connector.test.ts new file mode 100644 index 0000000000..6b31107f24 --- /dev/null +++ b/packages/client/src/node/connector.test.ts @@ -0,0 +1,153 @@ +import { once } from "node:events"; +import { createServer } from "node:http"; +import { afterEach, expect, test } from "vitest"; +import { WebSocketServer, WebSocket } from "ws"; +import { createHostTransportFactory, HostTransportBudget } from "./index.js"; +const cleanup: (() => Promise)[] = []; +afterEach(async () => { + for (const close of cleanup.splice(0).toReversed()) await close(); +}); +async function endpoint() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + cleanup.push(async () => { + for (const socket of wss.clients) socket.terminate(); + await new Promise((resolve) => wss.close(() => server.close(() => resolve()))); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing port"); + return { wss, url: `ws://127.0.0.1:${address.port}/ws` }; +} +test("connector retains per-attempt headers and WebSocket subprotocols", async () => { + const server = await endpoint(); + const connected = once(server.wss, "connection"); + const transport = createHostTransportFactory(server.url)({ + url: server.url, + headers: { Authorization: "Bearer example" }, + protocols: ["paseo-test"], + }); + cleanup.push(async () => transport.close()); + const [socket, request] = (await connected) as [WebSocket, import("node:http").IncomingMessage]; + expect(request.headers.authorization).toBe("Bearer example"); + expect(socket.protocol).toBe("paseo-test"); +}); +test("a slow consumer cannot exceed the shared byte budget and close releases reserved bytes", async () => { + const server = await endpoint(); + const budget = new HostTransportBudget(4 * 1024 * 1024); + const connected = once(server.wss, "connection"); + const transport = createHostTransportFactory(server.url, budget)({ url: server.url }); + cleanup.push(async () => transport.close()); + const ready = new Promise((resolve) => transport.onOpen(resolve)); + const [socket] = (await connected) as [WebSocket]; + socket.pause(); + await ready; + const frame = new Uint8Array(1024 * 1024); + for (let index = 0; index < 4; index++) transport.send(frame); + expect(budget.bufferedBytes).toBe(4 * 1024 * 1024); + expect(() => transport.send(frame)).toThrow("Connected host buffer limit reached"); + expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit); + transport.close(); + await expect.poll(() => budget.bufferedBytes).toBe(0); +}); +test("direct connector preserves 25 MiB binary frames in both directions", async () => { + const server = await endpoint(); + const connected = once(server.wss, "connection"); + const transport = createHostTransportFactory(server.url)({ url: server.url }); + cleanup.push(async () => transport.close()); + const ready = new Promise((resolve) => transport.onOpen(resolve)); + const received = new Promise((resolve, reject) => { + transport.onMessage((bytes, binary) => binary && resolve(bytes as Uint8Array)); + transport.onError(reject); + }); + const [socket] = (await connected) as [WebSocket]; + await ready; + const bytes = new Uint8Array(25 * 1024 * 1024).fill(173); + const echoed = once(socket, "message"); + transport.send(bytes); + const [sent, binary] = await echoed; + expect(binary).toBe(true); + expect(Buffer.from(sent as Uint8Array).equals(bytes)).toBe(true); + socket.send(bytes); + expect(Buffer.from(await received).equals(bytes)).toBe(true); +}); + +import { + createDaemonChannel, + generateKeyPair, + exportPublicKey, + type Transport, +} from "@getpaseo/relay/e2ee"; +test("encrypted connector rejects overload synchronously and preserves the physical link for sibling traffic", async () => { + const server = await endpoint(); + const keys = generateKeyPair(); + const received: (string | ArrayBuffer)[] = []; + let peer!: WebSocket; + let ready!: Promise; + server.wss.on("connection", (socket) => { + peer = socket; + const wire: Transport = { + send: (data) => socket.send(data), + close: () => socket.close(), + 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, + }); + }); + ready = createDaemonChannel(wire, keys, { + onmessage: (data) => { + received.push(data); + }, + }); + }); + const port = new URL(server.url).port; + const budget = new HostTransportBudget(4 * 1024 * 1024); + const transport = createHostTransportFactory( + `relay://127.0.0.1:${port}/srv_slow?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`, + budget, + )({ url: server.url }); + cleanup.push(async () => transport.close()); + const errors: unknown[] = []; + let closed = false; + transport.onError((error) => errors.push(error)); + transport.onClose(() => { + closed = true; + }); + await new Promise((resolve) => transport.onOpen(resolve)); + await ready; + peer.pause(); + const frame = new Uint8Array(1024 * 1024).fill(173); + let rejected: unknown; + for (let index = 0; index < 8; index++) { + try { + transport.send(frame); + } catch (error) { + rejected = error; + break; + } + } + expect(rejected).toMatchObject({ code: "resource_limit" }); + expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit); + await new Promise((resolve) => setImmediate(resolve)); + expect(errors).toEqual([]); + expect(closed).toBe(false); + transport.send("sibling control"); + peer.resume(); + await expect.poll(() => received.includes("sibling control")).toBe(true); + expect(received.filter((data) => data instanceof ArrayBuffer).length).toBeGreaterThan(0); + expect( + received + .filter((data) => data instanceof ArrayBuffer) + .every((data) => Buffer.from(data).equals(frame)), + ).toBe(true); + expect(server.wss.clients.size).toBe(1); +}); diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts new file mode 100644 index 0000000000..857c2ba432 --- /dev/null +++ b/packages/client/src/node/index.ts @@ -0,0 +1,195 @@ +export { createHostChannelTransport } from "../hosts/index.js"; +import { HostTransportBudget } from "./buffering.js"; +export { HostTransportBudget } from "./buffering.js"; +import { homedir } from "node:os"; +import { WebSocket } from "ws"; +import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target"; +import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js"; +import { createWebSocketTransportFactory } from "../daemon-client-websocket-transport.js"; +import { createRelayE2eeTransportFactory } from "../daemon-client-relay-e2ee-transport.js"; +import type { + DaemonTransport, + DaemonTransportFactory, + WebSocketLike, +} from "../daemon-client-transport-types.js"; +import { DaemonClient, type DaemonClientConfig } from "../daemon-client.js"; + +export { parseHostTarget }; +export type { SshTunnel } from "./ssh-tunnel.js"; +export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js"; + +/** A factory owns one attempt, including a fresh SSH process and its teardown. */ +export function createHostTransportFactory( + connection: string, + budget = new HostTransportBudget(), +): DaemonTransportFactory { + const target = parseHostTarget(connection); + return (attemptOptions) => { + const opens = new Set<() => void>(); + const closes = new Set<(event?: unknown) => void>(); + const errors = new Set<(event?: unknown) => void>(); + const messages = new Set<(data: unknown, binary: boolean) => void>(); + let closed = false; + let closeEvent: unknown; + let transport: DaemonTransport | undefined; + let teardown = () => {}; + let disposeSocket = () => {}; + let assertOutboundCapacity = (bytes: number) => budget.assertCapacity(bytes); + let sshTunnel: SshTunnel | null = null; + const close = (code?: number, reason?: string) => { + if (closed) return; + closed = true; + try { + transport?.close(code, reason); + } finally { + try { + disposeSocket(); + teardown(); + } finally { + for (const handler of closes) handler(closeEvent); + } + } + }; + void (async () => { + let socketTarget: Extract; + if (target.kind === "ssh") { + const tunnel = await createSshTunnel(target.ssh); + sshTunnel = tunnel; + teardown = () => tunnel.close(); + socketTarget = parseHostTarget(tunnel.endpoint) as Extract; + } else socketTarget = target; + if (closed) { + teardown(); + return; + } + const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir()); + let factory = createWebSocketTransportFactory((url, options) => { + const socket = new WebSocket( + socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url, + options?.protocols, + { + headers: options?.headers, + ...(socketPath ? { socketPath } : {}), + maxPayload: 100 * 1024 * 1024, + }, + ); + disposeSocket = () => socket.terminate(); + assertOutboundCapacity = (bytes) => { + if (socket.bufferedAmount + bytes > 128 * 1024 * 1024) { + throw Object.assign(new Error("Host socket buffer limit reached"), { + code: "resource_limit", + }); + } + budget.assertCapacity(bytes); + }; + const pending = new Set<() => void>(); + const send = socket.send.bind(socket); + socket.send = (data, ...args: unknown[]) => { + const bytes = + typeof data === "string" + ? Buffer.byteLength(data) + : Buffer.byteLength(data as Uint8Array); + assertOutboundCapacity(bytes); + const release = budget.reserve(bytes); + pending.add(release); + const callback = args.find( + (value): value is (error?: Error) => void => typeof value === "function", + ); + const sendOptions = args.find((value) => value !== null && typeof value === "object") as + | { binary?: boolean } + | undefined; + try { + send(data, sendOptions ?? {}, (error) => { + release(); + pending.delete(release); + callback?.(error); + }); + } catch (error) { + release(); + pending.delete(release); + throw error; + } + }; + socket.once("close", () => { + for (const release of pending) release(); + pending.clear(); + }); + return socket as unknown as WebSocketLike; + }); + if (socketTarget.offer) + factory = createRelayE2eeTransportFactory({ + baseFactory: factory, + daemonPublicKeyB64: socketTarget.offer.daemonPublicKeyB64, + logger: { warn() {} }, + assertOutboundCapacity: (bytes) => assertOutboundCapacity(bytes), + }); + transport = factory({ ...attemptOptions, url: socketTarget.url }); + transport.onOpen(() => { + if (!closed) for (const handler of opens) handler(); + }); + transport.onClose((event) => { + closeEvent = event; + close(); + }); + transport.onError((error) => { + if (closed) return; + const detail = sshTunnel?.failureDetail() ?? ""; + const failure = + /permission denied|authentication failed|host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test( + detail, + ) + ? Object.assign( + new Error( + "SSH authentication or host verification failed. Check the SSH configuration on this daemon.", + ), + { code: "authentication_failed" }, + ) + : error; + for (const handler of errors) handler(failure); + close(); + }); + transport.onMessage((data, binary) => { + if (!closed) for (const handler of messages) handler(data, binary); + }); + })().catch(() => { + for (const handler of errors) handler(new Error("Host transport failed")); + close(); + }); + return { + send(data) { + if (closed || !transport) throw new Error("Host transport is unavailable"); + transport.send(data); + }, + close, + onOpen(handler) { + opens.add(handler); + return () => opens.delete(handler); + }, + onClose(handler) { + closes.add(handler); + return () => closes.delete(handler); + }, + onError(handler) { + errors.add(handler); + return () => errors.delete(handler); + }, + onMessage(handler) { + messages.add(handler); + return () => messages.delete(handler); + }, + }; + }; +} +export function createHostClient( + connection: string, + config: Omit, + budget?: HostTransportBudget, +): DaemonClient { + const target = parseHostTarget(connection); + return new DaemonClient({ + ...config, + url: "ws://host-connector/ws", + ...(target.password ? { password: target.password } : {}), + transportFactory: createHostTransportFactory(connection, budget), + }); +} diff --git a/packages/client/src/node/ssh-tunnel.ts b/packages/client/src/node/ssh-tunnel.ts new file mode 100644 index 0000000000..33900d0baa --- /dev/null +++ b/packages/client/src/node/ssh-tunnel.ts @@ -0,0 +1,90 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createServer, type Server, type Socket } from "node:net"; +import { buildSshTunnelArgs, type SshTransportTarget } from "@getpaseo/protocol/ssh-transport"; + +const SSH_STDERR_LIMIT = 8192; + +export interface SshTunnel { + endpoint: string; + close(): void; + failureDetail(): string | null; +} + +function formatSshFailure( + stderr: string, + code: number | null, + signal: NodeJS.Signals | null, +): string { + const detail = stderr.trim(); + if (detail) return detail; + if (signal) return `ssh exited with signal ${signal}`; + return `ssh exited with code ${code ?? "unknown"}`; +} + +export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null { + return failure ?? (stderr.trim() || null); +} + +export function createSshTunnel(target: SshTransportTarget): Promise { + let server: Server | null = null; + let socket: Socket | null = null; + let child: ChildProcessWithoutNullStreams | null = null; + let stderr = ""; + let failure: string | null = null; + + function close(): void { + server?.close(); + server = null; + socket?.destroy(); + socket = null; + if (child && !child.killed) child.kill(); + child = null; + } + + return new Promise((resolve, reject) => { + server = createServer((acceptedSocket) => { + socket = acceptedSocket; + server?.close(); + server = null; + + child = spawn("ssh", buildSshTunnelArgs(target), { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT); + }); + child.on("error", (error) => { + failure = error.message; + acceptedSocket.destroy(error); + }); + child.on("exit", (code, signal) => { + if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal); + acceptedSocket.destroy(failure ? new Error(failure) : undefined); + }); + acceptedSocket.on("error", () => undefined); + acceptedSocket.on("close", () => { + if (child && !child.killed) child.kill(); + }); + acceptedSocket.pipe(child.stdin); + child.stdout.pipe(acceptedSocket); + }); + server.once("error", (error) => { + close(); + reject(error); + }); + server.listen(0, "127.0.0.1", () => { + const address = server?.address(); + if (!address || typeof address === "string") { + close(); + reject(new Error("Failed to allocate the SSH tunnel port")); + return; + } + resolve({ + endpoint: `127.0.0.1:${address.port}`, + close, + failureDetail: () => resolveSshFailureDetail(failure, stderr), + }); + }); + }); +} diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 770ae5f50f..1dd6665592 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -12,6 +12,10 @@ "./*": { "types": "./dist/*.d.ts", "default": "./dist/*.js" + }, + "./addresses": { + "types": "./dist/addresses.d.ts", + "default": "./dist/addresses.js" } }, "publishConfig": { diff --git a/packages/protocol/src/addresses.ts b/packages/protocol/src/addresses.ts new file mode 100644 index 0000000000..6a8328b93c --- /dev/null +++ b/packages/protocol/src/addresses.ts @@ -0,0 +1,5 @@ +/** Agent identity is qualified by its daemon; transport and reachability are separate. */ +export interface AgentAddress { + serverId: string; + agentId: string; +} diff --git a/packages/protocol/src/agent-labels.ts b/packages/protocol/src/agent-labels.ts index 2bf05950dd..e2eb008093 100644 --- a/packages/protocol/src/agent-labels.ts +++ b/packages/protocol/src/agent-labels.ts @@ -1,3 +1,5 @@ +import type { AgentAddress } from "./addresses.js"; +export const PARENT_SERVER_ID_LABEL = "paseo.parent-server-id"; export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id"; const OPEN_AGENT_TAB_LABEL_PREFIX = "paseo.open-agent-tab."; @@ -29,3 +31,23 @@ export function hasOpenAgentTab(labels: Record | null | undefin ([label, value]) => isOpenAgentTabLabel(label) && value === "true", ); } + +// Absent parent server label means the same host. +export function getParentAddressFromLabels( + labels: Record | null | undefined, + serverId: string, +): AgentAddress | null { + const agentId = getParentAgentIdFromLabels(labels); + if (!agentId) return null; + const parentServerId = labels?.[PARENT_SERVER_ID_LABEL]; + return { + serverId: + typeof parentServerId === "string" && parentServerId.trim() + ? parentServerId.trim() + : serverId, + agentId, + }; +} +export function parentAddressLabels(parent: AgentAddress): Record { + return { [PARENT_AGENT_ID_LABEL]: parent.agentId, [PARENT_SERVER_ID_LABEL]: parent.serverId }; +} diff --git a/packages/protocol/src/binary-frames/demux.test.ts b/packages/protocol/src/binary-frames/demux.test.ts index 718a7c6763..c56e417528 100644 --- a/packages/protocol/src/binary-frames/demux.test.ts +++ b/packages/protocol/src/binary-frames/demux.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import { decodeBinaryFrame, + encodeHostRouteFrame, + decodeHostRouteFrame, + MAX_HOST_ROUTE_ID_BYTES, encodeFileTransferFrame, encodeTerminalStreamFrame, FileTransferOpcode, @@ -51,3 +54,38 @@ describe("binary frame demux", () => { expect(decodeBinaryFrame(new Uint8Array([0xff, 0]))).toBeNull(); }); }); + +describe("host route binary framing", () => { + it("wraps terminal and file frames unchanged and leaves their direct interpretation intact", () => { + const frames = [ + encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Input, slot: 7, payload: "echo" }), + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: "upload", + payload: new Uint8Array([0, 255, 128, 1]), + }), + ]; + for (const payload of frames) { + const wire = encodeHostRouteFrame("channel", payload); + const decoded = decodeBinaryFrame(wire); + expect(decoded).toEqual({ kind: "host_route", frame: { routeId: "channel", payload } }); + expect(wire.subarray(10)).toEqual(payload); + expect(decodeBinaryFrame(decodeHostRouteFrame(wire)!.payload)).toEqual( + decodeBinaryFrame(payload), + ); + } + }); + it("rejects truncated, oversized identifiers and invalid UTF-8 without throwing", () => { + for (const bytes of [ + new Uint8Array([0x30]), + new Uint8Array([0x30, 0, 4, 65]), + new Uint8Array([0x30, 0, 0]), + new Uint8Array([0x30, 1, 1]), + new Uint8Array([0x30, 0, 1, 255]), + ]) + expect(decodeBinaryFrame(bytes)).toBeNull(); + expect(() => + encodeHostRouteFrame("a".repeat(MAX_HOST_ROUTE_ID_BYTES + 1), new Uint8Array()), + ).toThrow("Invalid host route ID"); + }); +}); diff --git a/packages/protocol/src/binary-frames/demux.ts b/packages/protocol/src/binary-frames/demux.ts index 31aca68cc6..a8503b898f 100644 --- a/packages/protocol/src/binary-frames/demux.ts +++ b/packages/protocol/src/binary-frames/demux.ts @@ -1,3 +1,4 @@ +import { HOST_ROUTE_OPCODE, decodeHostRouteFrame, type HostRouteFrame } from "./host-route.js"; import { decodeFileTransferFrame, FileTransferOpcode, @@ -10,11 +11,16 @@ import { } from "./terminal.js"; export type BinaryFrame = + | { kind: "host_route"; frame: HostRouteFrame } | { kind: "terminal"; frame: TerminalStreamFrame } | { kind: "file_transfer"; frame: FileTransferFrame }; export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null { switch (bytes[0]) { + case HOST_ROUTE_OPCODE: { + const frame = decodeHostRouteFrame(bytes); + return frame ? { kind: "host_route", frame } : null; + } case TerminalStreamOpcode.Output: case TerminalStreamOpcode.Input: case TerminalStreamOpcode.Resize: diff --git a/packages/protocol/src/binary-frames/host-route.ts b/packages/protocol/src/binary-frames/host-route.ts new file mode 100644 index 0000000000..f45cd714f9 --- /dev/null +++ b/packages/protocol/src/binary-frames/host-route.ts @@ -0,0 +1,33 @@ +import { MAX_WEBSOCKET_MESSAGE_BYTES } from "../websocket-message-limit.js"; +/** Routing adds a bounded header; the enclosed terminal/file frame is never re-encoded. */ +export const HOST_ROUTE_OPCODE = 0x30; +export const MAX_HOST_ROUTE_ID_BYTES = 256; +export const MAX_HOST_ROUTE_HEADER_BYTES = 3 + MAX_HOST_ROUTE_ID_BYTES; +export const MAX_ROUTED_PAYLOAD_BYTES = MAX_WEBSOCKET_MESSAGE_BYTES - MAX_HOST_ROUTE_HEADER_BYTES; +export interface HostRouteFrame { + routeId: string; + payload: Uint8Array; +} +export function encodeHostRouteFrame(routeId: string, payload: Uint8Array): Uint8Array { + const id = new TextEncoder().encode(routeId); + if (!id.length || id.length > MAX_HOST_ROUTE_ID_BYTES) throw new Error("Invalid host route ID"); + if (payload.byteLength > MAX_ROUTED_PAYLOAD_BYTES) throw new Error("Host frame is too large"); + const frame = new Uint8Array(3 + id.length + payload.byteLength); + frame[0] = HOST_ROUTE_OPCODE; + new DataView(frame.buffer).setUint16(1, id.length); + frame.set(id, 3); + frame.set(payload, 3 + id.length); + return frame; +} +export function decodeHostRouteFrame(bytes: Uint8Array): HostRouteFrame | null { + if (bytes[0] !== HOST_ROUTE_OPCODE || bytes.length < 3) return null; + const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(1); + if (!length || length > MAX_HOST_ROUTE_ID_BYTES || bytes.length < 3 + length) return null; + if (bytes.length - 3 - length > MAX_ROUTED_PAYLOAD_BYTES) return null; + try { + const routeId = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(3, 3 + length)); + return { routeId, payload: bytes.subarray(3 + length) }; + } catch { + return null; + } +} diff --git a/packages/protocol/src/binary-frames/index.ts b/packages/protocol/src/binary-frames/index.ts index 533b8fe453..cb24172960 100644 --- a/packages/protocol/src/binary-frames/index.ts +++ b/packages/protocol/src/binary-frames/index.ts @@ -1,3 +1,4 @@ export * from "./demux.js"; export * from "./file-transfer.js"; export * from "./terminal.js"; +export * from "./host-route.js"; diff --git a/packages/protocol/src/host-target.test.ts b/packages/protocol/src/host-target.test.ts new file mode 100644 index 0000000000..df0127e573 --- /dev/null +++ b/packages/protocol/src/host-target.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "vitest"; +import { parseHostTarget } from "./host-target.js"; + +test.each([ + ["127.0.0.1:6767", "tcp://127.0.0.1:6767"], + ["4321", "tcp://127.0.0.1:4321"], + ["[::1]:4321", "tcp://[::1]:4321"], + ["unix:///tmp/paseo.sock", "unix:///tmp/paseo.sock"], + ["pipe://\\\\.\\pipe\\paseo", "pipe://\\\\.\\pipe\\paseo"], + ["wss://example.com/custom?token=secret", "wss://example.com"], + ["tcp://example.com:6767?password=secret", "tcp://example.com:6767"], +])( + "the shared connector accepts %s and exposes only safe display text", + (connection, description) => { + expect(parseHostTarget(connection).description).toBe(description); + }, +); +test("SSH daemon credentials are separate from SSH authentication and excluded from display text", () => { + expect(parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777&password=secret")).toEqual({ + kind: "ssh", + ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 }, + password: "secret", + description: "ssh://build-box:2222 (daemon port 7777)", + }); +}); +test.each([ + "", + "ssh://-bad", + "ssh://user:secret@host", + "ssh://host?unknown=secret", + "unix://", + "tcp://host:0", + "C:\\Users\\socket", + "alice:example-secret@localhost:6767", + "not-a-scheme://localhost:6767", + "localhost/path:6767", + "localhost?password=example-secret:6767", + "localhost#example-secret:6767", + "tcp://localhost/path:6767", + "relay://alice:example-secret@localhost:6767/server?key=private", +])("invalid target syntax is rejected without echoing %s", (connection) => { + expect(() => parseHostTarget(connection)).toThrow("Invalid host connection"); +}); + +test("SSH aliases and DNS names retain their connection semantics", () => { + expect(parseHostTarget("build-box.example.:6767").description).toBe( + "tcp://build-box.example.:6767", + ); + expect(parseHostTarget("ssh://deploy@my_ssh-alias")).toMatchObject({ + kind: "ssh", + ssh: { host: "deploy@my_ssh-alias" }, + }); +}); + +test("encoded SSH usernames cannot leak into the target description", () => { + expect(parseHostTarget("ssh://alice%3Aexample-secret@my-alias").description).toBe( + "ssh://my-alias (daemon port 6767)", + ); +}); + +test("pairing offers cannot publish a credential-bearing or malformed relay authority", () => { + for (const endpoint of [ + "alice:example-secret@localhost:6767", + "localhost/path:6767", + "localhost?token=example-secret:6767", + ]) { + const offer = { + v: 2, + serverId: "srv_test", + daemonPublicKeyB64: "private-key", + relay: { endpoint, useTls: false }, + }; + const url = `https://app.paseo.sh/#offer=${Buffer.from(JSON.stringify(offer)).toString("base64url")}`; + expect(() => parseHostTarget(url)).toThrow("Invalid host connection"); + } +}); diff --git a/packages/protocol/src/host-target.ts b/packages/protocol/src/host-target.ts new file mode 100644 index 0000000000..498b20b1e7 --- /dev/null +++ b/packages/protocol/src/host-target.ts @@ -0,0 +1,137 @@ +import { parseConnectionOfferFromUrl, type ConnectionOffer } from "./connection-offer.js"; +import { + buildDaemonWebSocketUrl, + buildRelayWebSocketUrl, + normalizeHostPort, + parseConnectionUri, + parseRelayConnectionUri, + shouldUseTlsForDefaultHostedRelay, +} from "./daemon-endpoints.js"; +import { parseSshTransportUri, type SshTransportTarget } from "./ssh-transport.js"; + +export type HostTarget = + | { kind: "ssh"; ssh: SshTransportTarget; password?: string; description: string } + | { + kind: "socket"; + url: string; + socketPath?: string; + password?: string; + offer?: ConnectionOffer; + description: string; + }; + +/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */ +export function parseHostTarget(input: string): HostTarget { + try { + return parse(input.trim()); + } catch { + throw Object.assign( + new Error( + "Invalid host connection. Use host:port, ws(s)://, tcp://, unix://, pipe://, ssh:// or a pairing URL.", + ), + { code: "invalid_connection" }, + ); + } +} +function parse(input: string): HostTarget { + if (!input) throw new Error("empty"); + if (input.startsWith("ssh://")) { + return sshTarget(input); + } + + if (input.startsWith("relay://") || input.includes("#connect=")) { + const relay = parseRelayConnectionUri(input); + return relayTarget(relay.offer, relay.password); + } + const offer = parseConnectionOfferFromUrl(input); + if (offer) return relayTarget(offer); + if ( + input.startsWith("unix://") || + input.startsWith("pipe://") || + input.startsWith("/") || + input.startsWith("~") || + input.startsWith("\\\\.\\pipe\\") + ) { + const socketPath = input.replace(/^(unix|pipe):\/\//, ""); + if (!socketPath || socketPath === "~") throw new Error("empty socket"); + const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\"); + return { + kind: "socket", + socketPath, + url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`, + description: `${pipe ? "pipe" : "unix"}://${socketPath}`, + }; + } + if (input.startsWith("tcp://")) return tcpTarget(input); + if (/^wss?:\/\//.test(input)) return websocketTarget(input); + const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input); + return { + kind: "socket", + url: buildDaemonWebSocketUrl(endpoint, { useTls: false }), + description: `tcp://${endpoint}`, + }; +} +function relayTarget(offer: ConnectionOffer, password?: string): HostTarget { + const endpoint = validatedEndpoint(offer.relay.endpoint); + return { + kind: "socket", + url: buildRelayWebSocketUrl({ + endpoint, + serverId: offer.serverId, + role: "client", + useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint), + }), + offer, + password, + description: `relay://${endpoint}/${encodeURIComponent(offer.serverId)}`, + }; +} + +function tcpTarget(input: string): HostTarget { + const uri = new URL(input); + if (uri.pathname && uri.pathname !== "/") throw new Error("Unexpected TCP path"); + if (uri.hash) throw new Error("Unexpected TCP fragment"); + const parts = parseConnectionUri(input); + const endpoint = validatedEndpoint( + `${parts.isIpv6 ? `[${parts.host}]` : parts.host}:${parts.port}`, + ); + return { + kind: "socket", + url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }), + password: parts.password, + description: `tcp://${endpoint}`, + }; +} +function websocketTarget(input: string): HostTarget { + const url = new URL(input); + if (!url.hostname || url.username || url.password || url.hash) throw new Error("invalid url"); + const password = url.searchParams.get("password") ?? undefined; + // Unknown query parameters may themselves contain credentials; discovery shows only the origin. + if (url.pathname === "/") url.pathname = "/ws"; + url.searchParams.delete("password"); + return { kind: "socket", url: url.toString(), password, description: url.origin }; +} + +function sshTarget(input: string): HostTarget { + const uri = new URL(input); + if (uri.searchParams.getAll("password").length > 1) throw new Error("Duplicate daemon password"); + const password = uri.searchParams.get("password") || undefined; + uri.searchParams.delete("password"); + const ssh = parseSshTransportUri(uri.toString()); + return { + kind: "ssh", + ssh, + ...(password ? { password } : {}), + description: `ssh://${uri.hostname}${ssh.sshPort ? `:${ssh.sshPort}` : ""} (daemon port ${ssh.daemonPort})`, + }; +} + +/** A bare endpoint has exactly one authority; URL syntax must use an explicit supported scheme. */ +function validatedEndpoint(input: string): string { + if (!/^(?:[a-zA-Z0-9_][a-zA-Z0-9_.-]*|\[[0-9a-fA-F:.]+\]):[0-9]{1,5}$/.test(input)) + throw new Error("Invalid endpoint"); + // URL parsing validates IPv6 literals and rejects invalid authority/port encodings. + const url = new URL(`http://${input}`); + if (!url.hostname) throw new Error("Missing endpoint host"); + return normalizeHostPort(input); +} diff --git a/packages/protocol/src/hosts.ts b/packages/protocol/src/hosts.ts new file mode 100644 index 0000000000..7d36a50236 --- /dev/null +++ b/packages/protocol/src/hosts.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +export const HostMetadataSchema = z.record(z.string().max(256), z.string().max(4096)); +export const HostAddSchema = z.object({ + connection: z.string().min(1).max(16384), + label: z.string().max(256).optional(), + metadata: HostMetadataSchema.optional(), + sourceKey: z.string().min(1).max(256).optional(), +}); +export const HostUpdateSchema = z.object({ + label: z.string().max(256).nullable().optional(), + metadata: HostMetadataSchema.optional(), + enabled: z.boolean().optional(), + connection: z.string().min(1).max(16384).optional(), + replaceIdentity: z.boolean().optional(), +}); +export const HostSummarySchema = z.object({ + registrationId: z.string(), + serverId: z.string().nullable(), + label: z.string().nullable(), + source: z.object({ id: z.string(), key: z.string() }), + metadata: HostMetadataSchema, + enabled: z.boolean(), + status: z.enum(["connecting", "online", "offline", "error", "disabled"]), + targetDescription: z.string(), + lastOnlineAt: z.string().nullable(), + nextRetryAt: z.string().nullable(), + error: z.object({ code: z.string(), message: z.string() }).nullable(), +}); +export const HostDirectorySchema = z.object({ + self: z.object({ serverId: z.string(), label: z.string() }), + hosts: z.array(HostSummarySchema), +}); +export type HostAdd = z.infer; +export type HostUpdate = z.infer; +export type HostSummary = z.infer; +export type HostDirectory = z.infer; + +export const HostsListRequestSchema = z.object({ + type: z.literal("hosts.list.request"), + requestId: z.string(), + observe: z.boolean().optional(), +}); +export const HostsAddRequestSchema = z.object({ + type: z.literal("hosts.add.request"), + requestId: z.string(), + input: HostAddSchema, +}); +export const HostsUpdateRequestSchema = z.object({ + type: z.literal("hosts.update.request"), + requestId: z.string(), + registrationId: z.string(), + patch: HostUpdateSchema, +}); +export const HostsRemoveRequestSchema = z.object({ + type: z.literal("hosts.remove.request"), + requestId: z.string(), + registrationId: z.string(), +}); +export const HostsRouteOpenRequestSchema = z.object({ + type: z.literal("hosts.route.open.request"), + requestId: z.string(), + serverId: z.string(), + scopeId: z.string().min(1).max(256), +}); +export const HostsRouteFrameRequestSchema = z.object({ + type: z.literal("hosts.route.frame.request"), + routeId: z.string(), + data: z.string().max(100 * 1024 * 1024), + binary: z.literal(false), +}); +export const HostsRouteCloseRequestSchema = z.object({ + type: z.literal("hosts.route.close.request"), + routeId: z.string().optional(), + scopeId: z.string().optional(), +}); +export const HostsListResponseSchema = z.object({ + type: z.literal("hosts.list.response"), + payload: z.object({ + requestId: z.string(), + directory: HostDirectorySchema, + subscriptionId: z.string().optional(), + }), +}); +export const HostsChangedSchema = z.object({ + type: z.literal("hosts.changed"), + payload: z.object({ subscriptionId: z.string(), directory: HostDirectorySchema }), +}); +export const HostsAddResponseSchema = z.object({ + type: z.literal("hosts.add.response"), + payload: z.object({ requestId: z.string(), host: HostSummarySchema }), +}); +export const HostsUpdateResponseSchema = z.object({ + type: z.literal("hosts.update.response"), + payload: z.object({ requestId: z.string(), host: HostSummarySchema }), +}); +export const HostsRemoveResponseSchema = z.object({ + type: z.literal("hosts.remove.response"), + payload: z.object({ requestId: z.string() }), +}); +export const HostsRouteOpenResponseSchema = z.object({ + type: z.literal("hosts.route.open.response"), + payload: z.object({ requestId: z.string(), routeId: z.string() }), +}); +export const HostsRouteFrameSchema = z.object({ + type: z.literal("hosts.route.frame"), + payload: z.object({ subscriptionId: z.string(), data: z.string(), binary: z.literal(false) }), +}); +export const HostsRouteClosedSchema = z.object({ + type: z.literal("hosts.route.closed"), + payload: z.object({ subscriptionId: z.string(), code: z.string(), message: z.string() }), +}); + +// Logical B sessions are admitted under the authenticated physical link, never hello auth. +export const HostsLinkOpenRequestSchema = z.object({ + type: z.literal("hosts.link.open.request"), + requestId: z.string(), + channelId: z.string().min(1).max(256), +}); +export const HostsLinkOpenResponseSchema = z.object({ + type: z.literal("hosts.link.open.response"), + payload: z.object({ requestId: z.string(), routeId: z.string() }), +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index e8df79eab1..4be3229b63 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,4 +1,23 @@ import { AgentMessageSchema } from "./agent-message.js"; +import { + HostsListRequestSchema, + HostsAddRequestSchema, + HostsUpdateRequestSchema, + HostsRemoveRequestSchema, + HostsLinkOpenRequestSchema, + HostsRouteOpenRequestSchema, + HostsRouteFrameRequestSchema, + HostsRouteCloseRequestSchema, + HostsListResponseSchema, + HostsChangedSchema, + HostsAddResponseSchema, + HostsUpdateResponseSchema, + HostsRemoveResponseSchema, + HostsLinkOpenResponseSchema, + HostsRouteOpenResponseSchema, + HostsRouteFrameSchema, + HostsRouteClosedSchema, +} from "./hosts.js"; import { PluginRegistryIdentitySchema } from "./plugin-registry.js"; import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js"; export { @@ -3208,6 +3227,15 @@ export const SubscriptionReleaseResponseSchema = z.object({ }); export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ + HostsListRequestSchema, + HostsAddRequestSchema, + HostsUpdateRequestSchema, + HostsRemoveRequestSchema, + HostsLinkOpenRequestSchema, + HostsRouteOpenRequestSchema, + HostsRouteFrameRequestSchema, + HostsRouteCloseRequestSchema, + BrowserHostRegisterRequestSchema, SubscriptionReleaseRequestSchema, SessionEventsSetSubscriptionRequestSchema, @@ -3577,6 +3605,8 @@ export const ServerInfoStatusPayloadSchema = z serverId: z.string().trim().min(1), hostname: ServerInfoHostnameSchema.optional(), version: ServerInfoVersionSchema.optional(), + // Authenticated producer for host registration ownership, separate from admission authority. + sourceId: z.string().optional(), // COMPAT(sessionPermissions): optional while clients support older daemons. permissions: z.array(DaemonPermissionSchema).optional(), // COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16. @@ -3598,6 +3628,8 @@ export const ServerInfoStatusPayloadSchema = z internalWorkspaces: z.boolean().optional(), backgroundWorkspaces: z.boolean().optional(), providersSnapshot: z.boolean().optional(), + connectedHosts: z.boolean().optional(), + hostMultiplexing: z.boolean().optional(), usageSources: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. providersSnapshotCwd: z.boolean().optional(), @@ -6879,6 +6911,16 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({ }); export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ + HostsListResponseSchema, + HostsChangedSchema, + HostsAddResponseSchema, + HostsUpdateResponseSchema, + HostsRemoveResponseSchema, + HostsLinkOpenResponseSchema, + HostsRouteOpenResponseSchema, + HostsRouteFrameSchema, + HostsRouteClosedSchema, + BrowserHostRegisterResponseSchema, SubscriptionReleaseResponseSchema, SessionEventsSetSubscriptionResponseSchema, @@ -7571,6 +7613,11 @@ export const WSPongMessageSchema = z.object({ export const WSHelloMessageSchema = z.object({ type: z.literal("hello"), + sessionPermissions: z.array(DaemonPermissionSchema).optional(), + routed: z.boolean().optional(), + producer: z + .object({ serverId: z.string().min(1).max(256), sourceId: z.string().min(1).max(1024) }) + .optional(), clientId: z.string().min(1), clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]), protocolVersion: z.number().int(), diff --git a/packages/protocol/src/paseo-tool-presentation/catalog.ts b/packages/protocol/src/paseo-tool-presentation/catalog.ts index 1706134523..0c35b128b7 100644 --- a/packages/protocol/src/paseo-tool-presentation/catalog.ts +++ b/packages/protocol/src/paseo-tool-presentation/catalog.ts @@ -195,6 +195,7 @@ export const PASEO_TOOL_CATALOG = { context: workspaceContext(), detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] }, }, + list_hosts: { label: "List hosts" }, list_workspaces: { label: "List workspaces" }, rename_workspace: { label: "Rename workspace", diff --git a/packages/server/package.json b/packages/server/package.json index afc1c4a399..6d957847ab 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 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\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/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\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index ec5bceb1cb..488867c657 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -5,7 +5,7 @@ import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization"; import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; -import { projectTimelineRows } from "./timeline-projection.js"; +import { projectTimelineRows, selectItemsByProjectedLimit } from "./timeline-projection.js"; const DEFAULT_MAX_ITEMS = 0; const MAX_TOOL_INPUT_CHARS = 400; @@ -210,6 +210,24 @@ export function curateAgentActivity( : "No activity to display."; } +export function summarizeAgentActivity(timeline: AgentTimelineItem[], limit?: number) { + const selection = selectItemsByProjectedLimit({ + items: timeline, + direction: "tail", + limit: limit ?? 0, + }); + const { totalProjected, shownProjected } = selection; + const noun = totalProjected === 1 ? "activity" : "activities"; + const countHeader = + limit && shownProjected < totalProjected + ? `Showing ${shownProjected} of ${totalProjected} ${noun} (limited to ${limit})` + : `Showing all ${totalProjected} ${noun}`; + return { + updateCount: timeline.length, + content: `${countHeader}\n\n${curateAgentActivity(selection.items)}`, + }; +} + interface ForkCursorBoundary { timelineEpoch: string; cursor: { epoch: string; seq: number }; diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index dbad9586db..7d154e9c14 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -1,3 +1,4 @@ +import { isSameOrDescendantPath } from "../path-utils.js"; import type { AgentListItemPayload, AgentSnapshotPayload, @@ -286,6 +287,66 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt }; } +function parseTimestamp(value: string | null | undefined): number { + if (!value) { + return 0; + } + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function resolveAgentListActivityTime(agent: AgentListItemPayload): number { + return Math.max( + parseTimestamp(agent.updatedAt), + parseTimestamp(agent.lastUserMessageAt), + parseTimestamp(agent.attentionTimestamp), + parseTimestamp(agent.archivedAt), + parseTimestamp(agent.createdAt), + ); +} + +function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number { + const attentionDelta = + Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false); + if (attentionDelta !== 0) { + return attentionDelta; + } + + const statusOrder = { + running: 0, + initializing: 1, + idle: 2, + error: 3, + closed: 4, + } as Record; + const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999); + if (statusDelta !== 0) { + return statusDelta; + } + + return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a); +} + +export function selectAgentListItems( + agents: readonly AgentSnapshotPayload[], + options: { + cwd?: string; + statuses?: AgentSnapshotPayload["status"][]; + sinceHours?: number; + limit?: number; + } = {}, +): AgentListItemPayload[] { + const statusFilter = options.statuses?.length ? new Set(options.statuses) : null; + const sinceMs = Date.now() - (options.sinceHours ?? 48) * 60 * 60 * 1000; + return agents + .map(toAgentListItemPayload) + .filter((agent) => !options.cwd || isSameOrDescendantPath(options.cwd, agent.cwd)) + .filter((agent) => !statusFilter || statusFilter.has(agent.status)) + .filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs) + .sort(compareAgentListItems) + .slice(0, options.limit ?? 50); +} + export function toRecentProviderSessionDescriptorPayload( session: ImportableProviderSession & { provider: string }, options: RecentProviderSessionProjectionOptions, diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index bf6d50480b..6b120abb1c 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -1,3 +1,4 @@ +import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js"; import type { Logger } from "pino"; @@ -10,7 +11,10 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; -import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; +import { + getParentAgentIdFromLabels, + getParentAddressFromLabels, +} from "@getpaseo/protocol/agent-labels"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; export type AgentUnarchiveController = Pick; @@ -292,7 +296,7 @@ async function resolvePromptSource( manager: Pick, storage: AgentStorage, ): Promise { - if (!source) return undefined; + if (!source || source.title) return source; const title = ( manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title )?.trim(); @@ -401,6 +405,7 @@ export interface SetupFinishNotificationParams { agentStorage: AgentStorage; childAgentId: string; callerAgentId: string; + childServerId?: string; requireParentOwnership?: boolean; /** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */ dispatchResult?: Promise; @@ -429,6 +434,7 @@ const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; interface FinishNotificationBodyInput { childAgentId: string; + serverId?: string; title: string; reason: FinishNotificationReason; lastAssistantMessage: string | null; @@ -438,12 +444,14 @@ interface FinishNotificationBodyInput { function formatFinishNotificationBody(params: FinishNotificationBodyInput): string { const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`; const sections = [statusLine]; + if (params.serverId) sections.push(`Server: ${params.serverId}`); if (params.reason === "needs permission" && params.permissionRequest) { sections.push( "Respond with `respond_to_permission` using the `agentId` and `requestId` below.", `\n${JSON.stringify( { agentId: params.childAgentId, + ...(params.serverId ? { serverId: params.serverId } : {}), requestId: params.permissionRequest.id, request: params.permissionRequest, }, @@ -480,6 +488,101 @@ const armedFinishNotifications = new WeakMap< Map> >(); +/** Creation waits and accepted follow-ups share notification ownership. */ +export function setupFinishWait(params: { + agentManager: AgentManager; + agentStorage: AgentStorage; + originServerId?: string; + childServerId?: string; + childAgentId: string; + callerAgentId: string; + wait(signal: AbortSignal): Promise; + afterPermission(requestId: string, signal: AbortSignal): Promise; + logger: Logger; +}): () => void { + const controller = new AbortController(); + const byManager = + armedFinishNotifications.get(params.agentManager) ?? + new Map>(); + armedFinishNotifications.set(params.agentManager, byManager); + const key = JSON.stringify([ + params.childServerId ?? null, + params.childAgentId, + params.callerAgentId, + ]); + const registrations = byManager.get(key) ?? new Set(); + byManager.set(key, registrations); + for (const previous of registrations) previous.stop(); + byManager.set(key, registrations); + let superseded = Promise.resolve(false); + const stop = () => { + controller.abort(); + registrations.delete(registration); + if (registrations.size === 0 && byManager.get(key) === registrations) byManager.delete(key); + }; + const registration: ArmedFinishNotification = { + stop, + supersedeWhen(accepted) { + superseded = superseded.then(async (prior) => prior || (await accepted)); + }, + }; + registrations.add(registration); + async function watch(): Promise { + while (!controller.signal.aborted) { + const result = await params.wait(controller.signal); + if (controller.signal.aborted || (await superseded) || result.status === "timeout") return; + const parent = getParentAddressFromLabels( + result.final?.labels, + params.childServerId ?? "self", + ); + if ( + parent?.agentId !== params.callerAgentId || + parent.serverId !== (params.originServerId ?? "self") + ) + return; + if ((await params.agentStorage.get(params.callerAgentId))?.archivedAt) return; + const reason = { idle: "finished", error: "errored", permission: "needs permission" }[ + result.status + ] as FinishNotificationReason; + const permissionRequest = result.final?.pendingPermissions[0]; + await sendPromptToAgent({ + agentManager: params.agentManager, + agentStorage: params.agentStorage, + agentId: params.callerAgentId, + prompt: formatFinishNotificationBody({ + childAgentId: params.childAgentId, + serverId: params.childServerId, + title: result.final?.title ?? params.childAgentId, + reason, + lastAssistantMessage: result.lastMessage, + permissionRequest, + }), + source: { + kind: "agent-notification", + agentId: params.childAgentId, + title: result.final?.title ?? params.childAgentId, + event: finishNotificationEvents[reason], + }, + activeTurnBehavior: "steer", + unarchive: false, + logger: params.logger, + }); + if (result.status !== "permission" || !permissionRequest) return; + await params.afterPermission(permissionRequest.id, controller.signal); + } + } + void watch() + .catch((error) => { + if (!controller.signal.aborted) + params.logger.warn( + { err: error, agentId: params.childAgentId }, + "Child finish wait stopped", + ); + }) + .finally(stop); + return stop; +} + export function setupFinishNotification(params: SetupFinishNotificationParams): () => void { const { agentManager, @@ -499,7 +602,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map(); armedFinishNotifications.set(agentManager, armedByManager); - const armedKey = JSON.stringify([childAgentId, callerAgentId]); + const armedKey = JSON.stringify([params.childServerId ?? null, childAgentId, callerAgentId]); const registrations = armedByManager.get(armedKey) ?? new Set(); armedByManager.set(armedKey, registrations); const previous = [...registrations]; diff --git a/packages/server/src/server/agent/host-operations/index.ts b/packages/server/src/server/agent/host-operations/index.ts new file mode 100644 index 0000000000..b69f0775a9 --- /dev/null +++ b/packages/server/src/server/agent/host-operations/index.ts @@ -0,0 +1,393 @@ +import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; +import type { ActiveTurnBehavior, AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { + DaemonClient, + type FetchWorkspacesEntry, + type WaitForFinishResult, +} from "@getpaseo/client/internal/daemon-client"; +import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types"; +import type { HostDirectory } from "@getpaseo/protocol/hosts"; +import { OWNER_PERMISSIONS } from "../../authorization/index.js"; +import type { ConnectedHosts } from "../../connected-hosts/index.js"; +import { resolveRequiredProviderModel } from "../mcp-shared.js"; +import { selectAgentListItems } from "../agent-projections.js"; +import { summarizeAgentActivity } from "../activity-curator.js"; +import { expandUserPath } from "../../path-utils.js"; +import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js"; + +export type HostOperation = ( + input: Record, + context: PaseoToolExecutionContext, +) => Promise; +export interface HostOperations { + createAgent: HostOperation; + createWorkspace: HostOperation; + listWorkspaces: HostOperation; + sendPrompt: HostOperation; + getStatus: HostOperation; + getActivity: HostOperation; + listAgents: HostOperation; + wait(agentId: string, signal: AbortSignal): Promise; + observe( + agentId: string, + handler: (snapshot: AgentSnapshotPayload | null) => void, + signal: AbortSignal, + ): Promise<() => Promise>; +} +export interface ToolHosts { + directory(): Promise; + use(serverId: string, action: (client: DaemonClient) => Promise): Promise; +} + +/** Daemon MCP has the daemon's authority, bounded by the destination's admission of its link. */ +export function createToolHosts(hosts: ConnectedHosts): ToolHosts { + return { + directory: () => hosts.list(), + async use(serverId, action) { + const lifetime = new AbortController(); + const transportFactory: DaemonTransportFactory = () => { + const opens = new Set<() => void>(); + const closes = new Set<(event?: unknown) => void>(); + const errors = new Set<(event: unknown) => void>(); + const messages = new Set<(data: unknown, binary: boolean) => void>(); + let closed = false; + let route: Awaited> | undefined; + void hosts + .openRoute({ + serverId, + scopeId: randomUUID(), + sourceId: "mcp", + originSessionId: "mcp", + permissions: OWNER_PERMISSIONS, + signal: lifetime.signal, + frame: (data) => { + for (const listener of messages) listener(data, typeof data !== "string"); + }, + ended: (code, message) => { + for (const listener of errors) + listener(Object.assign(new Error(message), { code, serverId })); + for (const listener of closes) listener(); + }, + }) + .then((opened) => { + route = opened; + if (closed) opened.close(); + else for (const listener of opens) listener(); + return undefined; + }) + .catch((error) => { + for (const listener of errors) listener(error); + for (const listener of closes) listener(error); + }); + return { + send: (data) => { + if (!route) throw new Error("Host route is not open"); + route.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data); + }, + close: () => { + closed = true; + route?.close(); + }, + onOpen: (listener) => { + opens.add(listener); + return () => opens.delete(listener); + }, + onClose: (listener) => { + closes.add(listener); + return () => closes.delete(listener); + }, + onError: (listener) => { + errors.add(listener); + return () => errors.delete(listener); + }, + onMessage: (listener) => { + messages.add(listener); + return () => messages.delete(listener); + }, + }; + }; + const client = new DaemonClient({ + url: "ws://host-route/ws", + clientId: randomUUID(), + transportFactory, + reconnect: { enabled: false }, + }); + try { + await client.connect(); + return await action(client); + } finally { + lifetime.abort(); + await client.close(); + } + }, + }; +} + +const remoteCreate = z + .object({ + provider: z.string().min(1), + title: z.string().trim().min(1).max(60), + initialPrompt: z.string().min(1), + workspaceId: z.string().min(1), + labels: z.record(z.string(), z.string()).optional(), + notifyOnFinish: z.boolean().optional(), + idempotencyKey: z.string().optional(), + settings: z + .object({ + modeId: z.string().optional(), + thinkingOptionId: z.string().optional(), + features: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + }) + .strict(); +const result = (structuredContent: unknown): PaseoToolResult => ({ + content: [], + structuredContent, +}); + +export function createHostResolver(input: { + serverId: string; + local: HostOperations; + hosts: () => ToolHosts; + callerAgentId?: string; + lockedCwd?: string; +}) { + return { + directory: () => input.hosts().directory(), + async use( + serverId: string | undefined, + action: (host: HostOperations) => Promise, + ): Promise { + const destination = serverId ?? input.serverId; + if (destination === input.serverId) return action(input.local); + if (input.lockedCwd) + throw new Error("This caller is restricted to its local working directory"); + return input.hosts().use(destination, async (client) => + action({ + async createAgent(args) { + const parsed = remoteCreate.parse(args); + const { provider, model } = resolveRequiredProviderModel(parsed.provider); + const workspace = (await client.fetchWorkspaces({})).entries.find( + (row) => row.id === parsed.workspaceId, + ); + if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`); + const labels = { + ...parsed.labels, + ...(input.callerAgentId + ? parentAddressLabels({ agentId: input.callerAgentId, serverId: input.serverId }) + : {}), + }; + const snapshot = await client.createAgent({ + config: { + provider, + model, + cwd: workspace.workspaceDirectory, + title: parsed.title, + modeId: parsed.settings?.modeId, + thinkingOptionId: parsed.settings?.thinkingOptionId, + featureValues: parsed.settings?.features, + }, + workspaceId: parsed.workspaceId, + labels, + initialPrompt: parsed.initialPrompt, + idempotencyKey: parsed.idempotencyKey, + }); + return result({ + agentId: snapshot.id, + type: snapshot.provider, + status: snapshot.status, + cwd: snapshot.cwd, + workspaceId: snapshot.workspaceId, + currentModeId: snapshot.currentModeId, + availableModes: snapshot.availableModes, + }); + }, + async sendPrompt(args) { + const agentId = args.agentId as string; + if (args.sessionMode !== undefined) + await client.setAgentMode(agentId, args.sessionMode as string); + await client.sendMessage(agentId, args.prompt as string, { + sourceAgentId: input.callerAgentId, + activeTurnBehavior: + (args.activeTurnBehavior as ActiveTurnBehavior | undefined) ?? + (input.callerAgentId ? "steer" : "interrupt"), + }); + const agent = (await client.fetchAgent(agentId))?.agent; + if (!agent) throw new Error(`Agent ${agentId} not found`); + return result({ + success: true, + status: agent.status, + guidance: + input.callerAgentId && args.notifyOnFinish !== false + ? "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives." + : "Dispatch acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.", + }); + }, + async getStatus(args) { + const agent = (await client.fetchAgent(args.agentId as string))?.agent; + if (!agent) throw new Error(`Agent ${args.agentId} not found`); + return result({ status: agent.status, snapshot: agent }); + }, + async getActivity(args) { + const agentId = args.agentId as string; + const limit = args.limit as number | undefined; + const timeline = await client.fetchAgentTimeline(agentId, { + direction: "tail", + limit: limit ?? 0, + }); + const agent = (await client.fetchAgent(agentId))?.agent; + if (!agent) throw new Error(`Agent ${agentId} not found`); + return result({ + agentId, + ...summarizeAgentActivity( + timeline.entries.map((entry) => entry.item), + limit, + ), + currentModeId: agent.currentModeId, + }); + }, + async listAgents(args) { + const { entries } = await client.fetchAgents({ + includeBackground: args.includeBackground === true, + filter: { includeArchived: args.includeArchived === true }, + }); + return result({ + agents: selectAgentListItems( + entries.map((entry) => entry.agent), + { + cwd: (args.cwd as string | undefined)?.trim() + ? expandUserPath(args.cwd as string) + : undefined, + statuses: args.statuses as AgentSnapshotPayload["status"][] | undefined, + sinceHours: args.sinceHours as number | undefined, + limit: args.limit as number | undefined, + }, + ), + }); + }, + async listWorkspaces(args) { + const { entries } = await client.fetchWorkspaces({ + includeBackground: args.includeBackground === true, + }); + return result({ workspaces: entries.map(workspaceSummary) }); + }, + async createWorkspace(args) { + const isolation = z.enum(["local", "worktree"]).parse(args.isolation); + const path = z.string().min(1).parse(args.path); + const response = await client.createWorkspace({ + source: + isolation === "local" + ? { kind: "directory", path, projectId: args.projectId as string | undefined } + : { + kind: "worktree", + cwd: path, + projectId: args.projectId as string | undefined, + action: + args.mode === "checkout-branch" || args.mode === "checkout-pr" + ? "checkout" + : "branch-off", + baseBranch: args.baseBranch as string | undefined, + branchName: args.branchName as string | undefined, + refName: args.branch as string | undefined, + githubPrNumber: args.prNumber as number | undefined, + worktreeSlug: args.worktreeSlug as string | undefined, + }, + title: args.title as string | undefined, + background: args.background as boolean | undefined, + }); + if (response.error || !response.workspace) + throw new Error(response.error ?? "Workspace creation failed"); + return result(workspaceSummary(response.workspace)); + }, + async observe(agentId, handler, signal) { + const observation = client.observeTimeline([agentId], { signal }); + const refresh = async () => handler((await client.fetchAgent(agentId))?.agent ?? null); + const unsubscribe = observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "agent_stream" || message.payload.agentId !== agentId) return; + const event = message.payload.event; + if ( + event.type === "permission_resolved" || + event.type === "turn_started" || + event.type === "turn_completed" || + event.type === "turn_canceled" || + event.type === "turn_failed" + ) + void refresh().catch(() => handler(null)); + }, + error: () => handler(null), + }); + try { + await observation.ready; + await refresh(); + } catch (error) { + unsubscribe(); + await observation.release(); + throw error; + } + return async () => { + unsubscribe(); + await observation.release(); + }; + }, + wait: (agentId, signal) => { + const close = () => void client.close(); + signal.addEventListener("abort", close, { once: true }); + return client + .waitForFinish(agentId, 0) + .finally(() => signal.removeEventListener("abort", close)); + }, + }), + ); + }, + }; +} +function workspaceSummary(workspace: FetchWorkspacesEntry) { + return { + workspaceId: workspace.id, + projectId: workspace.projectId, + cwd: workspace.workspaceDirectory, + isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local", + kind: workspace.workspaceKind === "checkout" ? "local_checkout" : workspace.workspaceKind, + title: workspace.title, + background: workspace.background, + }; +} + +/** Wait pauses at permissions; observe only until that request clears before re-arming it. */ +export async function waitForPermissionChange( + host: HostOperations, + agentId: string, + requestId: string, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + let changed!: () => void; + let failed!: (error: unknown) => void; + const ready = new Promise((resolve, reject) => { + changed = resolve; + failed = reject; + }); + const abort = () => failed(signal.reason ?? new Error("Finish wait stopped")); + signal.addEventListener("abort", abort, { once: true }); + let release: (() => Promise) | undefined; + try { + release = await host.observe( + agentId, + (snapshot) => { + if (!snapshot) failed(new Error(`Agent ${agentId} is unavailable`)); + else if (!snapshot.pendingPermissions.some((request) => request.id === requestId)) + changed(); + }, + signal, + ); + await ready; + } finally { + signal.removeEventListener("abort", abort); + await release?.(); + } +} diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index a5117b36b1..97ded06e69 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,10 @@ +import { + createHostResolver, + waitForPermissionChange, + type HostOperation, + type HostOperations, + type ToolHosts, +} from "../host-operations/index.js"; import { AgentDirectory } from "../directory.js"; import { stat } from "node:fs/promises"; import { z } from "zod"; @@ -16,14 +23,12 @@ import { AgentSnapshotPayloadSchema, WorkspaceScriptPayloadSchema, } from "../../messages.js"; -import type { AgentListItemPayload } from "../../messages.js"; import { buildStoredAgentPayload, - toAgentListItemPayload, + selectAgentListItems, toAgentPayload, } from "../agent-projections.js"; -import { curateAgentActivity } from "../activity-curator.js"; -import { selectItemsByProjectedLimit } from "../timeline-projection.js"; +import { summarizeAgentActivity } from "../activity-curator.js"; import type { AgentStorage } from "../agent-storage.js"; import { AgentNotFoundError, ensureAgentLoaded } from "../agent-loading.js"; import { isStoredAgentProviderAvailable } from "../../persistence-hooks.js"; @@ -72,6 +77,7 @@ import { type PromptDispatchDisposition, sendPromptToAgent, setupFinishNotification, + setupFinishWait, waitForAgentRunStartWithTimeout, } from "../agent-prompt.js"; import { respondToAgentPermission } from "../permission-response.js"; @@ -110,6 +116,8 @@ import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-confi import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; export interface PaseoToolHostDependencies { + serverId?: string; + hosts?: () => ToolHosts; agentManager: AgentManager; agentStorage: AgentStorage; terminalManager?: TerminalManager | null; @@ -165,22 +173,8 @@ export interface PaseoToolHostDependencies { logger: Logger; } -function parseTimestamp(value: string | null | undefined): number { - if (!value) { - return 0; - } - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? 0 : parsed; -} - -function resolveAgentListActivityTime(agent: AgentListItemPayload): number { - return Math.max( - parseTimestamp(agent.updatedAt), - parseTimestamp(agent.lastUserMessageAt), - parseTimestamp(agent.attentionTimestamp), - parseTimestamp(agent.archivedAt), - parseTimestamp(agent.createdAt), - ); +function settledStatus(status: string): "error" | "idle" { + return status === "error" ? "error" : "idle"; } interface ProviderSummary { @@ -328,28 +322,6 @@ function toProviderSummary(entry: { }; } -function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number { - const attentionDelta = - Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false); - if (attentionDelta !== 0) { - return attentionDelta; - } - - const statusOrder = { - running: 0, - initializing: 1, - idle: 2, - error: 3, - closed: 4, - } as Record; - const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999); - if (statusDelta !== 0) { - return statusDelta; - } - - return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a); -} - function resolveScheduleProviderAndModel(params: { provider?: string; defaultProvider: AgentProvider; @@ -685,7 +657,66 @@ function createToolCatalog( return result.data; }; + const localHostOperations = new Map(); const tools = new Map(); + const hostTools = new Map([ + ["create_agent", "createAgent"], + ["create_workspace", "createWorkspace"], + ["list_workspaces", "listWorkspaces"], + ["send_agent_prompt", "sendPrompt"], + ["get_agent_status", "getStatus"], + ["get_agent_activity", "getActivity"], + ["list_agents", "listAgents"], + ] as const satisfies ReadonlyArray); + const hostResolver = createHostResolver({ + serverId: options.serverId ?? "self", + callerAgentId, + lockedCwd: callerContext?.lockedCwd, + hosts: () => { + if (!options.hosts) throw new Error("Connected hosts are not configured"); + return options.hosts(); + }, + local: { + createAgent: (args, context) => localHostOperations.get("create_agent")!(args, context), + createWorkspace: (args, context) => + localHostOperations.get("create_workspace")!(args, context), + listWorkspaces: (args, context) => localHostOperations.get("list_workspaces")!(args, context), + sendPrompt: (args, context) => localHostOperations.get("send_agent_prompt")!(args, context), + getStatus: (args, context) => localHostOperations.get("get_agent_status")!(args, context), + getActivity: (args, context) => localHostOperations.get("get_agent_activity")!(args, context), + listAgents: (args, context) => localHostOperations.get("list_agents")!(args, context), + async observe(agentId, handler, signal) { + const unsubscribe = agentManager.subscribe( + (event) => { + if (event.type === "agent_state") handler(toAgentPayload(event.agent)); + }, + { agentId, replayState: true }, + ); + const stop = () => unsubscribe(); + signal.addEventListener("abort", stop, { once: true }); + return async () => { + signal.removeEventListener("abort", stop); + unsubscribe(); + }; + }, + async wait(agentId, signal) { + const settled = await agentManager.waitForAgentEvent(agentId, { + signal, + waitForActive: true, + }); + const snapshot = agentManager.getAgent(agentId); + const final = snapshot + ? await serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger) + : null; + return { + status: settled.permission ? "permission" : settledStatus(settled.status), + final, + lastMessage: settled.lastMessage, + error: final?.lastError ?? null, + }; + }, + }, + }); const registerTool = ( name: string, config: PaseoToolConfig, @@ -695,6 +726,76 @@ function createToolCatalog( if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) { return; } + const operation = hostTools.get(name as Parameters[0]); + if (operation) { + localHostOperations.set(name, handler); + const original = config.inputSchema; + const selector = { + serverId: z + .string() + .min(1) + .optional() + .describe("Destination server ID from list_hosts; omit for this host."), + }; + config = { + ...config, + inputSchema: + original instanceof z.ZodObject + ? original.extend(selector) + : { ...(original as z.ZodRawShape), ...selector }, + }; + handler = async ({ serverId, ...input }, context) => { + const destination = serverId ?? options.serverId; + try { + const response = await hostResolver.use(serverId, (host) => + host[operation](input, context), + ); + let childAgentId: string | undefined; + if (name === "create_agent") { + childAgentId = (response.structuredContent as { agentId: string }).agentId; + } else if (name === "send_agent_prompt" && destination !== options.serverId) { + childAgentId = input.agentId as string; + } + if (childAgentId && callerAgentId && input.notifyOnFinish !== false) { + setupFinishWait({ + agentManager, + agentStorage, + originServerId: options.serverId, + childServerId: destination, + childAgentId, + callerAgentId, + logger: childLogger, + wait: (signal) => + hostResolver.use(destination, (host) => host.wait(childAgentId, signal)), + afterPermission: (requestId, signal) => + hostResolver.use(destination, (host) => + waitForPermissionChange(host, childAgentId, requestId, signal), + ), + }); + } + return { + ...response, + structuredContent: { + ...(response.structuredContent as object), + ...(destination ? { serverId: destination } : {}), + }, + }; + } catch (error) { + if (!options.serverId) throw error; + const failure = { + serverId: destination, + error: error instanceof Error ? error.message : String(error), + ...(error instanceof Error && "code" in error ? { code: error.code } : {}), + ...(error instanceof Error && "outcome" in error ? { outcome: error.outcome } : {}), + }; + return { + isError: true, + content: [{ type: "text", text: JSON.stringify(failure) }], + structuredContent: failure, + }; + } + }; + } tools.set(name, { name, title: config.title, @@ -1259,6 +1360,19 @@ function createToolCatalog( }); } + registerTool( + "list_hosts", + { + title: "List hosts", + description: "Discover this host and registered destination hosts for fleet tools.", + inputSchema: {}, + }, + async () => ({ + content: [], + structuredContent: ensureValidJson(await hostResolver.directory()), + }), + ); + registerTool( "create_workspace", { @@ -1520,10 +1634,15 @@ function createToolCatalog( workspaceId: resolvedArgs.workspaceId, thinking: parsedArgs.settings?.thinkingOptionId, features: parsedArgs.settings?.features, - labels: parsedArgs.labels, + labels: { + ...parsedArgs.labels, + ...(callerAgentId && !resolvedArgs.detached && options.serverId + ? { "paseo.parent-server-id": options.serverId } + : {}), + }, mode: parsedArgs.settings?.modeId, promptFailure: "throw", - notifyOnFinish, + notifyOnFinish: false, detached: resolvedArgs.detached, callerAgentId, callerContext, @@ -1849,6 +1968,7 @@ function createToolCatalog( agentManager, agentStorage, childAgentId: agentId, + childServerId: options.serverId, callerAgentId, waitForTurnStart: false, dispatchResult, @@ -1869,6 +1989,7 @@ function createToolCatalog( agentManager, agentStorage, childAgentId: agentId, + childServerId: options.serverId, callerAgentId, waitForTurnStart: true, logger: childLogger, @@ -2004,8 +2125,6 @@ function createToolCatalog( }) => { const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined; const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd; - const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null; - const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000; if (!options.workspaceRegistry) throw new Error("Workspace registry is not configured"); const directory = new AgentDirectory({ manager: agentManager, @@ -2025,13 +2144,10 @@ function createToolCatalog( new Set(providerSnapshotManager.listRegisteredProviderIds()), ), }); - const agents = (await directory.list({ includeBackground, includeArchived })) - .map(toAgentListItemPayload) - .filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd)) - .filter((agent) => !statusFilter || statusFilter.has(agent.status)) - .filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs) - .sort(compareAgentListItems) - .slice(0, limit); + const agents = selectAgentListItems( + await directory.list({ includeBackground, includeArchived }), + { cwd: requestedCwd, statuses, sinceHours, limit }, + ); return { content: [], @@ -3028,29 +3144,12 @@ function createToolCatalog( const timeline = agentManager.getTimeline(agentId); const snapshot = agentManager.getAgent(agentId); - const selection = selectItemsByProjectedLimit({ - items: timeline, - direction: "tail", - limit: limit ?? 0, - }); - const curatedContent = curateAgentActivity(selection.items); - const { totalProjected, shownProjected } = selection; - - const noun = totalProjected === 1 ? "activity" : "activities"; - const countHeader = - limit && shownProjected < totalProjected - ? `Showing ${shownProjected} of ${totalProjected} ${noun} (limited to ${limit})` - : `Showing all ${totalProjected} ${noun}`; - - const contentWithCount = `${countHeader}\n\n${curatedContent}`; - return { content: [], structuredContent: ensureValidJson({ agentId, - updateCount: timeline.length, + ...summarizeAgentActivity(timeline, limit), currentModeId: snapshot?.currentModeId ?? null, - content: contentWithCount, }), }; }, diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 0893abe1a3..80599a74a3 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -6,6 +6,14 @@ type OutboundOperation = SessionOutboundMessage["type"]; export type PermissionRequirement = DaemonPermission | readonly DaemonPermission[] | null; const INBOUND_PERMISSION = { + "hosts.list.request": "daemon.read", + "hosts.add.request": "tunnel.manage", + "hosts.update.request": "tunnel.manage", + "hosts.remove.request": "tunnel.manage", + "hosts.link.open.request": null, + "hosts.route.open.request": null, + "hosts.route.frame.request": null, + "hosts.route.close.request": null, abort_request: "workspace.write", "agent.config.apply.request": ["workspace.write", "hub.execute"], "agent.detach.request": "workspace.write", @@ -216,6 +224,15 @@ const INBOUND_PERMISSION = { } as const satisfies Record; const OUTBOUND_PERMISSION = { + "hosts.list.response": "daemon.read", + "hosts.changed": "daemon.read", + "hosts.add.response": "tunnel.manage", + "hosts.update.response": "tunnel.manage", + "hosts.remove.response": "tunnel.manage", + "hosts.link.open.response": null, + "hosts.route.open.response": null, + "hosts.route.frame": null, + "hosts.route.closed": null, "agent.create.response": ["workspace.write", "hub.execute"], "agent.create.update": ["workspace.write", "hub.execute"], "workspace.create.update": "workspace.manage", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 73e668735f..5125a0b923 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1393,6 +1393,11 @@ export async function createPaseoDaemon( const createAgentToolHostDependencies = ( runtime: PaseoToolRuntimeContext, ): PaseoToolHostDependencies => ({ + serverId, + hosts: () => { + if (!wsServer) throw new Error("Daemon is not accepting host operations"); + return wsServer.toolHosts(); + }, agentManager, agentStorage, terminalManager, diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts new file mode 100644 index 0000000000..0efd8d86d6 --- /dev/null +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -0,0 +1,1482 @@ +import { z } from "zod"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { afterEach, expect, test } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { createHostClient, createHostChannelTransport } from "@getpaseo/client/node"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import type { HostDirectory } from "@getpaseo/protocol/hosts"; + +const cleanup: (() => Promise)[] = []; +afterEach(async () => { + for (const close of cleanup.splice(0).toReversed()) await close(); +}); +async function daemon( + options: Parameters[0] = {}, +): Promise { + const instance = await createTestPaseoDaemon(options); + cleanup.push(() => instance.close()); + return instance; +} +async function api(instance: TestPaseoDaemon) { + const client = createHostClient(`127.0.0.1:${instance.port}`, { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + await client.connect(); + expect(client.getLastServerInfoMessage()).toMatchObject({ sourceId: "owner" }); + expect(client.getLastServerInfoMessage()).not.toHaveProperty("principalId"); + const paseo = createPaseoApi(client); + cleanup.push(async () => { + await paseo.dispose(); + await client.close(); + }); + return { paseo, client }; +} +async function online(paseo: ReturnType): Promise { + let directory!: HostDirectory; + await expect + .poll( + async () => { + directory = await paseo.hosts.list(); + return directory.hosts[0]?.status; + }, + { timeout: 15000 }, + ) + .toBe("online"); + return directory; +} + +test("SDK saves unreachable hosts, rejects syntax and preserves source identity without exposing secrets", async () => { + const a = await daemon(); + const { paseo } = await api(a); + await expect(paseo.hosts.add({ connection: "ssh://-bad" })).rejects.toMatchObject({ + code: "invalid_connection", + }); + for (const connection of [ + "alice:example-secret@localhost:6767", + "not-a-scheme://localhost:6767", + "localhost/path:6767", + ]) { + const failure = await paseo.hosts.add({ connection }).catch((error: unknown) => error); + expect(failure).toMatchObject({ code: "invalid_connection" }); + expect(String(failure)).not.toContain("example-secret"); + expect((await paseo.hosts.list()).hosts).toEqual([]); + } + const saved = await paseo.hosts.add({ + connection: "tcp://127.0.0.1:1?password=private-secret", + label: "Unavailable", + metadata: { team: "infra" }, + sourceKey: "stable", + }); + expect(saved).toMatchObject({ + registrationId: expect.stringMatching(/^hst_/), + serverId: null, + label: "Unavailable", + source: { id: "owner", key: "stable" }, + metadata: { team: "infra" }, + enabled: true, + }); + expect(JSON.stringify(await paseo.hosts.list())).not.toContain("private-secret"); + await paseo.hosts.update(saved.registrationId, { enabled: false }); + expect( + await paseo.hosts.add({ + connection: "tcp://127.0.0.1:1?password=private-secret", + label: "Unavailable", + metadata: { team: "infra" }, + sourceKey: "stable", + }), + ).toMatchObject({ registrationId: saved.registrationId, enabled: false, status: "disabled" }); + await expect( + paseo.hosts.add({ connection: "127.0.0.1:2", sourceKey: "stable" }), + ).rejects.toMatchObject({ code: "source_conflict" }); + await paseo.hosts.remove(saved.registrationId); + expect((await paseo.hosts.list()).hosts).toEqual([]); +}); + +test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origin disposal", async () => { + const b = await daemon(); + const wire = await hostWire(b); + const a = await daemon(); + const { paseo } = await api(a); + const observation = paseo.hosts.observe(); + expect((await observation.ready).hosts).toEqual([]); + const saved = await paseo.hosts.add({ connection: wire.connection, label: "B" }); + const directory = await online(paseo); + const serverId = directory.hosts[0]!.serverId!; + const first = await paseo.hosts.client(serverId); + const second = await paseo.hosts.client(serverId); + expect(wire.connections()).toBe(1); + expect(wire.active()).toBe(1); + const workspaces = await second.workspaces.list({ subscribe: {} }); + expect(workspaces.entries).toEqual([]); + await first.dispose(); + expect(wire.active()).toBe(1); + expect((await second.workspaces.list()).entries).toEqual([]); + await expect(second.hosts.client(directory.self.serverId)).rejects.toMatchObject({ + code: "unsupported_capability", + }); + await paseo.hosts.update(saved.registrationId, { enabled: false }); + await expect(paseo.hosts.client(serverId)).rejects.toMatchObject({ code: "host_disabled" }); + await paseo.hosts.update(saved.registrationId, { enabled: true }); + await online(paseo); + await expect + .poll( + async () => { + try { + return (await second.workspaces.list()).entries.length; + } catch { + return -1; + } + }, + { timeout: 20000 }, + ) + .toBe(0); + expect(wire.connections()).toBe(2); + expect(wire.active()).toBe(1); + await second.dispose(); + expect(wire.active()).toBe(1); + await observation.release(); + await paseo.dispose(); +}); + +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); + await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const directory = await online(owner.paseo); + const serverId = directory.hosts[0]!.serverId!; + const viewerClient = createHostClient(`127.0.0.1:${a.port}`, { + clientId: "overlapping", + sessionPermissions: ["daemon.read", "workspace.read"], + reconnect: { enabled: false }, + }); + await viewerClient.connect(); + const viewer = createPaseoApi(viewerClient); + cleanup.push(async () => { + await viewer.dispose(); + await viewerClient.close(); + }); + const remote = await viewer.hosts.client(serverId); + expect((await remote.workspaces.list()).entries).toEqual([]); + await expect( + remote.workspaces.create({ source: { kind: "directory", path: b.paseoHome } }), + ).rejects.toMatchObject({ code: "access_denied" }); + await expect(viewer.hosts.add({ connection: "127.0.0.1:1" })).rejects.toMatchObject({ + code: "permission_denied", + }); + expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1); + 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 origin.dispose(); + }); + const input = { source: { kind: "directory" as const, path: b.staticDir } }; + const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as { + code: string; + }; + const remoteError = (await remote.workspaces.create(input).catch((error: unknown) => error)) as { + code: string; + }; + expect(directError.code).toBe("access_denied"); + expect(remoteError.code).toBe(directError.code); +}); + +test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => { + const b = await daemon(); + const wire = await hostWire(b); + const a = await daemon(); + const admin = await api(a); + await admin.paseo.hosts.add({ connection: wire.connection }); + const serverId = (await online(admin.paseo)).hosts[0]!.serverId!; + const remote = await admin.paseo.hosts.client(serverId); + const workspace = await remote.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const one = await workspace.terminals.create({ + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], + name: "First", + }); + const two = await workspace.terminals.create({ + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], + name: "Second", + }); + const firstOrigin = await api(a); + const secondOrigin = await api(a); + const first = await RoutePeer.open(firstOrigin.client, serverId); + const second = await RoutePeer.open(secondOrigin.client, serverId); + await first.subscribeTerminal(one.id); + await second.subscribeTerminal(two.id); + expect(wire.connections()).toBe(1); + first.input("FIRST_ONLY\n"); + second.input("SECOND_ONLY\n"); + await expect.poll(() => first.output()).toContain("FIRST_ONLY"); + await expect.poll(() => second.output()).toContain("SECOND_ONLY"); + expect(first.output()).not.toContain("SECOND_ONLY"); + expect(second.output()).not.toContain("FIRST_ONLY"); + const firstBytes = new Uint8Array([0, 255, 128, 173]); + const secondBytes = new Uint8Array([173, 128, 255, 0]); + const paths = await Promise.all([ + first.upload("first.bin", firstBytes), + second.upload("second.bin", secondBytes), + ]); + expect((await readFile(paths[0]!)).equals(firstBytes)).toBe(true); + expect((await readFile(paths[1]!)).equals(secondBytes)).toBe(true); + + const binary = wire.frames.filter((frame) => frame.binary); + expect(binary.length).toBeGreaterThan(0); + expect(binary.every((frame) => decodeHostRouteFrame(frame.bytes) !== null)).toBe(true); + expect( + binary.some((frame) => { + const payload = decodeHostRouteFrame(frame.bytes)!.payload; + return Buffer.from(payload).equals( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + slot: 0, + payload: "FIRST_ONLY\n", + }), + ); + }), + ).toBe(true); + const json = wire.frames + .filter((frame) => !frame.binary) + .map((frame) => JSON.parse(frame.bytes.toString())); + expect(json.filter((frame) => frame.type === "hello")).toHaveLength(1); + expect(json.some((frame) => frame.message?.type === "hosts.link.open.request")).toBe(true); + expect( + json.some((frame) => frame.message?.binary === true || frame.message?.payload?.binary === true), + ).toBe(false); + await first.close(); + second.input("STILL_SECOND\n"); + await expect.poll(() => second.output()).toContain("STILL_SECOND"); + await second.close(); + await one.kill(); + await two.kill(); + await remote.dispose(); +}); + +import { + encodeFileTransferFrame, + FileTransferOpcode, + decodeHostRouteFrame, + decodeTerminalStreamFrame, + encodeTerminalStreamFrame, + TerminalStreamOpcode, +} from "@getpaseo/protocol/binary-frames/index"; +import { + WSOutboundMessageSchema, + type SessionOutboundMessage, + type SessionInboundMessage, +} from "@getpaseo/protocol/messages"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +class RoutePeer { + readonly messages: SessionOutboundMessage[] = []; + readonly bytes: Uint8Array[] = []; + private slot = -1; + private detach = () => {}; + private constructor( + private readonly origin: DaemonClient, + private readonly routeId: string, + ) { + const binary = origin.subscribeHostFrames((frame) => { + if (frame.routeId === routeId) this.bytes.push(frame.payload); + }); + const text = origin.subscribeRawMessages((message) => { + if (message.type !== "hosts.route.frame" || message.payload.subscriptionId !== routeId) + return; + { + const frame = WSOutboundMessageSchema.parse(JSON.parse(message.payload.data)); + if (frame.type === "session") this.messages.push(frame.message); + } + }); + this.detach = () => { + binary(); + text(); + }; + } + static async open(origin: DaemonClient, serverId: string) { + const peer = new RoutePeer( + origin, + await origin.openHostRoute(serverId, "same-untrusted-scope"), + ); + origin.sendHostFrame( + peer.routeId, + JSON.stringify({ + type: "hello", + clientId: "same-untrusted-client", + clientType: "cli", + protocolVersion: 1, + capabilities: { owned_subscriptions: true }, + }), + ); + await expect + .poll( + () => + peer.messages.filter( + (message) => message.type === "status" && message.payload.status === "server_info", + ).length, + ) + .toBe(1); + const info = peer.messages.find( + (message) => message.type === "status" && message.payload.status === "server_info", + ); + if (info?.type !== "status") throw new Error("Missing routed server info"); + expect(info.payload).toMatchObject({ + sourceId: `route:owner:${origin.getLastServerInfoMessage()!.serverId}:owner`, + }); + expect(info.payload).not.toHaveProperty("principalId"); + return peer; + } + send(message: SessionInboundMessage) { + this.origin.sendHostFrame(this.routeId, JSON.stringify({ type: "session", message })); + } + async subscribeTerminal(terminalId: string) { + this.send({ type: "subscribe_terminal_request", terminalId, requestId: "same-request" }); + await expect + .poll( + () => + this.messages.filter((message) => message.type === "subscribe_terminal_response").length, + ) + .toBe(1); + const response = this.messages.find( + (message) => message.type === "subscribe_terminal_response", + ); + if (response?.type !== "subscribe_terminal_response" || response.payload.slot === null) + throw new Error("Missing terminal response"); + this.slot = response.payload.slot!; + } + input(text: string) { + this.origin.sendHostFrame( + this.routeId, + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + slot: this.slot, + payload: text, + }), + ); + } + async upload(fileName: string, bytes: Uint8Array) { + const requestId = "same-upload-request"; + const modifiedAt = new Date().toISOString(); + this.send({ + type: "file.upload.request", + requestId, + fileName, + mimeType: "application/octet-stream", + size: bytes.length, + modifiedAt, + }); + for (const frame of [ + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: "application/octet-stream", + size: bytes.length, + encoding: "binary", + modifiedAt, + fileName, + }, + }), + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileChunk, requestId, payload: bytes }), + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId }), + ]) + this.origin.sendHostFrame(this.routeId, frame); + await expect + .poll(() => this.messages.some((message) => message.type === "file.upload.response")) + .toBe(true); + const response = this.messages.find((message) => message.type === "file.upload.response"); + if (response?.type !== "file.upload.response" || !response.payload.file) + throw new Error("Upload failed"); + return response.payload.file.path; + } + output() { + return this.bytes + .flatMap((bytes) => { + const frame = decodeTerminalStreamFrame(bytes); + return frame?.opcode === TerminalStreamOpcode.Output + ? [new TextDecoder().decode(frame.payload)] + : []; + }) + .join(""); + } + async close() { + this.detach(); + this.origin.closeHostRoute(this.routeId); + } +} + +import { mkdir, writeFile, readFile, chmod, stat, mkdtemp, rm } from "node:fs/promises"; +import { join, dirname, basename } from "node:path"; +import { tmpdir, userInfo } from "node:os"; +import { resolveDaemonVersion } from "../daemon-version.js"; + +test("identity discovery failures remain inspectable and changing a pinned destination requires explicit replacement", async () => { + const a = await daemon(); + const b = await daemon(); + const c = await daemon(); + const { paseo } = await api(a); + const self = await paseo.hosts.add({ connection: `127.0.0.1:${a.port}` }); + await expect.poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code).toBe("self_host"); + expect((await paseo.hosts.list()).hosts[0]!.serverId).toBe(a.daemon.getServerId()); + await paseo.hosts.remove(self.registrationId); + const original = await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const bId = (await online(paseo)).hosts[0]!.serverId; + const duplicate = await paseo.hosts.add({ connection: `ws://127.0.0.1:${b.port}/ws` }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[1]!.error?.code) + .toBe("duplicate_host"); + expect((await paseo.hosts.list()).hosts[1]!.serverId).toBe(bId); + await paseo.hosts.remove(duplicate.registrationId); + await paseo.hosts.update(original.registrationId, { connection: `127.0.0.1:${c.port}` }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code) + .toBe("server_id_mismatch"); + expect((await paseo.hosts.list()).hosts[0]!.serverId).toBe(bId); + await paseo.hosts.update(original.registrationId, { + connection: `127.0.0.1:${c.port}`, + replaceIdentity: true, + }); + expect((await online(paseo)).hosts[0]!.serverId).toBe(c.daemon.getServerId()); +}); + +test("disabled host configuration and source survive a fresh daemon instance", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-host-restart-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const a = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => a.close()); + const { paseo } = await api(a); + const host = await paseo.hosts.add({ + connection: "tcp://127.0.0.1:1?password=secret", + label: "Persistent", + metadata: { team: "infra" }, + sourceKey: "stable", + }); + await paseo.hosts.update(host.registrationId, { enabled: false }); + // Windows exposes inherited ACLs, not POSIX owner/group/other mode bits. + if (process.platform !== "win32") + expect((await stat(join(a.paseoHome, "hosts.json"))).mode & 0o777).toBe(0o600); + await a.close(); + const restarted = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false }); + cleanup.push(() => restarted.close()); + const next = await api(restarted); + expect((await next.paseo.hosts.list()).hosts).toEqual([ + expect.objectContaining({ + registrationId: host.registrationId, + label: "Persistent", + metadata: { team: "infra" }, + source: { id: "owner", key: "stable" }, + enabled: false, + status: "disabled", + }), + ]); +}); + +test("server plugins use ordinary remote SDK operations and retain qualified source ownership on B", async () => { + const b = await daemon(); + const a = await daemon(); + const { paseo, client } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const serverId = (await online(paseo)).hosts[0]!.serverId!; + const directory = join(a.staticDir, "host-plugin"); + await mkdir(directory); + await writeFile( + join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "connected-host-test", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), + ); + await writeFile( + join(directory, "index.server.ts"), + ` +import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; +const operate = defineRpc({ name: "operate", input: z.object({ serverId: z.string(), path: z.string() }), output: z.object({ workspaceId: z.string(), registrationId: z.string(), sourceId: z.string() }) }); +export default function contribute(server) { + server.handle(operate, async (input, context) => { + const remote = await context.paseo.hosts.client(input.serverId); + try { + const workspace = await remote.workspaces.create({ source: { kind: "directory", path: input.path }, title: "Plugin remote workspace" }); + const host = await remote.hosts.add({ connection: "127.0.0.1:1", sourceKey: "plugin-stable", metadata: { team: "plugin" } }); + return { workspaceId: workspace.id, registrationId: host.registrationId, sourceId: host.source.id }; + } finally { await remote.dispose(); } + }); + return () => {}; +}`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const result = await client.invokePluginRpc("connected-host-test", "operate", { + serverId, + path: b.staticDir, + }); + expect(result).toEqual({ + workspaceId: expect.stringMatching(/^wks_/), + registrationId: expect.stringMatching(/^hst_/), + sourceId: `route:owner:${a.daemon.getServerId()}:plugin%3Aconnected-host-test`, + }); + const direct = await api(b); + expect((await direct.paseo.workspaces.list()).entries).toHaveLength(1); + expect((await direct.paseo.hosts.list()).hosts[0]).toMatchObject({ + source: { + id: `route:owner:${a.daemon.getServerId()}:plugin%3Aconnected-host-test`, + key: "plugin-stable", + }, + metadata: { team: "plugin" }, + }); + await client.removePlugin("connected-host-test"); + expect((await direct.paseo.workspaces.list()).entries).toHaveLength(1); +}); + +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +test("CLI host commands manage the selected daemon and emit secret-free structured output", async () => { + const a = await daemon(); + const cli = async (...args: string[]) => + JSON.parse( + ( + await promisify(execFile)( + process.execPath, + [ + "--conditions=source", + "--import", + "tsx", + fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${a.port}`, + "host", + ...args, + "--json", + ], + { timeout: 20000 }, + ) + ).stdout, + ); + const malformed = await cli("add", "alice:example-secret@localhost:6767").catch( + (error: unknown) => error, + ); + expect(malformed).toHaveProperty("stderr"); + expect((malformed as { stderr: string }).stderr).toContain("invalid_connection"); + expect((malformed as { stderr: string }).stderr).not.toContain("example-secret"); + expect(await cli("ls")).toEqual([]); + const saved = await cli( + "add", + "tcp://127.0.0.1:1?password=cli-secret", + "--label", + "CLI host", + "--meta", + "team=infra", + ); + expect(saved).toMatchObject({ + label: "CLI host", + metadata: { team: "infra" }, + registrationId: expect.stringMatching(/^hst_/), + }); + expect(JSON.stringify(await cli("ls"))).not.toContain("cli-secret"); + expect( + await cli( + "update", + saved.registrationId, + "--meta", + "team=tools", + "--meta", + "other=value", + "--remove-meta", + "team", + ), + ).toMatchObject({ metadata: { other: "value" } }); + expect(await cli("disable", saved.registrationId)).toMatchObject({ + status: "disabled", + enabled: false, + }); + expect(await cli("inspect", saved.registrationId)).toMatchObject({ + enabled: false, + targetDescription: "tcp://127.0.0.1:1", + }); + expect(await cli("enable", saved.registrationId)).toMatchObject({ enabled: true }); + await cli("rm", saved.registrationId); + expect(await cli("ls")).toEqual([]); +}, 60000); + +test("resuming an A session with narrower grants terminates existing routes and restores observations with current authority", async () => { + const b = await daemon(); + const a = await daemon(); + const admin = await api(a); + await admin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const serverId = (await online(admin.paseo)).hosts[0]!.serverId!; + const origin = createHostClient(`127.0.0.1:${a.port}`, { + clientId: "resumed-authority", + reconnect: { enabled: false }, + }); + await origin.connect(); + const paseo = createPaseoApi(origin); + cleanup.push(async () => { + await paseo.dispose(); + await origin.close(); + }); + const remote = await paseo.hosts.client(serverId); + const observed = await remote.workspaces.list({ subscribe: {} }); + const snapshots: unknown[] = []; + observed.subscription.subscribe({ + snapshot: (snapshot) => snapshots.push(snapshot), + update() {}, + }); + const restricted = createHostClient(`127.0.0.1:${a.port}`, { + clientId: "resumed-authority", + sessionPermissions: ["daemon.read", "workspace.read"], + reconnect: { enabled: false }, + }); + await restricted.connect(); + cleanup.push(() => restricted.close()); + await expect.poll(() => snapshots.length, { timeout: 15000 }).toBe(2); + await expect( + remote.workspaces.create({ source: { kind: "directory", path: b.staticDir } }), + ).rejects.toMatchObject({ code: "access_denied" }); + expect((await remote.workspaces.list()).entries).toEqual([]); + await remote.dispose(); +}); + +import { WebSocketServer, WebSocket } from "ws"; +import { createServer } from "node:http"; +import type { Socket } from "node:net"; +import { once } from "node:events"; +import { hashSync } from "bcryptjs"; + +test("old capabilities fail before execution and authentication errors recover through credential replacement", async () => { + const http = createServer(); + const ws = new WebSocketServer({ server: http }); + const operations: string[] = []; + ws.on("connection", (socket) => + socket.on("message", (data) => { + const frame = JSON.parse(data.toString()); + if (frame.type === "hello") + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "srv_old", + hostname: "old", + version: "0.1.0", + }, + }, + }), + ); + else if (frame.type === "session") operations.push(frame.message.type); + }), + ); + 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((resolve) => ws.close(() => resolve())); + await new Promise((resolve) => http.close(() => resolve())); + }); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Missing old daemon port"); + const old = createHostClient(`127.0.0.1:${address.port}`, { + clientId: "old-capability", + reconnect: { enabled: false }, + }); + await old.connect(); + cleanup.push(() => old.close()); + await expect(createPaseoApi(old).hosts.add({ connection: "127.0.0.1:1" })).rejects.toMatchObject({ + code: "unsupported_capability", + }); + expect(operations).toEqual([]); + const a = await daemon(); + const { paseo } = await api(a); + const registration = await paseo.hosts.add({ connection: `127.0.0.1:${address.port}` }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code) + .toBe("unsupported_capability"); + await expect(paseo.hosts.client("srv_old")).rejects.toMatchObject({ + code: "unsupported_capability", + }); + expect(operations).toEqual([]); + await paseo.hosts.remove(registration.registrationId); + const b = await createTestPaseoDaemon({ auth: { password: hashSync("right-password", 4) } }); + cleanup.push(() => b.close()); + const saved = await paseo.hosts.add({ + connection: `tcp://127.0.0.1:${b.port}?password=wrong-password`, + }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[0]!.error?.code) + .toBe("authentication_failed"); + expect(JSON.stringify(await paseo.hosts.list())).not.toContain("wrong-password"); + await paseo.hosts.update(saved.registrationId, { + connection: `tcp://127.0.0.1:${b.port}?password=right-password`, + }); + expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId()); +}); + +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"); + await once(reservation, "listening"); + const address = reservation.address(); + if (!address || typeof address === "string") throw new Error("Missing reserved port"); + await new Promise((resolve) => reservation.close(() => resolve())); + const a = await daemon(); + const { paseo } = await api(a); + const host = await paseo.hosts.add({ connection: `127.0.0.1:${address.port}` }); + await expect.poll(async () => (await paseo.hosts.list()).hosts[0]!.status).toBe("offline"); + const b = await createTestPaseoDaemon({ listenPort: address.port }); + cleanup.push(() => b.close()); + expect((await online(paseo)).hosts[0]).toMatchObject({ + registrationId: host.registrationId, + serverId: b.daemon.getServerId(), + status: "online", + nextRetryAt: null, + error: null, + }); +}); + +test("losing a sent mutation result retains ordinary errors, identifies the destination and never replays it", async () => { + const b = await daemon(); + const a = await daemon(); + const { paseo, client } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const serverId = (await online(paseo)).hosts[0]!.serverId!; + const remote = await paseo.hosts.client(serverId); + const pending = remote.config.patch({ pluginsEnabled: false }); + const result = pending.catch((error: unknown) => error); + await client.close(); + expect(await result).toMatchObject({ + code: "DAEMON_CONNECTION_LOST", + outcome: "unknown", + serverId, + }); + await expect(remote.workspaces.list()).rejects.toMatchObject({ + code: "DAEMON_CONNECTION_LOST", + serverId, + }); + const direct = await api(b); + await direct.client.close(); + const terminalFailure = (await direct.paseo.terminals + .list() + .catch((error: unknown) => error)) as Error; + await expect(remote.terminals.list()).rejects.toThrow(terminalFailure.message); + const creation = { + source: { kind: "directory" as const, path: "/tmp" }, + workspaceId: "offline-never-created", + }; + const creationFailure = (await direct.paseo.workspaces + .create(creation) + .catch((error: unknown) => error)) as Error; + await expect(remote.workspaces.create(creation)).rejects.toThrow(creationFailure.message); + await remote.dispose(); +}); + +test("disposing an origin during routed handshake settles acquisition and closes only its pending session", async () => { + const http = createServer(); + const ws = new WebSocketServer({ server: http }); + let connections = 0; + let routeHello = false; + let routeClosed = false; + 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") + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "srv_pending", + hostname: "pending", + version: "0.12.0", + features: { + connectedHosts: true, + hostMultiplexing: true, + ownedSubscriptions: true, + }, + }, + }, + }), + ); + if (frame.message?.type === "hosts.link.open.request") + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "hosts.link.open.response", + payload: { requestId: frame.message.requestId, routeId: "pending-channel" }, + }, + }), + ); + if (frame.message?.type === "hosts.route.frame.request") routeHello = true; + if (frame.message?.type === "hosts.route.close.request") routeClosed = true; + }); + }); + 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((resolve) => ws.close(() => resolve())); + await new Promise((resolve) => http.close(() => resolve())); + }); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Missing pending endpoint port"); + const a = await daemon(); + const { paseo } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${address.port}` }); + await online(paseo); + const pending = paseo.hosts.client("srv_pending"); + const result = pending.catch((error: unknown) => error); + await expect.poll(() => routeHello).toBe(true); + await paseo.dispose(); + expect(await result).toBeInstanceOf(Error); + await expect.poll(() => routeClosed).toBe(true); + expect(ws.clients.size).toBe(1); + expect(connections).toBe(1); + routeClosed = false; + const next = await api(a); + await expect(next.paseo.hosts.client("srv_pending")).rejects.toMatchObject({ + code: "host_offline", + serverId: "srv_pending", + }); + await expect.poll(() => routeClosed).toBe(true); + expect(ws.clients.size).toBe(1); + expect(connections).toBe(1); + expect((await next.paseo.workspaces.list()).entries).toEqual([]); + expect((await next.paseo.hosts.list()).hosts[0]?.status).toBe("online"); +}); + +test("source-key registration remains idempotent at directory capacity", async () => { + const { ConnectedHosts } = await import("./index.js"); + const home = await mkdtemp(join(tmpdir(), "hosts-capacity-")); + cleanup.push(() => rm(home, { recursive: true, force: true })); + const rows = Array.from({ length: 128 }, (_, index) => ({ + registrationId: `saved-${index}`, + serverId: null, + label: null, + source: { id: "plugin:capacity", key: `key-${index}` }, + metadata: {}, + enabled: false, + status: "disabled", + targetDescription: "127.0.0.1:1", + lastOnlineAt: null, + nextRetryAt: null, + error: null, + connection: "127.0.0.1:1", + })); + await writeFile(join(home, "hosts.json"), JSON.stringify(rows)); + const registry = new ConnectedHosts(home, { serverId: "self", label: "self" }); + cleanup.push(() => registry.close()); + const actor = { sourceId: "plugin:capacity", manageSources: false }; + await expect( + registry.add({ connection: "127.0.0.1:1", sourceKey: "key-0" }, actor), + ).resolves.toMatchObject({ registrationId: "saved-0", enabled: false }); + await expect( + registry.add({ connection: "127.0.0.1:1", sourceKey: "new" }, actor), + ).rejects.toMatchObject({ code: "resource_limit" }); +}); + +/** Real WebSocket proxy records the A–B physical wire without replacing either daemon. */ +async function hostWire(b: TestPaseoDaemon) { + const http = createServer(); + const wss = new WebSocketServer({ server: http }); + const frames: { binary: boolean; bytes: Buffer; direction: "toB" | "fromB" }[] = []; + let connections = 0; + wss.on("connection", (downstream) => { + connections++; + const upstream = new WebSocket(`ws://127.0.0.1:${b.port}/ws`); + const pending: (() => void)[] = []; + upstream.on("open", () => { + for (const send of pending.splice(0)) send(); + }); + downstream.on("message", (data, binary) => { + const bytes = Buffer.from(data as Buffer); + frames.push({ binary, bytes, direction: "toB" }); + const send = () => { + if (upstream.readyState === 1) upstream.send(bytes, { binary }); + }; + if (upstream.readyState === 0) pending.push(send); + else send(); + }); + upstream.on("message", (data, binary) => { + const bytes = Buffer.from(data as Buffer); + frames.push({ binary, bytes, direction: "fromB" }); + if (downstream.readyState === 1) downstream.send(bytes, { binary }); + }); + upstream.on("error", () => downstream.close()); + upstream.on("close", () => downstream.close()); + downstream.on("close", () => upstream.close()); + }); + http.listen(0, "127.0.0.1"); + await once(http, "listening"); + cleanup.push(async () => { + for (const socket of wss.clients) socket.terminate(); + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => http.close(() => resolve())); + }); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Missing wire port"); + return { + connection: `127.0.0.1:${address.port}`, + frames, + connections: () => connections, + active: () => wss.clients.size, + }; +} + +test("a resumed physical link re-admits every logical session under its current grant ceiling", async () => { + const b = await daemon(); + const parent = createHostClient(`127.0.0.1:${b.port}`, { clientId: "physical-grants" }); + cleanup.push(() => parent.close()); + await parent.connect(); + const serverId = parent.getLastServerInfoMessage()!.serverId; + const logical = new DaemonClient({ + url: "ws://logical/ws", + clientId: "logical", + transportFactory: createHostChannelTransport(parent, serverId, "logical", null, true), + }); + cleanup.push(() => logical.close()); + const connecting = logical.connect(); + expect(() => logical.ensureConnected()).not.toThrow(); + const scoped = createPaseoApi(logical); + cleanup.push(() => scoped.dispose()); + const initialCall = scoped.workspaces.list(); + await connecting; + expect((await initialCall).entries).toEqual([]); + const recoveryCalls: Promise[] = []; + const stopRecovery = logical.subscribeConnectionStatus((state) => { + if (state.status === "connecting") + recoveryCalls.push(scoped.workspaces.list().catch((error: unknown) => error)); + }); + cleanup.push(async () => stopRecovery()); + const bytes = Uint8Array.from({ length: 256 * 1024 + 123 }, (_, index) => index % 251); + const upload = await logical.uploadFile({ + fileName: "routed.bin", + mimeType: "application/octet-stream", + bytes, + }); + expect(upload.error).toBeNull(); + const downloaded = await logical.readFile( + dirname(upload.file!.path), + basename(upload.file!.path), + ); + expect(Buffer.from(downloaded.bytes).equals(bytes)).toBe(true); + + const observed = await scoped.workspaces.list({ subscribe: {} }); + const snapshots: unknown[] = []; + observed.subscription.subscribe({ snapshot: (value) => snapshots.push(value), update() {} }); + const narrowed = createHostClient(`127.0.0.1:${b.port}`, { + clientId: "physical-grants", + sessionPermissions: ["daemon.read", "workspace.read"], + reconnect: { enabled: false }, + }); + cleanup.push(() => narrowed.close()); + await narrowed.connect(); + await expect.poll(() => snapshots.length, { timeout: 15000 }).toBe(2); + expect(logical.getLastServerInfoMessage()?.permissions).toEqual([ + "daemon.read", + "workspace.read", + ]); + await expect( + scoped.workspaces.create({ source: { kind: "directory", path: b.staticDir } }), + ).rejects.toMatchObject({ code: "access_denied" }); + const restored = createHostClient(`127.0.0.1:${b.port}`, { + clientId: "physical-grants", + reconnect: { enabled: false }, + }); + cleanup.push(() => restored.close()); + await restored.connect(); + await expect.poll(() => snapshots.length, { timeout: 15000 }).toBe(3); + expect(logical.getLastServerInfoMessage()?.permissions).toContain("workspace.write"); + expect(recoveryCalls.length).toBeGreaterThanOrEqual(2); + for (const call of recoveryCalls) expect(await call).toMatchObject({ entries: [] }); + await scoped.workspaces.create({ source: { kind: "directory", path: b.staticDir } }); +}); + +import { DaemonLinkEndpoint, VirtualHostSocket } from "./index.js"; +import { SessionDelivery } from "../session/owned-subscriptions/index.js"; +import { sendBoundedPhysicalFrameAndWait } from "../websocket/physical-socket.js"; + +test("channel output accounts for escaped text, awaits physical drain, and preserves its sibling", async () => { + const http = createServer(); + const wss = new WebSocketServer({ server: http }); + http.listen(0, "127.0.0.1"); + await once(http, "listening"); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Missing port"); + const accepted = once(wss, "connection"); + const tcpAccepted = once(http, "connection"); + const receiver = new WebSocket(`ws://127.0.0.1:${address.port}`); + const received: { binary: boolean; bytes: Buffer }[] = []; + receiver.on("message", (bytes, binary) => + received.push({ bytes: Buffer.from(bytes as Buffer), binary }), + ); + const [physical] = (await accepted) as [WebSocket]; + const [tcp] = (await tcpAccepted) as [Socket]; + await once(receiver, "open"); + cleanup.push(async () => { + receiver.terminate(); + physical.terminate(); + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => http.close(() => resolve())); + }); + const delivery = new SessionDelivery( + async (source, message) => { + expect(delivery.permits(source, message)).toBe(true); + await sendBoundedPhysicalFrameAndWait({ + socket: physical, + frame: JSON.stringify({ type: "session", message }), + onHighWater: () => { + throw new Error("Shared link overflowed"); + }, + }); + }, + async (source, frame) => { + expect(delivery.permitsBinary(source, frame)).toBe(true); + await sendBoundedPhysicalFrameAndWait({ + socket: physical, + frame, + onHighWater: () => { + throw new Error("Shared link overflowed"); + }, + }); + }, + ); + delivery.attach(physical, true); + const channels: VirtualHostSocket[] = []; + const endpoint = new DaemonLinkEndpoint({ + identity: "authenticated", + permissions: () => ["daemon.read"], + routed: () => false, + bufferedAmount: () => physical.bufferedAmount, + delivery, + admit: async (socket) => { + channels.push(socket); + }, + }); + cleanup.push(async () => { + endpoint.close(); + await delivery.close(); + }); + for (const channelId of ["slow", "sibling"]) { + const request = { type: "hosts.link.open.request" as const, requestId: channelId, channelId }; + await delivery.request(physical, request, () => endpoint.dispatch(request)); + } + await expect(channels[0]!.send('"'.repeat(34 * 1024 * 1024))).rejects.toThrow( + "Host channel output limit reached", + ); + expect(channels[0]!.readyState).toBe(3); + expect(physical.readyState).toBe(1); + await channels[1]!.send("sibling-before"); + await expect + .poll(() => received.some((frame) => !frame.binary && frame.bytes.includes("sibling-before"))) + .toBe(true); + receiver.pause(); + // Hold real physical writes independently of the OS receive-buffer size. + // A paused receiver alone can still accept the entire frame on Windows. + tcp.cork(); + let completed = false; + const bytes = new Uint8Array(8 * 1024 * 1024).fill(173); + const pending = channels[1]!.send(bytes).then(() => { + completed = true; + return undefined; + }); + await expect.poll(() => physical.bufferedAmount).toBeGreaterThan(0); + expect(completed).toBe(false); + expect(physical.bufferedAmount).toBeLessThan(64 * 1024 * 1024); + receiver.resume(); + tcp.uncork(); + await pending; + await channels[1]!.send("sibling-after"); + await expect + .poll(() => received.some((frame) => !frame.binary && frame.bytes.includes("sibling-after"))) + .toBe(true); + const binary = received.find((frame) => frame.binary)!; + expect(Buffer.from(decodeHostRouteFrame(binary.bytes)!.payload).equals(bytes)).toBe(true); + expect(wss.clients.size).toBe(1); +}); + +import { existsSync } from "node:fs"; +test.skipIf(process.platform !== "linux" || !existsSync("/usr/sbin/sshd"))( + "real SSH shares one tunnel across scopes and owns fresh attempts after recovery", + async () => { + const root = await mkdtemp(join(tmpdir(), "hosts-ssh-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const run = promisify(execFile); + for (const name of ["host", "client"]) + await run("ssh-keygen", ["-t", "ed25519", "-N", "", "-f", join(root, name)]); + const authorized = await readFile(join(root, "client.pub")); + await writeFile(join(root, "authorized_keys"), authorized); + const portServer = createServer(); + portServer.listen(0, "127.0.0.1"); + await once(portServer, "listening"); + const address = portServer.address(); + if (!address || typeof address === "string") throw new Error("Missing SSH port"); + await new Promise((resolve) => portServer.close(() => resolve())); + await writeFile( + join(root, "sshd_config"), + `Port ${address.port}\nListenAddress 127.0.0.1\nHostKey ${root}/host\nPidFile ${root}/pid\nAuthorizedKeysFile ${root}/authorized_keys\nStrictModes no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nUsePAM no\nAllowTcpForwarding yes\n`, + ); + await writeFile( + join(root, "ssh"), + `#!/bin/sh\nprintf 'attempt\\n' >> '${root}/attempts'\nexec /usr/bin/ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile='${root}/known_hosts' -i '${root}/client' "$@"\n`, + ); + await chmod(join(root, "ssh"), 0o700); + const originalPath = process.env.PATH; + process.env.PATH = `${root}:${originalPath}`; + cleanup.push(async () => { + process.env.PATH = originalPath; + }); + const b = await createTestPaseoDaemon({ auth: { password: hashSync("ssh-password", 4) } }); + cleanup.push(() => b.close()); + const a = await daemon(); + const { paseo, client } = await api(a); + const host = await paseo.hosts.add({ + connection: `ssh://${userInfo().username}@127.0.0.1:${address.port}?daemonPort=${b.port}&password=ssh-password`, + }); + await expect.poll(async () => (await paseo.hosts.list()).hosts[0]?.status).toBe("offline"); + const sshd = spawn("/usr/sbin/sshd", [ + "-D", + "-f", + join(root, "sshd_config"), + "-E", + join(root, "sshd.log"), + ]); + cleanup.push(async () => { + sshd.kill(); + }); + await online(paseo); + const attempts = async () => + (await readFile(join(root, "attempts"), "utf8")).trim().split("\n").length; + const before = await attempts(); + const first = await paseo.hosts.client(b.daemon.getServerId()); + const sibling = await paseo.hosts.client(b.daemon.getServerId()); + expect((await sibling.workspaces.list()).entries).toEqual([]); + expect(await attempts()).toBe(before); + await first.dispose(); + expect((await sibling.workspaces.list()).entries).toEqual([]); + expect(await attempts()).toBe(before); + const workspace = await sibling.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const terminal = await workspace.terminals.create({ + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], + }); + const stream = await RoutePeer.open(client, b.daemon.getServerId()); + await stream.subscribeTerminal(terminal.id); + stream.input("SSH_BINARY_BEFORE\n"); + await expect.poll(() => stream.output()).toContain("SSH_BINARY_BEFORE"); + await stream.close(); + expect(await attempts()).toBe(before); + + await paseo.hosts.update(host.registrationId, { enabled: false }); + await paseo.hosts.update(host.registrationId, { enabled: true }); + await online(paseo); + await expect + .poll( + async () => { + try { + return (await sibling.workspaces.list()).entries.length; + } catch { + return -1; + } + }, + { timeout: 15000 }, + ) + .toBe(1); + expect(await attempts()).toBe(before + 1); + const resumed = await RoutePeer.open(client, b.daemon.getServerId()); + await resumed.subscribeTerminal(terminal.id); + resumed.input("SSH_BINARY_AFTER\n"); + await expect.poll(() => resumed.output()).toContain("SSH_BINARY_AFTER"); + await resumed.close(); + await terminal.kill(); + expect(await attempts()).toBe(before + 1); + await sibling.dispose(); + await paseo.hosts.update(host.registrationId, { enabled: false }); + await writeFile(join(root, "authorized_keys"), ""); + await paseo.hosts.update(host.registrationId, { enabled: true }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[0]?.error?.code, { timeout: 15000 }) + .toBe("authentication_failed"); + expect((await paseo.hosts.list()).hosts[0]?.nextRetryAt).toBeNull(); + await writeFile(join(root, "authorized_keys"), authorized); + await paseo.hosts.update(host.registrationId, { enabled: true }); + await online(paseo); + }, + 60000, +); + +test("malformed routing headers terminate their physical source and leave the daemon usable", async () => { + const b = await daemon(); + const socket = new WebSocket(`ws://127.0.0.1:${b.port}/ws`); + cleanup.push(async () => socket.terminate()); + await once(socket, "open"); + const info = once(socket, "message"); + socket.send( + JSON.stringify({ + type: "hello", + clientId: "malformed-route", + clientType: "cli", + protocolVersion: 1, + capabilities: { owned_subscriptions: true }, + }), + ); + await info; + const opened = once(socket, "message"); + socket.send( + JSON.stringify({ + type: "session", + message: { type: "hosts.link.open.request", requestId: "channel", channelId: "channel" }, + }), + ); + const [response] = await opened; + expect(JSON.parse(String(response)).message.type).toBe("hosts.link.open.response"); + const closed = once(socket, "close"); + socket.send(new Uint8Array([0x30, 0, 3, 97])); + expect((await closed)[0]).toBe(1008); + expect((await (await api(b)).paseo.workspaces.list()).entries).toEqual([]); +}); + +test("cancelling a logical scope during admission settles it and releases its owned channel", async () => { + const source = {}; + const messages: SessionOutboundMessage[] = []; + const delivery = new SessionDelivery((_source, message) => { + messages.push(message); + }); + delivery.attach(source, true); + let pendingSocket: VirtualHostSocket | undefined; + const endpoint = new DaemonLinkEndpoint({ + identity: "physical", + permissions: () => ["daemon.read"], + routed: () => false, + bufferedAmount: () => 0, + delivery, + admit: (socket) => + new Promise((resolve) => { + pendingSocket = socket; + socket.once("close", resolve); + }), + }); + const open = { + type: "hosts.link.open.request" as const, + requestId: "pending", + channelId: "logical-scope", + }; + const acquiring = delivery.request(source, open, () => endpoint.dispatch(open)); + expect(pendingSocket).toBeDefined(); + const cancel = { type: "hosts.route.close.request" as const, scopeId: "logical-scope" }; + expect(endpoint.handles(cancel)).toBe(true); + await delivery.request(source, cancel, () => endpoint.dispatch(cancel)); + await acquiring; + expect(pendingSocket!.readyState).toBe(3); + expect(messages.some((message) => message.type === "hosts.link.open.response")).toBe(false); + expect(messages.filter((message) => message.type === "hosts.route.closed")).toHaveLength(1); + endpoint.close(); + await delivery.close(); +}); + +test("agent MCP discovers hosts, creates children on self and B, and notifies their parent", async () => { + const parentPrompts: string[] = []; + const a = await daemon({ + agentClients: createTestAgentClients({ + onStartTurn: (prompt, config) => { + if (config.title === "Fleet parent") parentPrompts.push(JSON.stringify(prompt)); + }, + }), + }); + const b = await daemon(); + const { paseo, client: aClient } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${b.port}`, label: "Fleet B" }); + const directory = await online(paseo); + const workspace = await paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test" }, + title: "Fleet parent", + }); + const mcp = new Client({ name: "fleet-delegation", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const result = await mcp.callTool({ name: "list_hosts", arguments: {} }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + self: { serverId: directory.self.serverId }, + hosts: [expect.objectContaining({ serverId: directory.hosts[0]!.serverId, label: "Fleet B" })], + }); + const call = async (name: string, args: Record) => { + const response = await mcp.callTool({ name, arguments: args }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return z.record(z.string(), z.unknown()).parse(response.structuredContent); + }; + const serverId = directory.hosts[0]!.serverId!; + const destination = await call("create_workspace", { + serverId, + isolation: "local", + path: b.staticDir, + title: "Destination workspace", + }); + expect(await call("list_workspaces", { serverId })).toMatchObject({ + serverId, + workspaces: [expect.objectContaining({ workspaceId: destination.workspaceId })], + }); + const self = await call("create_agent", { + title: "Self child", + provider: "claude/claude-test", + initialPrompt: "Finish local delegation", + }); + const remote = await call("create_agent", { + serverId, + workspaceId: destination.workspaceId, + title: "B child", + provider: "claude/claude-test", + initialPrompt: "Finish destination delegation", + }); + expect(self.serverId).toBe(directory.self.serverId); + expect(remote.serverId).toBe(serverId); + const bApi = await api(b); + expect( + (await bApi.paseo.agents.ref(String(remote.agentId)).refresh())?.agent.labels, + ).toMatchObject({ + "paseo.parent-agent-id": parent.id, + "paseo.parent-server-id": directory.self.serverId, + }); + await expect.poll(() => parentPrompts.join("\n")).toContain(`Agent ${self.agentId}`); + await expect.poll(() => parentPrompts.join("\n")).toContain(`Agent ${remote.agentId}`); + expect(parentPrompts.join("\n")).toContain(`Server: ${serverId}`); + expect(parentPrompts.join("\n")).toContain(`Server: ${directory.self.serverId}`); + + await aClient.createAgent({ + agentId: String(remote.agentId), + provider: "claude", + model: "claude-test", + cwd: a.staticDir, + workspaceId: workspace.id, + title: "Unrelated equal ID on A", + }); + expect(await call("get_agent_status", { serverId, agentId: remote.agentId })).toMatchObject({ + serverId, + snapshot: { id: remote.agentId, title: "B child", cwd: b.staticDir }, + }); + expect(await call("get_agent_status", { agentId: remote.agentId })).toMatchObject({ + serverId: directory.self.serverId, + snapshot: { title: "Unrelated equal ID on A" }, + }); + const beforeFollowup = parentPrompts.filter((prompt) => + prompt.includes(`Agent ${remote.agentId}`), + ).length; + expect(beforeFollowup).toBe(1); + expect( + await call("send_agent_prompt", { + serverId, + agentId: remote.agentId, + prompt: "REMOTE_FOLLOWUP_MARKER", + sessionMode: "full-access", + }), + ).toMatchObject({ success: true, serverId, guidance: expect.stringContaining("notified") }); + expect(await call("get_agent_activity", { serverId, agentId: remote.agentId })).toMatchObject({ + serverId, + agentId: remote.agentId, + currentModeId: "full-access", + content: expect.stringContaining("REMOTE_FOLLOWUP_MARKER"), + }); + expect(await call("list_agents", { serverId })).toMatchObject({ + serverId, + agents: expect.arrayContaining([ + expect.objectContaining({ id: remote.agentId, title: "B child", cwd: b.staticDir }), + ]), + }); + expect(await call("list_agents", {})).toMatchObject({ + serverId: directory.self.serverId, + agents: expect.arrayContaining([ + expect.objectContaining({ + id: remote.agentId, + title: "Unrelated equal ID on A", + cwd: a.staticDir, + }), + ]), + }); + await expect + .poll(() => parentPrompts.filter((prompt) => prompt.includes(`Agent ${remote.agentId}`)).length) + .toBe(2); + expect(parentPrompts.findLast((prompt) => prompt.includes(`Agent ${remote.agentId}`))).toContain( + `Server: ${serverId}`, + ); + expect((await aClient.fetchAgent(String(remote.agentId)))?.agent.lastUserMessageAt).toBeNull(); + expect(JSON.stringify(await aClient.fetchAgentTimeline(String(remote.agentId)))).not.toContain( + "REMOTE_FOLLOWUP_MARKER", + ); + + for (const selector of [{}, { serverId: directory.self.serverId }]) { + expect(await call("get_agent_status", { ...selector, agentId: self.agentId })).toMatchObject({ + serverId: directory.self.serverId, + snapshot: { title: "Self child" }, + }); + const previous = parentPrompts.filter((prompt) => + prompt.includes(`Agent ${self.agentId}`), + ).length; + expect( + await call("send_agent_prompt", { + ...selector, + agentId: self.agentId, + prompt: "SELF_FOLLOWUP_MARKER", + }), + ).toMatchObject({ + success: true, + serverId: directory.self.serverId, + disposition: "turn_started", + }); + expect(await call("get_agent_activity", { ...selector, agentId: self.agentId })).toMatchObject({ + serverId: directory.self.serverId, + content: expect.stringContaining("SELF_FOLLOWUP_MARKER"), + }); + expect(await call("list_agents", selector)).toMatchObject({ + serverId: directory.self.serverId, + agents: expect.arrayContaining([ + expect.objectContaining({ id: self.agentId, title: "Self child" }), + ]), + }); + await expect + .poll(() => parentPrompts.filter((prompt) => prompt.includes(`Agent ${self.agentId}`)).length) + .toBe(previous + 1); + } +}); diff --git a/packages/server/src/server/connected-hosts/index.ts b/packages/server/src/server/connected-hosts/index.ts new file mode 100644 index 0000000000..1a8f7c2731 --- /dev/null +++ b/packages/server/src/server/connected-hosts/index.ts @@ -0,0 +1,539 @@ +export { DaemonLinkEndpoint, VirtualHostSocket } from "./link.js"; +export { ConnectedHostSession } from "./session.js"; +import { randomUUID, createHash } from "node:crypto"; +import { mkdir, readFile, rename, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { + createHostClient, + createHostChannelTransport, + parseHostTarget, + HostTransportBudget, +} from "@getpaseo/client/node"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { + HostSummarySchema, + HostAddSchema, + HostUpdateSchema, + type HostDirectory, + type HostSummary, + type HostAdd, + type HostUpdate, +} from "@getpaseo/protocol/hosts"; +import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages"; + +export class HostError extends Error { + constructor( + readonly code: string, + message: string, + readonly serverId?: string, + ) { + super(message); + } +} +const SavedSchema = HostSummarySchema.extend({ connection: z.string() }); +type Saved = z.infer; +export interface HostActor { + sourceId: string; + manageSources: boolean; +} +interface Live { + client: DaemonClient; + accepted: boolean; + stop(): void; +} +interface Route { + registrationId: string; + close(code?: string, message?: string): void; +} + +/** Daemon-owned configuration, identity pinning, retry and routed session lifetime. */ +export class ConnectedHosts { + private readonly budget = new HostTransportBudget(); + private rows: Saved[] = []; + private readonly live = new Map(); + private readonly routes = new Set(); + private readonly listeners = new Set<() => void>(); + private queue: Promise = Promise.resolve(); + private readonly ready: Promise; + private closed = false; + constructor( + private readonly home: string, + private readonly self: HostDirectory["self"], + ) { + this.ready = this.load(); + void this.ready.catch(() => {}); + } + private async load(): Promise { + try { + this.rows = z + .array(SavedSchema) + .parse(JSON.parse(await readFile(join(this.home, "hosts.json"), "utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") + throw new Error("Cannot read saved connected hosts", { cause: error }); + } + if (this.closed) return; + for (const row of this.rows) { + row.status = row.enabled ? "connecting" : "disabled"; + row.nextRetryAt = null; + this.start(row); + } + } + async list(): Promise { + await this.ready; + return { + self: { ...this.self }, + hosts: this.rows.map(({ connection: _secret, ...row }) => structuredClone(row)), + }; + } + observe(changed: () => void): () => void { + this.listeners.add(changed); + return () => this.listeners.delete(changed); + } + private changed(): void { + for (const listener of this.listeners) listener(); + } + private async persist(rows: Saved[]): Promise { + await mkdir(this.home, { recursive: true }); + const temp = join(this.home, `.hosts-${randomUUID()}.tmp`); + try { + await writeFile(temp, JSON.stringify(rows), { mode: 0o600 }); + await rename(temp, join(this.home, "hosts.json")); + } finally { + await rm(temp, { force: true }); + } + } + private mutate(run: () => Promise): Promise { + const result = this.queue.then(async () => { + await this.ready; + if (this.closed) throw new HostError("host_offline", "Daemon is stopping"); + return run(); + }); + this.queue = result.catch(() => {}); + return result; + } + private summary(row: Saved): HostSummary { + const { connection: _secret, ...summary } = row; + return structuredClone(summary); + } + add(input: HostAdd, actor: HostActor): Promise { + return this.mutate(async () => { + input = HostAddSchema.parse(input); + const target = parseHostTarget(input.connection); + const source = { id: actor.sourceId, key: input.sourceKey ?? randomUUID() }; + const existing = this.rows.find( + (row) => row.source.id === source.id && row.source.key === source.key, + ); + if (existing) { + if ( + existing.connection === input.connection && + existing.label === (input.label ?? null) && + JSON.stringify(Object.entries(existing.metadata).sort()) === + JSON.stringify(Object.entries(input.metadata ?? {}).sort()) + ) + return this.summary(existing); + throw new HostError( + "source_conflict", + "This source key already exists. Update its registration explicitly.", + ); + } + if (this.rows.length >= 128) + throw new HostError("resource_limit", "Remove a host before adding another (limit 128)"); + const row: Saved = { + registrationId: `hst_${randomUUID()}`, + serverId: null, + connection: input.connection, + label: input.label ?? null, + metadata: input.metadata ?? {}, + source, + enabled: true, + status: "connecting", + targetDescription: target.description, + lastOnlineAt: null, + nextRetryAt: null, + error: null, + }; + await this.persist([...this.rows, row]); + this.rows.push(row); + this.start(row); + this.changed(); + return this.summary(row); + }); + } + update(id: string, patch: HostUpdate, actor: HostActor): Promise { + return this.mutate(async () => { + patch = HostUpdateSchema.parse(patch); + const previous = this.require(id); + if ( + (patch.connection !== undefined || patch.metadata !== undefined || patch.replaceIdentity) && + actor.sourceId !== previous.source.id && + !actor.manageSources + ) + throw new HostError( + "permission_denied", + "Only the source or access manager can replace its connection and metadata", + ); + if (patch.replaceIdentity && patch.connection === undefined) + throw new HostError( + "invalid_connection", + "Identity replacement requires a replacement connection", + ); + const row: Saved = { + ...previous, + ...(patch.label !== undefined ? { label: patch.label } : {}), + ...(patch.metadata !== undefined ? { metadata: patch.metadata } : {}), + ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}), + }; + if (patch.connection !== undefined) { + row.connection = patch.connection; + row.targetDescription = parseHostTarget(patch.connection).description; + } + if (patch.replaceIdentity) row.serverId = null; + const restart = + patch.connection !== undefined || patch.enabled !== undefined || patch.replaceIdentity; + if (restart) { + row.status = row.enabled ? "connecting" : "disabled"; + row.error = null; + row.nextRetryAt = null; + } + const rows = this.rows.map((entry) => (entry === previous ? row : entry)); + await this.persist(rows); + if (restart) this.stop(id, row.enabled ? "host_offline" : "host_disabled"); + Object.assign(previous, row); + this.rows = rows.map((entry) => (entry === row ? previous : entry)); + if (restart) this.start(previous); + this.changed(); + return this.summary(previous); + }); + } + remove(id: string, actor: HostActor): Promise { + return this.mutate(async () => { + const row = this.require(id); + if (actor.sourceId !== row.source.id && !actor.manageSources) + throw new HostError( + "permission_denied", + "Only the source or access manager can remove this host. Disable it to stop access.", + ); + const rows = this.rows.filter((entry) => entry !== row); + await this.persist(rows); + this.stop(id, "host_unknown"); + this.rows = rows; + this.changed(); + }); + } + private require(id: string): Saved { + const row = this.rows.find((entry) => entry.registrationId === id); + if (!row) throw new HostError("host_unknown", "Unknown host registration"); + return row; + } + private stop(id: string, code = "host_offline"): void { + this.live.get(id)?.stop(); + this.live.delete(id); + for (const route of this.routes) + if (route.registrationId === id) + route.close(code, "Host access was disabled, replaced or removed"); + } + private start(row: Saved): void { + if (!row.enabled || this.closed) return; + let stopped = false; + const client = createHostClient( + row.connection, + { + clientId: `host-link-${this.self.serverId}-${row.registrationId}`, + connectTimeoutMs: 10000, + }, + this.budget, + ); + const live: Live = { + client, + accepted: false, + stop: () => { + stopped = true; + void client.close(); + }, + }; + this.live.set(row.registrationId, live); + const accept = async () => { + try { + const info = client.getLastServerInfoMessage(); + if (!info) throw new HostError("host_offline", "No daemon identity received"); + if (row.serverId && info.serverId !== row.serverId) + throw new HostError( + "server_id_mismatch", + "Daemon identity changed. Verify the destination and explicitly replace its identity.", + ); + if (!row.serverId) + await this.mutate(async () => { + if (stopped) return; + const discovered = { ...row, serverId: info.serverId }; + await this.persist(this.rows.map((entry) => (entry === row ? discovered : entry))); + row.serverId = info.serverId; + }); + if (info.serverId === this.self.serverId) + throw new HostError( + "self_host", + "This connection reaches this daemon. Remove it or replace the connection.", + ); + if (this.rows.find((entry) => entry.serverId === info.serverId) !== row) + throw new HostError( + "duplicate_host", + "Another registration already owns this daemon. Remove the duplicate registration.", + ); + if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true) + throw new HostError( + "unsupported_capability", + "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 () => { + if (stopped) return; + if (this.rows.find((entry) => entry.serverId === info.serverId) !== row) + throw new HostError("duplicate_host", "Another registration already owns this daemon"); + const next = { + ...row, + serverId: info.serverId, + status: "online" as const, + lastOnlineAt: new Date().toISOString(), + error: null, + nextRetryAt: null, + }; + await this.persist(this.rows.map((entry) => (entry === row ? next : entry))); + if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return; + Object.assign(row, next); + live.accepted = true; + this.changed(); + }); + } catch (error) { + if (stopped) return; + row.status = "error"; + row.error = connectionFailure(error, client.authFailureReason !== null) ?? { + code: "host_offline", + message: "Host connection failed", + }; + row.nextRetryAt = null; + this.changed(); + await client.close(); + } + }; + client.subscribeConnectionStatus((state) => { + if (stopped || row.status === "error") return; + live.accepted = false; + if (state.status === "connected") { + void accept(); + return; + } + for (const route of this.routes) + if (route.registrationId === row.registrationId) route.close(); + row.status = state.status === "connecting" ? "connecting" : "offline"; + row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : 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; + client.setReconnectEnabled(false); + void client.close(); + } + this.changed(); + }); + void client.connect().catch(() => {}); + } + async openRoute(input: { + serverId: string; + scopeId: string; + sourceId: string; + originSessionId: string; + permissions: readonly DaemonPermission[]; + signal: AbortSignal; + frame(data: string | Uint8Array): void; + ended(code: string, message: string): void; + }): Promise<{ send(data: string | Uint8Array): void; close(): void }> { + await this.ready; + const row = this.rows.find((entry) => entry.serverId === input.serverId); + if (!row) throw new HostError("host_unknown", `Unknown host ${input.serverId}`, input.serverId); + if (!row.enabled) + throw new HostError( + "host_disabled", + `Enable host ${input.serverId} before using it`, + input.serverId, + ); + if (row.status !== "online") + throw new HostError( + row.error?.code ?? "host_offline", + row.error?.message ?? `Host ${input.serverId} is offline`, + input.serverId, + ); + if (this.routes.size >= 128) throw new HostError("resource_limit", "Too many routed sessions"); + const live = this.live.get(row.registrationId); + const client = live?.client; + if (!live?.accepted || !client?.isConnected) + throw new HostError("host_offline", "Host is offline", input.serverId); + const transport = createHostChannelTransport( + client, + input.serverId, + randomUUID(), + null, + true, + )({ url: "ws://host-link/ws" }); + let closed = false; + let verified = false; + let helloSent = false; + const timer = setTimeout(() => route.close("host_offline", "Host handshake timed out"), 15000); + timer.unref(); + const route: Route = { + registrationId: row.registrationId, + close: (code = "host_offline", message = `Host ${input.serverId} disconnected`) => { + if (closed) return; + closed = true; + input.signal.removeEventListener("abort", abort); + clearTimeout(timer); + this.routes.delete(route); + try { + transport.close(); + } finally { + failed(new HostError(code, message, input.serverId)); + input.ended(code, message); + } + }, + }; + this.routes.add(route); + const abort = () => + route.close("host_offline", "Origin session closed or its authority changed"); + input.signal.addEventListener("abort", abort, { once: true }); + let opened!: () => void; + let failed!: (error: unknown) => void; + const ready = new Promise((resolve, reject) => { + opened = resolve; + failed = reject; + }); + if (input.signal.aborted) { + route.close(); + } + transport.onOpen(opened); + transport.onError((error) => { + const authenticationFailed = + error instanceof Error && "code" in error && error.code === "authentication_failed"; + const code = authenticationFailed ? "authentication_failed" : "host_offline"; + const message = authenticationFailed + ? "Host authentication failed. Check the SSH configuration and daemon credentials." + : "Host transport failed"; + failed(new HostError(code, message, input.serverId)); + route.close(code, message); + }); + transport.onClose(() => { + failed(new HostError("host_offline", "Host transport closed")); + route.close(); + }); + transport.onMessage((data, binary) => { + if (closed) return; + if (!verified) { + if (binary) { + route.close(); + return; + } + try { + const envelope = JSON.parse(String(data)); + if (envelope.type === "hello.rejected") { + route.close("authentication_failed", "Replace the host credentials"); + return; + } + const info = + envelope.type === "session" && envelope.message.type === "status" + ? envelope.message.payload + : null; + if (info?.status !== "server_info") return; + if (info.serverId !== row.serverId) { + route.close( + "server_id_mismatch", + "Host identity changed; explicit replacement required", + ); + return; + } + if (info.features?.connectedHosts !== true) { + route.close("unsupported_capability", "Update the destination daemon"); + return; + } + verified = true; + clearTimeout(timer); + } catch { + route.close(); + return; + } + } + input.frame(binary ? (data as Uint8Array) : String(data)); + }); + await ready; + if (closed) throw new HostError("host_offline", "Host closed during connection"); + return { + close: () => route.close(), + send: (data) => { + const binary = typeof data !== "string"; + if (closed) throw new HostError("host_offline", "Host is offline"); + if (!helloSent) { + if (binary) throw new HostError("invalid_message", "Expected hello"); + const hello = JSON.parse(data as string) as WSHelloMessage; + if (hello.type !== "hello") throw new HostError("invalid_message", "Expected hello"); + const clientId = `route-${createHash("sha256") + .update( + JSON.stringify([ + this.self.serverId, + input.sourceId, + input.originSessionId, + input.scopeId, + ]), + ) + .digest("hex")}`; + transport.send( + JSON.stringify({ + ...hello, + clientId, + routed: true, + producer: { serverId: this.self.serverId, sourceId: input.sourceId }, + sessionPermissions: input.permissions, + auth: undefined, + }), + ); + helloSent = true; + return; + } + if (!verified) throw new HostError("host_offline", "Host identity has not been verified"); + try { + transport.send(data); + } catch { + route.close("resource_limit", "Host buffer limit reached or transport unavailable"); + } + }, + }; + } + async close(): Promise { + this.closed = true; + await this.ready; + await this.queue; + for (const id of this.live.keys()) this.stop(id); + for (const route of this.routes) route.close(); + this.listeners.clear(); + } +} + +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") + return { code: "authentication_failed", message: error.message }; + if (authenticationFailed) + return { + code: "authentication_failed", + message: "Host rejected the credentials. Replace the connection credentials.", + }; + return null; +} diff --git a/packages/server/src/server/connected-hosts/link.ts b/packages/server/src/server/connected-hosts/link.ts new file mode 100644 index 0000000000..85fe06ed4e --- /dev/null +++ b/packages/server/src/server/connected-hosts/link.ts @@ -0,0 +1,210 @@ +import type { SessionDelivery } from "../session/owned-subscriptions/index.js"; +import { EventEmitter } from "node:events"; +import { createHash } from "node:crypto"; +import { encodeHostRouteFrame, type HostRouteFrame } from "@getpaseo/protocol/binary-frames/index"; +import type { + DaemonPermission, + SessionInboundMessage, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +/** A logical socket has no network resources or authority of its own. */ +export class VirtualHostSocket extends EventEmitter { + readyState = 1; + constructor( + private readonly output: (data: string | Uint8Array) => Promise, + private readonly ended: (code: number, reason: string) => void, + private readonly buffered: () => number, + private readonly transformHello: (data: string) => string, + ) { + super(); + } + get bufferedAmount(): number { + return this.buffered(); + } + private helloPending = true; + feed(data: string | Uint8Array): void { + if (this.readyState !== 1) return; + if (this.helloPending) { + if (typeof data !== "string") { + this.close(1008, "Expected hello"); + return; + } + try { + data = this.transformHello(data); + } catch { + this.close(1008, "Invalid hello"); + return; + } + this.helloPending = false; + } + this.emit("message", typeof data === "string" ? data : Buffer.from(data)); + } + async send(data: string | Uint8Array | ArrayBuffer): Promise { + if (this.readyState !== 1) throw new Error("Host channel closed"); + await this.output( + typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data), + ); + } + close(code = 1000, reason = "Host channel closed"): void { + if (this.readyState !== 1) return; + this.readyState = 3; + this.ended(code, reason); + this.emit("close", 1000, "Host channel closed"); + } +} + +/** One authenticated physical source owns all its channels and their admission ceiling. */ +export class DaemonLinkEndpoint { + private readonly channels = new Map(); + private closed = false; + constructor( + private readonly parent: { + identity: string; + permissions(): readonly DaemonPermission[]; + routed(): boolean; + bufferedAmount(): number | null; + delivery: SessionDelivery; + admit(socket: VirtualHostSocket, permissions: readonly DaemonPermission[]): Promise; + }, + ) {} + handles(message: SessionInboundMessage): boolean { + return ( + message.type === "hosts.link.open.request" || + (message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) || + (message.type === "hosts.route.close.request" && + ((!!message.routeId && this.channels.has(message.routeId)) || + [...this.channels.values()].some((channel) => channel.scopeId === message.scopeId))) + ); + } + async dispatch(message: SessionInboundMessage): Promise { + if (message.type === "hosts.link.open.request") { + const error = (code: string, text: string) => + this.parent.delivery.reply({ + type: "rpc_error", + payload: { + requestId: message.requestId, + requestType: message.type, + code, + error: text, + }, + }); + if (this.closed || this.parent.routed()) { + error("unsupported_capability", "Connected hosts support one hop"); + return; + } + if (this.channels.size >= 128) { + error("resource_limit", "Too many host channels"); + return; + } + let socket: VirtualHostSocket; + const owner = this.parent.delivery.begin("host-link", undefined, () => { + this.channels.delete(owner.id); + socket?.close(); + }); + const id = owner.id; + // Capture actual authenticated authority. The caller hello can only reduce it. + const ceiling = [...this.parent.permissions()]; + socket = new VirtualHostSocket( + async (data) => { + const output: SessionOutboundMessage | null = + typeof data === "string" + ? { + type: "hosts.route.frame", + payload: { subscriptionId: id, data, binary: false }, + } + : null; + const frame = typeof data === "string" ? null : encodeHostRouteFrame(id, data); + const bytes = output + ? Buffer.byteLength(JSON.stringify({ type: "session", message: output })) + : frame!.byteLength; + const buffered = this.parent.bufferedAmount(); + // Account for the exact escaped outer frame, retaining control-frame headroom. + if (buffered === null || buffered + bytes > 64 * 1024 * 1024 - 65536) { + socket.close(1009, "Host channel output limit reached"); + throw new Error("Host channel output limit reached"); + } + try { + if (output) await owner.emitAndWait(output); + else await owner.emitBinaryAndWait(frame!); + } catch (failure) { + socket.close(); + throw failure; + } + }, + (code, reason) => { + this.channels.delete(id); + let failure = "host_offline"; + if (code === 1009) failure = "resource_limit"; + if (code === 1008) failure = "permission_denied"; + owner.emit({ + type: "hosts.route.closed", + payload: { + subscriptionId: id, + code: failure, + message: reason, + }, + }); + void owner.release(); + }, + () => this.parent.bufferedAmount() ?? 0, + (data) => { + const hello = JSON.parse(data); + if (hello.type !== "hello" || typeof hello.clientId !== "string") + throw new Error("Expected hello"); + return JSON.stringify({ + ...hello, + auth: undefined, + routed: true, + clientId: `link-${createHash("sha256") + .update(JSON.stringify([this.parent.identity, hello.clientId])) + .digest("hex")}`, + }); + }, + ); + this.channels.set(id, { scopeId: message.channelId, socket }); + try { + await this.parent.admit(socket, ceiling); + if (this.closed || socket.readyState !== 1) { + socket.close(); + return; + } + this.parent.delivery.reply({ + type: "hosts.link.open.response", + payload: { requestId: message.requestId, routeId: id }, + }); + } catch { + socket.close(); + error("host_offline", "Could not admit host channel"); + } + return; + } + if (message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) { + this.channels.get(message.routeId)!.socket.feed(message.data); + return; + } + if (message.type === "hosts.route.close.request") { + for (const [id, channel] of this.channels) { + if (message.routeId === id || (message.scopeId && message.scopeId === channel.scopeId)) + channel.socket.close(); + } + } + return; + } + binary(frame: HostRouteFrame): boolean { + const socket = this.channels.get(frame.routeId)?.socket; + if (!socket) return false; + socket.feed(frame.payload); + return true; + } + close(reason: "host_offline" | "permission_denied" = "host_offline"): void { + if (this.closed) return; + this.closed = true; + for (const { socket } of this.channels.values()) + socket.close( + reason === "permission_denied" ? 1008 : 1000, + "Physical link authority or connection changed", + ); + this.channels.clear(); + } +} diff --git a/packages/server/src/server/connected-hosts/session.ts b/packages/server/src/server/connected-hosts/session.ts new file mode 100644 index 0000000000..811daaa723 --- /dev/null +++ b/packages/server/src/server/connected-hosts/session.ts @@ -0,0 +1,252 @@ +import { encodeHostRouteFrame, type HostRouteFrame } from "@getpaseo/protocol/binary-frames/index"; +import type { + SessionInboundMessage, + SessionOutboundMessage, + DaemonPermission, +} from "@getpaseo/protocol/messages"; +import { ConnectedHosts, HostError, type HostActor } from "./index.js"; +import type { SessionDelivery } from "../session/owned-subscriptions/index.js"; + +type HostRequest = Extract< + SessionInboundMessage, + { type: T } +>; +interface SessionRoute { + scopeId: string; + source: object; + send(data: string | Uint8Array): void; + release(): Promise; + fail(code: string, message: string): void; +} + +/** Adapts host capabilities to source-owned session delivery. */ +export class ConnectedHostSession { + private readonly routes = new Map(); + constructor( + private readonly hosts: ConnectedHosts, + private readonly delivery: SessionDelivery, + private readonly actor: HostActor, + private readonly permissions: () => DaemonPermission[], + private readonly emit: (message: SessionOutboundMessage) => void, + private readonly routed: () => boolean, + private readonly originSessionId: string, + private readonly bufferedAmount: (source: object) => number | null, + ) {} + binary(frame: HostRouteFrame, source: object): void { + const route = this.routes.get(frame.routeId); + if (!route || route.source !== source) return; + try { + route.send(frame.payload); + } catch { + route.fail("resource_limit", "Host input buffer limit reached"); + } + } + setSource(sourceId: string): void { + this.actor.sourceId = sourceId; + } + dispatch(message: SessionInboundMessage): Promise | undefined { + if (!message.type.startsWith("hosts.")) return; + return this.handle(message); + } + revoke(): void { + for (const route of this.routes.values()) + route.fail( + "permission_denied", + "Caller authority changed. Reconnecting with current grants.", + ); + void this.delivery.releaseFamily("host-route"); + } + private async handle(message: SessionInboundMessage): Promise { + try { + if (this.routed() && message.type.startsWith("hosts.route.")) + throw new HostError( + "unsupported_capability", + "Connected hosts support one hop. Connect to the managing daemon to select a host.", + ); + await this.execute(message); + } catch (error) { + this.fail(message, error); + } + } + private fail(message: SessionInboundMessage, error: unknown): void { + let failure = new HostError( + "host_error", + "Host operation failed. Check the connection and retry.", + ); + if (error instanceof HostError) failure = error; + else if (error instanceof Error && "code" in error && error.code === "invalid_connection") + failure = new HostError("invalid_connection", error.message); + if (message.type === "hosts.route.frame.request") { + const route = this.routes.get(message.routeId); + if (route && route.source === this.delivery.currentSource) + route.fail(failure.code, failure.message); + return; + } + if (!("requestId" in message) || !message.requestId) return; + this.emit({ + type: "rpc_error", + payload: { + requestId: message.requestId, + requestType: message.type, + code: failure.code, + error: failure.message, + }, + }); + } + private async execute(message: SessionInboundMessage): Promise { + const actor = { ...this.actor, manageSources: this.permissions().includes("access.manage") }; + switch (message.type) { + case "hosts.list.request": + return this.list(message); + case "hosts.add.request": + this.emit({ + type: "hosts.add.response", + payload: { + requestId: message.requestId, + host: await this.hosts.add(message.input, actor), + }, + }); + return; + case "hosts.update.request": + this.emit({ + type: "hosts.update.response", + payload: { + requestId: message.requestId, + host: await this.hosts.update(message.registrationId, message.patch, actor), + }, + }); + return; + case "hosts.remove.request": + await this.hosts.remove(message.registrationId, actor); + this.emit({ type: "hosts.remove.response", payload: { requestId: message.requestId } }); + return; + case "hosts.route.open.request": + return this.open(message); + case "hosts.route.frame.request": { + const route = this.routes.get(message.routeId); + if (!route || route.source !== this.delivery.currentSource) + throw new HostError("host_unknown", "Unknown routed session"); + route.send(message.data); + return; + } + case "hosts.route.close.request": { + const route = message.routeId + ? this.routes.get(message.routeId) + : [...this.routes.values()].find( + (entry) => + entry.scopeId === message.scopeId && entry.source === this.delivery.currentSource, + ); + if (route && route.source === this.delivery.currentSource) await route.release(); + return; + } + } + } + private async list(message: HostRequest<"hosts.list.request">): Promise { + if (!message.observe) { + this.emit({ + type: "hosts.list.response", + payload: { requestId: message.requestId, directory: await this.hosts.list() }, + }); + return; + } + let stop = () => {}; + const owner = this.delivery.begin("hosts", undefined, () => stop()); + try { + stop = this.hosts.observe(() => { + void this.hosts + .list() + .then((directory) => { + owner.emit({ type: "hosts.changed", payload: { subscriptionId: owner.id, directory } }); + return undefined; + }) + .catch(() => owner.release()); + }); + this.emit({ + type: "hosts.list.response", + payload: { + requestId: message.requestId, + subscriptionId: owner.id, + directory: await this.hosts.list(), + }, + }); + } catch (error) { + await owner.release(); + throw error; + } + } + private async open(message: HostRequest<"hosts.route.open.request">): Promise { + if (this.delivery.subscriptionIds(this.delivery.currentSource!, "host-route").length >= 32) + throw new HostError("resource_limit", "Too many routed sessions for this caller"); + let close = () => {}; + const owner = this.delivery.begin("host-route", undefined, () => { + this.routes.delete(owner.id); + close(); + }); + const fail = (code: string, text: string) => { + owner.emit({ + type: "hosts.route.closed", + payload: { subscriptionId: owner.id, code, message: text }, + }); + void owner.release(); + }; + this.routes.set(owner.id, { + scopeId: message.scopeId, + source: owner.source, + send: () => { + throw new HostError("host_offline", "Host is connecting"); + }, + release: owner.release, + fail, + }); + try { + const route = await this.hosts.openRoute({ + serverId: message.serverId, + scopeId: message.scopeId, + sourceId: this.actor.sourceId, + originSessionId: this.originSessionId, + permissions: this.permissions(), + signal: owner.signal, + frame: (data) => { + const output: SessionOutboundMessage | null = + typeof data === "string" + ? { + type: "hosts.route.frame", + payload: { subscriptionId: owner.id, data, binary: false }, + } + : null; + const binary = typeof data === "string" ? null : encodeHostRouteFrame(owner.id, data); + const size = output + ? Buffer.byteLength(JSON.stringify({ type: "session", message: output })) + : binary!.byteLength; + const buffered = this.bufferedAmount(owner.source); + if (buffered === null || buffered + size > 32 * 1024 * 1024) { + fail("resource_limit", "The receiving client cannot keep up with host output"); + return; + } + if (output) owner.emit(output); + else owner.emitBinary(binary!); + }, + ended: fail, + }); + close = route.close; + if (owner.signal.aborted) { + route.close(); + return; + } + this.routes.set(owner.id, { + scopeId: message.scopeId, + source: owner.source, + send: route.send, + release: owner.release, + fail, + }); + this.emit({ + type: "hosts.route.open.response", + payload: { requestId: message.requestId, routeId: owner.id }, + }); + } catch (error) { + await owner.release(); + throw error; + } + } +} diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 4da97d6eba..facf6dffd7 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -1,3 +1,14 @@ +import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; +import { createHostClient } from "@getpaseo/client/node"; +import { hashSync } from "bcryptjs"; +import { once } from "node:events"; +import { + decodeTerminalStreamFrame, + encodeTerminalStreamFrame, + TerminalStreamOpcode, +} from "@getpaseo/protocol/binary-frames/index"; +import type { DaemonClient as HostClient } from "@getpaseo/client/internal/daemon-client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, test } from "vitest"; import { withTimeout } from "../../utils/promise-timeout.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; @@ -194,9 +205,9 @@ async function waitForCapturedLog( let relayProcess: ChildProcess | null = null; let relayStdoutLines: string[] = []; - const startRelay = async (options: { useLocalRelay?: boolean } = {}) => { + const startRelay = async (options: { useLocalRelay?: boolean; port?: number } = {}) => { relayStdoutLines = []; - relayPort = await getAvailablePort(); + relayPort = options.port ?? (await getAvailablePort()); const relayDir = path.resolve(process.cwd(), "../relay"); const relayArgs = [ "wrangler", @@ -255,10 +266,152 @@ async function waitForCapturedLog( const stopRelay = async () => { if (!relayProcess) return; - relayProcess.kill("SIGTERM"); + const process = relayProcess; relayProcess = null; + const exited = once(process, "exit"); + process.kill("SIGTERM"); + await withTimeout(exited, 10000, "Local relay did not stop"); }; + test("connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", async () => { + let a: Awaited> | undefined; + let b: Awaited> | undefined; + let origin: ReturnType | undefined; + let api: PaseoApi | undefined; + let remote: PaseoApi | undefined; + let sibling: PaseoApi | undefined; + let terminalRoute: Awaited> | undefined; + let wire: Awaited> | undefined; + try { + await startRelay({ useLocalRelay: true }); + const port = relayPort; + b = await createTestPaseoDaemon({ + relayEnabled: true, + relayEndpoint: `127.0.0.1:${port}`, + relayUseTls: false, + relayPublicUseTls: false, + auth: { password: hashSync("relay-password", 4) }, + }); + a = await createTestPaseoDaemon(); + origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() }); + await origin.connect(); + api = createPaseoApi(origin); + const offerUrl = await getPairingOfferUrl({ + paseoHome: b.paseoHome, + relayEnabled: true, + relayEndpoint: b.config.relayEndpoint, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + wire = await relayWire(port); + const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`); + connection.searchParams.set("key", daemonPublicKeyB64); + connection.searchParams.set("password", "wrong-password"); + const saved = await api.hosts.add({ connection: connection.toString() }); + await expect + .poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 }) + .toBe("authentication_failed"); + expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/); + connection.searchParams.set("password", "relay-password"); + await api.hosts.update(saved.registrationId, { connection: connection.toString() }); + await expect + .poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 }) + .toBe("online"); + const admittedConnections = wire.connections(); + expect(wire.active()).toBe(1); + remote = await api.hosts.client(serverId); + sibling = await api.hosts.client(serverId); + expect(wire.connections()).toBe(admittedConnections); + expect(wire.active()).toBe(1); + expect((await remote.workspaces.list()).entries).toEqual([]); + const observed = await remote.workspaces.list({ subscribe: {} }); + let snapshots = 0; + observed.subscription.subscribe({ + snapshot: () => { + snapshots++; + }, + update() {}, + }); + const workspace = await remote.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + title: "Relayed workspace", + }); + const terminal = await workspace.terminals.create({ + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], + }); + terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); + expect(wire.connections()).toBe(admittedConnections); + terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n"); + await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE"); + terminalRoute.close(); + terminalRoute = undefined; + const before = snapshots; + await stopRelay(); + await expect + .poll( + async () => { + try { + await remote!.workspaces.list(); + return "online"; + } catch (error) { + return (error as { code: string }).code; + } + }, + { timeout: 15000 }, + ) + .toBe("DAEMON_CONNECTION_LOST"); + await startRelay({ useLocalRelay: true, port }); + await expect + .poll( + async () => { + try { + return (await remote!.workspaces.list()).entries.length; + } catch { + return -1; + } + }, + { timeout: 30000 }, + ) + .toBe(1); + await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before); + expect(wire.active()).toBe(1); + terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); + terminalRoute.write("ENCRYPTED_BINARY_AFTER\n"); + await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER"); + terminalRoute.close(); + terminalRoute = undefined; + await remote.dispose(); + remote = undefined; + expect((await sibling.workspaces.list()).entries).toHaveLength(1); + await sibling.terminals.ref(terminal.id).kill(); + await api.dispose(); + await expect(sibling.workspaces.list()).rejects.toThrow(); + const direct = createHostClient(`127.0.0.1:${b.port}`, { + clientId: crypto.randomUUID(), + password: "relay-password", + reconnect: { enabled: false }, + }); + try { + await direct.connect(); + const directApi = createPaseoApi(direct); + expect((await directApi.workspaces.list()).entries).toHaveLength(1); + await directApi.dispose(); + } finally { + await direct.close(); + } + } finally { + terminalRoute?.close(); + await remote?.dispose(); + await sibling?.dispose(); + await api?.dispose(); + await origin?.close(); + await a?.close(); + await b?.close(); + await wire?.close(); + await stopRelay(); + } + }, 120000); + test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => { await startRelay({ useLocalRelay: true }); const daemon = await createTestPaseoDaemon({ @@ -932,3 +1085,112 @@ async function waitForCapturedLog( } }, 90000); }); + +async function openConnectedHostTerminal(origin: HostClient, serverId: string, terminalId: string) { + const routeId = await origin.openHostRoute(serverId, crypto.randomUUID()); + const messages: SessionOutboundMessage[] = []; + let output = ""; + const detachBinary = origin.subscribeHostFrames((routed) => { + if (routed.routeId !== routeId) return; + const frame = decodeTerminalStreamFrame(routed.payload); + if (frame?.opcode === TerminalStreamOpcode.Output) + output += new TextDecoder().decode(frame.payload); + }); + const detach = origin.subscribeRawMessages((message) => { + if (message.type !== "hosts.route.frame" || message.payload.subscriptionId !== routeId) return; + const frame = WSOutboundMessageSchema.parse(JSON.parse(message.payload.data)); + if (frame.type === "session") messages.push(frame.message); + }); + const close = () => { + detach(); + detachBinary(); + origin.closeHostRoute(routeId); + }; + try { + origin.sendHostFrame( + routeId, + JSON.stringify({ + type: "hello", + clientId: "relay-binary", + clientType: "cli", + protocolVersion: 1, + }), + ); + await expect + .poll(() => + messages.some( + (message) => message.type === "status" && message.payload.status === "server_info", + ), + ) + .toBe(true); + origin.sendHostFrame( + routeId, + JSON.stringify({ + type: "session", + message: { type: "subscribe_terminal_request", terminalId, requestId: "binary" }, + }), + ); + await expect + .poll(() => messages.some((message) => message.type === "subscribe_terminal_response")) + .toBe(true); + const response = messages.find((message) => message.type === "subscribe_terminal_response"); + if (response?.type !== "subscribe_terminal_response" || response.payload.slot == null) + throw new Error("Missing binary stream slot"); + const slot = response.payload.slot; + return { + close, + output: () => output, + write: (payload: string) => + origin.sendHostFrame( + routeId, + encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Input, slot, payload }), + ), + }; + } catch (error) { + close(); + throw error; + } +} + +async function relayWire(relayPort: number) { + const sockets = new Set(); + let connections = 0; + let active = 0; + const server = net.createServer((source) => { + const target = net.connect(relayPort, "127.0.0.1"); + sockets.add(source); + sockets.add(target); + let counted = false; + target.on("connect", () => { + counted = true; + connections++; + active++; + }); + target.on("error", () => source.destroy()); + source.on("error", () => target.destroy()); + target.on("close", () => { + if (counted) active--; + sockets.delete(target); + source.destroy(); + }); + source.on("close", () => { + sockets.delete(source); + target.destroy(); + }); + source.pipe(target); + target.pipe(source); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing relay wire port"); + return { + port: address.port, + connections: () => connections, + active: () => active, + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 5b85d12a2b..b837502f0c 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -288,6 +288,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => { }); interface SessionForTestOptions { + sendToSource?: SessionOptions["onMessageToSource"]; clientId?: string; permissions?: readonly DaemonPermission[]; agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; @@ -441,6 +442,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { daemonRuntimeConfig: options.daemonRuntimeConfig, permissions: options.permissions ?? OWNER_PERMISSIONS, }; + if (options.sendToSource) sessionOptions.onMessageToSource = options.sendToSource; return new Session(sessionOptions); } @@ -5813,3 +5815,40 @@ test("provider snapshots preserve versionless visibility while capabilities upda ]); expect(references.compactSnapshot!.entries[0]!.modes![0]!.icon).toBe("ShieldCheck"); }); + +test("a failing direct source observes fire-and-forget file replies without unhandled rejections", async () => { + const root = mkdtempSync(join(tmpdir(), "session-send-failure-")); + writeFileSync(join(root, "readme.txt"), "source reply"); + let attempts = 0; + const unhandled: unknown[] = []; + const collect = (error: unknown) => unhandled.push(error); + process.on("unhandledRejection", collect); + const session = createSessionForTest({ + sendToSource: async () => { + attempts++; + throw new Error("Physical source closed during send"); + }, + }); + try { + const source = {}; + session.updateClientCapabilities(null, source); + await session.handleMessage( + { + type: "file_explorer_request", + requestId: "direct-file", + cwd: root, + path: "readme.txt", + mode: "file", + acceptBinary: false, + }, + source, + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(attempts).toBeGreaterThan(0); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", collect); + await session.cleanup(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f2e5e28bf2..69f2c53b45 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,4 +1,6 @@ import { AgentDirectory } from "./agent/directory.js"; +import { ConnectedHostSession } from "./connected-hosts/index.js"; +import type { ConnectedHosts } from "./connected-hosts/index.js"; import { searchTimeline } from "./agent/chat-search/index.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; @@ -434,16 +436,20 @@ const nodeSessionFileSystem: SessionFileSystem = { type AgentMcpTransportFactory = () => Promise; export interface SessionOptions { + connectedHosts?: ConnectedHosts; + sourceId?: string; + routed?: boolean; browserToolsBroker?: BrowserToolsBroker | null; clientId: string; permissions: readonly DaemonPermission[]; appVersion?: string | null; clientCapabilities?: Record | null; onMessage: (msg: SessionOutboundMessage) => void; - onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void; + onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void | Promise; onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; getTransportBufferedAmount?: (source?: object) => number | null; + getConnectedHostBufferedAmount?: () => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise; publishScriptStatusUpdate?: (message: ScriptStatusUpdateMessage) => void; @@ -666,17 +672,31 @@ interface ClientActivity { } export class Session { + private readonly connectedHostSession: ConnectedHostSession | null; + private routed = false; + isRouted(): boolean { + return this.routed; + } + getHostSourceId(): string { + return this.hostSourceId; + } + private hostSourceId: string; + setHostSource(sourceId: string): void { + this.hostSourceId = sourceId; + this.connectedHostSession?.setSource(sourceId); + } + setRouted(routed: boolean): void { + this.routed = routed; + } readonly delivery = new SessionDelivery( (source, message) => { if (!this.authorization.allowsOutbound(message)) return; - if (this.onMessageToSource) this.onMessageToSource(source, message); - else this.onMessage(message); + if (this.onMessageToSource) return this.onMessageToSource(source, message); + this.onMessage(message); }, (source, frame) => { if (this.onBinaryMessageToSource) { - void this.onBinaryMessageToSource(source, frame).catch((error) => - this.sessionLogger.warn({ err: error }, "Failed to emit binary frame"), - ); + return this.onBinaryMessageToSource(source, frame); } else this.emitBinary(frame); }, (source, message) => this.workspaceSetupMessageForClient(message, source), @@ -698,7 +718,7 @@ export class Session { private readonly sessionId: string; private readonly onMessage: (msg: SessionOutboundMessage) => void; private readonly onMessageToSource: - | ((source: object, msg: SessionOutboundMessage) => void) + | ((source: object, msg: SessionOutboundMessage) => void | Promise) | null; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; private readonly onBinaryMessageToSource: @@ -856,13 +876,20 @@ export class Session { this.browserToolsBroker = options.browserToolsBroker; this.clientId = clientId; this.authorization = new SessionAuthorization(permissions); + this.appVersion = appVersion ?? null; this.clientCapabilities = parseClientCapabilities(clientCapabilities); this.sessionId = uuidv4(); + this.hostSourceId = hostSource(options); + this.connectedHostSession = createHostSession(options, this); this.onMessage = onMessage; - this.onMessageToSource = onMessageToSource ?? null; + this.onMessageToSource = onMessageToSource + ? (source, message) => this.observeSourceSend(() => onMessageToSource(source, message)) + : null; this.onBinaryMessage = onBinaryMessage ?? null; - this.onBinaryMessageToSource = onBinaryMessageToSource ?? null; + this.onBinaryMessageToSource = onBinaryMessageToSource + ? (source, frame) => this.observeSourceSend(() => onBinaryMessageToSource(source, frame)) + : null; this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0); this.onLifecycleIntent = onLifecycleIntent ?? null; this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; @@ -2165,7 +2192,7 @@ export class Session { requestId, requestType: msg.type, error: `Session is not authorized for ${msg.type}`, - code: "access_denied", + code: msg.type.startsWith("hosts.") ? "permission_denied" : "access_denied", }, }); } @@ -2212,6 +2239,7 @@ export class Session { public setPermissions(permissions: readonly DaemonPermission[]): void { this.authorization.replacePermissions(permissions); + this.connectedHostSession?.revoke(); if (!this.authorization.allowsPermission("workspace.write")) { void this.delivery .releaseFamily("browser-host") @@ -2366,11 +2394,10 @@ export class Session { private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise { const promise = + this.connectedHostSession?.dispatch(msg) ?? this.dispatchSubscriptionMessage(msg, source) ?? this.dispatchVoiceAndControlMessage(msg) ?? - this.dispatchAgentRewindMessage(msg, source) ?? - this.dispatchAgentRelationshipMessage(msg) ?? - this.dispatchAgentTimelineMessage(msg, source) ?? + this.dispatchAgentObservationMessage(msg, source) ?? this.dispatchHubExecutionMessage(msg) ?? this.dispatchCreationMessage(msg, source) ?? this.dispatchAgentLifecycleMessage(msg) ?? @@ -2388,6 +2415,17 @@ export class Session { if (promise) await promise; } + private dispatchAgentObservationMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { + return ( + this.dispatchAgentRewindMessage(msg, source) ?? + this.dispatchAgentRelationshipMessage(msg) ?? + this.dispatchAgentTimelineMessage(msg, source) + ); + } + private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { return ( this.dispatchWorkspaceStateMessage(msg) ?? @@ -3169,6 +3207,10 @@ export class Session { } public async handleBinaryFrame(binaryFrame: BinaryFrame, source: object): Promise { + if (binaryFrame.kind === "host_route") { + this.connectedHostSession?.binary(binaryFrame.frame, source); + return; + } if (!this.authorization.allowsPermission("workspace.write")) { return; } @@ -8543,6 +8585,20 @@ export class Session { this.emitBinary(frame); } + private observeSourceSend(send: () => void | Promise): Promise { + let pending: Promise; + try { + pending = Promise.resolve(send()); + } catch (error) { + pending = Promise.reject(error); + } + // Legacy producers fire and forget; owned streams await the same failure. + void pending.catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to emit source frame"), + ); + return pending; + } + private emitForSource(msg: SessionOutboundMessage, source?: object): void { if (source && this.onMessageToSource) { msg = this.delivery.authorizeReply(msg, source); @@ -8691,3 +8747,21 @@ function legacyWantsEvent( return true; } } + +function createHostSession(options: SessionOptions, session: Session): ConnectedHostSession | null { + if (!options.connectedHosts) return null; + return new ConnectedHostSession( + options.connectedHosts, + session.delivery, + { sourceId: options.sourceId ?? "owner", manageSources: false }, + () => session.getPermissions(), + (message) => session.publish(message), + () => session.isRouted(), + session.getSessionId(), + () => options.getConnectedHostBufferedAmount?.() ?? 0, + ); +} + +function hostSource(options: SessionOptions): string { + return options.sourceId ?? "owner"; +} diff --git a/packages/server/src/server/session/owned-subscriptions/index.test.ts b/packages/server/src/server/session/owned-subscriptions/index.test.ts index 8e8e33a4ce..4f4ab58382 100644 --- a/packages/server/src/server/session/owned-subscriptions/index.test.ts +++ b/packages/server/src/server/session/owned-subscriptions/index.test.ts @@ -169,3 +169,40 @@ test("failed teardown still closes the delivery and rejects late work", async () delivery.request(source, { type: "ping", requestId: "late" }, async () => {}), ).rejects.toThrow("Session delivery is closed"); }); + +test("owned send completion retains source proofs and reports physical failures to awaiting producers", async () => { + const source = {}; + const failure = new Error("Physical send failed"); + const delivery = new SessionDelivery( + async (socket, message) => { + expect(socket).toBe(source); + expect(delivery.permits(socket, message)).toBe(true); + throw failure; + }, + async (socket, frame) => { + expect(socket).toBe(source); + expect(delivery.permitsBinary(socket, frame)).toBe(true); + throw failure; + }, + ); + delivery.attach(source, true); + await delivery.request( + source, + { type: "hosts.link.open.request", requestId: "open", channelId: "scope" }, + async () => { + const owner = delivery.begin("host-link", undefined, () => {}); + await expect( + owner.emitAndWait({ + type: "hosts.route.frame", + payload: { subscriptionId: owner.id, data: "text", binary: false }, + }), + ).rejects.toBe(failure); + await expect(owner.emitBinaryAndWait(new Uint8Array([0, 255]))).rejects.toBe(failure); + await owner.release(); + await expect(owner.emitBinaryAndWait(new Uint8Array([0]))).rejects.toThrow( + "Subscription source is closed", + ); + }, + ); + await delivery.close(); +}); diff --git a/packages/server/src/server/session/owned-subscriptions/index.ts b/packages/server/src/server/session/owned-subscriptions/index.ts index 6ee5c91fa5..c17e5829be 100644 --- a/packages/server/src/server/session/owned-subscriptions/index.ts +++ b/packages/server/src/server/session/owned-subscriptions/index.ts @@ -51,6 +51,8 @@ export interface OwnedSubscription { readonly signal: AbortSignal; emit(message: SessionOutboundMessage): void; emitBinary(frame: Uint8Array): void; + emitAndWait(message: SessionOutboundMessage): Promise; + emitBinaryAndWait(frame: Uint8Array): Promise; release(): Promise; } @@ -67,8 +69,14 @@ export class SessionDelivery { private closing?: Promise; constructor( - private readonly send: (source: object, message: SessionOutboundMessage) => void, - private readonly sendBinary: (source: object, frame: Uint8Array) => void = () => {}, + private readonly send: ( + source: object, + message: SessionOutboundMessage, + ) => void | Promise, + private readonly sendBinary: ( + source: object, + frame: Uint8Array, + ) => void | Promise = () => {}, private readonly project: ( source: object, message: SessionOutboundMessage, @@ -319,7 +327,18 @@ export class SessionDelivery { emitBinary: (frame) => { if (!owner.active || !source.active) return; this.proofs.set(frame, owner); - this.sendBinary(source.socket, frame); + void Promise.resolve(this.sendBinary(source.socket, frame)).catch(() => {}); + }, + emitAndWait: async (message) => { + if (!owner.active || !source.active) throw new Error("Subscription source is closed"); + const projected = this.project(source.socket, withSubscriptionId(message, responseId)); + this.proofs.set(projected, owner); + await this.send(source.socket, projected); + }, + emitBinaryAndWait: async (frame) => { + if (!owner.active || !source.active) throw new Error("Subscription source is closed"); + this.proofs.set(frame, owner); + await this.sendBinary(source.socket, frame); }, release: () => this.releaseOwner(owner), }; @@ -414,7 +433,7 @@ export class SessionDelivery { private sendOwned(owner: DeliveryOwner, message: SessionOutboundMessage): void { const projected = this.project(owner.source.socket, message); this.proofs.set(projected, owner); - this.send(owner.source.socket, projected); + void Promise.resolve(this.send(owner.source.socket, projected)).catch(() => {}); } private releaseOwner(owner: RetainedOwner): Promise { diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 10694a6ebe..a3c821abd1 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -71,6 +71,18 @@ const sessionMock = vi.hoisted(() => { wantsSourceEvent = (source: object) => !this.delivery.isModern(source); getSessionId = vi.fn(() => "mock-session-id"); getPermissions = vi.fn(() => this.args.permissions as string[]); + setPermissions = (permissions: string[]) => { + this.args.permissions = permissions; + }; + private routed = false; + setRouted = (routed: boolean) => { + this.routed = routed; + }; + isRouted = () => this.routed; + setHostSource = (sourceId: string) => { + this.args.sourceId = sourceId; + }; + getHostSourceId = () => this.args.sourceId as string; allowsInbound = vi.fn(() => true); allowsPermission = vi.fn(() => true); publish = vi.fn((message: unknown) => { @@ -473,16 +485,12 @@ function holdNextSessionMessage(session: (typeof sessionMock.instances)[number]) function holdSessionCleanup(session: (typeof sessionMock.instances)[number]): { finish: () => void; } { - let finish = () => {}; - session.cleanup.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - return { - finish: () => finish(), - }; + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + session.cleanup.mockImplementationOnce(() => pending); + return { finish }; } describe("relay external socket reconnect behavior", () => { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 13d439526b..8f36e1a2b8 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,7 @@ +import { createToolHosts } from "./agent/host-operations/index.js"; +import { HOST_ROUTE_OPCODE } from "@getpaseo/protocol/binary-frames/index"; +import { DaemonLinkEndpoint, VirtualHostSocket } from "./connected-hosts/index.js"; +import { ConnectedHosts } from "./connected-hosts/index.js"; import { stat } from "node:fs/promises"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import { CreationService } from "./creation/index.js"; @@ -437,6 +441,8 @@ export interface WebSocketLike { } interface SessionConnectionBase { + hubExecutionAgents?: HubExecutionAgents; + permissionCeiling?: readonly DaemonPermission[]; session: Session; principalId: string; sessionKey: string; @@ -460,13 +466,14 @@ interface PluginSessionConnection extends SessionConnectionBase { type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection; interface SocketSessionOptions { + sourceId?: string; clientId: string; appVersion: string | null; clientCapabilities: Record | null; permissions: readonly DaemonPermission[]; connectionLogger: pino.Logger; onMessage: (message: SessionOutboundMessage) => void; - onMessageToSource?: (source: object, message: SessionOutboundMessage) => void; + onMessageToSource?: (source: object, message: SessionOutboundMessage) => void | Promise; onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; getTransportBufferedAmount?: (source?: object) => number | null; @@ -527,6 +534,7 @@ function requireWebSocketServices(params: { export class VoiceAssistantWebSocketServer { private readonly logger: pino.Logger; private readonly wss: WebSocketServer; + private readonly hostLinks = new Map(); private readonly pendingConnections: Map = new Map(); private readonly sessions: Map = new Map(); private readonly socketIdentities: Map = new Map(); @@ -550,6 +558,11 @@ export class VoiceAssistantWebSocketServer { private readonly workspaceAutoName: WorkspaceAutoName; private readonly downloadTokenStore: DownloadTokenStore; private readonly paseoHome: string; + private readonly connectedHosts: ConnectedHosts; + + toolHosts() { + return createToolHosts(this.connectedHosts); + } private readonly passwordHash: string | undefined; private readonly credentialSource: DaemonAuthConfig | undefined; private readonly worktreesRoot: string | undefined; @@ -701,6 +714,7 @@ export class VoiceAssistantWebSocketServer { this.workspaceAutoName = workspaceAutoName; this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; + this.connectedHosts = new ConnectedHosts(paseoHome, { serverId, label: getHostName() }); this.passwordHash = auth?.password; this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.daemonConfigStore = daemonConfigStore; @@ -1047,6 +1061,12 @@ export class VoiceAssistantWebSocketServer { principalId: string, permissions: readonly DaemonPermission[], ): void { + for (const [socket, link] of this.hostLinks) { + if (this.sessions.get(socket)?.principalId === principalId) { + link.close("permission_denied"); + this.hostLinks.delete(socket); + } + } for (const pending of this.pendingConnections.values()) { if (pending.admission?.principalId === principalId) { pending.admission = { ...pending.admission, permissions }; @@ -1054,7 +1074,11 @@ export class VoiceAssistantWebSocketServer { } for (const connection of new Set(this.externalSessionsByKey.values())) { if (connection.principalId === principalId) { - connection.session.setPermissions(permissions); + connection.session.setPermissions( + connection.permissionCeiling + ? permissions.filter((permission) => connection.permissionCeiling!.includes(permission)) + : permissions, + ); } } } @@ -1071,6 +1095,7 @@ export class VoiceAssistantWebSocketServer { public async close(): Promise { this.prepareForShutdown(); + await this.connectedHosts.close(); this.unsubscribeSpeechReadiness?.(); this.unsubscribeSpeechReadiness = null; this.unsubscribeDaemonConfigChange?.(); @@ -1159,6 +1184,20 @@ export class VoiceAssistantWebSocketServer { message: WSOutboundMessage, reportRejection = false, ): void { + const prepared = this.prepareJsonDelivery(sockets, message, reportRejection); + if (!prepared) return; + for (const ws of prepared.sockets) { + this.sendFrameToClient(ws, prepared.payload, prepared.bytes, () => { + this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount); + }); + } + } + + private prepareJsonDelivery( + sockets: Iterable, + message: WSOutboundMessage, + reportRejection: boolean, + ): { sockets: WebSocketLike[]; payload: string; bytes: number } | null { const writableSockets = [...sockets].filter((ws) => { if ( message.type === "session" && @@ -1180,7 +1219,7 @@ export class VoiceAssistantWebSocketServer { return this.ensureOutboundCapacity(ws, 0); }); if (writableSockets.length === 0) { - return; + return null; } let payload: string; @@ -1188,14 +1227,30 @@ export class VoiceAssistantWebSocketServer { payload = JSON.stringify(message); } catch (err) { this.logger.warn({ err }, "ws_serialize_failed"); - return; + return null; } - const payloadBytes = outboundFrameByteLength(payload); - for (const ws of writableSockets) { - this.sendFrameToClient(ws, payload, payloadBytes, () => { - this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount); + return { sockets: writableSockets, payload, bytes: outboundFrameByteLength(payload) }; + } + + private async sendMessageToClientAndWait( + ws: WebSocketLike, + message: WSOutboundMessage, + ): Promise { + const prepared = this.prepareJsonDelivery([ws], message, true); + if (!prepared) throw new Error("Source message delivery unavailable"); + try { + const sent = await sendBoundedPhysicalFrameAndWait({ + socket: ws, + frame: prepared.payload, + frameBytes: prepared.bytes, + onHighWater: () => this.closeAtOutboundHighWater(ws), }); + if (!sent) throw new Error("Source is closed"); + this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount); + } catch (err) { + this.logger.warn({ err }, "ws_send_failed"); + throw err; } } @@ -1394,6 +1449,10 @@ export class VoiceAssistantWebSocketServer { appVersion, clientCapabilities, permissions: admission.permissions, + sourceId: + lifecycle.kind === "ephemeral-plugin" + ? `plugin:${lifecycle.pluginId}` + : admission.principalId, connectionLogger, onMessage: (msg) => { if (!connection) { @@ -1401,11 +1460,11 @@ export class VoiceAssistantWebSocketServer { } this.sendToConnection(connection, wrapSessionMessage(msg)); }, - onMessageToSource: (source, msg) => { + onMessageToSource: async (source, msg) => { if (!connection || !connection.sockets.has(source as WebSocketLike)) { return; } - this.sendToClient(source as WebSocketLike, wrapSessionMessage(msg)); + await this.sendMessageToClientAndWait(source as WebSocketLike, wrapSessionMessage(msg)); }, onBinaryMessage: (frame) => { if (!connection) { @@ -1446,6 +1505,7 @@ export class VoiceAssistantWebSocketServer { const base: SessionConnectionBase = { session, principalId: admission.principalId, + hubExecutionAgents: admission.hubExecutionAgents, sessionKey: sessionConnectionKey(admission.principalId, clientId), clientId, appVersion, @@ -1463,6 +1523,17 @@ export class VoiceAssistantWebSocketServer { private createSocketSession(options: SocketSessionOptions): Session { return new Session({ + connectedHosts: this.connectedHosts, + getConnectedHostBufferedAmount: () => { + let total = 0; + for (const socket of this.sessions.keys()) { + if (socket instanceof VirtualHostSocket) continue; + if (typeof socket.bufferedAmount !== "number") return null; + total += socket.bufferedAmount; + } + return total; + }, + sourceId: options.sourceId, browserToolsBroker: this.browserToolsBroker, clientId: options.clientId, appVersion: options.appVersion, @@ -1616,8 +1687,16 @@ export class VoiceAssistantWebSocketServer { } this.clearPendingConnection(ws); - const admitted = pending.admission; + let admitted = pending.admission; if (!admitted) throw new Error("Admitted hello has no session admission"); + if (message.sessionPermissions) + admitted = { + ...admitted, + permissions: admitted.permissions.filter((permission) => + message.sessionPermissions!.includes(permission), + ), + }; + pending.admission = admitted; pending.identity.clientId = clientId; if (message.appVersion) { pending.identity.appVersion = message.appVersion; @@ -1640,6 +1719,9 @@ export class VoiceAssistantWebSocketServer { lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" }, admission: admitted, }); + connection.permissionCeiling = message.sessionPermissions; + connection.session.setRouted(message.routed === true); + this.assignHostSource(connection, message, admitted.principalId, pluginId); this.sessions.set(ws, connection); if (connection.lifecycle === "reconnectable") { this.externalSessionsByKey.set(sessionKey, connection); @@ -1755,6 +1837,14 @@ export class VoiceAssistantWebSocketServer { clearTimeout(existing.externalDisconnectCleanupTimeout); existing.externalDisconnectCleanupTimeout = null; } + for (const socket of existing.sockets) { + this.hostLinks.get(socket)?.close("permission_denied"); + this.hostLinks.delete(socket); + } + existing.permissionCeiling = message.sessionPermissions; + existing.session.setPermissions(pending.admission!.permissions); + existing.session.setRouted(message.routed === true); + this.assignHostSource(existing, message, pending.admission!.principalId); const newAppVersion = message.appVersion ?? null; if (newAppVersion && newAppVersion !== existing.appVersion) { existing.appVersion = newAppVersion; @@ -1785,6 +1875,21 @@ export class VoiceAssistantWebSocketServer { ); } + private assignHostSource( + connection: SessionConnection, + hello: WSHelloMessage, + principalId: string, + pluginId?: string, + ): void { + if (pluginId) return; + const producer = hello.producer; + const sourceId = + hello.routed && producer + ? `route:${[principalId, producer.serverId, producer.sourceId].map(encodeURIComponent).join(":")}` + : principalId; + connection.session.setHostSource(sourceId); + } + private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload { return { status: "server_info", @@ -1792,11 +1897,14 @@ export class VoiceAssistantWebSocketServer { serverId: this.serverId, hostname: getHostName(), version: this.daemonVersion, + sourceId: session.getHostSourceId(), permissions: session.getPermissions(), // COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16. desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { + connectedHosts: true, + hostMultiplexing: true, usageSources: true, ownedSubscriptions: true, agentRequestReceipts: true, @@ -2040,6 +2148,9 @@ export class VoiceAssistantWebSocketServer { error?: Error; }, ): Promise { + this.hostLinks.get(ws)?.close(); + this.hostLinks.delete(ws); + this.applicationSocketLease.release(ws); const identity = this.socketIdentities.get(ws); const identityFields = identity ? toConnectionLogFields(identity) : {}; @@ -2088,6 +2199,10 @@ export class VoiceAssistantWebSocketServer { this.finishPluginSocketCleanup(ws); return; } + if (connection.session.isRouted()) { + await this.cleanupConnection(connection, "Routed session disconnected"); + return; + } this.incrementRuntimeCounter("sessionDisconnectedWaitingReconnect"); if (connection.externalDisconnectCleanupTimeout) { clearTimeout(connection.externalDisconnectCleanupTimeout); @@ -2230,6 +2345,12 @@ export class VoiceAssistantWebSocketServer { return false; } const decodedFrame = decodeBinaryFrame(asBytes); + if (asBytes[0] === HOST_ROUTE_OPCODE && !decodedFrame) { + ws.close(1008, "Invalid host routing frame"); + return true; + } + if (decodedFrame?.kind === "host_route" && this.hostLinks.get(ws)?.binary(decodedFrame.frame)) + return true; if (!decodedFrame) { return false; } @@ -2384,11 +2505,38 @@ export class VoiceAssistantWebSocketServer { } } + private hostLink(ws: WebSocketLike, connection: SessionConnection): DaemonLinkEndpoint { + let link = this.hostLinks.get(ws); + if (!link) { + link = new DaemonLinkEndpoint({ + identity: JSON.stringify([connection.principalId, connection.clientId]), + permissions: () => connection.session.getPermissions(), + routed: () => connection.session.isRouted(), + bufferedAmount: () => ws.bufferedAmount ?? null, + delivery: connection.session.delivery, + admit: (socket, permissions) => + this.attachSocket(socket, undefined, undefined, false, { + principalId: connection.principalId, + permissions, + }), + }); + this.hostLinks.set(ws, link); + } + return link; + } + private async dispatchSessionMessage( ws: WebSocketLike, activeConnection: SessionConnection, message: Extract, ): Promise { + const link = this.hostLink(ws, activeConnection); + if (link.handles(message.message)) { + await activeConnection.session.delivery.request(ws, message.message, () => + link.dispatch(message.message), + ); + return; + } this.recordInboundSessionRequestType(message.message.type); const controlRpc = getControlRpcLogInfo(message.message); if (controlRpc) { diff --git a/public-docs/cli.md b/public-docs/cli.md index f118d35894..dd61ef60ae 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -64,6 +64,31 @@ Entries may also include `loginErrors`. Structured output preserves numeric valu A completed query exits with status `0`, including when individual reports are unavailable or failed. Connection or request failures, unsupported hosts, unknown report IDs, and conflicting selectors exit with status `1` and write the error to stderr. Human output says **No usage reports returned** for an empty result; this does not establish whether account discovery succeeded for every source. +## Connected hosts + +`--host` selects the daemon whose saved connections you manage. Registrations use their +`registrationId`, not their label. + +```sh +paseo --host localhost:6767 host add 'ssh://deploy@build-box' --label build-box --meta team=infra +paseo --host localhost:6767 host ls --json +paseo --host localhost:6767 host inspect hst_123 +paseo --host localhost:6767 host update hst_123 --meta team=tools --remove-meta obsolete +paseo --host localhost:6767 host update hst_123 --connection 'ssh://deploy@new-address' +paseo --host localhost:6767 host disable hst_123 +paseo --host localhost:6767 host enable hst_123 +paseo --host localhost:6767 host rm hst_123 +``` + +Add accepts TCP/host-and-port, `ws://`/`wss://`, local socket/pipe, SSH, and relay/pairing URLs. +SSH connects to an already running daemon. Add `?password=...` to an SSH URI when that daemon requires a password (`&password=...` after `daemonPort`). Unreachable targets remain saved and retry automatically. +`ls` includes disabled and failed hosts. `inspect` includes safe connection details and recovery guidance. +JSON output never includes connection credentials. + +`update --meta` replaces supplied keys; `--remove-meta` removes keys. Replacing a connection retains +the pinned daemon identity. Add `--replace-identity` with `--connection` only after verifying a changed +destination. `rm` removes the saved connection; agents and workspaces on that host keep running. + ## Provider diagnostics Ask the daemon to inspect the provider environment it actually uses: diff --git a/public-docs/cli.zh-CN.md b/public-docs/cli.zh-CN.md index 4384c8219a..4d2cec8372 100644 --- a/public-docs/cli.zh-CN.md +++ b/public-docs/cli.zh-CN.md @@ -62,6 +62,30 @@ paseo usage ls --quiet 查询完成时以状态 `0` 退出,即使个别报告不可用或失败也是如此。连接或请求失败、不支持的主机、未知的报告 ID 以及相互冲突的选择器会以状态 `1` 退出,并把错误写到 stderr。结果为空时,人类可读的输出会显示 **No usage reports returned**;这并不能说明每个来源的账号发现是否都成功了。 +## 已连接的主机 {#connected-hosts} + +`--host` 选择要管理其已保存连接的 daemon。请用注册项的 `registrationId`,不要用标签。 + +```sh +paseo --host localhost:6767 host add 'ssh://deploy@build-box' --label build-box --meta team=infra +paseo --host localhost:6767 host ls --json +paseo --host localhost:6767 host inspect hst_123 +paseo --host localhost:6767 host update hst_123 --meta team=tools --remove-meta obsolete +paseo --host localhost:6767 host update hst_123 --connection 'ssh://deploy@new-address' +paseo --host localhost:6767 host disable hst_123 +paseo --host localhost:6767 host enable hst_123 +paseo --host localhost:6767 host rm hst_123 +``` + +`add` 接受 TCP/主机和端口、`ws://`/`wss://`、本地 socket/管道、SSH,以及 relay/配对 URL。 +SSH 连接到已经运行的 daemon。如果该 daemon 需要密码,在 SSH URI 中添加 `?password=...`(已有 `daemonPort` 时使用 `&password=...`)。无法访问的目标仍会保存,并自动重试。 +`ls` 包括已禁用和连接失败的主机。`inspect` 包括不含凭据的连接详情和恢复说明。 +JSON 输出不会包含连接凭据。 + +`update --meta` 替换指定的键;`--remove-meta` 删除键。替换连接会保留已固定的 daemon 身份。 +只有确认目标已改变后,才应同时使用 `--replace-identity` 和 `--connection`。 +`rm` 删除已保存的连接;该主机上的 Agent 和工作区会继续运行。 + ## Provider 诊断 {#provider-diagnostics} 让 daemon 检查它实际使用的 Provider 环境: diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index e456e625bb..16fde6470b 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -27,6 +27,26 @@ Local plugins are directory sources installed into one Paseo daemon. A plugin ca Plugin code is trusted and unsandboxed. Client surfaces run in the Paseo app. Backend contributions run in a subprocess with access to the daemon machine, including its files, processes, credentials, and network. +## Connected hosts + +`usePaseo().hosts` in a client surface and `context.paseo.hosts` in a server handler or lifecycle hook +use the same [SDK host API](/sdk/reference#clienthosts). They describe the selected daemon's saved +connections. Existing `useHosts()` and `getPaseoClient(serverId)` keep describing app-known hosts. + +```ts +const remote = await context.paseo.hosts.client("srv_build_box"); +try { + return await remote.workspaces.list(); +} finally { + await remote.dispose(); +} +``` + +A returned API owns its observations. Teardown of the plugin API also disposes derived scopes. +Keep remote calls inside the handler or lifecycle scope that owns them. Calling B does not forward +B's plugin lifecycle hooks to A. Registrations created by a routed plugin retain a source qualified +by the authenticated link and originating plugin. + ## Project files `paseo plugin init /absolute/path/to/my-plugin` creates: diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 4f43b2b616..a70d91e7bc 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -25,6 +25,25 @@ nav: 参考 插件代码是受信任的,没有沙盒。客户端界面在 Paseo 应用中运行。后端贡献在一个子进程中运行,可以访问 daemon 所在的机器,包括它的文件、进程、凭据和网络。 +## 已连接的主机 {#connected-hosts} + +客户端界面中的 `usePaseo().hosts` 与服务端处理函数或生命周期钩子中的 `context.paseo.hosts` +使用相同的 [SDK 主机 API](/sdk/reference#clienthosts)。它们描述所选 daemon 保存的连接。 +现有的 `useHosts()` 和 `getPaseoClient(serverId)` 仍描述应用已知的主机。 + +```ts +const remote = await context.paseo.hosts.client("srv_build_box"); +try { + return await remote.workspaces.list(); +} finally { + await remote.dispose(); +} +``` + +返回的 API 拥有自己的观察。销毁插件 API 时也会释放由它派生的作用域。 +远程调用应留在拥有它的处理函数或生命周期作用域内。调用 B 不会把 B 的插件生命周期钩子转发到 A。 +通过路由插件创建的注册项保留其来源身份,其中包含经过认证的链路和发起调用的插件。 + ## 项目文件 {#project-files} `paseo plugin init /absolute/path/to/my-plugin` 会创建: diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 76490974fe..2867261c32 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -59,6 +59,8 @@ Create a new client after `close()`. Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, `background`, and `labels`. +`parent` accepts a host-local agent ID, an `AgentAddress` (`{ serverId, agentId }`), or an agent handle. A handle’s `address` keeps its host identity after disconnect. When creating through `hosts.client(serverId)`, pass the parent handle or complete address; the SDK carries parent metadata to the destination. + `background` is workspace creation intent. It cannot accompany an existing `workspaceId`. See [background workspaces](./workspaces.md) for caller inheritance and inclusive discovery. @@ -80,6 +82,7 @@ See [background workspaces](./workspaces.md) for caller inheritance and inclusiv | Member | Result | Behavior | | ------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `string` | Stable daemon agent ID. | +| `address` | `AgentAddress` | Complete `{ serverId, agentId }` identity; requires a connected host to establish it. | | `workspaceId` | `string \| null` | Current workspace placement. | | `cwd` | `string \| null` | Current working directory. | | `status` | Agent status or `null` | Current lifecycle status. | @@ -203,6 +206,63 @@ Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options `config.patch(patch, requestId?)` validates, persists, and returns an updated configuration. Use this administrative surface for host configuration, not per-agent choices. A patch affects every client and future agent using that daemon. +## `client.hosts` + +Manage hosts connected to the selected daemon. Both the managing daemon and destination must support connected hosts. + +```ts +const directory = await client.hosts.list(); +const registration = await client.hosts.add({ + connection: "ssh://deploy@build-box", + label: "Build box", + metadata: { team: "infra" }, + sourceKey: "build-box", +}); +await client.hosts.update(registration.registrationId, { enabled: false }); +await client.hosts.remove(registration.registrationId); +``` + +`list()` returns `{ self: { serverId, label }, hosts }`. Each host includes its registration and server +IDs, label, authenticated source, string metadata, enabled state, connection status, safe target +description, last-online/retry timestamps, and an actionable error. Credentials are never returned. +A valid but unreachable connection is saved and retried. Invalid syntax fails without saving. + +`update(registrationId, patch)` accepts `label`, `metadata`, `enabled`, `connection`, and +`replaceIdentity`. Metadata replaces the whole map. Replacing a connection preserves the expected +server ID unless `replaceIdentity: true` explicitly accepts a new destination. + +`observe()` returns an owned subscription. Await `ready`, subscribe for snapshots and `hosts.changed` +updates, then call `release()`. Observations restore a current snapshot after reconnect. + +```ts +const remote = await client.hosts.client("srv_build_box"); +try { + const workspaces = await remote.workspaces.list(); + const providers = await remote.providers.listAvailable(); + await remote.agents.ref("agent_on_build_box").send("Continue the investigation"); +} finally { + await remote.dispose(); +} +``` + +`client(serverId)` returns a caller-owned `PaseoApi` bound to that destination through the selected +daemon. The ordinary APIs keep their signatures. Paths and IDs belong to the destination. Selecting +the local server ID returns a local scope. Routing supports one hop. + +Disposing one scope releases its observations without closing siblings or deleting the saved host. +Origin disposal cascades. Observations recover through the ordinary client lifecycle. While connecting, +new requests use the existing bounded send queue; disconnected requests fail. Connection loss clears +old waiters and queued sends. Ordinary non-recoverable requests are not replayed; existing +acknowledged/idempotent creation recovery retains its request identity. A submitted request whose response +is lost rejects with `DaemonConnectionError` (`code: "DAEMON_CONNECTION_LOST"`), optional +`outcome: "unknown"` and known `serverId`. Inspect before retrying a mutation. + +Registration and host acquisition failures expose a stable `code`: `host_unknown`, `host_disabled`, `host_offline`, +`permission_denied`, `unsupported_capability`, `authentication_failed`, `server_id_mismatch`, +`self_host`, or `duplicate_host`. Ordinary RPC permission codes, including `access_denied`, pass through +unchanged. Multiplexing is a transport; consumers use the same client/API operations and lifecycle. +Discovery does not grant execution authority. + ## Errors and cleanup Connection, validation, rejection, and timeout failures reject their promise. Turn outcomes are returned through `PaseoAgentRunResult.status` because permission and provider errors are expected agent states. diff --git a/public-docs/sdk/reference.zh-CN.md b/public-docs/sdk/reference.zh-CN.md index a09c342731..fcda38bc89 100644 --- a/public-docs/sdk/reference.zh-CN.md +++ b/public-docs/sdk/reference.zh-CN.md @@ -57,6 +57,8 @@ nav: API 参考 创建选项包括 `config`、`cwd`、`parent`、`title`、`prompt`、`env`、`outputSchema`、`images`、`attachments`、`git`、`worktree`、`autoArchive`、`background` 和 `labels`。 +`parent` 接受主机本地的 Agent ID、`AgentAddress`(`{ serverId, agentId }`)或 Agent 句柄。句柄的 `address` 在断开连接后仍保留其主机身份。通过 `hosts.client(serverId)` 创建时,请传入父 Agent 句柄或完整地址;SDK 会把父级元数据带到目标主机。 + `background` 表示创建工作区的意图。它不能与已有的 `workspaceId` 一起使用。调用方继承规则和包含后台的发现方式见[后台工作区](./workspaces.md)。 `config` 接受: @@ -77,6 +79,7 @@ nav: API 参考 | 成员 | 结果 | 行为 | | ------------------------------ | --------------------------------- | ----------------------------------------------------------------------- | | `id` | `string` | 稳定的 daemon Agent ID。 | +| `address` | `AgentAddress` | 完整的 `{ serverId, agentId }` 身份;需要连接到主机才能确定。 | | `workspaceId` | `string \| null` | 当前所在的工作区。 | | `cwd` | `string \| null` | 当前工作目录。 | | `status` | Agent 状态或 `null` | 当前的生命周期状态。 | @@ -197,6 +200,57 @@ capture 接受可选的 `start`、`end`、`stripAnsi` 和 `requestId`。行范 `config.patch(patch, requestId?)` 会校验、持久化并返回更新后的配置。这个管理接口用于主机配置,而不是针对单个 Agent 的选择。一次 patch 会影响使用该 daemon 的每个客户端和之后的每个 Agent。 +## `client.hosts` {#clienthosts} + +管理连接到所选 daemon 的主机。管理连接的 daemon 和目标主机都必须支持已连接主机功能。 + +```ts +const directory = await client.hosts.list(); +const registration = await client.hosts.add({ + connection: "ssh://deploy@build-box", + label: "Build box", + metadata: { team: "infra" }, + sourceKey: "build-box", +}); +await client.hosts.update(registration.registrationId, { enabled: false }); +await client.hosts.remove(registration.registrationId); +``` + +`list()` 返回 `{ self: { serverId, label }, hosts }`。每个主机包括注册 ID 和服务器 ID、标签、经过认证的来源、字符串元数据、启用状态、连接状态、不含凭据的目标描述、上次在线和重试时间戳,以及带恢复说明的错误。它不会返回凭据。 +语法有效但无法访问的连接会被保存并重试。无效语法会直接报错,不会保存。 + +`update(registrationId, patch)` 接受 `label`、`metadata`、`enabled`、`connection` 和 `replaceIdentity`。 +元数据会替换整个映射。替换连接会保留预期的服务器 ID,除非通过 `replaceIdentity: true` 显式接受新目标。 + +`observe()` 返回一个由调用方持有的订阅。等待 `ready`,订阅快照和 `hosts.changed` 更新,然后调用 `release()`。 +重新连接后,观察会恢复并提供当前快照。 + +```ts +const remote = await client.hosts.client("srv_build_box"); +try { + const workspaces = await remote.workspaces.list(); + const providers = await remote.providers.listAvailable(); + await remote.agents.ref("agent_on_build_box").send("Continue the investigation"); +} finally { + await remote.dispose(); +} +``` + +`client(serverId)` 返回一个由调用方持有的 `PaseoApi`,通过所选 daemon 绑定到该目标。 +普通 API 的签名不变。路径和 ID 属于目标主机。选择本地服务器 ID 会返回本地作用域。路由支持一跳。 + +释放一个作用域会释放它的观察,不会关闭同级作用域或删除已保存的主机。 +释放源作用域会同时释放派生作用域。观察通过普通客户端生命周期恢复。 +连接过程中,新请求使用现有的有界发送队列;断开连接时请求会失败。连接丢失会清除旧的等待项和排队发送项。 +普通的不可恢复请求不会重放;现有的已确认或幂等创建恢复会保留其请求身份。 +已提交但响应丢失的请求会以 `DaemonConnectionError` 拒绝(`code: "DAEMON_CONNECTION_LOST"`), +并附带可选的 `outcome: "unknown"` 和已知的 `serverId`。重试修改操作前,请先检查目标状态。 + +注册和获取主机失败时会提供稳定的 `code`:`host_unknown`、`host_disabled`、`host_offline`、 +`permission_denied`、`unsupported_capability`、`authentication_failed`、`server_id_mismatch`、 +`self_host` 或 `duplicate_host`。普通 RPC 权限代码(包括 `access_denied`)保持不变。 +多路复用属于传输层;调用方使用相同的客户端/API 操作和生命周期。发现主机不会授予执行权限。 + ## 错误与清理 {#errors-and-cleanup} 连接、校验、拒绝和超时失败都会让对应的 promise reject。轮次的结果通过 `PaseoAgentRunResult.status` 返回,因为权限和 Provider 错误是 Agent 预期内的状态。 diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 6fa13ba0ec..0ec90d2f8b 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -487,7 +487,7 @@ test("Windows units and integration run independently with complete routed cover assert.doesNotMatch(invocations[0], / -t /); assert.equal( invocations[1], - 'vitest run --maxWorkers=1 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"', + 'vitest run --maxWorkers=1 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"', ); const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8"); assert.doesNotMatch(serverConfig, /maxWorkers/); From 5878680f7f632f70e875a8fceaf8f5125df63d5a Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:20:13 +0000 Subject: [PATCH 09/62] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 7c3e526100..3f3180e274 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-Et1ofRqkqFoGETnbeqXuy2RerFT0kkk5F+VwDXak+fk= +sha256-La6xekVRQ9F6UulA6OHX5SzaXv6L0qZ2KpGkRScFd8Q= From d0568f9f1b61791e4523887f677fc97435b15e7c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 18:28:03 +0700 Subject: [PATCH 10/62] Batch agent reports and completion notifications (#7561) * fix(server): batch agent reports and completion notifications Admit agent-originated messages immediately so a child can report and finish before one recipient-owned delivery. Keep human prompts direct, retain individual provenance through replay, and complete send receipts only after provider dispatch. * test(server): wire queued delivery into bridge integration fixture * test(server): resolve batching CLI fixture independently of cwd Workspace CI runs from packages/server, while the original regression was verified from the repository root. Resolve the CLI entrypoint relative to this test file in both contexts. * test(server): run batching CLI fixture with source exports Server integration CI builds only the client dependency stack. Match the existing CLI subprocess fixtures so the send regression exercises workspace sources without requiring compiled server output. * test(server): await persisted batch receipts within integration deadline The provider-start callback precedes asynchronous receipt commits. Use the same explicit integration deadline as dispatch instead of the matcher default of one second on Windows CI. --- docs/agent-lifecycle.md | 7 +- packages/app/src/composer/actions.ts | 2 +- packages/app/src/runtime/host-runtime.test.ts | 1 + packages/cli/src/commands/agent/send.ts | 11 +- packages/client/src/daemon-client.ts | 35 +- packages/protocol/src/agent-message.ts | 1 + packages/protocol/src/messages.ts | 4 + .../src/server/agent/activity-curator.test.ts | 6 +- .../src/server/agent/agent-manager.test.ts | 78 +++ .../server/src/server/agent/agent-manager.ts | 99 ++-- .../agent/agent-messages/delivery.test.ts | 285 +++++++++ .../server/agent/agent-messages/delivery.ts | 189 ++++++ .../server/agent/agent-messages/envelope.ts | 221 +++++++ .../src/server/agent/agent-messages/index.ts | 202 +------ .../src/server/agent/agent-prompt.test.ts | 76 ++- .../server/src/server/agent/agent-prompt.ts | 49 +- .../src/server/agent/create-agent/create.ts | 9 +- .../src/server/agent/host-operations/index.ts | 16 +- .../src/server/agent/mcp-server.test.ts | 98 ++-- .../agent/providers/opencode/bridge.test.ts | 14 +- .../src/server/agent/tools/paseo-tools.ts | 55 +- packages/server/src/server/bootstrap.ts | 5 + .../server/connected-hosts/hosts.e2e.test.ts | 551 +++++++++++++----- packages/server/src/server/session.test.ts | 2 + packages/server/src/server/session.ts | 150 +++-- .../session.workspace-git-watch.test.ts | 2 + ...on.workspace-resolution-invariants.test.ts | 2 + .../src/server/session.workspaces.test.ts | 10 + .../snapshot-mutation-ownership.test.ts | 2 + .../src/server/test-utils/agent-messages.ts | 43 ++ .../src/server/test-utils/session-stubs.ts | 13 + .../server/src/server/websocket-server.ts | 37 ++ .../server/src/server/wire-compat.test.ts | 2 + skills/paseo/SKILL.md | 8 +- 34 files changed, 1725 insertions(+), 560 deletions(-) create mode 100644 packages/server/src/server/agent/agent-messages/delivery.test.ts create mode 100644 packages/server/src/server/agent/agent-messages/delivery.ts create mode 100644 packages/server/src/server/agent/agent-messages/envelope.ts create mode 100644 packages/server/src/server/test-utils/agent-messages.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index b49e8309db..ffcec92ddd 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -75,7 +75,12 @@ Setup belongs to the workspace, including combined workspace-plus-agent creation Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. -`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. +`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` for explicit reporting or no completion callback. + +Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches. + +The pending window is in memory. Clean shutdown flushes it before closing agents; an abrupt crash can lose it. A durable send receipt remains pending until actual dispatch acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued admission within their original timeout and cancellation scope. + Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown. diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 4ae1764a02..7e197b4f13 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -54,7 +54,7 @@ export interface ComposerSendClient { images: Array<{ data: string; mimeType: string }>; attachments: ReturnType["attachments"]; }, - ) => Promise; + ) => Promise; uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{ requestId: string; file: { diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 1425dc3560..8f4d2acab5 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -136,6 +136,7 @@ class FakeDaemonClient { if (response) await response; const failure = this.sendAgentMessageFailures.shift(); if (failure) throw failure; + return {}; } async waitForSentMessages(count: number): Promise { diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index 1f288d4ca2..acdea38507 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -14,7 +14,7 @@ import { extname, resolve } from "node:path"; /** Result type for agent send command */ export interface AgentSendResult { agentId: string; - status: "sent" | "completed" | "timeout" | "permission" | "error"; + status: "queued" | "sent" | "completed" | "timeout" | "permission" | "error"; message: string; } @@ -201,7 +201,7 @@ export async function runSendCommand( const sourceAgentId = process.env.PASEO_AGENT_ID?.trim() || undefined; // Send the message - await client.sendAgentMessage(agentIdArg, promptInput, { + const receipt = await client.sendAgentMessage(agentIdArg, promptInput, { images, sourceAgentId, activeTurnBehavior: options.behavior ?? (sourceAgentId ? "steer" : "interrupt"), @@ -215,8 +215,11 @@ export async function runSendCommand( type: "single", data: { agentId: agentIdArg, - status: "sent", - message: "Message sent, not waiting for completion", + status: receipt.deliveryStatus ?? "sent", + message: + receipt.deliveryStatus === "queued" + ? "Message queued for delivery after two quiet seconds" + : "Message sent, not waiting for completion", }, schema: agentSendSchema, }; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 0741b08147..94d20585e2 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -435,6 +435,8 @@ export interface DaemonClientTrace { export interface SendMessageOptions { sourceAgentId?: string; + sourceAgentServerId?: string; + sessionMode?: string; messageId?: string; /** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */ activeTurnBehavior?: ActiveTurnBehavior; @@ -3732,26 +3734,34 @@ export class DaemonClient { async sendAgentMessage( agentId: string, text: string, - options?: SendMessageOptions, - ): Promise { + options: SendMessageOptions = {}, + ): Promise<{ deliveryStatus?: "queued" }> { if ( - options?.sourceAgentId && + options.sourceAgentId && this.lastServerInfoMessage?.features?.agentMessageProvenance !== true ) { throw new Error("Update the Paseo host to send messages with agent provenance."); } + if ( + options.sourceAgentServerId && + this.lastServerInfoMessage?.features?.agentMessageBatching !== true + ) { + throw new Error("Update the Paseo host to send messages with a qualified agent address."); + } const requestId = this.createRequestId(); - const messageId = options?.messageId ?? crypto.randomUUID(); + const messageId = options.messageId ?? crypto.randomUUID(); const message = SessionInboundMessageSchema.parse({ type: "send_agent_message_request", requestId, agentId, text, - sourceAgentId: options?.sourceAgentId, + sourceAgentId: options.sourceAgentId, + sourceAgentServerId: options.sourceAgentServerId, + sessionMode: options.sessionMode, ...(messageId ? { messageId } : {}), - ...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}), - ...(options?.images ? { images: options.images } : {}), - ...(options?.attachments ? { attachments: options.attachments } : {}), + activeTurnBehavior: options.activeTurnBehavior, + images: options.images, + attachments: options.attachments, }); const payload = await this.sendRequest({ requestId, @@ -3770,10 +3780,15 @@ export class DaemonClient { if (!payload.accepted) { throw new Error(payload.error ?? "sendAgentMessage rejected"); } + return payload.deliveryStatus ? { deliveryStatus: payload.deliveryStatus } : {}; } - async sendMessage(agentId: string, text: string, options?: SendMessageOptions): Promise { - await this.sendAgentMessage(agentId, text, options); + async sendMessage( + agentId: string, + text: string, + options?: SendMessageOptions, + ): Promise<{ deliveryStatus?: "queued" }> { + return this.sendAgentMessage(agentId, text, options); } async rewindAgent( diff --git a/packages/protocol/src/agent-message.ts b/packages/protocol/src/agent-message.ts index 5caf63656d..81f871be26 100644 --- a/packages/protocol/src/agent-message.ts +++ b/packages/protocol/src/agent-message.ts @@ -5,6 +5,7 @@ export const AgentMessageSchema = z.object({ event: z.enum(["message", "finished", "errored", "permission-required", "closed"]), sender: z.object({ id: z.string().min(1), + serverId: z.string().min(1).optional(), title: z.string().min(1).optional(), }), text: z.string(), diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4be3229b63..8eff2fbbd2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1387,6 +1387,8 @@ export const SendAgentMessageRequestSchema = z.object({ text: z.string(), /** Opaque sender identity for agent-originated prompts. */ sourceAgentId: z.string().min(1).optional(), + sourceAgentServerId: z.string().min(1).optional(), + sessionMode: z.string().optional(), messageId: z.string().optional(), // Client-provided ID for deduplication activeTurnBehavior: ActiveTurnBehaviorSchema.optional(), images: z.array(ImageAttachmentSchema).optional(), @@ -3776,6 +3778,7 @@ export const ServerInfoStatusPayloadSchema = z // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), agentMessageProvenance: z.boolean().optional(), + agentMessageBatching: z.boolean().optional(), // COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31. agentTurnIdentity: z.boolean().optional(), // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. @@ -5026,6 +5029,7 @@ export const SendAgentMessageResponseMessageSchema = z.object({ requestId: z.string(), agentId: z.string(), accepted: z.boolean(), + deliveryStatus: z.literal("queued").optional(), error: z.string().nullable(), }), }); diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 77814f1779..c98bbae54d 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; -import { formatAgentMessage, projectAgentMessage } from "./agent-messages/index.js"; +import { formatAgentMessage, projectAgentMessages } from "./agent-messages/index.js"; function toolCallItem(params: { callId: string; @@ -81,10 +81,10 @@ describe("curateAgentActivity", () => { it("renders delivered agent messages with their event, sender, and body", () => { const delivered = (source: Parameters[0]["source"], text: string) => - projectAgentMessage({ + projectAgentMessages({ type: "user_message", text: formatAgentMessage({ id: `msg-${text.length}`, source, text }), - })!; + })[0]!; const timeline: AgentTimelineItem[] = [ delivered( { kind: "agent-message", agentId: "agt_lead", title: "CI lead" }, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index e40a5f7720..ef5b361962 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -11991,3 +11991,81 @@ test.each([ expect(timelineStore.itemReads).toBe(readsBeforeDispatch); }, ); + +test.each(["live", "history", "accepted"] as const)( + "batched agent entries retain their identities through %s ingestion", + async (ingestion) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-batch-provenance-")); + const entries = [ + { + id: "report", + source: { kind: "agent-message" as const, agentId: "worker", serverId: "remote-host" }, + text: "Result ", + }, + { + id: "finish", + source: { + kind: "agent-notification" as const, + agentId: "worker", + serverId: "remote-host", + event: "finished" as const, + }, + text: "Worker finished", + }, + ]; + const prompt = entries.map(formatAgentMessage).join("\n\n"); + const item: AgentTimelineItem = { + type: "user_message", + text: prompt, + clientMessageId: "report", + messageId: "provider-batch", + }; + const codex = fakeCodexEmitting( + ingestion === "history" ? { historyItems: [item] } : { turnItems: [item] }, + ); + const manager = new AgentManager({ clients: { codex }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + try { + if (ingestion === "history") + await manager.hydrateTimelineFromProvider(agent.id, { force: true }); + else if (ingestion === "accepted") { + for await (const _event of manager.streamAgent(agent.id, prompt, { + clientMessageId: "report", + })) { + } + } else await manager.runAgent(agent.id, { text: "start" }); + expect( + manager + .getTimeline(agent.id) + .filter((row) => row.type === "tool_call") + .map((row) => ({ callId: row.callId, agentMessage: row.agentMessage })), + ).toEqual([ + { + callId: "paseo-agent-message:report", + agentMessage: { + event: "message", + sender: { id: "worker", serverId: "remote-host" }, + text: "Result ", + }, + }, + { + callId: "paseo-agent-message:finish", + agentMessage: { + event: "finished", + sender: { id: "worker", serverId: "remote-host" }, + text: "Worker finished", + }, + }, + ]); + expect(manager.getTimeline(agent.id).filter((row) => row.type === "user_message")).toEqual( + [], + ); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull(); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 5f1373ea5b..de7fe1d986 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -79,7 +79,7 @@ import { type PendingForegroundRun, } from "./agent-run-state.js"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; -import { parseAgentMessage, projectAgentMessage } from "./agent-messages/index.js"; +import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; @@ -658,13 +658,13 @@ function buildExplicitTimelineSeedForRegister( function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] { const rows: AgentTimelineRow[] = []; for (const entry of entries) { - const item = projectAgentMessage(entry.item); - if (!item) continue; - rows.push({ - seq: rows.length + 1, - timestamp: entry.timestamp ?? new Date().toISOString(), - item: limitAgentTimelineItemContent(item), - }); + for (const item of projectAgentMessages(entry.item)) { + rows.push({ + seq: rows.length + 1, + timestamp: entry.timestamp ?? new Date().toISOString(), + item: limitAgentTimelineItemContent(item), + }); + } } return rows; } @@ -4136,9 +4136,9 @@ export class AgentManager { for await (const rawEvent of agent.session.streamHistory()) { const event = limitAgentStreamEventContent(rawEvent); if (event.type === "timeline") { - const item = projectAgentMessage(event.item); - if (!item) continue; - historyEvents.push({ ...event, item }); + for (const item of projectAgentMessages(event.item)) { + historyEvents.push({ ...event, item }); + } } else if (event.type === "provider_subagent") { providerSubagentEvents.push(event); } @@ -4203,9 +4203,9 @@ export class AgentManager { if (event.type !== "timeline") { continue; } - const item = projectAgentMessage(event.item); - if (!item) continue; - historyEvents.push({ ...event, item }); + for (const item of projectAgentMessages(event.item)) { + historyEvents.push({ ...event, item }); + } } } catch (error) { this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history"); @@ -4537,42 +4537,32 @@ export class AgentManager { }): Promise { const { agent, event, options, flags } = params; - const item = projectAgentMessage(event.item); - if (!item) { - flags.shouldDispatchEvent = false; - flags.shouldNotifyWaiters = false; - return; - } - - event.item = item; - if ( - event.item.type === "user_message" && - event.item.clientMessageId && - this.reconcileSubmittedPromptEcho(agent, event.item, event.turnId) - ) { - flags.shouldDispatchEvent = false; - flags.shouldNotifyWaiters = false; - return; - } - - if (options?.fromHistory) { - this.recordTimeline( - agent.id, - event.item, - event.timestamp ? { timestamp: event.timestamp } : undefined, - ); - flags.shouldDispatchEvent = false; - flags.shouldNotifyWaiters = false; - return; - } - - this.recordAndDispatchTimelineItem(agent.id, event.item, event.provider, event.turnId); - if (event.item.type === "user_message") { - agent.lastUserMessageAt = new Date(); - this.emitState(agent); - } flags.shouldDispatchEvent = false; - flags.shouldNotifyWaiters = true; + flags.shouldNotifyWaiters = false; + for (const item of projectAgentMessages(event.item)) { + if ( + item.type === "user_message" && + item.clientMessageId && + this.reconcileSubmittedPromptEcho(agent, item, event.turnId) + ) + continue; + + if (options?.fromHistory) { + this.recordTimeline( + agent.id, + item, + event.timestamp ? { timestamp: event.timestamp } : undefined, + ); + continue; + } + + this.recordAndDispatchTimelineItem(agent.id, item, event.provider, event.turnId); + if (item.type === "user_message") { + agent.lastUserMessageAt = new Date(); + this.emitState(agent); + } + flags.shouldNotifyWaiters = true; + } } private onStreamTurnCompleted(params: { @@ -4818,16 +4808,17 @@ export class AgentManager { outcome: "accepted" | "rejected", options?: { messageId?: string; providerMessageId?: string; turnId?: string }, ): void { - const item = projectAgentMessage({ + const items = projectAgentMessages({ type: "user_message", text: submittedPromptText(prompt), clientMessageId, ...(options?.messageId ? { messageId: options.messageId } : {}), }); - if (!item) return; - // Human attempts stay in history on rejection; delivery notifications require acceptance. - if (outcome === "rejected" && item.type !== "user_message") return; - this.recordSubmittedPromptItem(agent, item, options); + for (const item of items) { + // Human attempts stay in history on rejection; delivery notifications require acceptance. + if (outcome === "rejected" && item.type !== "user_message") continue; + this.recordSubmittedPromptItem(agent, item, options); + } } private recordSubmittedPromptItem( diff --git a/packages/server/src/server/agent/agent-messages/delivery.test.ts b/packages/server/src/server/agent/agent-messages/delivery.test.ts new file mode 100644 index 0000000000..8cd480c1fa --- /dev/null +++ b/packages/server/src/server/agent/agent-messages/delivery.test.ts @@ -0,0 +1,285 @@ +import { expect, test } from "vitest"; +import { createAgentMessageDelivery, parseAgentMessages } from "./index.js"; +import type { AgentPromptInput } from "../agent-sdk-types.js"; + +function createClock() { + let now = 0; + const jobs = new Map<() => void, number>(); + return { + schedule(delayMs: number, callback: () => void) { + jobs.set(callback, now + delayMs); + return () => { + jobs.delete(callback); + }; + }, + advance(ms: number) { + now += ms; + for (const [callback, deadline] of jobs) { + if (deadline > now) continue; + jobs.delete(callback); + callback(); + } + }, + }; +} + +test("a queued child report and completion reach the recipient in one steer after two quiet seconds", async () => { + const clock = createClock(); + const sent: Array<{ recipientId: string; prompt: AgentPromptInput }> = []; + const delivery = createAgentMessageDelivery({ + schedule: clock.schedule, + dispatch: async (batch) => { + sent.push(batch); + return "steered"; + }, + onError: (error) => { + throw error; + }, + }); + const receipt = delivery.enqueue({ + recipientId: "parent", + messageId: "report", + prompt: "Results ", + source: { kind: "agent-message", agentId: "worker" }, + }); + expect(receipt).toMatchObject({ status: "queued", messageId: "report" }); + expect(sent).toEqual([]); + clock.advance(1000); + delivery.enqueue({ + recipientId: "parent", + messageId: "finish", + prompt: "Worker finished", + source: { kind: "agent-notification", agentId: "worker", event: "finished" }, + }); + clock.advance(1999); + expect(sent).toEqual([]); + clock.advance(1); + await delivery.close(); + expect( + sent.map((batch) => ({ + recipientId: batch.recipientId, + messages: parseAgentMessages(String(batch.prompt)), + })), + ).toEqual([ + { + recipientId: "parent", + messages: [ + { + id: "report", + source: { kind: "agent-message", agentId: "worker" }, + text: "Results ", + }, + { + id: "finish", + source: { kind: "agent-notification", agentId: "worker", event: "finished" }, + text: "Worker finished", + }, + ], + }, + ]); +}); + +test("recipient timers are independent, later messages form another batch, and close flushes pending work", async () => { + const clock = createClock(); + const sent: string[] = []; + const delivery = createAgentMessageDelivery({ + schedule: clock.schedule, + dispatch: async (request) => { + sent.push(`${request.recipientId}:${request.messageIds.join(",")}`); + return "steered"; + }, + onError: (error) => { + throw error; + }, + }); + const source = { kind: "agent-message" as const, agentId: "worker" }; + const a = delivery.enqueue({ recipientId: "a", messageId: "first", source, prompt: "one" }); + clock.advance(1000); + const b = delivery.enqueue({ recipientId: "b", messageId: "independent", source, prompt: "two" }); + clock.advance(1000); + await a.dispatched; + expect(sent).toEqual(["a:first"]); + delivery.enqueue({ recipientId: "a", messageId: "later", source, prompt: "three" }); + clock.advance(1000); + await b.dispatched; + expect(sent).toEqual(["a:first", "b:independent"]); + await delivery.close(); + expect(sent).toEqual(["a:first", "b:independent", "a:later"]); + expect(() => delivery.enqueue({ recipientId: "a", source, prompt: "closed" })).toThrow("closed"); +}); + +test("explicit interrupt and conflicting session modes retain ordered dispatch boundaries", async () => { + const sent: Array<{ + activeTurnBehavior: string; + sessionMode?: string; + messageIds: readonly string[]; + }> = []; + const delivery = createAgentMessageDelivery({ + dispatch: async (request) => { + sent.push(request); + request.onDispatch("steered"); + return "steered"; + }, + onError: (error) => { + throw error; + }, + }); + const source = { kind: "agent-message" as const, agentId: "worker" }; + delivery.enqueue({ + recipientId: "parent", + source, + prompt: "interrupt", + messageId: "interrupt", + activeTurnBehavior: "interrupt", + }); + delivery.enqueue({ + recipientId: "parent", + source, + prompt: "review", + messageId: "review", + sessionMode: "plan", + }); + delivery.enqueue({ + recipientId: "parent", + source, + prompt: "execute", + messageId: "execute", + sessionMode: "full-access", + }); + delivery.enqueue({ + recipientId: "parent", + source: { kind: "agent-notification", agentId: "worker", event: "finished" }, + prompt: "done", + messageId: "finished", + }); + await delivery.close(); + expect( + sent.map(({ activeTurnBehavior, sessionMode, messageIds }) => ({ + activeTurnBehavior, + sessionMode, + messageIds, + })), + ).toEqual([ + { activeTurnBehavior: "interrupt", sessionMode: undefined, messageIds: ["interrupt"] }, + { activeTurnBehavior: "steer", sessionMode: "plan", messageIds: ["review"] }, + { + activeTurnBehavior: "steer", + sessionMode: "full-access", + messageIds: ["execute", "finished"], + }, + ]); +}); + +test("dispatch failures reject waits before and after flush and do not poison later messages", async () => { + const clock = createClock(); + const failed = new Error("provider rejected"); + const errors: unknown[] = []; + const delivery = createAgentMessageDelivery({ + schedule: clock.schedule, + dispatch: async (request) => { + if (request.messageIds.includes("bad")) throw failed; + return "steered"; + }, + onError: (error) => { + errors.push(error); + }, + }); + const message = { + recipientId: "parent", + source: { kind: "agent-message" as const, agentId: "worker" }, + prompt: "report", + }; + const bad = delivery.enqueue({ ...message, messageId: "bad" }); + const before = expect( + delivery.waitForDispatch("parent", new AbortController().signal), + ).rejects.toBe(failed); + clock.advance(2000); + const after = expect( + delivery.waitForDispatch("parent", new AbortController().signal), + ).rejects.toBe(failed); + await Promise.all([before, after, expect(bad.dispatched).rejects.toBe(failed)]); + const next = delivery.enqueue({ ...message, messageId: "next" }); + await delivery.close(); + await expect(next.dispatched).resolves.toBe("steered"); + expect(errors).toEqual([failed]); +}); + +test("canceling a completion wait leaves queued delivery intact", async () => { + const clock = createClock(); + const delivery = createAgentMessageDelivery({ + schedule: clock.schedule, + dispatch: async () => "steered", + onError: (error) => { + throw error; + }, + }); + const receipt = delivery.enqueue({ + recipientId: "parent", + source: { kind: "agent-message", agentId: "worker" }, + prompt: "report", + }); + const controller = new AbortController(); + const wait = delivery.waitForDispatch("parent", controller.signal); + controller.abort(); + await expect(wait).rejects.toMatchObject({ name: "AbortError" }); + await delivery.close(); + await expect(receipt.dispatched).resolves.toBe("steered"); +}); + +test("batches retain each sender's attachment context and image order", async () => { + let prompt: AgentPromptInput = ""; + const delivery = createAgentMessageDelivery({ + dispatch: async (request) => { + prompt = request.prompt; + return "steered"; + }, + onError: (error) => { + throw error; + }, + }); + const firstImage = { type: "image" as const, data: "Zmlyc3Q=", mimeType: "image/png" }; + const secondImage = { type: "image" as const, data: "c2Vjb25k", mimeType: "image/png" }; + delivery.enqueue({ + recipientId: "parent", + messageId: "first", + source: { kind: "agent-message", agentId: "one" }, + prompt: [{ type: "text", text: "First" }, firstImage], + }); + delivery.enqueue({ + recipientId: "parent", + messageId: "second", + source: { kind: "agent-message", agentId: "two" }, + prompt: [ + { type: "text", text: "Second" }, + { + type: "github_issue", + mimeType: "application/github-issue", + number: 42, + title: "Context", + url: "https://example.com/42", + body: "Attached", + }, + secondImage, + ], + }); + await delivery.close(); + expect(prompt).toEqual([ + { + type: "text", + text: '\nFirst\n', + }, + firstImage, + { + type: "text", + text: '\nSecond\n\nGitHub Issue #42: Context\nhttps://example.com/42\n\nAttached\n', + }, + secondImage, + ]); + expect( + parseAgentMessages( + (prompt as Exclude) + .flatMap((block) => (block.type === "text" ? [block.text] : [])) + .join("\n"), + )?.map((message) => message.source?.agentId), + ).toEqual(["one", "two"]); +}); diff --git a/packages/server/src/server/agent/agent-messages/delivery.ts b/packages/server/src/server/agent/agent-messages/delivery.ts new file mode 100644 index 0000000000..e9313903d2 --- /dev/null +++ b/packages/server/src/server/agent/agent-messages/delivery.ts @@ -0,0 +1,189 @@ +import type { PromptDispatchDisposition } from "../agent-prompt.js"; +import { randomUUID } from "node:crypto"; +import type { AgentPromptInput, AgentPromptContentBlock } from "../agent-sdk-types.js"; +import { prepareAgentMessage, type AgentPromptSource } from "./envelope.js"; + +interface Message { + recipientId: string; + messageId?: string; + source: AgentPromptSource; + prompt: AgentPromptInput; + sessionMode?: string; + unarchive?: boolean; + clearPendingPermissions?: boolean; + activeTurnBehavior?: "steer" | "interrupt"; + beforeDispatch?: () => void; + onDispatch?: (disposition: PromptDispatchDisposition) => void; +} +interface Receipt { + status: "queued"; + messageId: string; + dispatched: Promise; +} +interface Admitted extends Message { + receipt: Receipt; + resolve(disposition: PromptDispatchDisposition): void; + reject(error: unknown): void; +} +interface DispatchRequest { + recipientId: string; + messageIds: readonly string[]; + prompt: AgentPromptInput; + activeTurnBehavior: "steer" | "interrupt"; + sessionMode?: string; + clearPendingPermissions: boolean; + unarchive: boolean; + onDispatch(disposition: PromptDispatchDisposition): void; +} +interface Dependencies { + dispatch(request: DispatchRequest): Promise; + onError(error: unknown, request: DispatchRequest): void; + /** Return a cancellation function. Production uses the daemon's event loop. */ + schedule?: (delayMs: number, callback: () => void) => () => void; +} +export interface AgentMessageDelivery { + enqueue(message: Message): Receipt; + /** Stop admission, flush pending messages, and await provider dispatch acknowledgments. */ + close(): Promise; + /** Existing completion waits include agent messages admitted before the wait. */ + waitForDispatch(recipientId: string, signal: AbortSignal): Promise; +} + +/** Recipient-owned agent delivery. Human prompts never enter this module. */ +export function createAgentMessageDelivery(dependencies: Dependencies): AgentMessageDelivery { + const schedule = + dependencies.schedule ?? + ((delayMs, callback) => { + const timer = setTimeout(callback, delayMs); + return () => clearTimeout(timer); + }); + const pending = new Map(); + const dispatching = new Map>(); + let closed = false; + let closing: Promise | undefined; + + function dispatch(recipientId: string, messages: Admitted[]): void { + const prompts = messages.map( + (message) => + prepareAgentMessage(message.prompt, message.source, message.receipt.messageId).prompt, + ); + const prompt: AgentPromptInput = prompts.every((entry) => typeof entry === "string") + ? prompts.join("\n\n") + : prompts.flatMap((entry): AgentPromptContentBlock[] => + typeof entry === "string" ? [{ type: "text", text: entry }] : entry, + ); + const request: DispatchRequest = { + recipientId, + messageIds: messages.map((message) => message.receipt.messageId), + prompt, + activeTurnBehavior: messages[0]!.activeTurnBehavior ?? "steer", + sessionMode: messages.find((message) => message.sessionMode)?.sessionMode, + clearPendingPermissions: messages.some((message) => message.clearPendingPermissions), + unarchive: messages.some((message) => message.unarchive !== false), + onDispatch: (disposition) => { + for (const message of messages) { + try { + message.onDispatch?.(disposition); + } catch (error) { + dependencies.onError(error, request); + } + } + }, + }; + const previous = dispatching.get(recipientId) ?? Promise.resolve(); + const dispatched = previous + .catch(() => undefined) + .then(async () => { + try { + for (const message of messages) message.beforeDispatch?.(); + const disposition = await dependencies.dispatch(request); + for (const message of messages) message.resolve(disposition); + return undefined; + } catch (error) { + for (const message of messages) message.reject(error); + dependencies.onError(error, request); + throw error; + } + }); + dispatching.set(recipientId, dispatched); + void dispatched + .finally(() => { + if (dispatching.get(recipientId) === dispatched) dispatching.delete(recipientId); + }) + .catch(() => undefined); + } + + function flush(recipientId: string): void { + const entry = pending.get(recipientId); + if (!entry) return; + pending.delete(recipientId); + entry.cancel(); + let group: Admitted[] = []; + let mode: string | undefined; + for (const message of entry.messages) { + // Explicitly conflicting send options require separate, ordered dispatches. + if ( + group.length && + ((message.activeTurnBehavior ?? "steer") !== (group[0]!.activeTurnBehavior ?? "steer") || + (mode && message.sessionMode && mode !== message.sessionMode)) + ) { + dispatch(recipientId, group); + group = []; + mode = undefined; + } + group.push(message); + mode ??= message.sessionMode; + } + if (group.length) dispatch(recipientId, group); + } + + return { + enqueue(message) { + if (closed) throw new Error("Agent message delivery is closed"); + let resolve!: Admitted["resolve"]; + let reject!: Admitted["reject"]; + const dispatched = new Promise((accepted, failed) => { + resolve = accepted; + reject = failed; + }); + void dispatched.catch(() => undefined); + const receipt: Receipt = { + status: "queued", + messageId: message.messageId ?? randomUUID(), + dispatched, + }; + const entry = pending.get(message.recipientId) ?? { messages: [], cancel: () => {} }; + entry.cancel(); + entry.messages.push({ ...message, receipt, resolve, reject }); + entry.cancel = schedule(2000, () => flush(message.recipientId)); + pending.set(message.recipientId, entry); + return receipt; + }, + async waitForDispatch(recipientId, signal) { + signal.throwIfAborted(); + const admitted = + pending.get(recipientId)?.messages.map((message) => message.receipt.dispatched) ?? []; + let aborted!: () => void; + const cancellation = new Promise((_resolve, reject) => { + aborted = () => reject(new DOMException("Wait aborted", "AbortError")); + signal.addEventListener("abort", aborted, { once: true }); + }); + try { + await Promise.race([ + Promise.all([dispatching.get(recipientId), ...admitted]), + cancellation, + ]); + } finally { + signal.removeEventListener("abort", aborted); + } + }, + close() { + if (closing) return closing; + closed = true; + for (const recipientId of pending.keys()) flush(recipientId); + // Each failure is reported and rejects its receipts; shutdown still drains other recipients. + closing = Promise.allSettled(dispatching.values()).then(() => undefined); + return closing; + }, + }; +} diff --git a/packages/server/src/server/agent/agent-messages/envelope.ts b/packages/server/src/server/agent/agent-messages/envelope.ts new file mode 100644 index 0000000000..2144d6ae02 --- /dev/null +++ b/packages/server/src/server/agent/agent-messages/envelope.ts @@ -0,0 +1,221 @@ +import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import type { + AgentPromptContentBlock, + AgentPromptInput, + AgentTimelineItem, +} from "../agent-sdk-types.js"; + +const agentIdSchema = z.string().min(1); +const sourceSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("agent-message"), + agentId: agentIdSchema, + serverId: z.string().min(1).optional(), + title: z.string().min(1).optional(), + }), + z.object({ + kind: z.literal("agent-notification"), + agentId: agentIdSchema, + serverId: z.string().min(1).optional(), + title: z.string().min(1).optional(), + event: z.enum(["finished", "errored", "permission-required", "closed"]), + }), +]); +export type AgentPromptSource = z.infer; + +interface AgentMessage { + id: string; + source: AgentPromptSource | null; + text: string; +} + +function escapeXml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +function unescapeXml(value: string): string | null { + if (/[<>]|&(?!amp;|lt;|gt;|quot;|apos;)/.test(value)) return null; + return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity: string) => { + switch (entity) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return '"'; + default: + return "'"; + } + }); +} + +export function formatAgentMessage(message: AgentMessage): string { + const { source } = message; + const attributes = source + ? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"` + : 'kind="user-message"'; + const server = source?.serverId ? ` source-server-id="${escapeXml(source.serverId)}"` : ""; + const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : ""; + const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : ""; + return `\n${escapeXml(message.text)}\n`; +} + +export function parseAgentMessage(text: string): AgentMessage | null { + const envelope = /^]*")+)>\n([\s\S]*)\n<\/paseo-system>$/.exec( + text, + ); + if (!envelope) return null; + const attributes = new Map(); + for (const match of envelope[1].matchAll(/\s+([a-z-]+)="([^"<>]*)"/g)) { + const value = unescapeXml(match[2]); + if (value === null || attributes.has(match[1])) return null; + attributes.set(match[1], value); + } + if (attributes.get("version") !== "1") return null; + const id = attributes.get("message-id"); + const body = unescapeXml(envelope[2]); + if (!id || body === null) return null; + if (attributes.get("kind") === "user-message") { + if (attributes.has("source-agent-id") || attributes.has("event")) return null; + return { id, source: null, text: body }; + } + const source = sourceSchema.safeParse({ + kind: attributes.get("kind"), + agentId: attributes.get("source-agent-id"), + serverId: attributes.get("source-server-id"), + event: attributes.get("event"), + title: attributes.get("source-agent-title"), + }); + if (!source.success) return null; + return { id, source: source.data, text: body }; +} + +/** Accept only a complete sequence of envelopes; prose around them stays user text. */ +export function parseAgentMessages(text: string): AgentMessage[] | null { + // Bodies are XML escaped, so a literal closing tag unambiguously ends an entry. + const parts = text.split(/(?<=<\/paseo-system>)\n+(?= message !== null) ? messages : null; +} + +function renderMessageText(block: AgentPromptContentBlock): string[] { + if (block.type === "image") return []; + if (block.type === "text") return [block.text]; + return [renderPromptAttachmentAsText(block)]; +} + +export function prepareAgentMessage( + prompt: AgentPromptInput, + source?: AgentPromptSource, + id?: string, +): { prompt: AgentPromptInput; messageId?: string } { + if (!source) { + const submittedText = + typeof prompt === "string" + ? prompt + : prompt + .flatMap((block) => + block.type === "text" && !("mimeType" in block) ? [block.text] : [], + ) + .join("\n") + .trim(); + // Quote reserved envelopes only when they arrived through the human send path. + // This provenance must survive provider history, not only the accepted local row. + if (!parseAgentMessages(submittedText) && !isSystemInjectedEnvelope(submittedText)) { + return { prompt, messageId: id }; + } + } + const messageId = id ?? randomUUID(); + const text = typeof prompt === "string" ? prompt : prompt.flatMap(renderMessageText).join("\n\n"); + // Render textual attachments before wrapping, so provider replay still contains one + // complete envelope instead of an envelope followed by provider-rendered context. + const envelope = formatAgentMessage({ id: messageId, source: source ?? null, text }); + return { + messageId, + prompt: + typeof prompt === "string" + ? envelope + : [{ type: "text", text: envelope }, ...prompt.filter((block) => block.type === "image")], + }; +} + +/** The same projection owns provider echoes, acceptance, import, and replay. */ +export function projectAgentMessages(item: AgentTimelineItem): AgentTimelineItem[] { + if (item.type === "user_message") { + const messages = parseAgentMessages(item.text); + if (messages) { + return messages.flatMap((message) => { + const projected = projectAgentMessage({ ...item, text: formatAgentMessage(message) }); + return projected ? [projected] : []; + }); + } + } + const projected = projectAgentMessage(item); + return projected ? [projected] : []; +} + +function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null { + if (item.type !== "user_message") return item; + const message = parseAgentMessage(item.text); + if (!message) { + // COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once + // legacy notification history is retired. Schedules still use this envelope. + return isSystemInjectedEnvelope(item.text) ? null : item; + } + const { source } = message; + if (!source) { + return { ...item, text: message.text, clientMessageId: message.id }; + } + return { + type: "tool_call", + callId: `paseo-agent-message:${message.id}`, + agentMessage: { + event: source.kind === "agent-message" ? "message" : source.event, + sender: { + id: source.agentId, + ...(source.serverId ? { serverId: source.serverId } : {}), + ...(source.title ? { title: source.title } : {}), + }, + text: message.text, + }, + // COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08 + // once clients can accept a dedicated timeline variant. Older clients still + // parse this existing shape and can expand the delivered body. + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: message.text, icon: "bot" }, + }; +} + +/** + * Wrap a body in … so the receiving agent + * recognizes the prompt as system-injected context — not a user turn. + * Used by schedule fires; agent messages use the versioned envelope. + */ +export function formatSystemNotificationPrompt(reason: string): string { + return `\n${reason}\n`; +} + +const SYSTEM_ENVELOPE_PATTERN = /^\n[\s\S]*\n<\/paseo-system>$/; + +export function isSystemInjectedEnvelope(text: string): boolean { + return SYSTEM_ENVELOPE_PATTERN.test(text); +} diff --git a/packages/server/src/server/agent/agent-messages/index.ts b/packages/server/src/server/agent/agent-messages/index.ts index 1543bf33b4..3615993334 100644 --- a/packages/server/src/server/agent/agent-messages/index.ts +++ b/packages/server/src/server/agent/agent-messages/index.ts @@ -1,191 +1,11 @@ -import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; -import { randomUUID } from "node:crypto"; -import { z } from "zod"; -import type { - AgentPromptContentBlock, - AgentPromptInput, - AgentTimelineItem, -} from "../agent-sdk-types.js"; - -const agentIdSchema = z.string().min(1); -const sourceSchema = z.discriminatedUnion("kind", [ - z.object({ - kind: z.literal("agent-message"), - agentId: agentIdSchema, - title: z.string().min(1).optional(), - }), - z.object({ - kind: z.literal("agent-notification"), - agentId: agentIdSchema, - title: z.string().min(1).optional(), - event: z.enum(["finished", "errored", "permission-required", "closed"]), - }), -]); -export type AgentPromptSource = z.infer; - -interface AgentMessage { - id: string; - source: AgentPromptSource | null; - text: string; -} - -function escapeXml(value: string): string { - return value.replace(/[&<>"']/g, (character) => { - switch (character) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - case '"': - return """; - default: - return "'"; - } - }); -} - -function unescapeXml(value: string): string | null { - if (/[<>]|&(?!amp;|lt;|gt;|quot;|apos;)/.test(value)) return null; - return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity: string) => { - switch (entity) { - case "amp": - return "&"; - case "lt": - return "<"; - case "gt": - return ">"; - case "quot": - return '"'; - default: - return "'"; - } - }); -} - -export function formatAgentMessage(message: AgentMessage): string { - const { source } = message; - const attributes = source - ? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"` - : 'kind="user-message"'; - const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : ""; - const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : ""; - return `\n${escapeXml(message.text)}\n`; -} - -export function parseAgentMessage(text: string): AgentMessage | null { - const envelope = /^]*")+)>\n([\s\S]*)\n<\/paseo-system>$/.exec( - text, - ); - if (!envelope) return null; - const attributes = new Map(); - for (const match of envelope[1].matchAll(/\s+([a-z-]+)="([^"<>]*)"/g)) { - const value = unescapeXml(match[2]); - if (value === null || attributes.has(match[1])) return null; - attributes.set(match[1], value); - } - if (attributes.get("version") !== "1") return null; - const id = attributes.get("message-id"); - const body = unescapeXml(envelope[2]); - if (!id || body === null) return null; - if (attributes.get("kind") === "user-message") { - if (attributes.has("source-agent-id") || attributes.has("event")) return null; - return { id, source: null, text: body }; - } - const source = sourceSchema.safeParse({ - kind: attributes.get("kind"), - agentId: attributes.get("source-agent-id"), - event: attributes.get("event"), - title: attributes.get("source-agent-title"), - }); - if (!source.success) return null; - return { id, source: source.data, text: body }; -} - -function renderMessageText(block: AgentPromptContentBlock): string[] { - if (block.type === "image") return []; - if (block.type === "text") return [block.text]; - return [renderPromptAttachmentAsText(block)]; -} - -export function prepareAgentMessage( - prompt: AgentPromptInput, - source?: AgentPromptSource, - id?: string, -): { prompt: AgentPromptInput; messageId?: string } { - if (!source) { - const submittedText = - typeof prompt === "string" - ? prompt - : prompt - .flatMap((block) => - block.type === "text" && !("mimeType" in block) ? [block.text] : [], - ) - .join("\n") - .trim(); - // Quote reserved envelopes only when they arrived through the human send path. - // This provenance must survive provider history, not only the accepted local row. - if (!parseAgentMessage(submittedText) && !isSystemInjectedEnvelope(submittedText)) { - return { prompt, messageId: id }; - } - } - const messageId = id ?? randomUUID(); - const text = typeof prompt === "string" ? prompt : prompt.flatMap(renderMessageText).join("\n\n"); - // Render textual attachments before wrapping, so provider replay still contains one - // complete envelope instead of an envelope followed by provider-rendered context. - const envelope = formatAgentMessage({ id: messageId, source: source ?? null, text }); - return { - messageId, - prompt: - typeof prompt === "string" - ? envelope - : [{ type: "text", text: envelope }, ...prompt.filter((block) => block.type === "image")], - }; -} - -/** The same projection owns provider echoes, acceptance, import, and replay. */ -export function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null { - if (item.type !== "user_message") return item; - const message = parseAgentMessage(item.text); - if (!message) { - // COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once - // legacy notification history is retired. Schedules still use this envelope. - return isSystemInjectedEnvelope(item.text) ? null : item; - } - const { source } = message; - if (!source) { - return { ...item, text: message.text, clientMessageId: message.id }; - } - return { - type: "tool_call", - callId: `paseo-agent-message:${message.id}`, - agentMessage: { - event: source.kind === "agent-message" ? "message" : source.event, - sender: { id: source.agentId, ...(source.title ? { title: source.title } : {}) }, - text: message.text, - }, - // COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08 - // once clients can accept a dedicated timeline variant. Older clients still - // parse this existing shape and can expand the delivered body. - name: "agent_message", - status: "completed", - error: null, - detail: { type: "plain_text", text: message.text, icon: "bot" }, - }; -} - -/** - * Wrap a body in … so the receiving agent - * recognizes the prompt as system-injected context — not a user turn. - * Used by schedule fires; agent messages use the versioned envelope. - */ -export function formatSystemNotificationPrompt(reason: string): string { - return `\n${reason}\n`; -} - -const SYSTEM_ENVELOPE_PATTERN = /^\n[\s\S]*\n<\/paseo-system>$/; - -export function isSystemInjectedEnvelope(text: string): boolean { - return SYSTEM_ENVELOPE_PATTERN.test(text); -} +export { + formatAgentMessage, + parseAgentMessage, + parseAgentMessages, + prepareAgentMessage, + projectAgentMessages, + formatSystemNotificationPrompt, + isSystemInjectedEnvelope, + type AgentPromptSource, +} from "./envelope.js"; +export { createAgentMessageDelivery, type AgentMessageDelivery } from "./delivery.js"; diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index dcdfdb1cbf..6ec66d10e0 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -1,10 +1,12 @@ +import { createTestAgentMessages } from "../test-utils/agent-messages.js"; import { formatSystemNotificationPrompt, isSystemInjectedEnvelope, parseAgentMessage, + parseAgentMessages, formatAgentMessage, prepareAgentMessage, - projectAgentMessage, + projectAgentMessages, } from "./agent-messages/index.js"; import { expect, it, test, vi } from "vitest"; import pino, { type Logger } from "pino"; @@ -143,6 +145,11 @@ function createFinishNotificationScenario( return { startWatchingChild() { setupFinishNotification({ + agentMessages: createTestAgentMessages({ + agentManager, + agentStorage, + logger: options?.logger ?? createTestLogger(), + }), agentManager, agentStorage, childAgentId: "child-agent", @@ -394,22 +401,25 @@ test("finish notifications report every concurrently pending permission", async scenario.requestChildPermission("permission-1"); scenario.requestChildPermission("permission-2"); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); + await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1)); expect( - scenario.parentPrompts().map((prompt) => { - const payload = parseAgentMessage(prompt)!.text.match( - /\n([\s\S]+?)\n<\/permission-request>/, - )?.[1]; - return JSON.parse(payload!).requestId; - }), + scenario + .parentPrompts() + .flatMap((prompt) => parseAgentMessages(prompt)!) + .map((message) => { + const payload = message.text.match( + /\n([\s\S]+?)\n<\/permission-request>/, + )?.[1]; + return JSON.parse(payload!).requestId; + }), ).toEqual(["permission-1", "permission-2"]); scenario.resolveChildPermission("permission-1"); scenario.resolveChildPermission("permission-2"); scenario.finishChild(); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3)); - expect(scenario.parentPrompts()[2]).toContain("finished."); + await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); + expect(scenario.parentPrompts()[1]).toContain("finished."); }); test("finish notifications survive repeated permission cycles", async () => { @@ -464,10 +474,9 @@ test("finish notifications log a rejected parent prompt without an unhandled rej expect(captured.records).toEqual([ expect.objectContaining({ - msg: "Failed to notify caller agent", - childAgentId: "child-agent", - callerAgentId: "caller-agent", - reason: "finished", + msg: "Agent message batch dispatch failed", + agentId: "caller-agent", + messageIds: [expect.any(String)], err: expect.objectContaining({ message: "parent provider rejected replacement" }), }), ]); @@ -525,6 +534,11 @@ it("does not notify archived callers", async () => { Reflect.set(agentStorage, "get", agentStorageGetSpy); setupFinishNotification({ + agentMessages: createTestAgentMessages({ + agentManager, + agentStorage, + logger: createTestLogger(), + }), agentManager, agentStorage, childAgentId: "child-agent", @@ -808,6 +822,36 @@ test("agent envelopes round-trip opaque sender IDs and XML-sensitive messages", expect(parseAgentMessage(encoded.replace('version="1"', 'version="1" version="1"'))).toBeNull(); }); +test("a delivered batch projects each message and completion separately in order", () => { + const entries = [ + { + id: "report", + source: { kind: "agent-message" as const, agentId: "remote::worker", title: "Worker" }, + text: "Result ", + }, + { + id: "finished", + source: { + kind: "agent-notification" as const, + agentId: "remote::worker", + event: "finished" as const, + }, + text: "Finished & ready", + }, + ]; + const text = entries.map(formatAgentMessage).join("\n\n"); + expect(projectAgentMessages({ type: "user_message", text })).toEqual( + entries.flatMap((entry) => + projectAgentMessages({ type: "user_message", text: formatAgentMessage(entry) }), + ), + ); + // Human submissions containing the same envelopes must survive replay as human text. + const pasted = prepareAgentMessage(text, undefined, "human-batch"); + expect(projectAgentMessages({ type: "user_message", text: String(pasted.prompt) })).toEqual([ + { type: "user_message", text, clientMessageId: "human-batch" }, + ]); +}); + test("agent envelope includes rendered attachment context and preserves images", () => { const image = { type: "image" as const, data: "aW1hZ2U=", mimeType: "image/png" }; const attachment = { @@ -849,11 +893,11 @@ test("a human pasting an agent envelope stays a human message through provider r }); const delivery = prepareAgentMessage(pasted, undefined, "human-submission"); expect( - projectAgentMessage({ + projectAgentMessages({ type: "user_message", text: String(delivery.prompt), messageId: "provider-echo", - }), + })[0], ).toEqual({ type: "user_message", text: pasted, diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 6b120abb1c..63c2309956 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -1,5 +1,9 @@ import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; -import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js"; +import { + prepareAgentMessage, + type AgentPromptSource, + type AgentMessageDelivery, +} from "./agent-messages/index.js"; import type { Logger } from "pino"; import type { @@ -316,6 +320,21 @@ async function resolvePromptSource( */ export async function sendPromptToAgent( params: SendPromptToAgentParams, +): Promise<{ disposition: PromptDispatchDisposition }> { + const source = await resolvePromptSource(params.source, params.agentManager, params.agentStorage); + const delivery = prepareAgentMessage(params.prompt, source, params.messageId); + return sendPreparedPromptToAgent({ + ...params, + prompt: delivery.prompt, + runOptions: delivery.messageId + ? { ...params.runOptions, clientMessageId: delivery.messageId } + : params.runOptions, + }); +} + +/** Dispatch daemon-prepared context without reclassifying it as human input. */ +export async function sendPreparedPromptToAgent( + params: Omit, ): Promise<{ disposition: PromptDispatchDisposition }> { const unarchive = params.unarchive ?? true; @@ -349,18 +368,12 @@ export async function sendPromptToAgent( await params.agentManager.setAgentMode(params.agentId, params.sessionMode); } - const source = await resolvePromptSource(params.source, params.agentManager, params.agentStorage); - const delivery = prepareAgentMessage(params.prompt, source, params.messageId); - const runOptions = delivery.messageId - ? { ...params.runOptions, clientMessageId: delivery.messageId } - : params.runOptions; - - return await startAgentRun(params.agentManager, params.agentId, delivery.prompt, params.logger, { + return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, { replaceRunning: true, onDispatch: params.onDispatch, activeTurnBehavior: params.activeTurnBehavior, clearPendingPermissions: params.clearPendingPermissions, - runOptions, + runOptions: params.runOptions, }); } @@ -401,6 +414,7 @@ export async function startCreatedAgentInitialPrompt( } export interface SetupFinishNotificationParams { + agentMessages: AgentMessageDelivery; agentManager: AgentManager; agentStorage: AgentStorage; childAgentId: string; @@ -490,6 +504,7 @@ const armedFinishNotifications = new WeakMap< /** Creation waits and accepted follow-ups share notification ownership. */ export function setupFinishWait(params: { + agentMessages: AgentMessageDelivery; agentManager: AgentManager; agentStorage: AgentStorage; originServerId?: string; @@ -545,10 +560,8 @@ export function setupFinishWait(params: { result.status ] as FinishNotificationReason; const permissionRequest = result.final?.pendingPermissions[0]; - await sendPromptToAgent({ - agentManager: params.agentManager, - agentStorage: params.agentStorage, - agentId: params.callerAgentId, + params.agentMessages.enqueue({ + recipientId: params.callerAgentId, prompt: formatFinishNotificationBody({ childAgentId: params.childAgentId, serverId: params.childServerId, @@ -560,12 +573,12 @@ export function setupFinishWait(params: { source: { kind: "agent-notification", agentId: params.childAgentId, + serverId: params.childServerId, title: result.final?.title ?? params.childAgentId, event: finishNotificationEvents[reason], }, activeTurnBehavior: "steer", unarchive: false, - logger: params.logger, }); if (result.status !== "permission" || !permissionRequest) return; await params.afterPermission(permissionRequest.id, controller.signal); @@ -677,19 +690,17 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): permissionRequest, }); - await sendPromptToAgent({ - agentManager, - agentStorage, - agentId: callerAgentId, + params.agentMessages.enqueue({ + recipientId: callerAgentId, prompt: body, source: { kind: "agent-notification", agentId: childAgentId, + title, event: finishNotificationEvents[reason], }, activeTurnBehavior: "steer", unarchive: false, - logger, }); } diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index f9fe10bd09..79f674e389 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -1,4 +1,4 @@ -import type { AgentPromptSource } from "../agent-messages/index.js"; +import type { AgentPromptSource, AgentMessageDelivery } from "../agent-messages/index.js"; import type { Logger } from "pino"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; @@ -23,6 +23,7 @@ import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp import { resolveCreateAgentIntent } from "./intent.js"; export interface CreateAgentCommandDependencies { + agentMessages?: AgentMessageDelivery; agentManager: AgentManager; agentStorage: AgentStorage; logger: Logger; @@ -180,6 +181,7 @@ export async function createAgentCommand( input.callerAgentId && resolved.prompt !== undefined ? setupFinishNotification({ + agentMessages: requireAgentMessages(dependencies), agentManager: dependencies.agentManager, agentStorage: dependencies.agentStorage, childAgentId: snapshot.id, @@ -558,3 +560,8 @@ async function resolveMcpCwd(params: { createdWorktree, }; } + +function requireAgentMessages(dependencies: CreateAgentCommandDependencies): AgentMessageDelivery { + if (!dependencies.agentMessages) throw new Error("Agent message delivery is not configured"); + return dependencies.agentMessages; +} diff --git a/packages/server/src/server/agent/host-operations/index.ts b/packages/server/src/server/agent/host-operations/index.ts index b69f0775a9..294e869555 100644 --- a/packages/server/src/server/agent/host-operations/index.ts +++ b/packages/server/src/server/agent/host-operations/index.ts @@ -207,10 +207,17 @@ export function createHostResolver(input: { }, async sendPrompt(args) { const agentId = args.agentId as string; - if (args.sessionMode !== undefined) + // Human sends retain the established mode RPC on older connected hosts. + // Agent sends apply their requested mode when the recipient dispatches the batch. + if (!input.callerAgentId && args.sessionMode !== undefined) { await client.setAgentMode(agentId, args.sessionMode as string); - await client.sendMessage(agentId, args.prompt as string, { + } + const receipt = await client.sendMessage(agentId, args.prompt as string, { + sessionMode: input.callerAgentId + ? (args.sessionMode as string | undefined) + : undefined, sourceAgentId: input.callerAgentId, + sourceAgentServerId: input.callerAgentId ? input.serverId : undefined, activeTurnBehavior: (args.activeTurnBehavior as ActiveTurnBehavior | undefined) ?? (input.callerAgentId ? "steer" : "interrupt"), @@ -219,10 +226,11 @@ export function createHostResolver(input: { if (!agent) throw new Error(`Agent ${agentId} not found`); return result({ success: true, + ...receipt, status: agent.status, guidance: - input.callerAgentId && args.notifyOnFinish !== false - ? "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives." + receipt.deliveryStatus === "queued" + ? "Queued for delivery after two quiet seconds. You will get notified when the prompted agent finishes, errors, or needs permission unless notifyOnFinish is false." : "Dispatch acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.", }); }, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index e774820dee..0599281f15 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,3 +1,5 @@ +import type { AgentMessageDelivery } from "./agent-messages/index.js"; +import { createTestAgentMessages } from "../test-utils/agent-messages.js"; import { setupFinishNotification, type PromptDispatchDisposition } from "./agent-prompt.js"; import { parseAgentMessage } from "./agent-messages/index.js"; import { execFileSync } from "node:child_process"; @@ -123,6 +125,9 @@ interface RegisteredMcpToolWithHandler extends RegisteredMcpTool { }>; } +const serverDeliveries = new WeakMap(); +const toolDeliveries = new WeakMap(); + function lookupTool( server: Awaited>, name: string, @@ -143,7 +148,10 @@ function registeredTool( if (!handler) { throw new Error(`MCP tool has no callable handler: ${name}`); } - return { ...tool, handler }; + const registered = { ...tool, handler }; + const delivery = serverDeliveries.get(server); + if (delivery) toolDeliveries.set(registered, delivery); + return registered; } async function invokeToolWithParsedInput( @@ -152,7 +160,25 @@ async function invokeToolWithParsedInput( ) { const parsed = await tool.inputSchema.safeParseAsync(input); expect(parsed.success).toBe(true); - return tool.handler(parsed.data); + const response = await tool.handler(parsed.data); + if (response.structuredContent.deliveryStatus === "queued") { + await toolDeliveries + .get(tool)! + .waitForDispatch(input.agentId as string, new AbortController().signal); + } + return response; +} + +async function sendMcpAndAwaitDispatch( + client: Client, + server: object, + input: Record, +) { + const response = await client.callTool({ name: "send_agent_prompt", arguments: input }); + await serverDeliveries + .get(server)! + .waitForDispatch(input.agentId as string, new AbortController().signal); + return response; } function agentsOf(response: { @@ -812,11 +838,17 @@ function createPaseoWorktreeForMcpTest(options: { } // Typed in-memory workspace port for catalogue tests without workspace setup. -function createAgentMcpServer(options: Parameters[0]) { - return createAgentMcpServerWithDependencies({ +async function createAgentMcpServer( + options: Parameters[0], +) { + const messages = createTestAgentMessages(options); + const server = await createAgentMcpServerWithDependencies({ + agentMessages: () => messages, workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} }, ...options, }); + serverDeliveries.set(server, messages); + return server; } describe("bundled Paseo skill", () => { @@ -3601,7 +3633,9 @@ describe("send_agent_prompt MCP tool", () => { }); expect(response.structuredContent).toMatchObject({ success: true, - disposition: steered ? "steered" : "turn_started", + ...(agentScoped + ? { deliveryStatus: "queued" } + : { disposition: steered ? "steered" : "turn_started" }), }); expect(client.sessions[1]!.prompts).toHaveLength(steered ? 1 : 2); expect(client.sessions[1]!.steeredPrompts).toHaveLength(steered ? 1 : 0); @@ -3648,7 +3682,7 @@ describe("send_agent_prompt MCP tool", () => { }); mcpClient = await connectInMemoryMcpClient(server); const send = (input: Record) => - mcpClient!.callTool({ name: "send_agent_prompt", arguments: input }); + sendMcpAndAwaitDispatch(mcpClient!, server, input); await send({ agentId: child.id, prompt: "Original work", @@ -3660,10 +3694,7 @@ describe("send_agent_prompt MCP tool", () => { notifyOnFinish: true, }); if (rejected) { - expect(await sending).toMatchObject({ - isError: true, - content: [{ type: "text", text: expect.stringContaining("Steer rejected") }], - }); + await expect(sending).rejects.toThrow("Steer rejected"); // Flush asynchronous notification work before checking that a rejected // dispatch did not notify. Then prove a later successful send still can. await new Promise((resolve) => setTimeout(resolve, 50)); @@ -3677,7 +3708,7 @@ describe("send_agent_prompt MCP tool", () => { const response = await sending; expect(response.structuredContent).toMatchObject({ success: true, - disposition: "steered", + deliveryStatus: "queued", }); } await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1)); @@ -3727,7 +3758,7 @@ describe("send_agent_prompt MCP tool", () => { }); mcpClient = await connectInMemoryMcpClient(server); const send = (input: Record) => - mcpClient!.callTool({ name: "send_agent_prompt", arguments: input }); + sendMcpAndAwaitDispatch(mcpClient!, server, input); await send({ agentId: child.id, prompt: "Original work", @@ -3738,10 +3769,7 @@ describe("send_agent_prompt MCP tool", () => { prompt: "New message", notifyOnFinish: true, }); - expect(await sending).toMatchObject({ - isError: true, - content: [{ type: "text", text: expect.stringContaining("Steer rejected") }], - }); + await expect(sending).rejects.toThrow("Steer rejected"); if (!finishDuringSteer) { await new Promise((resolve) => setTimeout(resolve, 50)); expect(client.sessions[0]!.prompts).toHaveLength(0); @@ -3799,7 +3827,7 @@ describe("send_agent_prompt MCP tool", () => { }); mcpClient = await connectInMemoryMcpClient(server); const send = (input: Record) => - mcpClient!.callTool({ name: "send_agent_prompt", arguments: input }); + sendMcpAndAwaitDispatch(mcpClient!, server, input); await send({ agentId: child.id, prompt: "Original work", @@ -3815,6 +3843,7 @@ describe("send_agent_prompt MCP tool", () => { }); for (const dispatchResult of [first, second]) { setupFinishNotification({ + agentMessages: serverDeliveries.get(server)!, agentManager: manager, agentStorage: storage, childAgentId: child.id, @@ -3891,11 +3920,14 @@ describe("send_agent_prompt MCP tool", () => { const response = await tool.handler(parsed.data as Record); + expect(response.structuredContent).toMatchObject({ deliveryStatus: "queued" }); + expect(spies.agentManager.subscribe).not.toHaveBeenCalled(); + await serverDeliveries + .get(server)! + .waitForDispatch("child-agent", new AbortController().signal); expect(spies.agentManager.subscribe).toHaveBeenCalled(); expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); - expect(response.structuredContent.guidance).toBe( - "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", - ); + expect(response.structuredContent.guidance).toContain("You will get notified"); }); it("acknowledges top-level prompts without a completion wait", async () => { @@ -4039,10 +4071,10 @@ describe("send_agent_prompt MCP tool", () => { unsubscribe(); expect(response.structuredContent).toMatchObject({ success: true, - disposition: "turn_started", + deliveryStatus: "queued", }); if (!immediateFinish) { - expect(response.structuredContent.status).toBe("running"); + expect(response.structuredContent.deliveryStatus).toBe("queued"); if (failure) childClient.sessions[0]!.failTurn("Retry failed"); else childClient.sessions[0]!.finishTurn("Retry done"); } @@ -4105,8 +4137,7 @@ describe("send_agent_prompt MCP tool", () => { }); expect(response.structuredContent).toMatchObject({ success: true, - disposition: "out_of_band", - status: "running", + deliveryStatus: "queued", }); expect(childClient.sessions[0]!.prompts).toHaveLength(1); expect(parseAgentMessage(childClient.sessions[0]!.prompts[0])).toMatchObject({ @@ -4177,9 +4208,8 @@ describe("send_agent_prompt MCP tool", () => { }); const response = await pending; expect(response.structuredContent).toMatchObject({ - status: "running", - guidance: - "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", + deliveryStatus: "queued", + guidance: expect.stringContaining("You will get notified"), }); expect(response.structuredContent).not.toHaveProperty("lastMessage"); @@ -4237,7 +4267,7 @@ describe("send_agent_prompt MCP tool", () => { }); expect(sent.structuredContent).toMatchObject({ success: true, - status: "running", + deliveryStatus: "queued", }); const childSession = childClient.sessions[0]!; await vi.waitFor(() => expect(childSession.prompts).toHaveLength(2)); @@ -4262,7 +4292,7 @@ describe("send_agent_prompt MCP tool", () => { } }); - it("reports a background prompt's accepted turn as running", async () => { + it("reports queue admission before the background prompt starts", async () => { const workdir = await mkdtemp(join(tmpdir(), "mcp-background-send-status-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const parentClient = new HeldTurnAgentClient("claude", false); @@ -4308,7 +4338,7 @@ describe("send_agent_prompt MCP tool", () => { acknowledgeTurnStart(); const response = await pending; - expect(response.structuredContent).toMatchObject({ success: true, status: "running" }); + expect(response.structuredContent).toMatchObject({ success: true, deliveryStatus: "queued" }); expect(agentManager.getAgent(child.id)?.lifecycle).toBe("running"); childSession.finishTurn(); @@ -4378,7 +4408,7 @@ describe("send_agent_prompt MCP tool", () => { unsubscribe(); expect(response.structuredContent).toMatchObject({ success: true, - disposition: "turn_started", + deliveryStatus: "queued", }); await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle")); await vi.waitFor(() => { @@ -4479,7 +4509,7 @@ describe("send_agent_prompt MCP tool to an OMP child", () => { expect(response.structuredContent).toMatchObject({ success: true, - disposition: "out_of_band", + deliveryStatus: "queued", status: "running", }); await vi.waitFor(() => { @@ -4547,8 +4577,8 @@ describe("send_agent_prompt MCP tool to an OMP child", () => { expect(response.structuredContent).toMatchObject({ success: true, - disposition: "out_of_band", - status: "running", + deliveryStatus: "queued", + status: "idle", }); await vi.waitFor(() => { expect(ompSession.prompts).toEqual([ diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 8fc6c23d93..33f43c4f79 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -15,6 +15,7 @@ import { AgentManager } from "../../agent-manager.js"; import { AgentStorage } from "../../agent-storage.js"; import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js"; import { createTestAgentClients } from "../../../test-utils/fake-agent-client.js"; +import { createTestAgentMessages } from "../../../test-utils/agent-messages.js"; import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tools/paseo-tools.js"; import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js"; import { @@ -83,7 +84,14 @@ describe("OpenCodeBridge", () => { const agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger); const agentManager = new AgentManager({ clients, registry: agentStorage, logger }); const providerSnapshotManager = new ProviderSnapshotManager({ logger, extraClients: clients }); - const dependencies = { agentManager, agentStorage, providerSnapshotManager, logger }; + const agentMessages = createTestAgentMessages({ agentManager, agentStorage, logger }); + const dependencies = { + agentManager, + agentStorage, + providerSnapshotManager, + logger, + agentMessages: () => agentMessages, + }; const bridge = new OpenCodeBridge({ paseoHome, logger }); await bridge.start(); bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies)); @@ -199,11 +207,13 @@ describe("OpenCodeBridge", () => { sessionID: "bound", ask: async () => undefined, }), - ).resolves.toMatchObject({ output: expect.stringContaining('"success": true') }); + ).resolves.toMatchObject({ output: expect.stringContaining('"deliveryStatus": "queued"') }); + await agentMessages.waitForDispatch(child.id, new AbortController().signal); } finally { await dispose(); } } finally { + await agentMessages.close(); await bridge.close(); await Promise.all( agentManager.listAgents().map((agent) => agentManager.closeAgent(agent.id)), diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 97ded06e69..74c4361c9e 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,4 @@ +import type { AgentMessageDelivery } from "../agent-messages/index.js"; import { createHostResolver, waitForPermissionChange, @@ -116,6 +117,7 @@ import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-confi import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; export interface PaseoToolHostDependencies { + agentMessages?: () => AgentMessageDelivery; serverId?: string; hosts?: () => ToolHosts; agentManager: AgentManager; @@ -657,6 +659,10 @@ function createToolCatalog( return result.data; }; + function agentMessages(): AgentMessageDelivery { + if (!options.agentMessages) throw new Error("Agent message delivery is not configured"); + return options.agentMessages(); + } const localHostOperations = new Map(); const tools = new Map(); const hostTools = new Map([ @@ -758,6 +764,7 @@ function createToolCatalog( } if (childAgentId && callerAgentId && input.notifyOnFinish !== false) { setupFinishWait({ + agentMessages: agentMessages(), agentManager, agentStorage, originServerId: options.serverId, @@ -1213,7 +1220,7 @@ function createToolCatalog( .optional() .default(true) .describe( - "Get notified when the created agent finishes, errors, or needs permission. Set false only for truly fire-and-forget agents.", + "Get notified when the created agent finishes, errors, or needs permission. Set false for explicit reporting or no completion callback.", ), }; const canonicalTopLevelInputSchema = { @@ -1274,7 +1281,7 @@ function createToolCatalog( .optional() .default(true) .describe( - "Get notified when the prompted agent finishes, errors, or needs permission. Set false only for truly fire-and-forget prompts.", + "Get notified when the prompted agent finishes, errors, or needs permission. Set false for explicit reporting or no completion callback.", ), }; const topLevelSendAgentPromptInputSchema = { @@ -1941,11 +1948,13 @@ function createToolCatalog( { title: "Send agent prompt", description: - "Send a task and return after dispatch acknowledgement. Calling agents receive finish notifications by default.", + "Send a task. Agent callers receive an immediate queued receipt; delivery follows after two quiet seconds per recipient. Completion reports include the target’s last response by default. Paseo supplies caller identity.", inputSchema: sendAgentPromptInputSchema, outputSchema: { success: z.boolean(), disposition: z.enum(["turn_started", "out_of_band", "steered"]).optional(), + deliveryStatus: z.literal("queued").optional(), + messageId: z.string().optional(), status: AgentStatusEnum, guidance: z.string().optional(), }, @@ -1963,8 +1972,10 @@ function createToolCatalog( acceptDispatch = resolve; }); const notifying = Boolean(callerAgentId && notifyOnFinish); - if (callerAgentId && notifyOnFinish) { + function prepareDispatch(): void { + if (!callerAgentId || !notifyOnFinish) return; stopNotification = setupFinishNotification({ + agentMessages: agentMessages(), agentManager, agentStorage, childAgentId: agentId, @@ -1986,6 +1997,7 @@ function createToolCatalog( if (!callerAgentId || !notifyOnFinish) return; stopNotification?.(); stopNotification = setupFinishNotification({ + agentMessages: agentMessages(), agentManager, agentStorage, childAgentId: agentId, @@ -1996,6 +2008,41 @@ function createToolCatalog( }); } + if (callerAgentId) { + if (!agentManager.getAgent(agentId) && !(await agentStorage.get(agentId))) { + throw new PaseoToolInputError(`Agent ${agentId} not found`); + } + const receipt = agentMessages().enqueue({ + recipientId: agentId, + prompt, + source: { + kind: "agent-message", + agentId: callerAgentId, + title: resolveCallerAgent()?.config.title ?? undefined, + }, + sessionMode, + activeTurnBehavior, + beforeDispatch: prepareDispatch, + onDispatch: confirmDispatch, + }); + void receipt.dispatched.catch(() => { + acceptDispatch(false); + stopNotification?.(); + }); + return { + content: [], + structuredContent: { + success: true, + deliveryStatus: receipt.status, + messageId: receipt.messageId, + status: agentManager.getAgent(agentId)?.lifecycle ?? "idle", + guidance: notifying + ? `Queued for delivery after two quiet seconds. ${PROMPTED_AGENT_NOTIFICATION_GUIDANCE}` + : "Queued for delivery after two quiet seconds; provider dispatch has not occurred.", + }, + }; + } + const { disposition } = await sendPromptToAgent({ agentManager, agentStorage, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 5125a0b923..09be15772e 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1393,6 +1393,10 @@ export async function createPaseoDaemon( const createAgentToolHostDependencies = ( runtime: PaseoToolRuntimeContext, ): PaseoToolHostDependencies => ({ + agentMessages: () => { + if (!wsServer) throw new Error("Daemon is not accepting agent messages"); + return wsServer.agentMessages; + }, serverId, hosts: () => { if (!wsServer) throw new Error("Daemon is not accepting host operations"); @@ -1830,6 +1834,7 @@ export async function createPaseoDaemon( scriptHealthMonitor.stop(); // Freeze both ingress and registration before taking the agent closure snapshot. wsServer?.prepareForShutdown(); + await wsServer?.agentMessages.close(); agentManager.prepareForShutdown(); await closeAllAgents(logger, agentManager); await withTimeout({ diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 0efd8d86d6..e24e431cd0 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -433,7 +433,7 @@ class RoutePeer { } } -import { mkdir, writeFile, readFile, chmod, stat, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, writeFile, readFile, chmod, stat, mkdtemp, rm, readdir } from "node:fs/promises"; import { join, dirname, basename } from "node:path"; import { tmpdir, userInfo } from "node:os"; import { resolveDaemonVersion } from "../daemon-version.js"; @@ -1310,173 +1310,408 @@ test("cancelling a logical scope during admission settles it and releases its ow await delivery.close(); }); -test("agent MCP discovers hosts, creates children on self and B, and notifies their parent", async () => { - const parentPrompts: string[] = []; - const a = await daemon({ - agentClients: createTestAgentClients({ - onStartTurn: (prompt, config) => { - if (config.title === "Fleet parent") parentPrompts.push(JSON.stringify(prompt)); - }, - }), - }); - const b = await daemon(); - const { paseo, client: aClient } = await api(a); - await paseo.hosts.add({ connection: `127.0.0.1:${b.port}`, label: "Fleet B" }); - const directory = await online(paseo); - const workspace = await paseo.workspaces.create({ - source: { kind: "directory", path: a.staticDir }, - }); - const parent = await workspace.agents.create({ - config: { provider: "claude/claude-test" }, - title: "Fleet parent", - }); - const mcp = new Client({ name: "fleet-delegation", version: "1" }); - cleanup.push(() => mcp.close()); - await mcp.connect( - new StreamableHTTPClientTransport( - new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), - ), - ); - const result = await mcp.callTool({ name: "list_hosts", arguments: {} }); - expect(result.isError).not.toBe(true); - expect(result.structuredContent).toMatchObject({ - self: { serverId: directory.self.serverId }, - hosts: [expect.objectContaining({ serverId: directory.hosts[0]!.serverId, label: "Fleet B" })], - }); - const call = async (name: string, args: Record) => { - const response = await mcp.callTool({ name, arguments: args }); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - return z.record(z.string(), z.unknown()).parse(response.structuredContent); - }; - const serverId = directory.hosts[0]!.serverId!; - const destination = await call("create_workspace", { - serverId, - isolation: "local", - path: b.staticDir, - title: "Destination workspace", - }); - expect(await call("list_workspaces", { serverId })).toMatchObject({ - serverId, - workspaces: [expect.objectContaining({ workspaceId: destination.workspaceId })], - }); - const self = await call("create_agent", { - title: "Self child", - provider: "claude/claude-test", - initialPrompt: "Finish local delegation", - }); - const remote = await call("create_agent", { - serverId, - workspaceId: destination.workspaceId, - title: "B child", - provider: "claude/claude-test", - initialPrompt: "Finish destination delegation", - }); - expect(self.serverId).toBe(directory.self.serverId); - expect(remote.serverId).toBe(serverId); - const bApi = await api(b); - expect( - (await bApi.paseo.agents.ref(String(remote.agentId)).refresh())?.agent.labels, - ).toMatchObject({ - "paseo.parent-agent-id": parent.id, - "paseo.parent-server-id": directory.self.serverId, - }); - await expect.poll(() => parentPrompts.join("\n")).toContain(`Agent ${self.agentId}`); - await expect.poll(() => parentPrompts.join("\n")).toContain(`Agent ${remote.agentId}`); - expect(parentPrompts.join("\n")).toContain(`Server: ${serverId}`); - expect(parentPrompts.join("\n")).toContain(`Server: ${directory.self.serverId}`); - - await aClient.createAgent({ - agentId: String(remote.agentId), - provider: "claude", - model: "claude-test", - cwd: a.staticDir, - workspaceId: workspace.id, - title: "Unrelated equal ID on A", - }); - expect(await call("get_agent_status", { serverId, agentId: remote.agentId })).toMatchObject({ - serverId, - snapshot: { id: remote.agentId, title: "B child", cwd: b.staticDir }, - }); - expect(await call("get_agent_status", { agentId: remote.agentId })).toMatchObject({ - serverId: directory.self.serverId, - snapshot: { title: "Unrelated equal ID on A" }, - }); - const beforeFollowup = parentPrompts.filter((prompt) => - prompt.includes(`Agent ${remote.agentId}`), - ).length; - expect(beforeFollowup).toBe(1); - expect( - await call("send_agent_prompt", { - serverId, - agentId: remote.agentId, - prompt: "REMOTE_FOLLOWUP_MARKER", - sessionMode: "full-access", - }), - ).toMatchObject({ success: true, serverId, guidance: expect.stringContaining("notified") }); - expect(await call("get_agent_activity", { serverId, agentId: remote.agentId })).toMatchObject({ - serverId, - agentId: remote.agentId, - currentModeId: "full-access", - content: expect.stringContaining("REMOTE_FOLLOWUP_MARKER"), - }); - expect(await call("list_agents", { serverId })).toMatchObject({ - serverId, - agents: expect.arrayContaining([ - expect.objectContaining({ id: remote.agentId, title: "B child", cwd: b.staticDir }), - ]), - }); - expect(await call("list_agents", {})).toMatchObject({ - serverId: directory.self.serverId, - agents: expect.arrayContaining([ - expect.objectContaining({ - id: remote.agentId, - title: "Unrelated equal ID on A", - cwd: a.staticDir, +test( + "agent MCP discovers hosts, creates children on self and B, and notifies their parent", + { timeout: 30000 }, + async () => { + const parentPrompts: string[] = []; + const a = await daemon({ + agentClients: createTestAgentClients({ + onStartTurn: (prompt, config) => { + if (config.title === "Fleet parent") parentPrompts.push(JSON.stringify(prompt)); + }, }), - ]), - }); - await expect - .poll(() => parentPrompts.filter((prompt) => prompt.includes(`Agent ${remote.agentId}`)).length) - .toBe(2); - expect(parentPrompts.findLast((prompt) => prompt.includes(`Agent ${remote.agentId}`))).toContain( - `Server: ${serverId}`, - ); - expect((await aClient.fetchAgent(String(remote.agentId)))?.agent.lastUserMessageAt).toBeNull(); - expect(JSON.stringify(await aClient.fetchAgentTimeline(String(remote.agentId)))).not.toContain( - "REMOTE_FOLLOWUP_MARKER", - ); - - for (const selector of [{}, { serverId: directory.self.serverId }]) { - expect(await call("get_agent_status", { ...selector, agentId: self.agentId })).toMatchObject({ - serverId: directory.self.serverId, - snapshot: { title: "Self child" }, }); - const previous = parentPrompts.filter((prompt) => - prompt.includes(`Agent ${self.agentId}`), + const b = await daemon(); + const { paseo, client: aClient } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${b.port}`, label: "Fleet B" }); + const directory = await online(paseo); + const workspace = await paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test" }, + title: "Fleet parent", + }); + const mcp = new Client({ name: "fleet-delegation", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const result = await mcp.callTool({ name: "list_hosts", arguments: {} }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + self: { serverId: directory.self.serverId }, + hosts: [ + expect.objectContaining({ serverId: directory.hosts[0]!.serverId, label: "Fleet B" }), + ], + }); + const call = async (name: string, args: Record) => { + const response = await mcp.callTool({ name, arguments: args }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return z.record(z.string(), z.unknown()).parse(response.structuredContent); + }; + const serverId = directory.hosts[0]!.serverId!; + const destination = await call("create_workspace", { + serverId, + isolation: "local", + path: b.staticDir, + title: "Destination workspace", + }); + expect(await call("list_workspaces", { serverId })).toMatchObject({ + serverId, + workspaces: [expect.objectContaining({ workspaceId: destination.workspaceId })], + }); + const self = await call("create_agent", { + title: "Self child", + provider: "claude/claude-test", + initialPrompt: "Finish local delegation", + }); + const remote = await call("create_agent", { + serverId, + workspaceId: destination.workspaceId, + title: "B child", + provider: "claude/claude-test", + initialPrompt: "Finish destination delegation", + }); + expect(self.serverId).toBe(directory.self.serverId); + expect(remote.serverId).toBe(serverId); + const bApi = await api(b); + expect( + (await bApi.paseo.agents.ref(String(remote.agentId)).refresh())?.agent.labels, + ).toMatchObject({ + "paseo.parent-agent-id": parent.id, + "paseo.parent-server-id": directory.self.serverId, + }); + await expect + .poll(() => parentPrompts.join("\n"), { timeout: 10000 }) + .toContain(`Agent ${self.agentId}`); + await expect + .poll(() => parentPrompts.join("\n"), { timeout: 10000 }) + .toContain(`Agent ${remote.agentId}`); + expect(parentPrompts.join("\n")).toContain(`Server: ${serverId}`); + expect(parentPrompts.join("\n")).toContain(`Server: ${directory.self.serverId}`); + + await aClient.createAgent({ + agentId: String(remote.agentId), + provider: "claude", + model: "claude-test", + cwd: a.staticDir, + workspaceId: workspace.id, + title: "Unrelated equal ID on A", + }); + expect(await call("get_agent_status", { serverId, agentId: remote.agentId })).toMatchObject({ + serverId, + snapshot: { id: remote.agentId, title: "B child", cwd: b.staticDir }, + }); + expect(await call("get_agent_status", { agentId: remote.agentId })).toMatchObject({ + serverId: directory.self.serverId, + snapshot: { title: "Unrelated equal ID on A" }, + }); + const beforeFollowup = parentPrompts.filter((prompt) => + prompt.includes(`Agent ${remote.agentId}`), ).length; + expect(beforeFollowup).toBe(1); expect( await call("send_agent_prompt", { - ...selector, - agentId: self.agentId, - prompt: "SELF_FOLLOWUP_MARKER", + serverId, + agentId: remote.agentId, + prompt: "REMOTE_FOLLOWUP_MARKER", + sessionMode: "full-access", }), - ).toMatchObject({ - success: true, - serverId: directory.self.serverId, - disposition: "turn_started", + ).toMatchObject({ success: true, serverId, guidance: expect.stringContaining("notified") }); + await bApi.client.waitForFinish(String(remote.agentId), 10000); + expect(await call("get_agent_activity", { serverId, agentId: remote.agentId })).toMatchObject({ + serverId, + agentId: remote.agentId, + currentModeId: "full-access", + content: expect.stringContaining("REMOTE_FOLLOWUP_MARKER"), }); - expect(await call("get_agent_activity", { ...selector, agentId: self.agentId })).toMatchObject({ - serverId: directory.self.serverId, - content: expect.stringContaining("SELF_FOLLOWUP_MARKER"), + expect(await call("list_agents", { serverId })).toMatchObject({ + serverId, + agents: expect.arrayContaining([ + expect.objectContaining({ id: remote.agentId, title: "B child", cwd: b.staticDir }), + ]), }); - expect(await call("list_agents", selector)).toMatchObject({ + expect(await call("list_agents", {})).toMatchObject({ serverId: directory.self.serverId, agents: expect.arrayContaining([ - expect.objectContaining({ id: self.agentId, title: "Self child" }), + expect.objectContaining({ + id: remote.agentId, + title: "Unrelated equal ID on A", + cwd: a.staticDir, + }), ]), }); await expect - .poll(() => parentPrompts.filter((prompt) => prompt.includes(`Agent ${self.agentId}`)).length) - .toBe(previous + 1); - } -}); + .poll( + () => parentPrompts.filter((prompt) => prompt.includes(`Agent ${remote.agentId}`)).length, + { timeout: 10000 }, + ) + .toBe(2); + expect( + parentPrompts.findLast((prompt) => prompt.includes(`Agent ${remote.agentId}`)), + ).toContain(`Server: ${serverId}`); + expect((await aClient.fetchAgent(String(remote.agentId)))?.agent.lastUserMessageAt).toBeNull(); + expect(JSON.stringify(await aClient.fetchAgentTimeline(String(remote.agentId)))).not.toContain( + "REMOTE_FOLLOWUP_MARKER", + ); + + for (const selector of [{}, { serverId: directory.self.serverId }]) { + expect(await call("get_agent_status", { ...selector, agentId: self.agentId })).toMatchObject({ + serverId: directory.self.serverId, + snapshot: { title: "Self child" }, + }); + const previous = parentPrompts.filter((prompt) => + prompt.includes(`Agent ${self.agentId}`), + ).length; + expect( + await call("send_agent_prompt", { + ...selector, + agentId: self.agentId, + prompt: "SELF_FOLLOWUP_MARKER", + }), + ).toMatchObject({ + success: true, + serverId: directory.self.serverId, + deliveryStatus: "queued", + }); + await aClient.waitForFinish(String(self.agentId), 10000); + expect( + await call("get_agent_activity", { ...selector, agentId: self.agentId }), + ).toMatchObject({ + serverId: directory.self.serverId, + content: expect.stringContaining("SELF_FOLLOWUP_MARKER"), + }); + expect(await call("list_agents", selector)).toMatchObject({ + serverId: directory.self.serverId, + agents: expect.arrayContaining([ + expect.objectContaining({ id: self.agentId, title: "Self child" }), + ]), + }); + await expect + .poll( + () => parentPrompts.filter((prompt) => prompt.includes(`Agent ${self.agentId}`)).length, + { timeout: 10000 }, + ) + .toBe(previous + 1); + } + }, +); + +test( + "remote agent reports, local CLI sends and automatic completion share the recipient batch", + { timeout: 30000 }, + async () => { + const parentPrompts: string[] = []; + const childStarted = Promise.withResolvers(); + const finishChild = Promise.withResolvers(); + cleanup.push(async () => { + finishChild.resolve(); + }); + const a = await daemon({ + agentClients: createTestAgentClients({ + onStartTurn: (prompt, config) => { + if (config.title === "Batch parent") + parentPrompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt)); + }, + }), + }); + const b = await daemon({ + agentClients: createTestAgentClients({ + onStartTurn: () => childStarted.resolve(), + beforeTurnComplete: () => finishChild.promise, + }), + }); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + await destination.paseo.hosts.add({ connection: `127.0.0.1:${a.port}` }); + const aDirectory = await online(origin.paseo); + const bDirectory = await online(destination.paseo); + const aId = aDirectory.self.serverId; + const bId = bDirectory.self.serverId; + const aWorkspace = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const bWorkspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const parent = await aWorkspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Batch parent", + }); + const child = await bWorkspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Batch child", + labels: { "paseo.parent-agent-id": parent.id, "paseo.parent-server-id": aId }, + }); + const caller = async (host: TestPaseoDaemon, agentId: string) => { + const mcp = new Client({ name: "batching-agent", version: "1" }); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${host.port}/mcp/agents?callerAgentId=${agentId}`), + ), + ); + cleanup.push(() => mcp.close()); + return mcp; + }; + const parentMcp = await caller(a, parent.id); + const childMcp = await caller(b, child.id); + const task = await parentMcp.callTool({ + name: "send_agent_prompt", + arguments: { serverId: bId, agentId: child.id, prompt: "Do the delegated task" }, + }); + expect(task.isError).not.toBe(true); + expect(task.content).toEqual([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining('"deliveryStatus": "queued"'), + }), + ]); + expect(task.structuredContent).toMatchObject({ + success: true, + deliveryStatus: "queued", + serverId: bId, + }); + // An idle recipient must not report old idle as completion of its queued task. + expect((await destination.client.waitForFinish(child.id, 50)).status).toBe("timeout"); + await childStarted.promise; + const report = await childMcp.callTool({ + name: "send_agent_prompt", + arguments: { + serverId: aId, + agentId: parent.id, + prompt: "REMOTE_REPORT ", + notifyOnFinish: false, + }, + }); + expect(report.isError).not.toBe(true); + expect(report.structuredContent).toMatchObject({ deliveryStatus: "queued", serverId: aId }); + expect(parentPrompts).toEqual([]); + + const cli = await promisify(execFile)( + process.execPath, + [ + "--conditions=source", + "--import", + "tsx", + fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${a.port}`, + "send", + parent.id, + "LOCAL_CLI_REPORT", + "--no-wait", + "--json", + ], + { + env: { ...process.env, PASEO_AGENT_ID: parent.id }, + timeout: 15000, + }, + ); + expect(JSON.parse(cli.stdout)).toMatchObject({ status: "queued" }); + expect(parentPrompts).toEqual([]); + const receiptStates = async () => { + const directory = join(a.paseoHome, "agent-requests"); + const receipts = await Promise.all( + (await readdir(directory)) + .filter((file) => file.endsWith(".json")) + .map(async (file) => JSON.parse(await readFile(join(directory, file), "utf8"))), + ); + return receipts + .filter( + (receipt) => receipt.agentId === parent.id && typeof receipt.fingerprint === "string", + ) + .map((receipt) => receipt.state) + .sort(); + }; + expect(await receiptStates()).toEqual(["pending", "pending"]); + finishChild.resolve(); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>"); + expect(parentPrompts[0]).toContain("LOCAL_CLI_REPORT"); + expect(parentPrompts[0]).toContain('event="finished"'); + expect(parentPrompts[0]).toContain(`source-agent-id="${child.id}"`); + await expect.poll(receiptStates, { timeout: 10000 }).toEqual(["completed", "completed"]); + const timeline = await origin.client.fetchAgentTimeline(parent.id); + const messages = timeline.entries.flatMap((row) => + row.item.type === "tool_call" && row.item.agentMessage ? [row.item.agentMessage] : [], + ); + expect( + messages.map((message) => [message.event, message.sender.id, message.sender.serverId]), + ).toEqual([ + ["message", child.id, bId], + ["message", parent.id, undefined], + ["finished", child.id, bId], + ]); + expect(messages[0]!.text).toBe("REMOTE_REPORT "); + expect(timeline.entries.filter((row) => row.item.type === "user_message")).toEqual([]); + + const human = await aWorkspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Human recipient", + }); + expect(await origin.client.sendAgentMessage(human.id, "HUMAN_PROMPT")).toEqual({}); + expect( + (await origin.client.fetchAgentTimeline(human.id)).entries.some( + (row) => row.item.type === "user_message" && row.item.text === "HUMAN_PROMPT", + ), + ).toBe(true); + }, +); + +test( + "remote top-level sends set the mode through the existing RPC before sending", + { timeout: 15000 }, + async () => { + const a = await daemon(); + const b = await daemon(); + const wire = await hostWire(b); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: wire.connection }); + const serverId = (await online(origin.paseo)).hosts[0]!.serverId!; + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const agent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "default" }, + title: "Human mode recipient", + }); + const mcp = new Client({ name: "human-mode-send", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${a.port}/mcp/agents`)), + ); + const response = await mcp.callTool({ + name: "send_agent_prompt", + arguments: { serverId, agentId: agent.id, prompt: "Human task", sessionMode: "full-access" }, + }); + expect(response.isError).not.toBe(true); + expect((await destination.client.fetchAgent(agent.id))?.agent.currentModeId).toBe( + "full-access", + ); + const requests = wire.frames + .filter((frame) => frame.direction === "toB" && !frame.binary) + .flatMap((frame) => { + const outer = JSON.parse(frame.bytes.toString()); + if (outer.type !== "session" || outer.message.type !== "hosts.route.frame.request") + return []; + const inner = JSON.parse(outer.message.data); + return inner.type === "session" ? [inner.message] : []; + }) + .filter( + (message) => + message.type === "set_agent_mode_request" || + message.type === "send_agent_message_request", + ); + expect(requests.map((message) => message.type)).toEqual([ + "set_agent_mode_request", + "send_agent_message_request", + ]); + expect(requests[1]).not.toHaveProperty("sessionMode"); + }, +); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index b837502f0c..7d881286fb 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,3 +1,4 @@ +import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, createTestCreationService, @@ -369,6 +370,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const sessionOptions: SessionOptions = { messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: options.clientId ?? "test-client", onMessage: (message) => messages.push(message), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 69f2c53b45..f49191e59e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import type { AgentMessageDelivery } from "./agent/agent-messages/index.js"; import { AgentDirectory } from "./agent/directory.js"; import { ConnectedHostSession } from "./connected-hosts/index.js"; import type { ConnectedHosts } from "./connected-hosts/index.js"; @@ -461,6 +462,7 @@ export interface SessionOptions { agentManager: AgentManager; agentStorage: AgentStorage; messageReceipts: Pick; + agentMessages: AgentMessageDelivery; creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; @@ -813,6 +815,7 @@ export class Session { private readonly hubExecutionController: HubExecutionController | null; private readonly workspaceScripts: WorkspaceScriptsService; private readonly messageReceipts: Pick; + private readonly agentMessages: AgentMessageDelivery; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; private readonly creationService: Pick; @@ -896,6 +899,7 @@ export class Session { this.pushNotifications = pushNotifications; this.paseoHome = paseoHome; this.messageReceipts = options.messageReceipts; + this.agentMessages = options.agentMessages; this.creationService = options.creationService; this.projectIcons = new ProjectIconReader(paseoHome); this.worktreesRoot = worktreesRoot; @@ -8221,17 +8225,42 @@ export class Session { }, "agent.session.send_agent_message", ); + let queued!: () => void; + const admitted = new Promise((acceptAdmission) => { + queued = acceptAdmission; + }); + let deliveryStatus: "queued" | undefined; const send = async () => { + if (msg.sourceAgentId) { + const receipt = this.agentMessages.enqueue({ + recipientId: agentId, + messageId: msg.messageId, + prompt, + source: { + kind: "agent-message", + agentId: msg.sourceAgentId, + serverId: msg.sourceAgentServerId, + title: !msg.sourceAgentServerId + ? (this.agentManager.getAgent(msg.sourceAgentId)?.config.title ?? undefined) + : undefined, + }, + activeTurnBehavior: msg.activeTurnBehavior ?? "steer", + sessionMode: msg.sessionMode, + clearPendingPermissions: true, + }); + deliveryStatus = "queued"; + queued(); + await receipt.dispatched; + return; + } const result = await sendPromptToAgent({ agentManager: this.agentManager, agentStorage: this.agentStorage, agentId, prompt, - source: msg.sourceAgentId - ? { kind: "agent-message", agentId: msg.sourceAgentId } - : undefined, messageId: msg.messageId, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt", + sessionMode: msg.sessionMode, clearPendingPermissions: true, logger: this.sessionLogger, }); @@ -8243,23 +8272,30 @@ export class Session { ); } }; - if (msg.messageId) { - await this.messageReceipts.send({ - agentId, - messageId: msg.messageId, - request: { - prompt, - sourceAgentId: msg.sourceAgentId, - activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt", - }, - prepare: async () => { - if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text); - }, - send, - }); - } else { - await send(); - } + const sent = msg.messageId + ? this.messageReceipts.send({ + agentId, + messageId: msg.messageId, + request: { + prompt, + sourceAgentId: msg.sourceAgentId, + sourceAgentServerId: msg.sourceAgentServerId, + sessionMode: msg.sessionMode, + activeTurnBehavior: + msg.activeTurnBehavior ?? (msg.sourceAgentId ? "steer" : "interrupt"), + }, + prepare: async () => { + if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text); + }, + send, + }) + : send(); + void sent.catch((error) => { + if (deliveryStatus === "queued") { + this.handleAgentRunError(agentId, error, "Failed to dispatch queued agent message"); + } + }); + await Promise.race([sent, admitted]); this.emit({ type: "send_agent_message_response", @@ -8267,6 +8303,7 @@ export class Session { requestId: msg.requestId, agentId, accepted: true, + ...(deliveryStatus ? { deliveryStatus } : {}), error: null, }, }); @@ -8308,40 +8345,6 @@ export class Session { } const agentId = resolved.agentId; - const live = this.agentManager.getAgent(agentId); - if (!live) { - const settled = await this.readSettledAgent(agentId); - if (!settled) { - this.emit({ - type: "wait_for_finish_response", - payload: { - requestId, - status: "error", - final: null, - error: `Agent not found: ${agentId}`, - lastMessage: null, - }, - }); - return; - } - const { record, lastMessage } = settled; - const final = this.buildStoredAgentPayload(record); - let status: "permission" | "error" | "idle"; - if (record.attentionReason === "permission") { - status = "permission"; - } else if (record.lastStatus === "error") { - status = "error"; - } else { - status = "idle"; - } - const error = resolveWaitForFinishError({ status, final }); - this.emit({ - type: "wait_for_finish_response", - payload: { requestId, status, final, error, lastMessage }, - }); - return; - } - const abortController = new AbortController(); const hasTimeout = typeof timeoutMs === "number" && timeoutMs > 0; const timeoutHandle = hasTimeout @@ -8351,6 +8354,45 @@ export class Session { : null; try { + await this.agentMessages.waitForDispatch( + agentId, + AbortSignal.any([abortController.signal, sourceSignal]), + ); + + const live = this.agentManager.getAgent(agentId); + if (!live) { + const settled = await this.readSettledAgent(agentId); + if (!settled) { + this.emit({ + type: "wait_for_finish_response", + payload: { + requestId, + status: "error", + final: null, + error: `Agent not found: ${agentId}`, + lastMessage: null, + }, + }); + return; + } + const { record, lastMessage } = settled; + const final = this.buildStoredAgentPayload(record); + let status: "permission" | "error" | "idle"; + if (record.attentionReason === "permission") { + status = "permission"; + } else if (record.lastStatus === "error") { + status = "error"; + } else { + status = "idle"; + } + const error = resolveWaitForFinishError({ status, final }); + this.emit({ + type: "wait_for_finish_response", + payload: { requestId, status, final, error, lastMessage }, + }); + return; + } + let result = await this.agentManager.waitForAgentEvent(agentId, { signal: AbortSignal.any([abortController.signal, sourceSignal]), waitForActive: true, diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 836703fd30..c836e6db5d 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,3 +1,4 @@ +import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, createTestCreationService, @@ -189,6 +190,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { const session = new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index e848e46f01..079dadc26f 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -1,3 +1,4 @@ +import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, createTestCreationService, @@ -93,6 +94,7 @@ function createHarness(input: { const session = new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test", permissions: OWNER_PERMISSIONS, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 89bc5a43e3..71759d33bf 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,3 +1,4 @@ +import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, createTestCreationService, @@ -647,6 +648,7 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, @@ -1009,6 +1011,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const session = asTestSession( new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", serverId: "test-server", @@ -1164,6 +1167,7 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, @@ -1303,6 +1307,7 @@ test("create_agent_request does not title an existing workspace from the agent p const session = asTestSession( new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, @@ -1574,6 +1579,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, @@ -2058,6 +2064,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy const session = asTestSession( new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, @@ -2228,6 +2235,7 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, @@ -2389,6 +2397,7 @@ test("close_items_request continues after an archive failure", async () => { const session = asTestSession( new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, @@ -3646,6 +3655,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 2cfe5d3e18..4af5e45c5b 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,3 +1,4 @@ +import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, createTestCreationService, @@ -104,6 +105,7 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, diff --git a/packages/server/src/server/test-utils/agent-messages.ts b/packages/server/src/server/test-utils/agent-messages.ts new file mode 100644 index 0000000000..b69da1f1c8 --- /dev/null +++ b/packages/server/src/server/test-utils/agent-messages.ts @@ -0,0 +1,43 @@ +import { createAgentMessageDelivery } from "../agent/agent-messages/index.js"; +import { + sendPreparedPromptToAgent, + waitForAgentRunStartWithTimeout, +} from "../agent/agent-prompt.js"; +import type { AgentManager } from "../agent/agent-manager.js"; +import type { AgentStorage } from "../agent/agent-storage.js"; +import type { Logger } from "pino"; + +/** Notification-format tests advance delivery immediately; debounce tests inject their own clock. */ +export function createTestAgentMessages(params: { + agentManager: AgentManager; + agentStorage: AgentStorage; + logger: Logger; +}) { + return createAgentMessageDelivery({ + schedule: (_delay, callback) => { + const timer = setTimeout(callback, 0); + return () => clearTimeout(timer); + }, + dispatch: async (request) => { + const result = await sendPreparedPromptToAgent({ + ...params, + agentId: request.recipientId, + prompt: request.prompt, + sessionMode: request.sessionMode, + unarchive: request.unarchive, + activeTurnBehavior: request.activeTurnBehavior, + clearPendingPermissions: request.clearPendingPermissions, + onDispatch: request.onDispatch, + runOptions: { clientMessageId: request.messageIds[0] }, + }); + if (result.disposition === "turn_started") + await waitForAgentRunStartWithTimeout(params.agentManager, request.recipientId); + return result.disposition; + }, + onError: (error, request) => + params.logger.error( + { err: error, agentId: request.recipientId, messageIds: request.messageIds }, + "Agent message batch dispatch failed", + ), + }); +} diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index c5cf271ad3..922f673935 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -1,3 +1,4 @@ +import { createAgentMessageDelivery } from "../agent/agent-messages/index.js"; import pino from "pino"; import { randomUUID } from "node:crypto"; import { afterEach, vi } from "vitest"; @@ -320,3 +321,15 @@ export function createTestCreationService(): SessionOptions["creationService"] { creationDirectories.push(directory); return new CreationService(directory, pino({ level: "silent" })); } + +/** Session fixtures without agent-originated sends still own the delivery lifetime. */ +export function createAgentMessageDeliveryStub(): SessionOptions["agentMessages"] { + return createAgentMessageDelivery({ + dispatch: async () => { + throw new Error("Unexpected agent message in session fixture"); + }, + onError: (error) => { + throw error; + }, + }); +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8f36e1a2b8..dff09358a0 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,11 @@ +import { + createAgentMessageDelivery, + type AgentMessageDelivery, +} from "./agent/agent-messages/index.js"; +import { + sendPreparedPromptToAgent, + waitForAgentRunStartWithTimeout, +} from "./agent/agent-prompt.js"; import { createToolHosts } from "./agent/host-operations/index.js"; import { HOST_ROUTE_OPCODE } from "@getpaseo/protocol/binary-frames/index"; import { DaemonLinkEndpoint, VirtualHostSocket } from "./connected-hosts/index.js"; @@ -547,6 +555,7 @@ export class VoiceAssistantWebSocketServer { private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; private readonly messageReceipts: MessageReceipts; + readonly agentMessages: AgentMessageDelivery; private readonly creationService: CreationService; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; @@ -693,6 +702,31 @@ export class VoiceAssistantWebSocketServer { this.orchestrationSkills = orchestrationSkills; this.agentManager = agentManager; this.agentStorage = agentStorage; + this.agentMessages = createAgentMessageDelivery({ + dispatch: async (batch) => { + const { disposition } = await sendPreparedPromptToAgent({ + agentManager, + agentStorage, + agentId: batch.recipientId, + prompt: batch.prompt, + activeTurnBehavior: batch.activeTurnBehavior, + sessionMode: batch.sessionMode, + clearPendingPermissions: batch.clearPendingPermissions, + unarchive: batch.unarchive, + runOptions: { clientMessageId: batch.messageIds[0] }, + onDispatch: batch.onDispatch, + logger: this.logger, + }); + if (disposition === "turn_started") + await waitForAgentRunStartWithTimeout(agentManager, batch.recipientId); + return disposition; + }, + onError: (err, batch) => + this.logger.error( + { err, agentId: batch.recipientId, messageIds: batch.messageIds }, + "Agent message batch dispatch failed", + ), + }); this.messageReceipts = new MessageReceipts(join(paseoHome, "agent-requests")); this.creationService = new CreationService( join(paseoHome, "creations"), @@ -1095,6 +1129,7 @@ export class VoiceAssistantWebSocketServer { public async close(): Promise { this.prepareForShutdown(); + await this.agentMessages.close(); await this.connectedHosts.close(); this.unsubscribeSpeechReadiness?.(); this.unsubscribeSpeechReadiness = null; @@ -1561,6 +1596,7 @@ export class VoiceAssistantWebSocketServer { agentManager: this.agentManager, agentStorage: this.agentStorage, messageReceipts: this.messageReceipts, + agentMessages: this.agentMessages, creationService: this.creationService, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, @@ -2047,6 +2083,7 @@ export class VoiceAssistantWebSocketServer { // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: true, agentMessageProvenance: true, + agentMessageBatching: true, // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: true, // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index a7b36f521d..c6896c3fb0 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,3 +1,4 @@ +import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, createTestCreationService, @@ -214,6 +215,7 @@ function createSessionForWireCompatTest(options?: { const session = new Session({ messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), clientId: "wire-compat-client", permissions: OWNER_PERMISSIONS, diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 83d329f948..e92bdf91ee 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -61,9 +61,9 @@ Agent-scoped creation always creates your subagent. Omit `workspaceId` to use yo Detach is an explicit user action in the subagents track, not an agent tool. A cross-workspace child remains your subagent even though it also appears as a normal tab in its workspace. -Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents. +Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` when you want explicit reporting or no completion callback. -**`send_agent_prompt`** — `{ agentId, prompt, activeTurnBehavior? }`. Use for follow-ups to an existing agent. Agent-scoped calls default to `steer`; top-level calls default to `interrupt`. Set `activeTurnBehavior` to `steer` or `interrupt` to override the default. Steering falls back to interruption when unavailable. CLI agent sends use the same defaults and accept `--behavior steer|interrupt`. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback. +**`send_agent_prompt`** — `{ agentId, prompt, activeTurnBehavior? }`. Use for follow-ups to an existing agent. Agent-scoped calls default to `steer`; top-level calls default to `interrupt`. Set `activeTurnBehavior` to `steer` or `interrupt` to override the default. Steering falls back to interruption when unavailable. CLI agent sends use the same defaults and accept `--behavior steer|interrupt`. Agent-scoped calls return a queued receipt immediately; messages to each recipient deliver after two quiet seconds. Top-level calls return after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback. **`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`. @@ -108,9 +108,9 @@ Schedules have the full list/inspect/update/pause/resume/run-once/log/delete sur Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows. -`create_agent` and `send_agent_prompt` return after startup or dispatch acknowledgement in both MCP scopes. Top-level callers have no parent callback; observe completion through status/activity tools or SDK/RPC subscriptions and wait APIs. +`create_agent` returns after startup. Agent-scoped `send_agent_prompt` returns after queue admission; provider dispatch follows after two quiet seconds. Top-level sends return after dispatch acknowledgement. Top-level callers have no parent callback; observe completion through status/activity tools or SDK/RPC subscriptions and wait APIs. -For agent-scoped `create_agent` and `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. Move on to other work. The notification arrives on its own. +For agent-scoped `create_agent` and `send_agent_prompt`, `notifyOnFinish` defaults to `true`. The caller receives the target’s completion, error, or permission report automatically, including its last response. Finish your turn with the result for automatic reporting. For a workflow that reports explicitly with `send_agent_prompt`, set `notifyOnFinish: false` when assigning the task. Paseo supplies caller identity for agent-scoped tools and CLI agent context; use that supplied identity. With finish notifications enabled, don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you. From c63e1204b0ba83c03e05657db5a9714f58a8b2d9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 18:28:23 +0700 Subject: [PATCH 11/62] Review all branch changes by default without automatic scope switching (#7576) * feat(git): default review to all branch changes Keep the selected comparison stable while agents edit and commit so reviewing the complete result does not require switching scopes or interrupt unfinished comments. * test(git): release Windows checkout handles during cleanup Allow transient filesystem handles from subscription teardown to close before removing the test repository. --- packages/app/e2e/browser/changes-pane.spec.ts | 140 +++++++++++++++++- packages/app/src/data/push-router.test.ts | 13 +- packages/app/src/data/push-router.ts | 4 +- .../app/src/git/checkout-status-cache.test.ts | 54 +++---- packages/app/src/git/checkout-status-cache.ts | 12 -- packages/app/src/git/diff-pane.tsx | 30 +++- packages/app/src/git/query-keys.ts | 2 +- packages/app/src/git/use-diff-query.ts | 29 +++- packages/app/src/git/use-working-diff.ts | 4 +- .../src/git/working-diff-comparison/index.ts | 28 +--- .../git/working-diff-comparison/state.test.ts | 90 ++--------- .../src/git/working-diff-comparison/state.ts | 40 +---- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + packages/app/src/review/state.ts | 2 +- packages/client/src/daemon-client.ts | 16 +- packages/protocol/src/messages.test.ts | 23 +++ packages/protocol/src/messages.ts | 5 +- .../server/agent/prompt-attachments.test.ts | 6 +- .../src/server/checkout-diff-cache.test.ts | 18 +++ .../server/src/server/checkout-diff-cache.ts | 4 +- .../src/server/checkout-diff-manager.ts | 10 +- .../checkout-diff-content.e2e.test.ts | 65 ++++++++ .../server/src/server/websocket-server.ts | 1 + .../src/server/workspace-git-service.ts | 2 +- .../server/src/utils/checkout-git.test.ts | 86 +++++++++++ packages/server/src/utils/checkout-git.ts | 88 +++++++++-- 34 files changed, 545 insertions(+), 236 deletions(-) diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 6d8acc05d3..61173613ea 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -262,24 +262,74 @@ test.afterEach(async () => { } }); -test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => { +test("Changes opens All changes for a clean checkout", async ({ page }) => { const workspace = await createWorkspaceWithCommittedDiff(); await openWorkspaceChangesSurface(page, workspace, 90_000); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); - await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed"); + await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("All changes"); await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0"); }); -test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => { +test("All changes combines committed and pending edits and preserves an unfinished review", async ({ + page, + context, +}) => { + const workspace = await createWorkspaceWithNetDiff(); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await configureDiffPresentation(page, { layout: "unified", wrapLines: false }); + await openWorkspaceChangesSurface(page, workspace); + const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); + const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); + const mode = tree.getByRole("button", { name: "Diff mode", exact: true }); + await expect(mode).toContainText("All changes"); + await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts"]); + await expectRenderedAddedLine(page, "committed"); + + await test.step("an unfinished review survives the first dirty edit", async () => { + await startReviewOnFirstChangedLine(page); + await page + .getByRole("textbox", { name: "Review comment" }) + .fill("Unfinished review stays here"); + await writeFile(path.join(workspace.repoPath, "c-untracked.txt"), "untracked\n"); + await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]); + await expect(mode).toContainText("All changes"); + await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue( + "Unfinished review stays here", + ); + await cancelInlineReview(page); + }); + await test.step("pending edits form a live net patch over the committed edit", async () => { + await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "working\n"); + await expectRenderedAddedLine(page, "working"); + await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("a-tracked.ts, +1, -1"); + }); + await test.step("committing the same contents preserves the rendered comparison", async () => { + execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath }); + execFileSync("git", ["commit", "-m", "Commit pending changes"], { cwd: workspace.repoPath }); + await expect(page.getByTestId("changes-primary-cta")).toContainText("Merge", { + timeout: 30_000, + }); + await expect(mode).toContainText("All changes"); + await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]); + await expectRenderedAddedLine(page, "working"); + }); + await test.step("undoing to the comparison base removes the net change", async () => { + await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "base\n"); + await expectNetComparisonFiles(page, ["b-committed.ts", "c-untracked.txt"]); + await expect(mode).toContainText("All changes"); + }); +}); + +test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await openWorkspaceChanges(page, workspace); const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); const mode = tree.getByTestId("changes-diff-status-trigger"); - await expect(mode).toContainText("Uncommitted"); + await expect(mode).toContainText("All changes"); await mode.click(); await page.getByTestId("changes-diff-mode-committed").click(); @@ -294,7 +344,23 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn }); await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n"); - await expect(mode).toContainText("Uncommitted", { timeout: 30_000 }); + await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible({ + timeout: 30_000, + }); + await expect(mode).toContainText("Committed"); + + await selectChangesComparison(page, "Uncommitted"); + execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath }); + execFileSync("git", ["commit", "-m", "Commit new working change"], { cwd: workspace.repoPath }); + await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(mode).toContainText("Uncommitted"); + await writeFile(path.join(workspace.repoPath, "another-working-change.txt"), "pending\n"); + await expect(tree.getByText("another-working-change.txt", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(mode).toContainText("Uncommitted"); }); test("an empty Changes comparison links to the populated comparison", async ({ page }) => { @@ -633,6 +699,7 @@ test("changes context menus expose folder revert and restore a file after confir const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); + await selectChangesComparison(page, "Uncommitted"); const tree = changesTree(page); await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); @@ -680,6 +747,7 @@ test("discarding a staged rename restores its source path", async ({ page }) => const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); + await selectChangesComparison(page, "Uncommitted"); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts"); @@ -710,6 +778,7 @@ test("discarding an untracked file removes it from the working tree", async ({ p const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); + await selectChangesComparison(page, "Uncommitted"); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt"); @@ -741,6 +810,7 @@ test("shows a revert error returned by the daemon", async ({ page }) => { await failNextDiscardRequest(page); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); + await selectChangesComparison(page, "Uncommitted"); await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); const confirmation = new Promise((resolve) => { @@ -892,6 +962,8 @@ test("compact Explorer renders inline hunks with a saved tree-only desktop prefe { key: CHANGES_PREFERENCES_KEY }, ); const explorer = await openCompactChanges(page, workspace); + await explorer.getByRole("button", { name: "Diff mode", exact: true }).click(); + await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click(); await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible(); await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible(); await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" }); @@ -1525,6 +1597,64 @@ async function createWorkspaceWithMountedTabDiff( return { id: createdWorkspace.workspace.id, repoPath: repo.path }; } +async function createWorkspaceWithNetDiff(): Promise { + const repo = await createTempGitRepo("changes-net-", { + files: [{ path: "a-tracked.ts", content: "base\n" }], + }); + const client = await connectSeedClient(); + cleanupTasks.push({ + run: async () => { + await client.close(); + await repo.cleanup(); + }, + }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path }); + await writeFile(path.join(repo.path, "a-tracked.ts"), "committed\n"); + await writeFile(path.join(repo.path, "b-committed.ts"), "committed only\n"); + execFileSync("git", ["add", "--all"], { cwd: repo.path }); + execFileSync("git", ["commit", "-m", "Committed changes"], { cwd: repo.path }); + const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } }); + if (!created.workspace) throw new Error(created.error ?? "Failed to create net diff workspace"); + return { id: created.workspace.id, repoPath: repo.path }; +} + +async function expectNetComparisonFiles(page: Page, files: string[]): Promise { + const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); + const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); + await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText( + files, + ); + await expect(panel.getByTestId(/^diff-file-\d+$/)).toHaveCount(files.length); + for (const file of files) { + await expect(diffHeaderForPath(panel, file)).toBeVisible(); + } +} + +async function expectRenderedAddedLine(page: Page, contents: string): Promise { + await changesTree(page).getByText("a-tracked.ts", { exact: true }).click(); + await expect(async () => { + await page.keyboard.press("Escape"); + await page.evaluate(() => navigator.clipboard.writeText("")); + const body = page.getByTestId("diff-file-0-body"); + const canvas = page.getByTestId("git-diff-canvas"); + const bounds = await body.boundingBox(); + if (!bounds) throw new Error("Expanded diff body has no bounds"); + const fontSize = await canvas.evaluate((element) => + Number.parseFloat(getComputedStyle(element).fontSize), + ); + // Replacement: hunk header, removed line, added line. + await page.mouse.click(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5, { + button: "right", + }); + const copyLine = page.getByRole("menuitem", { name: "Copy line", exact: true }); + await expect(copyLine).toBeEnabled({ timeout: 1000 }); + await copyLine.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 1000 }) + .toBe(contents); + }).toPass({ timeout: 15_000 }); +} + async function createWorkspaceWithCommittedDiff(): Promise { const repo = await createTempGitRepo("changes-committed-", { files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }], diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index 3bf9e99f9b..6599c28467 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -51,7 +51,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} emit: (message: Extract) => void; subscribeCheckoutDiffCalls: Array<{ cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean }; subscriptionId: string; }>; unsubscribeCheckoutDiffCalls: string[]; @@ -67,7 +67,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} }; const subscribeCheckoutDiffCalls: Array<{ cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean }; subscriptionId: string; }> = []; const unsubscribeCheckoutDiffCalls: string[] = []; @@ -379,12 +379,12 @@ describe("server data push router", () => { }); }); - it("subscribes active checkout diff queries and writes matching diff events", () => { + it.each(["base", "all"] as const)("routes %s diff snapshots and updates", (mode) => { const queryClient = new QueryClient(); const fake = createFakeClient(); const serverId = "server-1"; const cwd = "/repo"; - const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); + const queryKey = checkoutDiffQueryKey(serverId, cwd, mode, "main", true); const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`; const observer = new QueryObserver(queryClient, { queryKey, @@ -397,17 +397,18 @@ describe("server data push router", () => { serverId, subscriptionId, cwd, - compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, + compare: { mode, baseRef: "main", ignoreWhitespace: true }, }), }); const unsubscribeObserver = observer.subscribe(() => undefined); const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId }); + expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1); const serverSubscriptionId = fake.subscribeCheckoutDiffCalls[0]!.subscriptionId; expect(fake.subscribeCheckoutDiffCalls).toEqual([ { cwd, - compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, + compare: { mode, baseRef: "main", ignoreWhitespace: true }, subscriptionId: serverSubscriptionId, }, ]); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 5ab3069bd0..75ac313169 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -33,7 +33,7 @@ type CheckoutDiffCachePayload = Omit { expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); }); - it("expires a manual working-diff comparison when the fetched dirty state flipped", async () => { - selectBaseComparison(true); + it("keeps a manual working-diff comparison when the fetched dirty state flips", async () => { + selectBaseComparison(); const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) }; await fetchCheckoutStatus({ client, serverId, cwd }); - expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: false })).toBe("base"); - expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted"); + expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe("base"); }); }); @@ -286,31 +284,25 @@ describe("applyCheckoutStatusUpdateFromEvent", () => { ).toBe("unauthenticated"); }); - it("expires a manual working-diff comparison when the pushed dirty state flipped", () => { - const queryClient = createQueryClient(); - selectBaseComparison(false); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })), - }); - - expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted"); - }); - - it("keeps a manual working-diff comparison while the pushed dirty state still matches", () => { - const queryClient = createQueryClient(); - selectBaseComparison(true); - - applyCheckoutStatusUpdateFromEvent({ - queryClient, - serverId, - message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })), - }); - - expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("base"); - }); + it.each(["base", "uncommitted"] as const)( + "keeps %s selected across dirty and clean pushes", + (comparison) => { + const queryClient = createQueryClient(); + selectWorkingDiffComparison({ serverId, cwd, comparison }); + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })), + }); + expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe(comparison); + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus({ isDirty: false })), + }); + expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe(comparison); + }, + ); it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => { const queryClient = createQueryClient(); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index c06499fef5..ca5cbb3a7a 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -9,7 +9,6 @@ import { } from "@/git/query-keys"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query"; -import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison"; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; export type { CheckoutPrStatusPayload } from "@/git/pr-status"; @@ -18,13 +17,8 @@ export interface CheckoutStatusClient { getCheckoutStatus: (cwd: string) => Promise; } -// Checkout status enters the app through exactly two doors: daemon pushes -// (applyCheckoutStatusUpdateFromEvent) and query fetches (fetchCheckoutStatus). Both run -// the dirty-state reactions, so they hold regardless of which screens are mounted. - export async function fetchCheckoutStatus({ client, - serverId, cwd, }: { client: CheckoutStatusClient; @@ -32,7 +26,6 @@ export async function fetchCheckoutStatus({ cwd: string; }): Promise { const payload = await client.getCheckoutStatus(cwd); - expireWorkingDiffComparisons({ serverId, cwd, isDirty: payload.isGit && payload.isDirty }); return payload; } @@ -78,11 +71,6 @@ export function applyCheckoutStatusUpdateFromEvent({ void queryClient.invalidateQueries({ queryKey: checkoutCommitsQueryKey(serverId, payload.cwd), }); - expireWorkingDiffComparisons({ - serverId, - cwd: payload.cwd, - isDirty: payload.isGit && payload.isDirty, - }); if (!prStatus) { return; diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 22a5c2df05..3dd84f256a 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -133,7 +133,7 @@ function useDiscardChangesAction({ }: { serverId: string; cwd: string; - diffMode: "uncommitted" | "base"; + diffMode: "uncommitted" | "base" | "all"; }): ((path: string, oldPath?: string) => void) | undefined { const { t } = useTranslation(); const toast = useToast(); @@ -281,11 +281,12 @@ export function DiffLayoutToggle({ } interface DiffModeMenuProps { - diffMode: "uncommitted" | "base"; + diffMode: "uncommitted" | "base" | "all"; committedDescription?: string; testIDPrefix?: string; onSelectUncommitted: () => void; onSelectBase: () => void; + onSelectAll: () => void; } export function DiffModeMenu({ @@ -294,10 +295,12 @@ export function DiffModeMenu({ testIDPrefix = "changes-diff", onSelectUncommitted, onSelectBase, + onSelectAll, }: DiffModeMenuProps) { const { t } = useTranslation(); const uncommittedLabel = t("workspace.git.diff.uncommitted"); const committedLabel = t("workspace.git.diff.committed"); + const allLabel = t("workspace.git.diff.all"); return ( - {diffMode === "uncommitted" ? uncommittedLabel : committedLabel} + {{ all: allLabel, uncommitted: uncommittedLabel, base: committedLabel }[diffMode]} @@ -321,6 +324,14 @@ export function DiffModeMenu({ }} + + {allLabel} + + void; + onSelectAll: () => void; onSelectUncommitted: () => void; } @@ -488,11 +500,12 @@ interface BuildChangesHeaderModelInput { committedDescription?: string; compact: boolean; cwd: string; - diffMode: "uncommitted" | "base"; + diffMode: "uncommitted" | "base" | "all"; gitActions: GitActions; mode: ChangesToolbarMode; onOpenPullRequest: () => void; onSelectBase: () => void; + onSelectAll: () => void; onSelectUncommitted: () => void; pullRequest: PrHint | null; selectedDiffStat: { additions: number; deletions: number } | null; @@ -521,6 +534,7 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): { mode: input.mode, selectedDiffStat: input.selectedDiffStat, onSelectBase: input.onSelectBase, + onSelectAll: input.onSelectAll, onSelectUncommitted: input.onSelectUncommitted, }, }; @@ -737,6 +751,7 @@ function ChangesComparisonToolbar({ committedDescription={model.committedDescription} onSelectUncommitted={model.onSelectUncommitted} onSelectBase={model.onSelectBase} + onSelectAll={model.onSelectAll} /> {model.selectedDiffStat ? ( [number]; function normalizeCheckoutDiffCompare(compare: { - mode: "uncommitted" | "base"; + mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean; -}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } { +}): { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean } { const ignoreWhitespace = compare.ignoreWhitespace === true; if (compare.mode === "uncommitted") { return { mode: "uncommitted", ignoreWhitespace }; } const trimmedBaseRef = compare.baseRef?.trim(); return trimmedBaseRef - ? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace } - : { mode: "base", ignoreWhitespace }; + ? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace } + : { mode: compare.mode, ignoreWhitespace }; } export function useCheckoutDiffQuery({ @@ -49,6 +50,11 @@ export function useCheckoutDiffQuery({ enabled = true, queryScope, }: UseCheckoutDiffQueryOptions) { + // COMPAT(checkoutDiffAll): added in v0.12.0, remove after 2027-04-21 once host floor includes All changes. + const allSupported = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.checkoutDiffAll === true, + ); + const requiresUpdate = mode === "all" && !allSupported; const retainedPanelActive = useRetainedPanelActive(); const queryEnabled = enabled && retainedPanelActive; const isConnected = useHostRuntimeIsConnected(serverId); @@ -71,7 +77,7 @@ export function useCheckoutDiffQuery({ return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey; }, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]); - const routeEnabled = Boolean(queryEnabled && isConnected && cwd); + const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate); const query = useReplicaQuery({ queryKey, @@ -91,7 +97,16 @@ export function useCheckoutDiffQuery({ }), }); - return deriveCheckoutDiffResult(query.data ?? null); + return deriveCheckoutDiffResult( + requiresUpdate && isConnected + ? { + requestId: "unsupported", + cwd, + files: [], + error: { code: "UNKNOWN", message: "Update the host to view All changes" }, + } + : (query.data ?? null), + ); } export interface CheckoutDiffResult { diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts index daeda1479d..f835d44f53 100644 --- a/packages/app/src/git/use-working-diff.ts +++ b/packages/app/src/git/use-working-diff.ts @@ -45,7 +45,6 @@ export function useWorkingDiff({ status?.error?.message ?? (isStatusError && statusError instanceof Error ? statusError.message : null); const baseRef = gitStatus?.baseRef ?? undefined; - const hasUncommittedChanges = Boolean(gitStatus?.isDirty); const currentBranchName = gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null; @@ -53,9 +52,9 @@ export function useWorkingDiff({ serverId, workspaceId, cwd, - isDirty: hasUncommittedChanges, }); const selectUncommitted = useCallback(() => selectComparison("uncommitted"), [selectComparison]); + const selectAll = useCallback(() => selectComparison("all"), [selectComparison]); const selectBase = useCallback(() => selectComparison("base"), [selectComparison]); const { @@ -111,6 +110,7 @@ export function useWorkingDiff({ diffMode, selectUncommitted, selectBase, + selectAll, files, diffPayloadError, diffTooLarge, diff --git a/packages/app/src/git/working-diff-comparison/index.ts b/packages/app/src/git/working-diff-comparison/index.ts index bee2c81b22..84a4b461d2 100644 --- a/packages/app/src/git/working-diff-comparison/index.ts +++ b/packages/app/src/git/working-diff-comparison/index.ts @@ -1,7 +1,6 @@ import { useCallback } from "react"; import { create } from "zustand"; import { - expireWorkingDiffComparisonsInState, resolveWorkingDiffComparisonFromState, selectWorkingDiffComparisonInState, type WorkingDiffCheckoutIdentity, @@ -13,7 +12,6 @@ interface WorkingDiffComparisonStore extends WorkingDiffComparisonState { select: ( input: WorkingDiffCheckoutIdentity & { comparison: WorkingDiffComparison; - isDirty: boolean; }, ) => void; } @@ -23,21 +21,18 @@ const useWorkingDiffComparisonStore = create((set) = select: (input) => set((state) => selectWorkingDiffComparisonInState(state, input)), })); -export function useWorkingDiffComparison( - input: WorkingDiffCheckoutIdentity & { isDirty: boolean }, -): { +export function useWorkingDiffComparison(input: WorkingDiffCheckoutIdentity): { comparison: WorkingDiffComparison; selectComparison: (comparison: WorkingDiffComparison) => void; } { - const { serverId, workspaceId, cwd, isDirty } = input; + const { serverId, workspaceId, cwd } = input; const comparison = useWorkingDiffComparisonStore((state) => - resolveWorkingDiffComparisonFromState(state, { serverId, workspaceId, cwd, isDirty }), + resolveWorkingDiffComparisonFromState(state, { serverId, workspaceId, cwd }), ); const select = useWorkingDiffComparisonStore((state) => state.select); const selectComparison = useCallback( - (next: WorkingDiffComparison) => - select({ serverId, workspaceId, cwd, isDirty, comparison: next }), - [cwd, isDirty, select, serverId, workspaceId], + (next: WorkingDiffComparison) => select({ serverId, workspaceId, cwd, comparison: next }), + [cwd, select, serverId, workspaceId], ); return { comparison, selectComparison }; } @@ -45,28 +40,17 @@ export function useWorkingDiffComparison( export function selectWorkingDiffComparison( input: WorkingDiffCheckoutIdentity & { comparison: WorkingDiffComparison; - isDirty: boolean; }, ): void { useWorkingDiffComparisonStore.getState().select(input); } export function resolveWorkingDiffComparison( - input: WorkingDiffCheckoutIdentity & { isDirty: boolean }, + input: WorkingDiffCheckoutIdentity, ): WorkingDiffComparison { return resolveWorkingDiffComparisonFromState(useWorkingDiffComparisonStore.getState(), input); } -export function expireWorkingDiffComparisons(input: { - serverId: string; - cwd: string; - isDirty: boolean; -}): void { - useWorkingDiffComparisonStore.setState((state) => - expireWorkingDiffComparisonsInState(state, input), - ); -} - export function resetWorkingDiffComparisons(): void { useWorkingDiffComparisonStore.setState({ overrides: {} }); } diff --git a/packages/app/src/git/working-diff-comparison/state.test.ts b/packages/app/src/git/working-diff-comparison/state.test.ts index f86038bdc1..95fbe47ffb 100644 --- a/packages/app/src/git/working-diff-comparison/state.test.ts +++ b/packages/app/src/git/working-diff-comparison/state.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - expireWorkingDiffComparisonsInState, resolveWorkingDiffComparisonFromState, selectWorkingDiffComparisonInState, type WorkingDiffComparisonState, @@ -26,79 +25,22 @@ describe("working diff comparison", () => { ); }); - it("defaults from checkout dirtiness and honors a matching manual selection", () => { - expect( - resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: true }), - ).toBe("uncommitted"); - expect( - resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: false }), - ).toBe("base"); - - const selected = selectWorkingDiffComparisonInState(emptyState(), { - ...checkout, - comparison: "base", - isDirty: true, - }); - expect(resolveWorkingDiffComparisonFromState(selected, { ...checkout, isDirty: true })).toBe( - "base", - ); + it.each([true, false])("defaults to All changes when checkout dirtiness is %s", (isDirty) => { + const status = { ...checkout, isDirty }; + expect(resolveWorkingDiffComparisonFromState(emptyState(), status)).toBe("all"); }); - it("masks and expires stale selections for every workspace on the checkout", () => { - let state = selectWorkingDiffComparisonInState(emptyState(), { - ...checkout, - comparison: "base", - isDirty: true, - }); - state = selectWorkingDiffComparisonInState(state, { - ...checkout, - workspaceId: "workspace-2", - comparison: "uncommitted", - isDirty: false, - }); - state = selectWorkingDiffComparisonInState(state, { - serverId: "server-2", - workspaceId: "workspace-3", - cwd: "/other", - comparison: "base", - isDirty: true, - }); - - expect(resolveWorkingDiffComparisonFromState(state, { ...checkout, isDirty: false })).toBe( - "base", - ); - const expired = expireWorkingDiffComparisonsInState(state, { - serverId: checkout.serverId, - cwd: checkout.cwd, - isDirty: false, - }); - expect(expired.overrides[workingDiffComparisonKey(checkout)]).toBeUndefined(); - expect( - expired.overrides[workingDiffComparisonKey({ ...checkout, workspaceId: "workspace-2" })], - ).toBeDefined(); - expect( - expired.overrides[ - workingDiffComparisonKey({ - serverId: "server-2", - workspaceId: "workspace-3", - cwd: "/other", - }) - ], - ).toBeDefined(); - }); - - it("keeps state identity when nothing expires", () => { - const state = selectWorkingDiffComparisonInState(emptyState(), { - ...checkout, - comparison: "base", - isDirty: true, - }); - expect( - expireWorkingDiffComparisonsInState(state, { - serverId: checkout.serverId, - cwd: checkout.cwd, - isDirty: true, - }), - ).toBe(state); - }); + it.each(["all", "base", "uncommitted"] as const)( + "keeps the selected %s comparison", + (comparison) => { + const selected = selectWorkingDiffComparisonInState(emptyState(), { + ...checkout, + comparison, + }); + expect(resolveWorkingDiffComparisonFromState(selected, checkout)).toBe(comparison); + expect( + resolveWorkingDiffComparisonFromState(selected, { ...checkout, workspaceId: "other" }), + ).toBe("all"); + }, + ); }); diff --git a/packages/app/src/git/working-diff-comparison/state.ts b/packages/app/src/git/working-diff-comparison/state.ts index 5c7c141376..cf077a7316 100644 --- a/packages/app/src/git/working-diff-comparison/state.ts +++ b/packages/app/src/git/working-diff-comparison/state.ts @@ -1,10 +1,7 @@ -export type WorkingDiffComparison = "uncommitted" | "base"; +export type WorkingDiffComparison = "uncommitted" | "base" | "all"; export interface WorkingDiffComparisonOverride { - serverId: string; - cwd: string; comparison: WorkingDiffComparison; - isDirtyAtSelection: boolean; } export interface WorkingDiffComparisonState { @@ -34,17 +31,13 @@ export function selectWorkingDiffComparisonInState( state: WorkingDiffComparisonState, input: WorkingDiffCheckoutIdentity & { comparison: WorkingDiffComparison; - isDirty: boolean; }, ): WorkingDiffComparisonState { return { overrides: { ...state.overrides, [workingDiffComparisonKey(input)]: { - serverId: input.serverId.trim(), - cwd: normalizeCwd(input.cwd), comparison: input.comparison, - isDirtyAtSelection: input.isDirty, }, }, }; @@ -52,34 +45,7 @@ export function selectWorkingDiffComparisonInState( export function resolveWorkingDiffComparisonFromState( state: WorkingDiffComparisonState, - input: WorkingDiffCheckoutIdentity & { isDirty: boolean }, + input: WorkingDiffCheckoutIdentity, ): WorkingDiffComparison { - const override = state.overrides[workingDiffComparisonKey(input)]; - // Status can render before boundary expiry runs, so resolution must also mask a stale - // selection under any ordering of the two updates. - if (override?.isDirtyAtSelection === input.isDirty) { - return override.comparison; - } - return input.isDirty ? "uncommitted" : "base"; -} - -export function expireWorkingDiffComparisonsInState( - state: WorkingDiffComparisonState, - input: { serverId: string; cwd: string; isDirty: boolean }, -): WorkingDiffComparisonState { - // Expire at the status boundary so selections cannot return after an unmounted surface - // misses a dirty-state transition. - const staleKeys = Object.entries(state.overrides) - .filter( - ([, override]) => - override.serverId === input.serverId.trim() && - override.cwd === normalizeCwd(input.cwd) && - override.isDirtyAtSelection !== input.isDirty, - ) - .map(([key]) => key); - if (staleKeys.length === 0) return state; - - const overrides = { ...state.overrides }; - for (const key of staleKeys) delete overrides[key]; - return { overrides }; + return state.overrides[workingDiffComparisonKey(input)]?.comparison ?? "all"; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index accd9cec6d..388f9be16f 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -956,6 +956,7 @@ export const ar: TranslationResources = { checkingRepository: "فحص المستودع...", notRepository: "ليس مستودع جيت", diffMode: "وضع الفرق", + all: "كل التغييرات", uncommitted: "غير ملتزم", committed: "ملتزم", branchUnknown: "مجهول", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 8002c0ff46..d64c19871d 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -964,6 +964,7 @@ export const en = { checkingRepository: "Checking repository...", notRepository: "Not a git repository", diffMode: "Diff mode", + all: "All changes", uncommitted: "Uncommitted", committed: "Committed", branchUnknown: "Unknown", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 464b1ab208..27c4ab8404 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -989,6 +989,7 @@ export const es: TranslationResources = { checkingRepository: "Comprobando repositorio...", notRepository: "No es un repositorio de git", diffMode: "modo diferencial", + all: "Todos los cambios", uncommitted: "No comprometido", committed: "Comprometido", branchUnknown: "Desconocido", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 4f8b43889a..29464ca9e2 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -982,6 +982,7 @@ export const fr: TranslationResources = { checkingRepository: "Vérification du dépôt…", notRepository: "Ce n’est pas un dépôt git", diffMode: "Mode de diff", + all: "Toutes les modifications", uncommitted: "Non commité", committed: "Commité", branchUnknown: "Inconnue", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 9fb11cb2e1..e3dba618ee 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -969,6 +969,7 @@ export const ja: TranslationResources = { checkingRepository: "リポジトリを確認中...", notRepository: "gitリポジトリではありません", diffMode: "差分モード", + all: "すべての変更", uncommitted: "未コミット", committed: "コミット済み", branchUnknown: "不明", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index e079ae90f1..0c82504aaa 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -963,6 +963,7 @@ export const ko: TranslationResources = { checkingRepository: "저장소 확인 중...", notRepository: "Git 저장소가 아닙니다", diffMode: "Diff 모드", + all: "모든 변경 사항", uncommitted: "커밋되지 않음", committed: "커밋됨", branchUnknown: "알 수 없음", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 84a5cb7c5f..842dea8f5e 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -979,6 +979,7 @@ export const ptBR: TranslationResources = { checkingRepository: "Verificando repositório...", notRepository: "Não é um repositório git", diffMode: "Modo de diff", + all: "Todas as alterações", uncommitted: "Sem commit", committed: "Com commit", branchUnknown: "Desconhecido", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 54f0c2d6ca..7a5881c1b7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -973,6 +973,7 @@ export const ru: TranslationResources = { checkingRepository: "Проверяем репозиторий...", notRepository: "Это не репозиторий Git", diffMode: "Режим диффа", + all: "Все изменения", uncommitted: "Незафиксированные изменения", committed: "Зафиксированные изменения", branchUnknown: "Неизвестная ветка", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 8476e55464..6cd2be6e81 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -948,6 +948,7 @@ export const zhCN: TranslationResources = { checkingRepository: "正在检查 repository...", notRepository: "不是 git repository", diffMode: "Diff 模式", + all: "全部更改", uncommitted: "未 commit", committed: "已 commit", branchUnknown: "未知", diff --git a/packages/app/src/review/state.ts b/packages/app/src/review/state.ts index 20fe98e098..0a1a965db5 100644 --- a/packages/app/src/review/state.ts +++ b/packages/app/src/review/state.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export type ReviewDraftMode = "uncommitted" | "base"; +export type ReviewDraftMode = "uncommitted" | "base" | "all"; export type ReviewDraftSide = "old" | "new"; export interface ReviewDraftComment { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 94d20585e2..de127c5b6d 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4395,10 +4395,10 @@ export class DaemonClient { } private normalizeCheckoutDiffCompare(compare: { - mode: "uncommitted" | "base"; + mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean; - }): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } { + }): { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean } { if (compare.mode === "uncommitted") { return compare.ignoreWhitespace === true ? { mode: "uncommitted", ignoreWhitespace: true } @@ -4407,17 +4407,17 @@ export class DaemonClient { const trimmedBaseRef = compare.baseRef?.trim(); if (!trimmedBaseRef) { return compare.ignoreWhitespace === true - ? { mode: "base", ignoreWhitespace: true } - : { mode: "base" }; + ? { mode: compare.mode, ignoreWhitespace: true } + : { mode: compare.mode }; } return compare.ignoreWhitespace === true - ? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace: true } - : { mode: "base", baseRef: trimmedBaseRef }; + ? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace: true } + : { mode: compare.mode, baseRef: trimmedBaseRef }; } async getCheckoutDiff( cwd: string, - compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, + compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ @@ -4433,7 +4433,7 @@ export class DaemonClient { observeCheckoutDiff( cwd: string, - compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, + compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean }, options?: { requestId?: string; signal?: AbortSignal }, ): OwnedSubscription { return this.observe( diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index ca3ab86135..373cef409b 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { AgentTimelineItemPayloadSchema, + SendAgentMessageRequestSchema, FileExplorerRequestSchema, PaseoWorktreeArchiveRequestSchema, parseServerInfoStatusPayload, @@ -548,3 +549,25 @@ test("agent messages are an optional semantic payload on existing tool rows", () }).success, ).toBe(false); }); + +test.each(["all", "base", "uncommitted"] as const)( + "preserves %s review attachments while ignoring unknown attachments", + (mode) => { + const attachment = { + type: "review", + mimeType: "application/paseo-review", + cwd: "/repo", + mode, + baseRef: "main", + comments: [], + }; + const parsed = SendAgentMessageRequestSchema.parse({ + type: "send_agent_message_request", + requestId: "review", + agentId: "agent", + text: "Review", + attachments: [{ type: "future_attachment" }, attachment], + }); + expect(parsed.attachments).toEqual([attachment]); + }, +); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 8eff2fbbd2..3e5deeb88e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1168,7 +1168,7 @@ export const ReviewAttachmentSchema = z.object({ type: z.literal("review"), mimeType: z.literal("application/paseo-review"), cwd: z.string(), - mode: z.enum(["uncommitted", "base"]), + mode: z.enum(["uncommitted", "base", "all"]), baseRef: z.string().nullable().optional(), comments: z.array(ReviewAttachmentCommentSchema), }); @@ -2201,7 +2201,7 @@ const CheckoutErrorSchema = z.object({ }); const CheckoutDiffCompareSchema = z.object({ - mode: z.enum(["uncommitted", "base"]), + mode: z.enum(["uncommitted", "base", "all"]), baseRef: z.string().optional(), ignoreWhitespace: z.boolean().optional(), }); @@ -3793,6 +3793,7 @@ export const ServerInfoStatusPayloadSchema = z fsEntryDuplicate: z.boolean().optional(), // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. checkoutDiscardChanges: z.boolean().optional(), + checkoutDiffAll: z.boolean().optional(), // COMPAT(agentProfiles): added in v0.3.2, remove gate after 2027-02-11. // An older daemon parses its persisted config strictly, so writing // agentProfiles to one is silently dropped. The client hides the feature diff --git a/packages/server/src/server/agent/prompt-attachments.test.ts b/packages/server/src/server/agent/prompt-attachments.test.ts index 41d4a424d8..2fd022547e 100644 --- a/packages/server/src/server/agent/prompt-attachments.test.ts +++ b/packages/server/src/server/agent/prompt-attachments.test.ts @@ -69,13 +69,13 @@ describe("prompt attachments", () => { ).toContain("GitLab MR !123: Fix race in worktree setup"); }); - it("renders review attachments with compact file, line, comment, and context details", () => { + it.each(["base", "uncommitted", "all"] as const)("renders %s reviews", (mode) => { expect( renderPromptAttachmentAsText({ type: "review", mimeType: "application/paseo-review", cwd: "/tmp/repo", - mode: "base", + mode, baseRef: "main", comments: [ { @@ -111,7 +111,7 @@ describe("prompt attachments", () => { }), ).toBe( [ - "Paseo review attachment (base)", + `Paseo review attachment (${mode})`, "CWD: /tmp/repo", "Base: main", "", diff --git a/packages/server/src/server/checkout-diff-cache.test.ts b/packages/server/src/server/checkout-diff-cache.test.ts index e2ac4f31ba..37f9283320 100644 --- a/packages/server/src/server/checkout-diff-cache.test.ts +++ b/packages/server/src/server/checkout-diff-cache.test.ts @@ -114,3 +114,21 @@ test("active reads survive completed-payload eviction and failed reads can retry }), ).toEqual({ diff: "retry" }); }); + +test.each(["base", "uncommitted"] as const)( + "%s invalidation refreshes All changes without merging base-ref cache entries", + async (mode) => { + const cache = new CheckoutDiffCache(() => 0); + const read = (baseRef: string, value: string) => + cache.read({ + cwd: "repo", + compare: { mode: "all", baseRef }, + load: async () => ({ diff: value }), + }); + expect(await read("main", "main-before")).toEqual({ diff: "main-before" }); + expect(await read("next", "next-before")).toEqual({ diff: "next-before" }); + cache.invalidate("repo", mode); + expect(await read("main", "main-after")).toEqual({ diff: "main-after" }); + expect(await read("next", "next-after")).toEqual({ diff: "next-after" }); + }, +); diff --git a/packages/server/src/server/checkout-diff-cache.ts b/packages/server/src/server/checkout-diff-cache.ts index 38675bea37..4547cdf2e5 100644 --- a/packages/server/src/server/checkout-diff-cache.ts +++ b/packages/server/src/server/checkout-diff-cache.ts @@ -35,7 +35,7 @@ export class CheckoutDiffCache { const key = JSON.stringify([ cwd, compare.mode, - compare.mode === "base" ? (compare.baseRef ?? null) : null, + compare.mode !== "uncommitted" ? (compare.baseRef ?? null) : null, compare.ignoreWhitespace === true, compare.includeStructured === true, ]); @@ -83,7 +83,7 @@ export class CheckoutDiffCache { invalidate(cwd: string, mode: CheckoutDiffCompare["mode"]): void { for (const key of new Set([...this.values.keys(), ...this.active.keys()])) { const [cachedCwd, cachedMode] = JSON.parse(key) as string[]; - if (cachedCwd !== cwd || cachedMode !== mode) continue; + if (cachedCwd !== cwd || (cachedMode !== mode && cachedMode !== "all")) continue; this.values.delete(key); const pending = this.active.get(key); if (pending) pending.invalidated = true; diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 628bb32a0a..6dc437297d 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -158,15 +158,15 @@ export class CheckoutDiffManager { } const trimmedBaseRef = compare.baseRef?.trim(); return trimmedBaseRef - ? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace } - : { mode: "base", ignoreWhitespace }; + ? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace } + : { mode: compare.mode, ignoreWhitespace }; } private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string { return JSON.stringify([ cwd, compare.mode, - compare.mode === "base" ? (compare.baseRef ?? "") : "", + compare.mode !== "uncommitted" ? (compare.baseRef ?? "") : "", compare.ignoreWhitespace === true, ]); } @@ -364,7 +364,7 @@ export class CheckoutDiffManager { } private async openTarget(target: CheckoutDiffWatchTarget): Promise { - if (target.compare.mode === "base") { + if (target.compare.mode !== "uncommitted") { const snapshot = this.workspaceGitService.peekSnapshot(target.cwd) ?? (await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false })); @@ -382,7 +382,7 @@ export class CheckoutDiffManager { return; } target.workspaceGitUnsubscribe = workspaceSubscription.unsubscribe; - return; + if (target.compare.mode === "base") return; } const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch( diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts index 75302d4168..09da7506c9 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -158,3 +159,67 @@ test.each( }, 60_000, ); + +test("All changes subscriptions refresh on file edits and commits", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-all-")); + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "file.txt"), "base\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base"); + git(cwd, "checkout", "-b", "feature"); + writeFileSync(join(cwd, "file.txt"), "committed\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "feature"); + const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "all", baseRef: "main" }); + try { + let latest = await subscription.ready; + expect(latest.error).toBeNull(); + subscription.subscribe({ + snapshot: (snapshot) => { + latest = snapshot; + }, + update: (message) => { + if (message.type === "checkout_diff_update") latest = message.payload; + }, + }); + writeFileSync(join(cwd, "file.txt"), "working\n"); + await expect + .poll( + () => + latest.files[0]?.hunks + .flatMap((hunk) => hunk.lines) + .filter((line) => line.type === "add") + .map((line) => line.content), + { timeout: 15_000 }, + ) + .toEqual(["working"]); + writeFileSync(join(cwd, "new.txt"), "untracked\n"); + await expect + .poll(() => latest.files.map((file) => file.path), { timeout: 15_000 }) + .toEqual(["file.txt", "new.txt"]); + const beforeCommit = latest.files; + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "working"); + expect( + (await ctx.client.getCheckoutDiff(cwd, { mode: "all", baseRef: "main" })).files, + ).toEqual(beforeCommit); + writeFileSync(join(cwd, "file.txt"), "base\n"); + await expect + .poll(() => latest.files.map((file) => file.path), { timeout: 15_000 }) + .toEqual(["new.txt"]); + git(cwd, "checkout", "--", "file.txt"); + await expect + .poll(() => latest.files.map((file) => file.path), { timeout: 15_000 }) + .toEqual(["file.txt", "new.txt"]); + } finally { + await subscription.release(); + } + } finally { + await ctx.cleanup(); + await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index dff09358a0..214fc8c9ce 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2096,6 +2096,7 @@ export class VoiceAssistantWebSocketServer { fsEntryDuplicate: true, // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. checkoutDiscardChanges: true, + checkoutDiffAll: true, // COMPAT(agentProfiles): added in v0.3.2, remove gate after 2027-02-11. agentProfiles: true, // COMPAT(agentConfigApply): added in v0.3.2, remove gate after 2027-02-11. diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index a2b89bed39..be62376130 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -827,7 +827,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private normalizeCheckoutDiffOptions(options: CheckoutDiffCompare): CheckoutDiffCompare { return { mode: options.mode, - ...(options.mode === "base" && options.baseRef !== undefined + ...(options.mode !== "uncommitted" && options.baseRef !== undefined ? { baseRef: options.baseRef } : {}), ...(options.ignoreWhitespace === true ? { ignoreWhitespace: true } : {}), diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index fe8ebdce47..0ef41b3b6f 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1398,6 +1398,92 @@ const x = 1; expect(diff.structured).toEqual([]); }); + it.each([ + { contents: "hello\n", expectedFiles: [], patch: "" }, + { + contents: "restored\n", + expectedFiles: [ + { path: "file.txt", isNew: false, isDeleted: false, additions: 1, deletions: 1 }, + ], + patch: "-hello\n+restored\n", + }, + ])( + "compares restored untracked base files against disk: $contents", + async ({ contents, expectedFiles, patch }) => { + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["rm", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "delete"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, "file.txt"), contents); + const indexPath = execFileSync("git", ["rev-parse", "--git-path", "index"], { + cwd: repoDir, + encoding: "utf8", + }).trim(); + const beforeIndex = readFileSync(join(repoDir, indexPath)); + const beforeStatus = execFileSync("git", ["status", "--porcelain=v1", "-z"], { + cwd: repoDir, + }); + const result = await getCheckoutDiff(repoDir, { + mode: "all", + baseRef: "main", + includeStructured: true, + }); + expect( + result.structured?.map(({ path, isNew, isDeleted, additions, deletions }) => ({ + path, + isNew, + isDeleted, + additions, + deletions, + })), + ).toEqual(expectedFiles); + expect(result.diff).toContain(patch); + expect(result.diff.length === 0).toBe(expectedFiles.length === 0); + expect(readFileSync(join(repoDir, indexPath))).toEqual(beforeIndex); + expect(execFileSync("git", ["status", "--porcelain=v1", "-z"], { cwd: repoDir })).toEqual( + beforeStatus, + ); + expect(readFileSync(join(repoDir, "file.txt"), "utf8")).toBe(contents); + expect((await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" })).diff).toContain( + "deleted file mode", + ); + expect((await getCheckoutDiff(repoDir, { mode: "uncommitted" })).diff).toContain( + "new file mode", + ); + }, + ); + + it("compares all changes as one net patch including untracked files", async () => { + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "committed\n"); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, "file.txt"), "working\n"); + writeFileSync(join(repoDir, "new.txt"), "untracked\n"); + const compare = { mode: "all", baseRef: "main", includeStructured: true } as const; + const before = await getCheckoutDiff(repoDir, compare); + expect(before.structured?.map((file) => file.path)).toEqual(["file.txt", "new.txt"]); + expect(before.diff).toContain("-hello\n+working\n"); + expect(before.diff).not.toContain("committed"); + expect(before.diff).toContain("+untracked"); + const committed = await getCheckoutDiff(repoDir, { ...compare, mode: "base" }); + expect(committed.diff).toContain("-hello\n+committed\n"); + const uncommitted = await getCheckoutDiff(repoDir, { ...compare, mode: "uncommitted" }); + expect(uncommitted.diff).toContain("-committed\n+working\n"); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "working"], { + cwd: repoDir, + }); + expect((await getCheckoutDiff(repoDir, compare)).diff).toBe(before.diff); + writeFileSync(join(repoDir, "file.txt"), "hello\n"); + expect((await getCheckoutDiff(repoDir, compare)).structured?.map((file) => file.path)).toEqual([ + "new.txt", + ]); + }); + it("falls back to the local base branch when origin is absent", async () => { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); writeFileSync(join(repoDir, "local-feature.txt"), "feature\n"); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index ce00702908..f2001e4651 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,7 +1,8 @@ import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; -import { open as openFile, readFile, stat as statFile } from "fs/promises"; +import { open as openFile, readFile, stat as statFile, mkdtemp, copyFile, rm } from "fs/promises"; +import { tmpdir } from "node:os"; import { setImmediate } from "node:timers/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; @@ -203,6 +204,7 @@ interface CheckoutFileChange { } interface CheckoutDiffRefs { + indexEnv?: { GIT_INDEX_FILE: string }; baseRef: string; targetRef?: string; includeUntracked: boolean; @@ -518,7 +520,7 @@ async function listCheckoutFileChanges( ignoreWhitespace, extra: ["-z", "--name-status", ...getCheckoutDiffRefArgs(refs)], }), - { cwd, envOverlay: READ_ONLY_GIT_ENV }, + { cwd, envOverlay: { ...READ_ONLY_GIT_ENV, ...refs.indexEnv } }, ); const nameStatusFields = nameStatusOut.split("\0"); for (let index = 0; index < nameStatusFields.length; ) { @@ -556,7 +558,7 @@ async function listCheckoutFileChanges( ["ls-files", "-z", "--others", "--exclude-standard"], { cwd, - envOverlay: READ_ONLY_GIT_ENV, + envOverlay: { ...READ_ONLY_GIT_ENV, ...refs.indexEnv }, }, ); for (const file of untrackedOut.split("\0").filter(Boolean)) { @@ -671,7 +673,7 @@ async function getTrackedNumstatByPath( }), { cwd, - envOverlay: READ_ONLY_GIT_ENV, + envOverlay: { ...READ_ONLY_GIT_ENV, ...refs.indexEnv }, maxOutputBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES, acceptExitCodes: [0], }, @@ -736,7 +738,7 @@ async function getTrackedDiffTextForPath(input: { }), { cwd: input.cwd, - envOverlay: READ_ONLY_GIT_ENV, + envOverlay: { ...READ_ONLY_GIT_ENV, ...input.refsForDiff.indexEnv }, maxOutputBytes: PER_FILE_DIFF_MAX_BYTES, }, ); @@ -779,7 +781,11 @@ async function getTrackedDiffTexts(input: { ...input.paths.map((path) => `:(literal)${path}`), ], }), - { cwd: input.cwd, envOverlay: READ_ONLY_GIT_ENV, maxOutputBytes: 8 * PER_FILE_DIFF_MAX_BYTES }, + { + cwd: input.cwd, + envOverlay: { ...READ_ONLY_GIT_ENV, ...input.refsForDiff.indexEnv }, + maxOutputBytes: 8 * PER_FILE_DIFF_MAX_BYTES, + }, ); const files = new Map(); if (!result.truncated) { @@ -905,7 +911,7 @@ export type CheckoutDiffResult = | { diff: ""; structured: []; diffTooLarge: true }; export interface CheckoutDiffCompare { - mode: "uncommitted" | "base"; + mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean; includeStructured?: boolean; @@ -3479,11 +3485,58 @@ async function resolveCheckoutDiffRefs( const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef); return { baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef, - targetRef: "HEAD", - includeUntracked: false, + targetRef: compare.mode === "base" ? "HEAD" : undefined, + includeUntracked: compare.mode === "all", }; } +// Git's working-tree diff ignores files absent from the index, even when they +// exist in the comparison tree. Restore those entries only in a private index. +async function prepareAllChangesIndex(cwd: string, baseRef: string) { + const untracked = await runGitCommand(["ls-files", "-z", "--others", "--exclude-standard"], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + }); + const paths = untracked.stdout.split("\0").filter(Boolean); + if (paths.length === 0) return null; + let entries = ""; + for (let start = 0; start < paths.length; start += TRACKED_DIFF_BATCH_SIZE) { + const result = await runGitCommand( + [ + "ls-tree", + "-r", + "-z", + baseRef, + "--", + ...paths.slice(start, start + TRACKED_DIFF_BATCH_SIZE).map((path) => `:(literal)${path}`), + ], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + entries += result.stdout; + } + if (!entries) return null; + const directory = await mkdtemp(resolve(tmpdir(), "paseo-all-changes-")); + const indexFile = resolve(directory, "index"); + try { + const index = await runGitCommand(["rev-parse", "--git-path", "index"], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + }); + const indexPath = resolveGitRevParsePath(cwd, index.stdout); + if (!indexPath) throw new Error("Git did not return an index path"); + await copyFile(indexPath, indexFile); + await runGitCommand(["update-index", "-z", "--index-info"], { + cwd, + input: entries, + envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile }, + }); + return { indexFile, cleanup: () => rm(directory, { recursive: true, force: true }) }; + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } +} + export async function getCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, @@ -3496,6 +3549,23 @@ export async function getCheckoutDiff( return { diff: "" }; } + const index = + compare.mode === "all" ? await prepareAllChangesIndex(cwd, refsForDiff.baseRef) : null; + try { + return await readCheckoutDiff(cwd, compare, { + ...refsForDiff, + indexEnv: index ? { GIT_INDEX_FILE: index.indexFile } : undefined, + }); + } finally { + await index?.cleanup(); + } +} + +async function readCheckoutDiff( + cwd: string, + compare: CheckoutDiffCompare, + refsForDiff: CheckoutDiffRefs, +): Promise { const ignoreWhitespace = compare.ignoreWhitespace === true; let effectiveRefsForDiff = refsForDiff; let changes: CheckoutFileChange[]; From 23c4df4818d8a3c7dfcdc70ba93f5670ec9ce334 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 18:28:31 +0700 Subject: [PATCH 12/62] Fix agents showing running after daemon restart (#7573) * fix(server): close stale agent status after daemon restart Repair persisted active statuses before serving clients. Let Claude process-tree cleanup run immediately after SDK close rather than waiting on an already closed control channel. * test(app): expect closed agent status after daemon restart --- .../browser/workspace-model-restart.spec.ts | 14 +- .../server/agent/providers/claude/agent.ts | 5 +- .../server/src/server/bootstrap.smoke.test.ts | 235 ++++++++++++++++++ packages/server/src/server/bootstrap.ts | 11 + 4 files changed, 256 insertions(+), 9 deletions(-) diff --git a/packages/app/e2e/browser/workspace-model-restart.spec.ts b/packages/app/e2e/browser/workspace-model-restart.spec.ts index e1dd703508..976e745549 100644 --- a/packages/app/e2e/browser/workspace-model-restart.spec.ts +++ b/packages/app/e2e/browser/workspace-model-restart.spec.ts @@ -404,7 +404,7 @@ async function fetchWorkspaceStatuses( } test.describe("Workspace model restart regressions", () => { - test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({ + test("browser-created same-cwd workspace preserves repaired restart status and migrated tab ownership", async ({ page, baseURL, }) => { @@ -423,9 +423,13 @@ test.describe("Workspace model restart regressions", () => { .toMatchObject({ id: LEGACY_AGENT_ID, workspaceId: seeded.workspaceA, - status: "running", + status: "closed", }); + await expect + .poll(() => fetchWorkspaceStatuses(client, [seeded.workspaceA, seeded.workspaceB])) + .toEqual({ [seeded.workspaceA]: "done", [seeded.workspaceB]: "done" }); + await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA)); await waitForSidebarHydration(page); await expectWorkspaceRowDoesNotShowIndicator(page, { @@ -472,15 +476,11 @@ test.describe("Workspace model restart regressions", () => { ]), ) .toMatchObject({ + [seeded.workspaceA]: "done", [seeded.workspaceB]: "done", [createdWorkspaceId]: "done", }); - // The restarted provider session may settle while the browser creates the sibling. Its - // initial running status is asserted above; this phase verifies that ownership never moves. - const workspaceStatuses = await fetchWorkspaceStatuses(client, [seeded.workspaceA]); - expect(["running", "done"]).toContain(workspaceStatuses[seeded.workspaceA]); - await expectWorkspaceRowDoesNotShowIndicator(page, { serverId, workspaceId: seeded.workspaceB, diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index c2f607665d..6a70d28d6a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2903,8 +2903,9 @@ class ClaudeAgentSession implements AgentSession { this.taskProtocolSource.reset(); this.input?.end(); this.query?.close?.(); - await this.awaitWithTimeout(this.query?.interrupt?.(), "close query interrupt"); - await this.awaitWithTimeout(this.query?.return?.(), "close query return"); + // close() already terminates the SDK query. Its control plane is no longer + // available; waiting for interrupt/return here delays our process-tree cleanup + // beyond the daemon's close deadline when the provider stops answering. this.query = null; this.input = null; // Terminate the entire process tree (claude + MCP children) to prevent diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 697dfda8a7..f6304491ab 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,5 +1,10 @@ import { resolveDaemonVersion } from "./daemon-version.js"; import os from "node:os"; +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import type { ChildProcess } from "node:child_process"; +import type { Query } from "@anthropic-ai/claude-agent-sdk"; +import { ClaudeAgentClient } from "./agent/providers/claude/agent.js"; import http from "node:http"; import path from "node:path"; import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -9,6 +14,14 @@ import { WebSocket } from "ws"; import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js"; import { loadConfig } from "./config.js"; +import { + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, + createPersistedProjectRecord, + createPersistedWorkspaceRecord, +} from "./workspace-registry.js"; +import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; +import { ensureAgentLoaded } from "./agent/agent-loading.js"; import { AgentManagerShuttingDownError } from "./agent/agent-manager.js"; import { hashDaemonPassword } from "./auth.js"; import { generateLocalPairingOffer } from "./pairing-offer.js"; @@ -1055,3 +1068,225 @@ function probeWebSocketConnection( }); }); } + +test("startup closes stale active records before serving status without resuming sessions", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-stale-status-")); + const logger = pino({ level: "silent" }); + const storagePath = path.join(root, ".paseo", "agents"); + const storage = new AgentStorage(storagePath, logger); + const timestamp = "2025-01-01T00:00:00.000Z"; + const registryDir = path.join(root, ".paseo", "projects"); + await new FileBackedProjectRegistry(path.join(registryDir, "projects.json"), logger).upsert( + createPersistedProjectRecord({ + projectId: "existing-project", + rootPath: root, + kind: "non_git", + displayName: "Existing", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + await new FileBackedWorkspaceRegistry(path.join(registryDir, "workspaces.json"), logger).upsert( + createPersistedWorkspaceRecord({ + workspaceId: "existing-workspace", + projectId: "existing-project", + cwd: root, + kind: "directory", + displayName: "Existing", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const records: StoredAgentRecord[] = ["running", "initializing", "idle", "error", "closed"].map( + (status, index) => ({ + id: randomUUID(), + workspaceId: "existing-workspace", + provider: "codex", + cwd: root, + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-02T00:00:00.000Z", + lastActivityAt: "2025-01-02T00:00:00.000Z", + lastStatus: status as StoredAgentRecord["lastStatus"], + title: `Keep ${status}`, + labels: { "paseo.parent-agent-id": "parent", custom: "keep" }, + persistence: { provider: "codex", sessionId: `session-${index}` }, + config: { modeId: "full-access" }, + requiresAttention: true, + attentionReason: "permission", + attentionTimestamp: "2025-01-02T00:00:00.000Z", + archivedAt: index === 1 ? "2025-01-03T00:00:00.000Z" : null, + }), + ); + for (const record of records) await storage.upsert(record); + let sessionsCreated = 0; + const handle = await createTestPaseoDaemon({ + paseoHomeRoot: root, + agentClients: createTestAgentClients({ + beforeCreateSession: async () => { + sessionsCreated++; + }, + }), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${handle.port}/ws` }); + try { + const expected = records.map((record) => ({ + ...record, + lastStatus: + record.lastStatus === "running" || record.lastStatus === "initializing" + ? "closed" + : record.lastStatus, + })); + expect( + (await handle.daemon.agentStorage.list()).sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual(expected.sort((a, b) => a.id.localeCompare(b.id))); + // A fresh reader proves the repaired states were written, rather than projected only in memory. + expect( + (await new AgentStorage(storagePath, logger).list()).sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual(expected); + expect(handle.daemon.agentManager.listAgents()).toEqual([]); + expect(sessionsCreated).toBe(0); + await client.connect(); + const snapshot = await client.fetchAgents({ filter: { includeArchived: true } }); + expect( + Object.fromEntries(snapshot.entries.map((entry) => [entry.agent.id, entry.agent.status])), + ).toEqual(Object.fromEntries(expected.map((record) => [record.id, record.lastStatus]))); + const opened = await ensureAgentLoaded(records[0]!.id, { + agentManager: handle.daemon.agentManager, + agentStorage: handle.daemon.agentStorage, + logger, + }); + expect(opened.id).toBe(records[0]!.id); + expect(opened.persistence?.sessionId).toBe("session-0"); + expect(opened.lifecycle).toBe("idle"); + await handle.daemon.agentManager.runAgent(opened.id, "hello"); + expect(handle.daemon.agentManager.getAgent(opened.id)?.lifecycle).toBe("idle"); + } finally { + await client.close(); + await handle.close(); + } +}, 30_000); + +test.each(["reject", "stall"] as const)( + "shutdown closes Claude and its process despite a %s peer and stalled SDK handshake", + async (failure) => { + let child: ChildProcess | undefined; + let ready!: () => void; + const processReady = new Promise((resolve) => { + ready = resolve; + }); + let finishStream!: () => void; + const streamFinished = new Promise((resolve) => { + finishStream = resolve; + }); + const never = new Promise(() => {}); + const claude = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + resolveBinary: async () => process.execPath, + queryFactory: ({ options }) => { + child = options.spawnClaudeCodeProcess!({ + command: process.execPath, + args: [ + "-e", + "process.on('SIGTERM', () => {}); console.log('ready'); setInterval(() => {}, 1000)", + ], + cwd: process.cwd(), + env: process.env, + signal: new AbortController().signal, + }) as ChildProcess; + child.stdout!.once("data", ready); + let initialized = false; + // An injected SDK adapter leaves an active turn and both close requests unanswered. + return { + async next() { + if (!initialized) { + initialized = true; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "shutdown-session", + permissionMode: "default", + }, + }; + } + await streamFinished; + return { done: true, value: undefined }; + }, + interrupt: () => never, + return: () => never, + close() {}, + async supportedModels() { + return []; + }, + async supportedCommands() { + return []; + }, + async setPermissionMode() {}, + async setModel() {}, + async applyFlagSettings() {}, + [Symbol.asyncIterator]() { + return this; + }, + } as Query; + }, + }); + let armed = false; + let peerRunning!: () => void; + const peerReady = new Promise((resolve) => { + peerRunning = resolve; + }); + const peers = createTestAgentClients({ + beforeTurnComplete: async () => { + peerRunning(); + await streamFinished; + }, + closeSession: async () => { + if (!armed) return; + if (failure === "reject") throw new Error("peer close rejected"); + await never; + }, + }); + const handle = await createTestPaseoDaemon({ agentClients: { claude, codex: peers.codex } }); + let stopped = false; + let run: Promise | undefined; + let peerRun: Promise | undefined; + try { + const agent = await handle.daemon.agentManager.createAgent( + { provider: "claude", cwd: handle.paseoHome }, + undefined, + { workspaceId: undefined }, + ); + run = handle.daemon.agentManager.runAgent(agent.id, "keep working").catch(() => undefined); + await processReady; + expect(handle.daemon.agentManager.getAgent(agent.id)?.lifecycle).toBe("running"); + const peer = await handle.daemon.agentManager.createAgent( + { provider: "codex", cwd: handle.paseoHome, modeId: "full-access" }, + undefined, + { workspaceId: undefined }, + ); + peerRun = handle.daemon.agentManager.runAgent(peer.id, "keep working").catch(() => undefined); + await peerReady; + armed = true; + await handle.daemon.stop(); + stopped = true; + // Windows taskkill is forceful on the first signal; POSIX escalates after SIGTERM. + expect(child!.exitCode !== null || child!.signalCode !== null).toBe(true); + expect((await handle.daemon.agentStorage.get(agent.id))?.lastStatus).toBe("closed"); + expect(handle.daemon.agentManager.getAgent(agent.id)).toBeNull(); + expect((await handle.daemon.agentStorage.get(peer.id))?.lastStatus).toBe("running"); + } finally { + finishStream(); + if (child && child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit"); + child.kill("SIGKILL"); + await exited; + } + if (!stopped) await handle.daemon.stop(); + await Promise.all([run, peerRun]); + await rm(path.dirname(handle.paseoHome), { recursive: true, force: true }); + await rm(handle.staticDir, { recursive: true, force: true }); + } + }, + 30_000, +); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 09be15772e..22bf93b625 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -975,6 +975,7 @@ export async function createPaseoDaemon( agentStorage, ); await agentStorage.initialize(); + await closeStaleAgentRecords(agentStorage); logger.info({ elapsed: elapsed() }, "Agent storage initialized"); await bootstrapWorkspaceRegistries({ serverId, @@ -1887,6 +1888,16 @@ export async function createPaseoDaemon( }; } +async function closeStaleAgentRecords(agentStorage: AgentStorage): Promise { + // No provider runtimes are loaded at startup. Repair interrupted + // shutdowns before any status consumer sees the persisted active states. + for (const record of await agentStorage.list()) { + if (record.lastStatus === "running" || record.lastStatus === "initializing") { + await agentStorage.upsert({ ...record, lastStatus: "closed" }); + } + } +} + /** * Closing an agent asks its provider to close the session and waits for the * answer. A provider that never answers must not hold the daemon open, so a From 1d383519dd1d1ae90b6e061e21d729eaffcc63ad Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 18:56:12 +0700 Subject: [PATCH 13/62] feat(app): always show the host picker with Add host on New workspace (#7580) The Host field used to render only with two or more hosts, and Add host only appeared on compact layouts. Single-host users had no sign that Paseo can drive other machines from this screen. The field now always renders and its menu always ends with Add host. --- .../app/e2e/browser/new-workspace-entry.spec.ts | 9 +++++++-- .../app/src/screens/new-workspace/form/index.tsx | 13 +++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/app/e2e/browser/new-workspace-entry.spec.ts b/packages/app/e2e/browser/new-workspace-entry.spec.ts index e6c788a8f4..3f1710b918 100644 --- a/packages/app/e2e/browser/new-workspace-entry.spec.ts +++ b/packages/app/e2e/browser/new-workspace-entry.spec.ts @@ -95,7 +95,7 @@ test.describe("New workspace entry points", () => { } }); - test("the New Workspace screen hides the host selector when there is only one host", async ({ + test("the New Workspace screen shows the host selector with Add host when there is only one host", async ({ page, }) => { const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-single-host-" }); @@ -120,7 +120,12 @@ test.describe("New workspace entry points", () => { await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({ timeout: 30_000, }); - await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0); + const hostTrigger = page.getByTestId("host-picker-trigger"); + await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 }); + await hostTrigger.click(); + await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible(); + await page.getByTestId("new-workspace-host-picker-add-host").click(); + await expect(page).toHaveURL(/\/settings\/general\?addHost=/); } finally { await seeded.cleanup(); } diff --git a/packages/app/src/screens/new-workspace/form/index.tsx b/packages/app/src/screens/new-workspace/form/index.tsx index d05adb2e61..c1cf1e2482 100644 --- a/packages/app/src/screens/new-workspace/form/index.tsx +++ b/packages/app/src/screens/new-workspace/form/index.tsx @@ -160,7 +160,8 @@ function useRefRenderOption(itemById: Map, isPending: boolea /** * The New workspace setup fields, one trigger per field, each opening its picker. Compact: a stack * of full-width fields with the label inside. Desktop: one meta row of badges. Either way, a field - * that does not apply to the selected project is left out, and Host only shows with a second host. + * that does not apply to the selected project is left out. Host always shows, even with one host, so + * its Add host action keeps multi-host discoverable. */ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement { const { t } = useTranslation(); @@ -170,7 +171,6 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement const isolationPicker = useIsolationPicker(isolation, isPending); const renderRefOption = useRefRenderOption(base.itemById, isPending); - const showHost = host.allHosts.length > 1; const selectedHostLabel = host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; const selectedProject = project.selectedProject; @@ -214,7 +214,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement testID="new-workspace-project-picker-trigger" /> ); - const hostField = showHost ? ( + const hostField = ( - ) : null; + ); const isolationField = isolation.canCreateWorktree ? ( {projectField} - {hostField ? {hostField} : null} + {hostField} {isolationField ? {isolationField} : null} {baseField ? {baseField} : null} From 995ad2f8787c284bc198be24c15bea98f87bab4a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 19:14:17 +0700 Subject: [PATCH 14/62] feat(plugin): render Markdown content with host UI (#7579) * feat(plugin): expose Markdown content component * docs(plugin): translate Markdown component reference --- .../app/e2e/browser/plugin-host-ui.spec.ts | 16 ++++++++++++++- packages/app/src/plugins/evaluate.test.ts | 3 ++- .../app/src/plugins/react-native/markdown.tsx | 6 ++++++ packages/app/src/plugins/react-native/ui.ts | 2 ++ .../app/test-stubs/react-native-unistyles.ts | 3 +++ packages/app/vitest.config.ts | 1 + packages/plugin/src/client/ui.ts | 7 +++++++ plugin-examples/local-plugin/client/main.tsx | 4 ++-- public-docs/plugins/reference.md | 20 +++++++++++++++++++ public-docs/plugins/reference.zh-CN.md | 18 +++++++++++++++++ 10 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/plugins/react-native/markdown.tsx diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index d8fd175b5f..a195f30aa2 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -10,7 +10,7 @@ import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspac const PLUGIN_ID = "plugin-host-ui-e2e"; const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client"; -import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui"; +import { Icon, Modal, useToast, Markdown } from "@getpaseo/plugin/client/ui"; import { useQueryClient } from "@tanstack/react-query"; import React, { useState } from "react"; import { Pressable, Text, View } from "react-native"; @@ -27,6 +27,9 @@ function ModalBody({ onSaved }) { return Plugin modal contexts ready + {"## Plugin report"} + {"Checks **passed**. [Documentation](https://paseo.sh/docs/plugins)"} + {""} Save issue @@ -92,10 +95,20 @@ async function openPluginModal(page: Page): Promise { await page.getByRole("button", { name: "Open plugin modal", exact: true }).click(); } +async function expectPluginMarkdown(page: Page): Promise { + await expect(page.getByRole("heading", { name: "Plugin report" })).toBeVisible(); + await expect(page.getByText("Checks passed.", { exact: false })).toBeVisible(); + await expect(page.getByRole("link", { name: "Documentation", exact: true })).toHaveAttribute( + "href", + "https://paseo.sh/docs/plugins", + ); +} + async function expectCenteredPluginDialog(page: Page): Promise { const dialog = page.getByRole("dialog"); await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible(); await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible(); + await expectPluginMarkdown(page); } async function closeCenteredPluginDialog(page: Page): Promise { @@ -107,6 +120,7 @@ async function closeCenteredPluginDialog(page: Page): Promise { async function expectCompactPluginSheet(page: Page): Promise { await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible(); await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible(); + await expectPluginMarkdown(page); } async function savePluginIssue(page: Page): Promise { diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 9d5ed28349..b4062b2376 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -695,7 +695,8 @@ describe("evaluatePluginClientBundle", () => { `(function(require) { const shared = require("@getpaseo/plugin"); const client = require("@getpaseo/plugin/client"); - const { ExternalLink, Loading } = require("@getpaseo/plugin/client/ui"); + const { ExternalLink, Loading, Markdown } = require("@getpaseo/plugin/client/ui"); + if (typeof Markdown !== "function") throw new Error("Markdown"); if (typeof ExternalLink !== "function") throw new Error("ExternalLink"); if (typeof Loading !== "function") throw new Error("Loading"); for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl", "useFileText", "useFileBytes", "useFileUrl", "PluginFileConflictError", "PluginFileDeletedError"]) { diff --git a/packages/app/src/plugins/react-native/markdown.tsx b/packages/app/src/plugins/react-native/markdown.tsx new file mode 100644 index 0000000000..109409b0f6 --- /dev/null +++ b/packages/app/src/plugins/react-native/markdown.tsx @@ -0,0 +1,6 @@ +import type { MarkdownProps } from "@getpaseo/plugin/client/ui"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; + +export function Markdown({ children }: MarkdownProps) { + return children ? : null; +} diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts index a33add1d4f..e431aa32eb 100644 --- a/packages/app/src/plugins/react-native/ui.ts +++ b/packages/app/src/plugins/react-native/ui.ts @@ -26,3 +26,5 @@ export { TextInput } from "./text-input"; export { copyText } from "./clipboard"; export { useToast } from "./toast"; export { StreamedText } from "./streamed-text"; + +export { Markdown } from "./markdown"; diff --git a/packages/app/test-stubs/react-native-unistyles.ts b/packages/app/test-stubs/react-native-unistyles.ts index 654053543f..adc8af93bf 100644 --- a/packages/app/test-stubs/react-native-unistyles.ts +++ b/packages/app/test-stubs/react-native-unistyles.ts @@ -1,6 +1,9 @@ +import { lightHighlightColors } from "@getpaseo/highlight"; + const testTheme = { colorScheme: "light", colors: { + syntax: lightHighlightColors, foreground: "#111111", foregroundMuted: "#666666", statusSuccess: "#15803d", diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 35272f336e..fd12cd6189 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -104,6 +104,7 @@ export default defineConfig({ deps: { fallbackCJS: true, inline: [ + "react-native-enriched-markdown", "zustand", "@tanstack/react-query", "react-native-web", diff --git a/packages/plugin/src/client/ui.ts b/packages/plugin/src/client/ui.ts index 7bd8d6884b..c012753181 100644 --- a/packages/plugin/src/client/ui.ts +++ b/packages/plugin/src/client/ui.ts @@ -178,3 +178,10 @@ export declare const Loading: ComponentType; /** Render a local document URL in an isolated frame. */ export declare const DocumentFrame: ComponentType<{ url: string; testID?: string }>; + +export interface MarkdownProps { + children: string; +} + +/** Renders Markdown using Paseo’s theme, selection, and link behavior. */ +export declare const Markdown: ComponentType; diff --git a/plugin-examples/local-plugin/client/main.tsx b/plugin-examples/local-plugin/client/main.tsx index 749012e0a9..b9fb3dc63f 100644 --- a/plugin-examples/local-plugin/client/main.tsx +++ b/plugin-examples/local-plugin/client/main.tsx @@ -8,7 +8,7 @@ import { } from "@getpaseo/plugin/client"; import { useCallback, useMemo } from "react"; import { Pressable, Text, View } from "react-native"; -import { ExternalLink } from "@getpaseo/plugin/client/ui"; +import { ExternalLink, Markdown } from "@getpaseo/plugin/client/ui"; import { incrementRpc } from "../shared/increment"; export function contributeClient(client: PluginClientContext) { @@ -107,7 +107,7 @@ export function ExamplePanel({ return ( - Workspace plugin panel + {"## Workspace plugin panel\n\nCall a **daemon RPC** from this panel."} Plugin documentation {openBrowser ? ( diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 16fde6470b..3729fa4e0e 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1220,6 +1220,26 @@ export function IssueActions({ theme }: PluginSurfaceProps) { } ``` +### Markdown + +Import `Markdown` from `@getpaseo/plugin/client/ui` to render a string with Paseo's +Markdown presentation on web, iOS, and Android: + +```tsx +import { Markdown } from "@getpaseo/plugin/client/ui"; + +{"## Review\n\nThe checks **passed**."}; +``` + +`children` is the only prop and must be a string. Updating it renders the new content +immediately; an empty string renders nothing. The host supplies theme, text selection, +code blocks, tables, diagrams, and link handling. Workspace file links and images use +workspace context when the containing surface supplies it. + +The component adds no page padding or scroll container. Use a surrounding layout or +scroll view when needed. Fetching content and showing loading or error states belong +to your plugin. + ### Modal `Modal` uses a bottom sheet on compact layouts and a centered dialog otherwise. The plugin owns diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index a70d91e7bc..fb5d1c9f70 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1061,6 +1061,24 @@ export function IssueActions({ theme }: PluginSurfaceProps) { } ``` +### Markdown {#markdown} + +从 `@getpaseo/plugin/client/ui` 导入 `Markdown`,即可在网页、iOS 和 Android 上 +使用 Paseo 的 Markdown 样式渲染字符串: + +```tsx +import { Markdown } from "@getpaseo/plugin/client/ui"; + +{"## 审查\n\n检查**通过**。"}; +``` + +`children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。 +宿主提供主题、文本选择、代码块、表格、图表和链接处理。 +当所在界面提供工作区上下文时,工作区文件链接和图片会使用该上下文。 + +组件不添加页面内边距或滚动容器。需要时请在外层使用布局或滚动视图。 +内容获取以及加载和错误状态由插件负责。 + ### Modal {#modal} `Modal` 在紧凑布局中使用底部面板,否则使用居中的对话框。`open` 状态由插件负责。 From 1e0a9d5af078c1f8c52cec950ae967d324cc8f90 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:57:22 +0200 Subject: [PATCH 15/62] fix(plugins): allow notification timeline transformers (#7582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 陈睿 Co-authored-by: Gavin Chen Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/src/plugins/evaluate.test.ts | 6 +++--- packages/app/src/plugins/evaluate.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index b4062b2376..f0c768139a 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -141,7 +141,7 @@ describe("evaluatePluginClientBundle", () => { Reflect.deleteProperty(globalThis, "__pluginRemovals"); }); - it("collects timeline transformers and renderers", () => { + it.each(["tool_call", "notification"])("collects %s transformers and renderers", (itemType) => { const plugin = evaluatePluginClientBundle( "reports", bundle(` @@ -149,7 +149,7 @@ describe("evaluatePluginClientBundle", () => { const schema = { safeParse(value) { return { success: true, data: value }; } }; plugin.addTimelineTransformer({ id: "test-report", - query: { itemType: "tool_call" }, + query: { itemType: "${itemType}" }, transform() { return { items: [] }; }, }); plugin.addTimelineRenderer({ @@ -162,7 +162,7 @@ describe("evaluatePluginClientBundle", () => { ); expect(plugin.timelineTransformers.map(({ id, query }) => ({ id, query }))).toEqual([ - { id: "test-report", query: { itemType: "tool_call" } }, + { id: "test-report", query: { itemType } }, ]); expect(plugin.timelineRenderers.map(({ kind, version }) => ({ kind, version }))).toEqual([ { kind: "test-report", version: 1 }, diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 0cc1a7666e..db5fece8c7 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -47,6 +47,7 @@ const TIMELINE_ITEM_TYPES = new Set([ "reasoning", "tool_call", "todo", + "notification", "error", "compaction", ]); From e522e6ea8e25140d2ef0c819986175ad6d6379b8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 21:44:53 +0700 Subject: [PATCH 16/62] fix(app): make the compact Explorer tab rail usable on phones (#7589) Popovers on Android landed one status bar below their trigger. React Native 0.86 stopped subtracting the status bar from window coordinates under edge-to-edge, so measureInWindow, touch pageY and a full-screen Modal already share screen-top coordinates; the old StatusBar.currentHeight compensation in the menu engine, tooltip, context menu and sidebar long-press now double-shifted every anchor. Compact tabs keep their natural width and the rail scrolls as soon as they overflow, instead of squeezing labels first and then scrolling anyway. They drop the inline close because the long-press sheet already closes a tab and a narrow X beside the label is easy to hit. Any scrolling rail keeps natural widths rather than collapsing every chip to the minimum, and the rail resets its offset when scrolling ends so tabs scrolled out of view come back. The Explorer rail paints the sidebar surface, so its chips climb the sidebar hover and selected tokens. Light themes paint the sidebar with surface2, which made the surface2 active chip invisible there. The compact Explorer closes with the same X as the compact agent list, and the compact + menu omits Diff because compact Changes already shows diffs inline. --- docs/explorer-sidebar.md | 7 +- docs/floating-panels.md | 10 ++- .../headers/sidebar-close-button.tsx | 59 ++++++++++++ packages/app/src/components/left-sidebar.tsx | 51 +++-------- .../use-long-press-drag-interaction.ts | 5 +- .../app/src/components/ui/context-menu.tsx | 5 +- .../src/components/ui/menu/menu-overlay.tsx | 10 +-- packages/app/src/components/ui/tooltip.tsx | 4 +- .../workspace/use-workspace-tab-layout.ts | 4 +- .../workspace/workspace-desktop-tabs-row.tsx | 71 ++++++++------- .../workspace/workspace-explorer-toggle.tsx | 12 +++ .../workspace/workspace-tab-chip-backdrop.ts | 30 +++++++ .../workspace/workspace-tab-layout.test.ts | 90 ++++++++++++++++++- .../screens/workspace/workspace-tab-layout.ts | 28 ++++-- .../app/src/workspace-tabs/launcher/index.tsx | 6 +- 15 files changed, 289 insertions(+), 103 deletions(-) create mode 100644 packages/app/src/components/headers/sidebar-close-button.tsx create mode 100644 packages/app/src/screens/workspace/workspace-tab-chip-backdrop.ts diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 9fe6bdfd7b..7207d14f87 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -83,7 +83,12 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes hide their close buttons through the panel manifest; close them from the tab context menu. -Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles. +Other tabs reveal the close control on hover. Compact layouts show no inline close on any tab, +since a narrow X beside the label is easy to hit by accident; the long-press menu closes it. +Compact tabs also keep their natural width and the rail scrolls as soon as they overflow, while +pointer layouts shrink tabs toward the clickable minimum first. A rail that scrolls never truncates. +The + menu opens compatible panels in the dock, including Agent and terminal profiles. Compact +layouts omit Diff from it because compact Changes shows each file's diff inline. Web also supports dragging agents and terminals into Explorer. Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab diff --git a/docs/floating-panels.md b/docs/floating-panels.md index c183dc6615..39ef49fae8 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -282,10 +282,12 @@ left = anchorRect.x - hostRect.x; bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset; ``` -Do this before adding any platform offset. If anchor and host are both measured -with `measureInWindow`, Android's status-bar coordinate behavior cancels out. -Only add a status-bar offset when the render surface is not measured in the same -coordinate system. See `tooltip.tsx` for that separate case. +Do this before adding any platform offset. The app draws edge-to-edge on Android, so +`measureInWindow`, touch `pageY`, and a full-screen `Modal` all share one coordinate +space that starts at the screen top. Do not add `StatusBar.currentHeight` to an anchor: +React Native 0.86 stopped subtracting the status bar from window coordinates under +edge-to-edge, and the old compensation now lands every menu, tooltip, and context menu +one status bar below its trigger. ## Gotcha 5 — The two-measurement flash diff --git a/packages/app/src/components/headers/sidebar-close-button.tsx b/packages/app/src/components/headers/sidebar-close-button.tsx new file mode 100644 index 0000000000..3beaa1f7a9 --- /dev/null +++ b/packages/app/src/components/headers/sidebar-close-button.tsx @@ -0,0 +1,59 @@ +import { X } from "lucide-react-native"; +import type { StyleProp, ViewStyle } from "react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { + IconButtonChromeProvider, + iconButtonChromeGlyphSize, + mutedIconColorMapping, +} from "@/components/ui/icon-button-chrome"; +import type { ShortcutKey } from "@/utils/format-shortcut"; + +const ThemedX = withUnistyles(X); +const NO_SHORTCUT: ShortcutKey[] = []; + +interface SidebarCloseButtonProps { + label: string; + onPress: () => void; + testID: string; + nativeID?: string; + accessibilityState?: { expanded: boolean }; + style?: StyleProp; +} + +/** + * The X that closes a compact sidebar overlay. It is the compact header's icon button, so the + * sidebar's close reads like the header it covers; every compact sidebar uses it. + */ +export function SidebarCloseButton({ + label, + onPress, + testID, + nativeID, + accessibilityState, + style, +}: SidebarCloseButtonProps) { + return ( + + + + + + ); +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 6cf9e29eb1..79f836d1d4 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -5,7 +5,6 @@ import { GitBranch, Server, Settings, - X, type LucideIcon, } from "lucide-react-native"; import { useTranslation } from "react-i18next"; @@ -21,14 +20,9 @@ import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; -import { - IconButtonChromeProvider, - iconButtonChromeGlyphSize, - mutedIconColorMapping, -} from "@/components/ui/icon-button-chrome"; +import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, @@ -68,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import type { ShortcutKey } from "@/utils/format-shortcut"; import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; import { @@ -81,8 +74,6 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; -const ThemedX = withUnistyles(X); - type SidebarTheme = ReturnType["theme"]; const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null; @@ -555,7 +546,15 @@ function MobileSidebar({ }, [closeSidebar]); const closeButton = useMemo( - () => , + () => ( + + ), [labels.closeSidebar, closeSidebar], ); @@ -621,34 +620,6 @@ function MobileSidebar({ ); } -const NO_SHORTCUT: ShortcutKey[] = []; - -/** The compact header's icon button, so the sidebar's close reads like the header it covers. */ -function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) { - return ( - - - - - - ); -} - function DesktopSidebar({ theme, workspaceGroups, diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts index 8e3a508b3b..ae1d67dce3 100644 --- a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts +++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import { Platform, StatusBar, type GestureResponderEvent } from "react-native"; +import type { GestureResponderEvent } from "react-native"; import * as Haptics from "expo-haptics"; import { isWeb as platformIsWeb } from "@/constants/platform"; import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; @@ -36,10 +36,9 @@ export function useLongPressDragInteraction(input: { if (!menuController || !touchStartRef.current) { return; } - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; menuController.setAnchorRect({ x: touchStartRef.current.x, - y: touchStartRef.current.y + statusBarHeight, + y: touchStartRef.current.y, width: 0, height: 0, }); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 86dd7e978b..995caf4a0b 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -6,8 +6,6 @@ import { type Ref, } from "react"; import { - Platform, - StatusBar, View, type GestureResponderEvent, type PressableProps, @@ -133,10 +131,9 @@ export function ContextMenuTrigger({ const point = coerceEventPoint(event); if (!point) return; - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; ctx.setAnchorRect({ x: point.pageX, - y: point.pageY + statusBarHeight, + y: point.pageY, width: 0, height: 0, }); diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index bc77ab58f4..9631dbbe9a 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -14,7 +14,6 @@ import { Modal, Platform, Pressable, - StatusBar, View, type StyleProp, type ViewStyle, @@ -162,16 +161,13 @@ export function useAnchoredPosition({ return undefined; } - // Capture status bar height synchronously before async measurement, so it cannot change - // or read back null between the measure call and its resolution. - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; let cancelled = false; + // The app draws edge-to-edge, so measureInWindow and the full-screen Modal share one + // coordinate space that starts at the screen top; no status bar offset applies. void measureElement(anchorRef.current).then((rect) => { if (cancelled) return undefined; - // On Android with statusBarTranslucent, measureInWindow returns coordinates relative to - // below the status bar while Modal content starts at the screen top. - setTriggerRect({ ...rect, y: rect.y + statusBarHeight }); + setTriggerRect(rect); return undefined; }); diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 72803f06d0..ae00768a34 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -18,7 +18,6 @@ import { Platform, Modal, Pressable, - StatusBar, View, type PressableProps, type StyleProp, @@ -474,11 +473,10 @@ export function TooltipContent({ return () => {}; } - const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; let cancelled = false; void measureElement(ctx.triggerRef.current).then((rect) => { - if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight }); + if (!cancelled) setTriggerRect(rect); return undefined; }); diff --git a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts index 409d957abd..367300a613 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts @@ -10,6 +10,7 @@ interface UseWorkspaceTabLayoutInput { tabLabelWidths: number[]; viewportWidthOverride?: number | null; metrics: WorkspaceTabLayoutMetrics; + compact: boolean; } interface UseWorkspaceTabLayoutResult { @@ -31,8 +32,9 @@ export function useWorkspaceTabLayout( viewportWidth: resolvedViewportWidth, tabLabelWidths: input.tabLabelWidths, metrics: input.metrics, + compact: input.compact, }), - [input.metrics, input.tabLabelWidths, resolvedViewportWidth], + [input.compact, input.metrics, input.tabLabelWidths, resolvedViewportWidth], ); return { diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 8074f4415f..f7645ac168 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -10,7 +10,7 @@ import React, { type Dispatch, type SetStateAction, } from "react"; -import { Pressable, Text, View, type LayoutChangeEvent } from "react-native"; +import { Pressable, Text, View, type LayoutChangeEvent, type ScrollView } from "react-native"; import { useFilePanelOpenWithMenu } from "@/file-panels"; import { CopyX, @@ -68,6 +68,10 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs- import type { PaneHost } from "@/panels/panel-manifest"; import type { WorkspaceTabLaunchPurpose } from "@/workspace-tabs/launcher"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; +import { + resolveWorkspaceTabChipBackdrop, + type WorkspaceTabRailSurface, +} from "@/screens/workspace/workspace-tab-chip-backdrop"; import type { Theme } from "@/styles/theme"; import { RenderProfile } from "@/utils/render-profiler"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; @@ -659,18 +663,6 @@ function useMiddleClickClose(onClose: () => void) { return ref; } -/** The chip fill the running-status ring has to knock out of. Mirrors `styles.tab*` exactly. */ -function resolveChipBackdrop({ - isActiveFocused, - isFilled, -}: { - isActiveFocused: boolean; - isFilled: boolean; -}): SurfaceBackdrop { - if (isActiveFocused) return "surface2"; - return isFilled ? "surface1" : "surface0"; -} - function TabHandleContent({ presentation, isHighlighted, @@ -730,6 +722,7 @@ function TabChip({ resolvedTabWidth, showLabel, showCloseButton, + rail, isCloseHovered, isClosingTab, presentation, @@ -750,6 +743,8 @@ function TabChip({ resolvedTabWidth: number; showLabel: boolean; showCloseButton: boolean; + /** The surface the row paints, so chip fills and their knockouts match the host. */ + rail: WorkspaceTabRailSurface; isCloseHovered: boolean; isClosingTab: boolean; presentation: WorkspaceTabPresentation; @@ -777,9 +772,11 @@ function TabChip({ const isActiveFocused = isActive && isFocused; const isHovered = hovered || isCloseHovered; const isHighlighted = isActiveFocused || isHovered; - const chipBackdrop: SurfaceBackdrop = resolveChipBackdrop({ + const isFilled = isActive || isHovered; + const chipBackdrop: SurfaceBackdrop = resolveWorkspaceTabChipBackdrop({ + rail, isActiveFocused, - isFilled: isActive || isHovered, + isFilled, }); const showCloseControl = showCloseButton && (isHovered || isNative || isCompact || isClosingTab); const closeButtonDragBlockers = isWeb @@ -796,9 +793,7 @@ function TabChip({ const tabChipStyle = useCallback( () => [ styles.tab, - isActiveFocused && styles.tabActive, - isActive && !isFocused && styles.tabActiveUnfocused, - !isActive && isHovered && styles.tabHovered, + isFilled && styles.tabFill(chipBackdrop), isWeb && isDragging && ({ cursor: "grabbing" } as object), { minWidth: resolvedTabWidth, @@ -806,7 +801,7 @@ function TabChip({ maxWidth: resolvedTabWidth, }, ], - [isActive, isActiveFocused, isDragging, isFocused, isHovered, resolvedTabWidth], + [chipBackdrop, isDragging, isFilled, resolvedTabWidth], ); const handleTabPointerEnter = useCallback(() => { @@ -1058,8 +1053,10 @@ function ResolvedWorkspaceDesktopTabsRow({ onExitFocusMode, }: ResolvedWorkspaceDesktopTabsRowProps) { const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); const newTabKeys = useShortcutKeys("workspace-tab-new"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); + const tabsScrollRef = useRef(null); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); const tabScrollBoundary = useHorizontalScrollBoundary(); const [labelMeasurements, setLabelMeasurements] = useState( @@ -1221,7 +1218,15 @@ function ResolvedWorkspaceDesktopTabsRow({ tabLabelWidths: trackSnapshot?.labelWidths ?? [], viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, metrics: layoutMetrics, + compact: isCompact, }); + // A scroll view keeps its offset when its content shrinks under the viewport, so a row that + // stops scrolling would leave the tabs it had panned past out of view with no way back. + useEffect(() => { + if (!layout.requiresHorizontalScrollFallback) { + tabsScrollRef.current?.scrollTo({ x: 0, animated: false }); + } + }, [layout.requiresHorizontalScrollFallback]); const handleDragEnd = useCallback( (nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => { @@ -1266,6 +1271,9 @@ function ResolvedWorkspaceDesktopTabsRow({ handle: handleNewTabKeyboardAction, }); + const rail: WorkspaceTabRailSurface = host === "explorer" ? "sidebar" : "surface"; + const onSidebar = rail === "sidebar"; + const renderTab = useCallback( ({ item, @@ -1306,6 +1314,7 @@ function ResolvedWorkspaceDesktopTabsRow({ resolvedTabWidth={resolvedTabWidth} showLabel={showLabel} showCloseButton={shouldShowCloseButton} + rail={rail} setHoveredCloseTabKey={setHoveredCloseTabKey} onNavigateTab={onNavigateTab} onCloseTab={onCloseTab} @@ -1334,6 +1343,7 @@ function ResolvedWorkspaceDesktopTabsRow({ onNavigateTab, onReloadAgent, onRenameTab, + rail, setHoveredCloseTabKey, tabMenuLabels, tabDropPreviewIndex, @@ -1353,7 +1363,7 @@ function ResolvedWorkspaceDesktopTabsRow({ const row = ( @@ -1379,6 +1389,7 @@ function ResolvedWorkspaceDesktopTabsRow({ /> >; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; @@ -1560,6 +1573,7 @@ function ResolvedDesktopTabChip({ resolvedTabWidth={resolvedTabWidth} showLabel={showLabel} showCloseButton={showCloseButton} + rail={rail} isCloseHovered={item.isCloseHovered} isClosingTab={item.isClosingTab} presentation={presentation} @@ -1589,6 +1603,9 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", overflow: "visible", }, + tabsContainerSidebar: { + backgroundColor: theme.colors.surfaceSidebar, + }, tabsScroll: { minWidth: 0, }, @@ -1638,15 +1655,9 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], userSelect: "none", }, - tabHovered: { - backgroundColor: theme.colors.surface1, - }, - tabActive: { - backgroundColor: theme.colors.surface2, - }, - tabActiveUnfocused: { - backgroundColor: theme.colors.surface1, - }, + tabFill: (backdrop: SurfaceBackdrop) => ({ + backgroundColor: theme.colors[backdrop], + }), tabHoverFrame: { position: "relative", }, diff --git a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx index 55a36f9779..1d4a989f07 100644 --- a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx +++ b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx @@ -2,6 +2,7 @@ import { PanelRight } from "lucide-react-native"; import { type StyleProp, type ViewStyle } from "react-native"; import { withUnistyles } from "react-native-unistyles"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; import { extraMutedIconColorMapping, iconButtonChromeGlyphSize, @@ -93,5 +94,16 @@ export function WorkspaceExplorerSidebarToggle({ ...toggleProps }: DesktopWorkspaceExplorerToggleProps) { if (owner === "header") return null; + if (owner === "mobile") { + // The compact Explorer is an overlay like the compact agent list, so it closes with the same X. + return ( + + ); + } return ; } diff --git a/packages/app/src/screens/workspace/workspace-tab-chip-backdrop.ts b/packages/app/src/screens/workspace/workspace-tab-chip-backdrop.ts new file mode 100644 index 0000000000..3484422c4e --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-tab-chip-backdrop.ts @@ -0,0 +1,30 @@ +import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; + +/** The surface a tab rail paints: a pane header or the Explorer sidebar. */ +export type WorkspaceTabRailSurface = "surface" | "sidebar"; + +/** + * The fill of a tab chip, named as the surface token it paints so the chip style and everything + * knocking out of it (the running-status ring, the trailing-action scrim) agree. + * + * Each rail climbs its own ladder: resting, filled (hovered, or active in an unfocused pane), + * selected. The sidebar rail uses the sidebar hover/selected tokens rather than the plain surface + * steps because light themes paint `surfaceSidebar` with `surface2`, so a `surface2` chip vanishes + * on it. + */ +export function resolveWorkspaceTabChipBackdrop({ + rail, + isActiveFocused, + isFilled, +}: { + rail: WorkspaceTabRailSurface; + isActiveFocused: boolean; + isFilled: boolean; +}): SurfaceBackdrop { + if (rail === "sidebar") { + if (isActiveFocused) return "surfaceSidebarSelected"; + return isFilled ? "surfaceSidebarHover" : "surfaceSidebar"; + } + if (isActiveFocused) return "surface2"; + return isFilled ? "surface1" : "surface0"; +} diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 14e7b89b31..7c7b98cee0 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -3,6 +3,9 @@ import { WorkspaceExplorerSidebarToggle, } from "./workspace-explorer-toggle"; import { describe, expect, it } from "vitest"; +import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; +import { resolveWorkspaceTabChipBackdrop } from "@/screens/workspace/workspace-tab-chip-backdrop"; +import { darkPureBlackTheme, darkTheme, lightTheme } from "@/styles/theme"; import { computeWorkspaceTabLayout, retainWorkspaceTabMeasuredWidth, @@ -27,6 +30,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 1200, tabLabelWidths: [56, 70, 49], metrics, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); @@ -41,6 +45,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 300, tabLabelWidths: [29, 52], metrics: { ...metrics, actionsReservedWidth: 36 }, + compact: false, }); expect(result.items).toEqual([ { width: 64, showLabel: true }, @@ -54,6 +59,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 1200, tabLabelWidths: [105], metrics, + compact: false, }); expect(result.requiresHorizontalScrollFallback).toBe(false); @@ -65,6 +71,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 460, tabLabelWidths: [168, 84, 56], metrics, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); @@ -83,6 +90,7 @@ describe("computeWorkspaceTabLayout", () => { rowPaddingHorizontal: 0, tabGap: 0, }, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); @@ -95,6 +103,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 404, tabLabelWidths: [98, 98, 98, 98], metrics, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); @@ -103,16 +112,43 @@ describe("computeWorkspaceTabLayout", () => { expect(result.items.every((item) => item.showLabel)).toBe(true); }); - it("uses horizontal scroll rather than shrinking below the clickable minimum", () => { + it("scrolls at natural widths rather than shrinking below the clickable minimum", () => { const result = computeWorkspaceTabLayout({ viewportWidth: 403, tabLabelWidths: [98, 98, 98, 98], metrics, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]); + expect(result.items.map((item) => item.width)).toEqual([132, 132, 132, 132]); + expect(result.items.every((item) => item.showLabel)).toBe(true); + }); + + it("keeps natural widths and no inline close on compact layouts while the tabs fit", () => { + const result = computeWorkspaceTabLayout({ + viewportWidth: 600, + tabLabelWidths: [56, 70, 49], + metrics, + compact: true, + }); + + expect(result.closeButtonPolicy).toBe("none"); + expect(result.requiresHorizontalScrollFallback).toBe(false); + expect(result.items.map((item) => item.width)).toEqual([90, 104, 83]); + }); + + it("scrolls compact layouts as soon as natural widths overflow instead of squeezing labels", () => { + const result = computeWorkspaceTabLayout({ + viewportWidth: 460, + tabLabelWidths: [168, 84, 56], + metrics, + compact: true, + }); + + expect(result.requiresHorizontalScrollFallback).toBe(true); + expect(result.items.map((item) => item.width)).toEqual([160, 118, 90]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); @@ -121,6 +157,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 1200, tabLabelWidths: [], metrics, + compact: false, }); expect(result.closeButtonPolicy).toBe("all"); @@ -133,6 +170,7 @@ describe("computeWorkspaceTabLayout", () => { viewportWidth: 1200, tabLabelWidths: [68, 104], metrics, + compact: false, }); expect(result.items.map((item) => item.width)).toEqual([102, 138]); @@ -169,4 +207,52 @@ describe("Explorer toggle ownership", () => { expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner })).not.toBeNull(); } }); + + it("closes the compact overlay with the same X as the compact agent list", () => { + expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner: "mobile" })?.type).toBe( + SidebarCloseButton, + ); + expect(WorkspaceExplorerSidebarToggle({ ...toggleProps, owner: "window" })?.type).not.toBe( + SidebarCloseButton, + ); + }); +}); + +describe("resolveWorkspaceTabChipBackdrop", () => { + const themes = { light: lightTheme, dark: darkTheme, pureBlack: darkPureBlackTheme }; + const rails = { + sidebar: "surfaceSidebar", + surface: "surface0", + } as const; + + it("paints the active chip in a colour the rail does not already use, in every theme", () => { + for (const [, theme] of Object.entries(themes)) { + for (const [rail, resting] of Object.entries(rails) as Array< + [keyof typeof rails, (typeof rails)[keyof typeof rails]] + >) { + const active = resolveWorkspaceTabChipBackdrop({ + rail, + isActiveFocused: true, + isFilled: true, + }); + const filled = resolveWorkspaceTabChipBackdrop({ + rail, + isActiveFocused: false, + isFilled: true, + }); + expect(theme.colors[active]).not.toBe(theme.colors[resting]); + expect(theme.colors[filled]).not.toBe(theme.colors[resting]); + expect(theme.colors[active]).not.toBe(theme.colors[filled]); + } + } + }); + + it("rests on the rail surface when the chip is neither active nor hovered", () => { + expect( + resolveWorkspaceTabChipBackdrop({ rail: "sidebar", isActiveFocused: false, isFilled: false }), + ).toBe("surfaceSidebar"); + expect( + resolveWorkspaceTabChipBackdrop({ rail: "surface", isActiveFocused: false, isFilled: false }), + ).toBe("surface0"); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index 2693e24cd7..cb89d9c54c 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -1,4 +1,4 @@ -export type WorkspaceTabCloseButtonPolicy = "all"; +export type WorkspaceTabCloseButtonPolicy = "all" | "none"; export interface WorkspaceTabLayoutMetrics { rowHorizontalInset: number; @@ -17,6 +17,12 @@ export interface WorkspaceTabLayoutInput { viewportWidth: number; tabLabelWidths: number[]; metrics: WorkspaceTabLayoutMetrics; + /** + * Compact layouts are touch-first. Tabs keep their natural width and the row scrolls as soon as + * they overflow, so a label is never squeezed to make room; chips carry no inline close because a + * narrow X beside the label is easy to hit by accident and the tab's long-press menu closes it. + */ + compact: boolean; } export interface WorkspaceTabLayoutItem { @@ -54,10 +60,11 @@ export function computeWorkspaceTabLayout( input: WorkspaceTabLayoutInput, ): WorkspaceTabLayoutResult { const tabCount = input.tabLabelWidths.length; + const closeButtonPolicy: WorkspaceTabCloseButtonPolicy = input.compact ? "none" : "all"; if (tabCount === 0) { return { items: [], - closeButtonPolicy: "all", + closeButtonPolicy, requiresHorizontalScrollFallback: false, }; } @@ -79,12 +86,19 @@ export function computeWorkspaceTabLayout( ); const naturalTotalWidth = naturalWidths.reduce((total, width) => total + width, 0); const minimumTotalWidth = input.metrics.minTabWidth * tabCount; - const requiresHorizontalScrollFallback = availableTabsWidth < minimumTotalWidth; + // A row that scrolls has no reason to truncate: the tabs keep their natural widths and the + // viewport pans over them. Pointer layouts first shrink toward the clickable minimum and scroll + // only once even that does not fit; compact layouts skip the shrink. + const requiresHorizontalScrollFallback = input.compact + ? naturalTotalWidth > availableTabsWidth + : availableTabsWidth < minimumTotalWidth; let resolvedWidths = naturalWidths; - if (requiresHorizontalScrollFallback) { - resolvedWidths = Array.from({ length: tabCount }, () => input.metrics.minTabWidth); - } else if (naturalTotalWidth > availableTabsWidth) { + if ( + !requiresHorizontalScrollFallback && + !input.compact && + naturalTotalWidth > availableTabsWidth + ) { const widthToRemove = naturalTotalWidth - availableTabsWidth; const shrinkCapacity = naturalTotalWidth - minimumTotalWidth; const shrinkRatio = widthToRemove / shrinkCapacity; @@ -102,7 +116,7 @@ export function computeWorkspaceTabLayout( width, showLabel: width > tabChromeWidth, })), - closeButtonPolicy: "all", + closeButtonPolicy, requiresHorizontalScrollFallback, }; } diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index 8da5a563ee..2029808f31 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -10,6 +10,7 @@ import { useRouter, type Href } from "expo-router"; import { useTranslation } from "react-i18next"; import { Globe, SquarePen, SquareTerminal } from "lucide-react-native"; import invariant from "tiny-invariant"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { pluginIconComponent } from "@/plugins/icons"; import { useInstalledPlugins } from "@/plugins/registry"; @@ -107,6 +108,7 @@ export function useWorkspaceTabLaunchCatalog(input: { invariant(launcher, "NewTabLauncherProvider is required"); const { config } = useDaemonConfig(serverId); const plugins = useInstalledPlugins(); + const isCompact = useIsCompactFormFactor(); ensurePanelsRegistered(); const launchSelection = useCallback( @@ -159,7 +161,8 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-changes", disabled: false, panelKind: "working_diff", - hidden: !launcher.showChanges, + // Compact Changes shows each file's diff inline, so a separate Diff panel is redundant. + hidden: !launcher.showChanges || isCompact, launch: launchSelection(BUILT_IN_SELECTIONS.diff), }, files: { @@ -252,6 +255,7 @@ export function useWorkspaceTabLaunchCatalog(input: { }, [ config?.terminalProfiles, editTerminalProfiles, + isCompact, launchSelection, launcher, plugins, From ce67efa9e7faa8024a60e78c7b6f0ecf5ca21c5c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:54:11 +0200 Subject: [PATCH 17/62] Keep Claude request context out of restored chats (#7590) * fix(server): skip Claude request-journal records when loading history Claude Code 2.1.304+ journals each API request into the transcript as api-request-blob records. They carry a message with a role and content, so the history loader rendered them as assistant messages: restored timelines gained the session's system context, including SessionStart hook output and skill and tool listings. Only user and assistant records are conversation turns once the system record handling has run, so short-circuit every other type. Closes #7102 Co-Authored-By: Claude Opus 5.5 * fix(claude): keep request context out of restored chats Claude request-journal records contain message-shaped tool context. Only conversation records should enter the ordinary history mapper, after compaction and background task notifications are handled. Co-authored-by: Kheva Mann Co-authored-by: rogerluan --------- Co-authored-by: Roger Oba Co-authored-by: Claude Opus 5.5 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Kheva Mann Co-authored-by: rogerluan --- .../claude/agent.request-journal.test.ts | 140 ++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 5 + 2 files changed, 145 insertions(+) create mode 100644 packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts diff --git a/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts b/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts new file mode 100644 index 0000000000..9be65da964 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts @@ -0,0 +1,140 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import type { AgentTimelineItem } from "../../agent-sdk-types.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +describe("Claude session request journal replay", () => { + let root: string; + let cwd: string; + let configDir: string; + + beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), "claude-request-journal-")); + cwd = path.join(root, "repo"); + configDir = path.join(root, "claude"); + mkdirSync(cwd); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + async function replay(records: unknown[]): Promise { + const historyDir = claudeProjectDirSync(cwd, { configDir }); + mkdirSync(historyDir, { recursive: true }); + writeFileSync( + path.join(historyDir, "journal-session.jsonl"), + records.map((record) => JSON.stringify(record)).join("\n"), + ); + const client = new ClaudeAgentClient({ logger: createTestLogger() }); + const session = await client.resumeSession( + { provider: "claude", sessionId: "journal-session" }, + { cwd }, + { env: { CLAUDE_CONFIG_DIR: configDir } }, + ); + try { + const items: AgentTimelineItem[] = []; + for await (const event of session.streamHistory()) { + if (event.type === "timeline") items.push(event.item); + } + return items; + } finally { + await session.close(); + } + } + + test.each(["api-request-shape", "api-request", "api-request-blob", "future-journal-record"])( + "ignores %s content while preserving the conversation and tool calls", + async (type) => { + const items = await replay([ + { type: "user", message: { role: "user", content: "Read package.json" } }, + { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "read-1", + name: "Read", + input: { file_path: "package.json" }, + }, + ], + }, + }, + ...["user", "system", "assistant"].map((role) => ({ + type, + message: { + role, + content: [ + { + type: "text", + text: "SessionStart hook output\n# MCP Server Instructions\n1000 tokens left", + }, + { + type: "tool_addition", + tool: { + name: "deferred_tool", + description: "Deferred tool", + input_schema: { type: "object", properties: {} }, + }, + }, + { + type: "tool_use", + id: "journal-only", + name: "Read", + input: { file_path: "old.json" }, + }, + ], + }, + })), + { + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: "paseo" }] }, + }, + ]); + + expect(items).toEqual([ + expect.objectContaining({ type: "user_message", text: "Read package.json" }), + expect.objectContaining({ type: "tool_call", callId: "read-1", name: "Read" }), + expect.objectContaining({ type: "assistant_message", text: "paseo" }), + ]); + }, + ); + + test("preserves system compaction and background task notifications", async () => { + const items = await replay([ + { + type: "system", + subtype: "compact_boundary", + compactMetadata: { trigger: "manual", preTokens: 1000 }, + }, + { + type: "system", + subtype: "task_notification", + uuid: "note-1", + task_id: "task-1", + status: "completed", + summary: "Background work finished", + }, + ]); + expect(items).toEqual([ + expect.objectContaining({ + type: "compaction", + status: "completed", + trigger: "manual", + preTokens: 1000, + }), + expect.objectContaining({ + type: "tool_call", + name: "task_notification", + status: "completed", + }), + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 6a70d28d6a..3d59851038 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -6403,6 +6403,11 @@ function convertClaudeHistoryEntryPreamble( if (entry.type === "user" && isSyntheticHistoryUserEntry(entry)) { return { shortCircuit: [] }; } + // Only user and assistant records are conversation turns. Claude Code 2.1.x also journals each + // API request as `api-request-blob` records whose message replays the system context. + if (entry.type !== "user" && entry.type !== "assistant") { + return { shortCircuit: [] }; + } const message = entry?.message; if (!message || !("content" in message)) { From 1198cddf635cfa57767386c734cb91e7c1f52b93 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 07:53:47 +0700 Subject: [PATCH 18/62] Document cross-host agent workflows in the Paseo skill (#7593) * docs(skills): explain cross-host agent workflows * docs(skills): shorten subagent waiting guidance --- skills/paseo/SKILL.md | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index e92bdf91ee..cb08f49aa4 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -1,10 +1,31 @@ --- name: paseo -description: Paseo reference for managing projects, workspaces, workspace scripts, agents, schedules, and heartbeats. +description: Paseo reference for managing hosts, projects, workspaces, workspace scripts, agents, schedules, and heartbeats. --- Paseo is a remote daemon that manages coding agents, terminals. Control it through MCP tools or the CLI. +## Connected hosts + +Call `list_hosts` to discover the calling daemon (`self`) and its registered destinations (`hosts`). Use a destination's `serverId` for tool calls; labels are display names. + +`create_workspace`, `list_workspaces`, `create_agent`, `send_agent_prompt`, `get_agent_status`, `get_agent_activity`, and `list_agents` accept `serverId`. Omit it for the calling daemon. Other MCP tools operate on the calling daemon. + +Keep an agent's `{ serverId, agentId }` together. For example, call `send_agent_prompt` with: + +```json +{ "serverId": "srv_build", "agentId": "agent_123", "prompt": "Continue", "notifyOnFinish": true } +``` + +Remote paths and workspace IDs belong to the destination. Remote `list_agents` has no caller-directory default; pass `cwd` to filter it. + +Manage registrations through the CLI; use the returned `registrationId` for updates and removal: + +```bash +paseo --host localhost:6768 host add localhost:6779 --label build +paseo --host localhost:6768 host ls --json +``` + ## Projects Manage the daemon's project registry through the CLI: @@ -57,7 +78,7 @@ paseo script stop [--cwd | --workspace ] Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. Agent profiles are the preferred source for these values. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent. -Agent-scoped creation always creates your subagent. Omit `workspaceId` to use your current workspace; pass a workspace returned by `create_workspace` for isolated delegation. Placement never changes parentage. +Agent-scoped creation always creates your subagent. On your own host, omit `workspaceId` to use your current workspace. On another host, first list or create a workspace with that `serverId`, then pass its `workspaceId` and the same `serverId` to `create_agent`. Placement never changes parentage. Detach is an explicit user action in the subagents track, not an agent tool. A cross-workspace child remains your subagent even though it also appears as a normal tab in its workspace. @@ -106,13 +127,9 @@ Schedules have the full list/inspect/update/pause/resume/run-once/log/delete sur ## Waiting -Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows. - -`create_agent` returns after startup. Agent-scoped `send_agent_prompt` returns after queue admission; provider dispatch follows after two quiet seconds. Top-level sends return after dispatch acknowledgement. Top-level callers have no parent callback; observe completion through status/activity tools or SDK/RPC subscriptions and wait APIs. - -For agent-scoped `create_agent` and `send_agent_prompt`, `notifyOnFinish` defaults to `true`. The caller receives the target’s completion, error, or permission report automatically, including its last response. Finish your turn with the result for automatic reporting. For a workflow that reports explicitly with `send_agent_prompt`, set `notifyOnFinish: false` when assigning the task. Paseo supplies caller identity for agent-scoped tools and CLI agent context; use that supplied identity. - -With finish notifications enabled, don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you. +When you create or prompt a subagent, `notifyOnFinish` defaults to `true` and reports completion, errors, or permission requests, including across hosts. +Finish your turn and wait for that notification instead of polling. +If you want an explicit reply with `send_agent_prompt`, have the sender check `list_hosts` for the recipient’s server before setting `notifyOnFinish: false`. ## CLI semantics From 5d2a424868464ab53fab136e8c02e64759c7f4f7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 03:48:56 +0200 Subject: [PATCH 19/62] docs(changelog): prepare 0.13.0-beta.1 --- CHANGELOG.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af0f7904ba..9c2861aecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,70 @@ # Changelog +## 0.13.0-beta.1 - 2026-10-22 + +Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. + +Connect your daemon to another machine, then ask an agent to delegate work there: + +```sh +paseo host add 192.168.1.20:6767 --label studio +paseo host add 'ssh://me@build-box' --label build-box +paseo host add 'relay://...' --label laptop +``` + +### Added + +- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424)) +- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593)) +- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373)) +- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576)) +- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453)) + +### Improved + +- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580)) +- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411)) +- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424)) +- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) +- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561)) +- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473)) +- Paused status ring animations inside hidden panels on iOS and Android ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen)) +- Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8)) + +### Plugins + +- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366)) +- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478)) +- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373)) +- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579)) +- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368)) +- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474)) +- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477)) +- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) + +### Fixed + +- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573)) +- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) +- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547)) +- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538)) +- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph)) +- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429)) +- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362)) +- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy)) +- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365)) +- Fixed `paseo agent archive` reporting `AGENT_NOT_FOUND` for agents beyond the first 200 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337)) +- Fixed archived agents reappearing in MCP `list_agents` after their history was read ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan)) +- Fixed detached agents created with `notifyOnFinish` never notifying their creator ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan)) +- Fixed OpenCode agents being told `notifyOnFinish` defaults to off ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep)) +- Fixed Pi subagent rows staying running when a resume is refused ([#7369](https://github.com/getpaseo/paseo/pull/7369)) +- Fixed Android source builds downloading a second NDK ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang)) +- Fixed Nix packages reporting the AGPL license instead of Apache-2.0 ([#7415](https://github.com/getpaseo/paseo/pull/7415)) + ## 0.12.0 - 2026-10-19 Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes. From 3fa625701eae6b3a07ccae18299200b1a7cf2776 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 03:53:53 +0200 Subject: [PATCH 20/62] chore(release): cut 0.13.0-beta.1 --- package-lock.json | 56 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++-- packages/client/package.json | 6 +-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 10 ++--- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 ++--- packages/website/package.json | 2 +- plugins/package.json | 2 +- 14 files changed, 55 insertions(+), 55 deletions(-) diff --git a/package-lock.json b/package-lock.json index c35ff101b9..9e0a02eb17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.12.0", + "version": "0.13.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.12.0", + "version": "0.13.0-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -35656,7 +35656,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.12.0", + "version": "0.13.0-beta.1", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37922,12 +37922,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.12.0", + "version": "0.13.0-beta.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.12.0", - "@getpaseo/protocol": "0.12.0", - "@getpaseo/server": "0.12.0", + "@getpaseo/client": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/server": "0.13.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -38174,10 +38174,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.12.0", + "version": "0.13.0-beta.1", "dependencies": { - "@getpaseo/protocol": "0.12.0", - "@getpaseo/relay": "0.12.0", + "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/relay": "0.13.0-beta.1", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -38189,7 +38189,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.12.0", + "version": "0.13.0-beta.1", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -38433,7 +38433,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.12.0", + "version": "0.13.0-beta.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -42085,7 +42085,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.12.0", + "version": "0.13.0-beta.1", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -42114,15 +42114,15 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.12.0", + "version": "0.13.0-beta.1", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.12.0", - "@getpaseo/protocol": "0.12.0", + "@getpaseo/client": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -42132,8 +42132,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.12.0", - "@getpaseo/protocol": "0.12.0", + "@getpaseo/client": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.1", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -42155,7 +42155,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.12.0", + "version": "0.13.0-beta.1", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -42182,7 +42182,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.12.0", + "version": "0.13.0-beta.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -42400,16 +42400,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.12.0", + "version": "0.13.0-beta.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.12.0", - "@getpaseo/highlight": "0.12.0", - "@getpaseo/plugin": "0.12.0", - "@getpaseo/protocol": "0.12.0", - "@getpaseo/relay": "0.12.0", + "@getpaseo/client": "0.13.0-beta.1", + "@getpaseo/highlight": "0.13.0-beta.1", + "@getpaseo/plugin": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/relay": "0.13.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -43330,7 +43330,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.12.0", + "version": "0.13.0-beta.1", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -44940,7 +44940,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.12.0", + "version": "0.13.0-beta.1", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 54514212cf..9a8a3322e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.12.0", + "version": "0.13.0-beta.1", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index a2e44700e1..dc3deb047d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.12.0", + "version": "0.13.0-beta.1", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index a4bacd86fa..2481184da6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.12.0", + "version": "0.13.0-beta.1", "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.12.0", - "@getpaseo/protocol": "0.12.0", - "@getpaseo/server": "0.12.0", + "@getpaseo/client": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/server": "0.13.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index d3f5cabb63..3f5b7c51a5 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.12.0", + "version": "0.13.0-beta.1", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.12.0", - "@getpaseo/relay": "0.12.0", + "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/relay": "0.13.0-beta.1", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1c8cc7687e..cbeffbb56d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.12.0", + "version": "0.13.0-beta.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 83006f32a2..b6392bafa1 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.12.0", + "version": "0.13.0-beta.1", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 696325f4f7..f714608069 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.12.0", + "version": "0.13.0-beta.1", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 55661a6276..b6f1243c6c 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.12.0", + "version": "0.13.0-beta.1", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -72,8 +72,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.12.0", - "@getpaseo/protocol": "0.12.0", + "@getpaseo/client": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -83,8 +83,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.12.0", - "@getpaseo/protocol": "0.12.0", + "@getpaseo/client": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.1", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 1dd6665592..f089ca926b 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.12.0", + "version": "0.13.0-beta.1", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 3cadb9c3e0..6b28a6e5d3 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.12.0", + "version": "0.13.0-beta.1", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 6d957847ab..2f06f218cd 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.12.0", + "version": "0.13.0-beta.1", "description": "Paseo backend server", "files": [ "dist/server", @@ -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.12.0", - "@getpaseo/highlight": "0.12.0", - "@getpaseo/plugin": "0.12.0", - "@getpaseo/protocol": "0.12.0", - "@getpaseo/relay": "0.12.0", + "@getpaseo/client": "0.13.0-beta.1", + "@getpaseo/highlight": "0.13.0-beta.1", + "@getpaseo/plugin": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/relay": "0.13.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 77cb53a0da..8f91671e25 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.12.0", + "version": "0.13.0-beta.1", "private": true, "type": "module", "scripts": { diff --git a/plugins/package.json b/plugins/package.json index 56f39fb45c..f71101b0c6 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.12.0", + "version": "0.13.0-beta.1", "private": true, "type": "module", "scripts": { From 68fa464cdf341dcc798cb76be4e37a1ab289aaeb Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:04:09 +0000 Subject: [PATCH 21/62] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 3f3180e274..d06b479e80 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-La6xekVRQ9F6UulA6OHX5SzaXv6L0qZ2KpGkRScFd8Q= +sha256-DkB70/NH4SriKZ6hMDhI7XfwkqjGfb9e1zC3dxjBaTg= From bad132884d603f7b91efecc9eb35442e14a5e268 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 10:15:43 +0700 Subject: [PATCH 22/62] Translate the 0.13.0-beta.1 changelog and render changelog code blocks (#7606) * fix(website): render changelog code blocks as blocks * docs(changelog): translate 0.13.0-beta.1 and write translations with each release --- CHANGELOG.zh-CN.md | 65 +++++++++++++++++++++++++++++++++ docs/release.md | 8 ++-- packages/website/src/styles.css | 4 +- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 92d3423036..ac7c00cbe7 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,5 +1,70 @@ # 更新日志 +## 0.13.0-beta.1 - 2026-10-22 + +折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。 + +将你的 daemon 连接到另一台机器,然后让 Agent 把工作委派过去: + +```sh +paseo host add 192.168.1.20:6767 --label studio +paseo host add 'ssh://me@build-box' --label build-box +paseo host add 'relay://...' --label laptop +``` + +### 新增 + +- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424)) +- 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593)) +- 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373)) +- Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576)) +- 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453)) + +### 改进 + +- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580)) +- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411)) +- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424)) +- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) +- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561)) +- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473)) +- iOS 和 Android 上隐藏面板中的状态环动画现在会暂停 ([#7423](https://github.com/getpaseo/paseo/pull/7423) by [@peterkeen](https://github.com/peterkeen)) +- 应用启动时只加载所选语言的翻译,减少了启动工作量 ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8)) +- 工作区布局未变化时不再重复保存 ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8)) + +### 插件 + +- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366)) +- 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478)) +- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373)) +- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579)) +- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368)) +- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474)) +- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477)) +- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) + +### 修复 + +- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573)) +- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) +- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547)) +- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538)) +- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph)) +- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429)) +- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362)) +- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy)) +- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365)) +- 修复了 `paseo agent archive` 对前 200 个之后的 Agent 报告 `AGENT_NOT_FOUND` 的问题 ([#7535](https://github.com/getpaseo/paseo/pull/7535) by [@L4XB](https://github.com/L4XB), [@hteo1337](https://github.com/hteo1337)) +- 修复了读取历史记录后已归档的 Agent 重新出现在 MCP `list_agents` 中的问题 ([#7532](https://github.com/getpaseo/paseo/pull/7532) by [@thomasvan](https://github.com/thomasvan)) +- 修复了使用 `notifyOnFinish` 创建的分离 Agent 从不通知其创建者的问题 ([#7528](https://github.com/getpaseo/paseo/pull/7528) by [@thomasvan](https://github.com/thomasvan)) +- 修复了 OpenCode Agent 被告知 `notifyOnFinish` 默认关闭的问题 ([#7383](https://github.com/getpaseo/paseo/pull/7383) by [@gszep](https://github.com/gszep)) +- 修复了恢复被拒绝时 Pi 子 Agent 行仍显示为运行中的问题 ([#7369](https://github.com/getpaseo/paseo/pull/7369)) +- 修复了 Android 源码构建下载第二个 NDK 的问题 ([#7403](https://github.com/getpaseo/paseo/pull/7403) by [@dwyanewang](https://github.com/dwyanewang)) +- 修复了 Nix 包将许可证报告为 AGPL 而不是 Apache-2.0 的问题 ([#7415](https://github.com/getpaseo/paseo/pull/7415)) + ## 0.12.0 - 2026-10-19 后台工作区、新的 Markdown 渲染器(手机上可跨段落选择文本)、重新设计的手机布局,以及超过 120 项修复。 diff --git a/docs/release.md b/docs/release.md index c6ba97e078..83a419b8ce 100644 --- a/docs/release.md +++ b/docs/release.md @@ -538,8 +538,8 @@ Older prerelease bodies on GitHub keep the notes they were synced with; `Release `CHANGELOG.zh-CN.md` holds the Chinese notes for `paseo.sh/zh-CN/changelog`. Only the website reads it; the app's **What's new** sheet, GitHub release bodies and F-Droid read `CHANGELOG.md`. -- Translations never gate a release. Translate a new entry in a follow-up PR, or in the changelog commit when you can. -- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice, so a beta rewritten in place drops its old translation until the new notes are translated. Delete Chinese entries whose English entry was renamed. +- The release agent writes the Chinese entry in the changelog commit, after the English entry is approved. Every beta that rewrites the English entry rewrites the Chinese one too, and promotion renames both. `packages/website/e2e/i18n.spec.ts` fails CI on the release commit when any entry has no current translation. +- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice. Delete Chinese entries whose English entry was renamed. - Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style). ## Changelog ownership @@ -654,7 +654,7 @@ Every entry covers `previous stable release → release source`: first beta, lat - [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green - [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog -- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release +- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release - [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved - [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag - [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully @@ -677,7 +677,7 @@ Every entry covers `previous stable release → release source`: first beta, lat - [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog - [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command - [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command -- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release +- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it - [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format - [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag - [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index 47da4f3c47..815657b5e5 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -269,7 +269,7 @@ color: color-mix(in srgb, var(--color-foreground) 78%, var(--color-primary)); } -.changelog-release-notes code { +.changelog-release-notes :not(pre) > code { border-radius: 0.25rem; background: var(--color-muted); padding: 0.125rem 0.375rem; @@ -522,6 +522,7 @@ width: max(22%, 7.5rem); } +.changelog-release-notes pre, .docs-prose pre { background-color: var(--color-card) !important; border: 1px solid var(--color-border); @@ -540,6 +541,7 @@ min-width: 100%; } +.changelog-release-notes pre code, .docs-prose pre code { background: transparent; padding: 0; From bdae614e95d7501f685cef2e65d1da864978d363 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 11:45:31 +0700 Subject: [PATCH 23/62] fix(app): restore chats after reopening unread workspaces (#7608) Parked pane focus was persisted without its in-memory restoration record, so cold starts exposed hidden Explorer tabs and never requested the agent timeline. Restore an ordinary pane at the layout boundary, including for existing saved layouts. --- .../sidebar-workspace-mark-unread.spec.ts | 43 ++++++++++++ .../src/stores/workspace-layout-actions.ts | 8 ++- .../workspace-layout-persistence.test.ts | 65 ++++++++++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts index bc3d4bbf95..23127bbdd9 100644 --- a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts @@ -6,6 +6,7 @@ import { seedMockAgentWorkspace, type MockAgentWorkspace, } from "../support/helpers/mock-agent"; +import { expectOpenTabCount } from "../support/helpers/sheet"; import { getServerId } from "../support/helpers/server-id"; import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar"; @@ -227,3 +228,45 @@ test("manual unread survives leaving the current workspace on compact layout", a await markUnreadThenResumeChat(page, workspaces.subject.workspaceId); await completeTurnAndLeaveOnCompact(page, workspaces); }); + +async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) { + const route = page.url(); + // End this app session before the turn, so its cached transcript cannot contain the new prompt. + await page.goto("about:blank"); + await workspace.client.sendAgentMessage( + workspace.agentId, + "A turn completed while the app was closed.", + ); + await workspace.client.waitForFinish(workspace.agentId, 20_000); + // Reopen the ordinary workspace route, without an agent open intent that would repair focus. + await page.goto(route); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toBeVisible(); +} + +async function expectRestoredCompactChat(page: Page) { + await expectOpenTabCount(page, 1); + await expect( + page.getByText("A turn completed while the app was closed.", { exact: true }), + ).toBeVisible(); +} + +test("reopening a marked unread workspace restores only its agent tab and current history on compact layout", async ({ + page, + workspaces, +}) => { + test.setTimeout(120_000); + await test.step("open a finished chat with one main tab", async () => { + await openCompactWorkspace(page, workspaces.subject); + await closeMobileAgentSidebar(page); + await expectOpenTabCount(page, 1); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); + }); + await test.step("mark the current workspace unread, then close and reopen the app", async () => { + await openMobileAgentSidebar(page); + await markAsUnread(page, workspaces.subject.workspaceId); + await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject); + }); + await test.step("restore the main tab without Explorer entries and load its latest history", async () => { + await expectRestoredCompactChat(page); + }); +}); diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 5e87c2eb65..79e0b4fc76 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -1192,7 +1192,10 @@ export function restoreWorkspaceLayout( const normalized = normalizeLayout(layout); let root = asInternalNode(normalized.root); let focusedPaneId = normalized.focusedPaneId; - if (!collectAllPanes(root).some((pane) => pane.id !== explorerSidebarPaneId)) { + const visibleOrdinaryPane = collectAllPanes(root).find( + (pane) => pane.id !== explorerSidebarPaneId, + ); + if (!visibleOrdinaryPane) { const ordinaryPane = findOrdinaryPane(root, explorerSidebarPaneId); if (ordinaryPane) { root = updatePaneInTree(root, { @@ -1214,6 +1217,9 @@ export function restoreWorkspaceLayout( sizes: [0.78, 0.22], }); } + } else if (focusedPaneId === null) { + // Parked focus has an in-memory restoration record that cannot survive reload. + focusedPaneId = visibleOrdinaryPane.id; } return { ...normalized, diff --git a/packages/app/src/stores/workspace-layout-persistence.test.ts b/packages/app/src/stores/workspace-layout-persistence.test.ts index 3a54204598..3398199ae4 100644 --- a/packages/app/src/stores/workspace-layout-persistence.test.ts +++ b/packages/app/src/stores/workspace-layout-persistence.test.ts @@ -1,7 +1,13 @@ import { indexedDB, IDBKeyRange } from "fake-indexeddb"; import { expect, it } from "vitest"; import type { StateStorage } from "zustand/middleware"; -import { createWorkspaceLayoutStore } from "./workspace-layout-store"; +import { + createWorkspaceLayoutStore, + collectAllTabs, + selectExplorerSidebarPaneId, +} from "./workspace-layout-store"; +import { deriveWorkspacePaneState } from "../screens/workspace/workspace-pane-state"; +import { selectVisibleAgentIds } from "../screens/workspace/visible-agent-ids"; Object.assign(globalThis, { indexedDB, IDBKeyRange }); @@ -88,3 +94,60 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra saved.getState().layoutByWorkspace[workspaceKey], ); }); + +it.each([1, 2])( + "restores the agent pane after parked focus in storage version %i", + async (version) => { + const values = new Map(); + const storage: StateStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + }; + const key = "host:workspace"; + const source = createWorkspaceLayoutStore(undefined, storage); + await source.persist.rehydrate(); + source.getState().setLayoutMode("compact"); + source.getState().openTab({ + workspaceKey: key, + target: { kind: "agent", agentId: "agent-a" }, + intent: "reveal", + }); + source.getState().unfocusPane(key); + expect(source.getState().layoutByWorkspace[key].focusedPaneId).toBeNull(); + const persisted = JSON.parse(values.get("workspace-layout-state")!); + persisted.version = version; + values.set("workspace-layout-state", JSON.stringify(persisted)); + const restored = createWorkspaceLayoutStore(undefined, storage); + restored.getState().setLayoutMode("compact"); + await restored.persist.rehydrate(); + restored.getState().reconcileTabs(key, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-a"], + autoOpenAgentIds: ["agent-a"], + knownTerminalIds: [], + standaloneTerminalIds: [], + }); + const layout = restored.getState().layoutByWorkspace[key]; + const tabs = collectAllTabs(layout.root); + const restoredExplorerId = selectExplorerSidebarPaneId(restored.getState(), key); + const main = deriveWorkspacePaneState({ layout, tabs }); + const visible = selectVisibleAgentIds({ + layout, + tabs, + routeFocused: true, + focusedPaneOnly: true, + explorerPaneId: restoredExplorerId, + explorerVisible: false, + }); + expect(layout.focusedPaneId).toBe("main"); + expect(main.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-a" }); + expect(main.tabs.map((t) => t.descriptor.kind)).toEqual(["agent"]); + expect(visible).toEqual(["agent-a"]); + }, +); From 76b32544c6ba804b135acef86bd4ed627c654ae2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 13:04:59 +0700 Subject: [PATCH 24/62] Give Markdown and HTML file tabs back their Preview and Source views (#7614) * fix(app): give Markdown and HTML file tabs back their Preview and Source views The built-in Markdown and HTML panels own one toolbar again: file size, line count, save status, vim mode, cursor, and a Preview | Source control over a body that shows the rendered preview or the editor from the same loaded content and editor model. Plain text files keep the toolbar on top. The host no longer renders a panel switcher. Source is the built-in panel for text files no other built-in claims, so it is never offered beside Markdown or HTML in the picker, the Open with menu, or Settings > Files. A line target opens the file's built-in panel in its Source view. * refactor(app): name the shared text file panel TextFilePanel Markdown and HTML own a Source view; they are not Source panels. The composite in file-panels/source/panel.tsx is now TextFilePanel, and SourcePanel is TextFilePanel without a Preview. Names only. --- docs/plugins.md | 8 +- .../e2e/browser/file-change-conflicts.spec.ts | 3 +- packages/app/e2e/browser/file-editing.spec.ts | 69 ++++++-- packages/app/e2e/browser/folder-links.spec.ts | 5 +- .../plugin-file-panel-examples.spec.ts | 52 ++---- .../browser/plugin-file-panel-picker.spec.ts | 45 +++-- .../plugin-file-panel-settings.spec.ts | 8 +- .../e2e/browser/plugin-file-panels.spec.ts | 10 +- .../helpers/markdown-file-panel-plugin.ts | 1 - packages/app/src/file-pane/bar.tsx | 58 ------- packages/app/src/file-pane/pane.tsx | 2 - packages/app/src/file-panels/builtin.tsx | 36 ++-- packages/app/src/file-panels/catalog.test.ts | 22 ++- packages/app/src/file-panels/catalog.ts | 7 +- packages/app/src/file-panels/choice/hook.ts | 5 +- .../app/src/file-panels/choice/model.test.ts | 46 ++--- packages/app/src/file-panels/choice/model.ts | 17 +- .../app/src/file-panels/choice/open-with.tsx | 41 ----- .../src/file-panels/choice/presentation.ts | 2 +- .../choice/switcher-layout.test.ts | 31 ---- .../src/file-panels/choice/switcher-layout.ts | 24 --- .../app/src/file-panels/choice/switcher.tsx | 138 --------------- packages/app/src/file-panels/index.ts | 7 +- packages/app/src/file-panels/resolve.test.ts | 11 ++ packages/app/src/file-panels/resolve.ts | 16 +- .../app/src/file-panels/settings/rows.test.ts | 16 +- packages/app/src/file-panels/settings/rows.ts | 9 +- packages/app/src/file-panels/source/panel.tsx | 144 +++++++++++----- .../app/src/file-panels/source/status.tsx | 122 ------------- .../app/src/file-panels/source/toolbar.tsx | 163 ++++++++++++++++++ packages/app/src/i18n/resources/ar.ts | 3 +- packages/app/src/i18n/resources/en.ts | 3 +- packages/app/src/i18n/resources/es.ts | 3 +- packages/app/src/i18n/resources/fr.ts | 3 +- packages/app/src/i18n/resources/ja.ts | 3 +- packages/app/src/i18n/resources/ko.ts | 3 +- packages/app/src/i18n/resources/pt-BR.ts | 3 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 3 +- plugin-examples/markdown-panel/README.md | 5 +- public-docs/plugins/reference.md | 52 +++--- public-docs/plugins/reference.zh-CN.md | 12 +- 42 files changed, 569 insertions(+), 645 deletions(-) delete mode 100644 packages/app/src/file-pane/bar.tsx delete mode 100644 packages/app/src/file-panels/choice/switcher-layout.test.ts delete mode 100644 packages/app/src/file-panels/choice/switcher-layout.ts delete mode 100644 packages/app/src/file-panels/choice/switcher.tsx delete mode 100644 packages/app/src/file-panels/source/status.tsx create mode 100644 packages/app/src/file-panels/source/toolbar.tsx diff --git a/docs/plugins.md b/docs/plugins.md index facc815ef6..30d99a00a8 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -566,9 +566,11 @@ one section's order from built-ins, plugin groups, and the section's preference Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes, -and resolves conflicts. The bar only switches panels and shows the dot the panel sets through -`tab.setModified`. Built-in panels (Markdown, HTML, Image, and Source, Paseo's editor) take the -same handle as plugin panels, so a fix to reading, watching, or writing lands in one place: +and resolves conflicts. The host has no bar and no notion of a panel's views: the Markdown and HTML +panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the +dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in +claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's +editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place: `packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel; make that panel faster instead. Source highlights a large file on native in idle-time slices, the lines around the opening line first. diff --git a/packages/app/e2e/browser/file-change-conflicts.spec.ts b/packages/app/e2e/browser/file-change-conflicts.spec.ts index 1a372af35d..cfc9ea1a55 100644 --- a/packages/app/e2e/browser/file-change-conflicts.spec.ts +++ b/packages/app/e2e/browser/file-change-conflicts.spec.ts @@ -182,7 +182,8 @@ test.describe("Workspace file change conflicts", () => { await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible(); await unlink(filePath); - await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible(); + await expectDeletedFileNotice(page); + await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible(); await writeFile(filePath, "# After\n", "utf8"); await expect(filePane(page).getByText("After", { exact: true })).toBeVisible(); diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index f06c244ba3..0266d02691 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "../support/fixtures"; import { expandFolder, @@ -55,10 +56,6 @@ async function openWorkspaceFile(page: Page, filename: string): Promise { await expectFileTabOpen(page, filename); } -function filePanelSwitcher(page: Page) { - return page.getByTestId("file-panel-switcher").filter({ visible: true }); -} - function htmlPreview(page: Page) { return { host: page.getByTestId("file-html-preview"), @@ -66,13 +63,32 @@ function htmlPreview(page: Page) { }; } -/** Switches the file tab to one of its panels from the bar's switcher. */ -async function selectFilePanel(page: Page, title: string): Promise { - const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true }); +async function selectFileView(page: Page, view: "Preview" | "Source"): Promise { + const option = page.getByTestId("file-panel-bar").getByRole("button", { + name: view, + exact: true, + }); await option.click(); await expect(option).toHaveAttribute("aria-selected", "true"); } +async function expectAbove(upper: Locator, lower: Locator): Promise { + const [upperBox, lowerBox] = await Promise.all([upper.boundingBox(), lower.boundingBox()]); + expect(upperBox).not.toBeNull(); + expect(lowerBox).not.toBeNull(); + expect(upperBox!.y + upperBox!.height).toBeLessThanOrEqual(lowerBox!.y); +} + +/** How many times the app has read the file's contents from the daemon. */ +function fileReadCount( + gate: Awaited>, + fileName: string, +): number { + return gate + .getClientRequests("file_explorer_request") + .filter((request) => request.mode === "file" && String(request.path).endsWith(fileName)).length; +} + async function expectMarkdownImageLoaded(page: Page, name: string): Promise { const image = page.getByRole("button", { name, exact: true }); await expect(image).toBeVisible(); @@ -482,9 +498,9 @@ test.describe("CodeMirror workspace file editing", () => { relativePath, ); await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md"); - const modeControl = filePanelSwitcher(page); + const modeControl = page.getByTestId("file-preview-mode"); await expect(modeControl).toBeVisible(); - await selectFilePanel(page, "Source"); + await selectFileView(page, "Source"); const editorHost = page.getByTestId("file-source-editor"); const content = editor(page); @@ -546,7 +562,7 @@ test.describe("CodeMirror workspace file editing", () => { ); await workspace.navigateTo(); await openWorkspaceFile(page, "notes.md"); - await selectFilePanel(page, "Source"); + await selectFileView(page, "Source"); const markdownScroller = page .getByTestId("file-source-editor") @@ -582,6 +598,11 @@ test.describe("CodeMirror workspace file editing", () => { await expect(page.getByTestId("file-source-editor")).toBeVisible(); await expect(page.getByLabel(/File size/)).toBeVisible(); await expect(page.getByLabel(/lines/)).toBeVisible(); + await expect(page.getByTestId("file-preview-mode")).toHaveCount(0); + await expectAbove( + page.getByTestId("file-panel-bar").filter({ visible: true }), + page.getByTestId("file-source-editor").filter({ visible: true }), + ); await replaceEditorText(page, "const autosaved = 2;\n"); await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible(); @@ -678,11 +699,12 @@ test.describe("CodeMirror workspace file editing", () => { await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible(); }); - test("refreshes Markdown and images while switching between Markdown and Source", async ({ + test("refreshes Markdown and images while preserving Preview and Source behavior", async ({ page, withWorkspace, }) => { test.setTimeout(90_000); + const gate = await installDaemonWebSocketGate(page); const workspace = await withWorkspace({ prefix: "file-editing-preview-" }); const markdownPath = path.join(workspace.repoPath, "notes.md"); const imagePath = path.join(workspace.repoPath, "pixel.png"); @@ -692,17 +714,30 @@ test.describe("CodeMirror workspace file editing", () => { await openWorkspaceFile(page, "notes.md"); const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true }); + const toolbar = page.getByTestId("file-panel-bar").filter({ visible: true }); await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible(); - await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible(); + await expect(toolbar.getByLabel(/^File size/)).toBeVisible(); + await expect(toolbar.getByLabel("2 lines")).toBeVisible(); await writeFile(markdownPath, "# Updated heading\n", "utf8"); await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible(); - await selectFilePanel(page, "Source"); + // Source shows the same read in the editor; the file is not read again. + const reads = fileReadCount(gate, "notes.md"); + await selectFileView(page, "Source"); await expect(page.getByTestId("file-source-editor")).toBeVisible(); + await expect(editor(page)).toContainText("# Updated heading"); + await expect(toolbar.getByLabel("Line 1, column 1")).toBeVisible(); + expect(fileReadCount(gate, "notes.md")).toBe(reads); + + // Preview shows the edits made in Source. await replaceEditorText(page, "# Saved from source\n"); - await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n"); - await selectFilePanel(page, "Markdown"); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible(); + await selectFileView(page, "Preview"); await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible(); + await expect(toolbar.getByLabel("Line 1, column 1")).toHaveCount(0); + await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n"); + await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).not.toBeVisible(); await openWorkspaceFile(page, "pixel.png"); const image = visibleFilePane.locator("img"); @@ -784,10 +819,10 @@ test.describe("CodeMirror workspace file editing", () => { ); await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible(); - await selectFilePanel(page, "Source"); + await selectFileView(page, "Source"); await expect(page.getByTestId("file-source-editor")).toBeVisible(); await expect(preview.host).toHaveCount(0); - await selectFilePanel(page, "HTML"); + await selectFileView(page, "Preview"); await expect(preview.host).toBeVisible(); }); diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts index f557d97737..a893fbcb68 100644 --- a/packages/app/e2e/browser/folder-links.spec.ts +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -14,9 +14,8 @@ async function openMarkdownPreview(page: Page) { await openFileFromExplorer(page, "README.md"); await expectFileTabOpen(page, "README.md"); await page - .getByTestId("file-panel-switcher") - .filter({ visible: true }) - .getByRole("button", { name: "Markdown", exact: true }) + .getByTestId("file-panel-bar") + .getByRole("button", { name: "Preview", exact: true }) .click(); await ensureWorkspaceAgentPaneVisible(page); } diff --git a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts index 0d1ab23476..9159a60644 100644 --- a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts @@ -71,10 +71,6 @@ async function captureWideAndCompact( await ready(); } -function filePanelSwitcher(page: Page) { - return page.getByTestId("file-panel-switcher").filter({ visible: true }); -} - /** Turns on vim keybindings without discarding settings the test writes later. */ async function enableVimKeybindings(page: Page) { await page.addInitScript(() => { @@ -94,16 +90,19 @@ async function replaceMarkdown(page: Page, text: string) { await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text); } -async function selectPanel(page: Page, title: string) { - await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click(); +async function openWithFromTabMenu(page: Page, name: string, option: string) { + await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open with…" }).hover(); + await page.getByRole("menuitem", { name: option }).click(); } -async function expectPanels(page: Page, titles: string[]) { - for (const title of titles) { - await expect( - filePanelSwitcher(page).getByRole("button", { name: title, exact: true }), - ).toBeInViewport({ ratio: 1 }); - } +async function selectFileView(page: Page, view: "Preview" | "Source") { + const option = page + .getByTestId("file-preview-mode") + .filter({ visible: true }) + .getByRole("button", { name: view, exact: true }); + await option.click(); + await expect(option).toHaveAttribute("aria-selected", "true"); } /** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */ @@ -126,9 +125,6 @@ async function expectMarkdownEditor(page: Page, text: string) { await expect(editor).toBeVisible(); await expect(editor).toContainText(text); await expect(editor).toBeInViewport({ ratio: 1 }); - // Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu. - await expect(filePanelSwitcher(page)).toContainText("Rich Markdown"); - await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 }); } test("directory examples mount the PDF frame and write Markdown through the file handle", async ({ @@ -197,7 +193,7 @@ test("directory examples mount the PDF frame and write Markdown through the file await expect( page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }), ).toBeVisible(); - await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible(); + await expect(page.getByRole("radio")).toHaveCount(2); await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => { // The previous layout's picker animates out (the sheet slides down, the dialog fades), so // both pickers are mounted for a moment after a resize. @@ -220,32 +216,23 @@ test("directory examples mount the PDF frame and write Markdown through the file .poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8")) .toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n"); await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden(); - await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]); await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => { await expectMarkdownEditor(page, "Edited by Rich Markdown"); }); - // Source is Paseo's editor; its cursor and vim mode are in its own status row. - await selectPanel(page, "Source"); + // "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the + // cursor and vim mode in the panel's toolbar. + await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo"); + await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible(); + await selectFileView(page, "Source"); const source = page.getByTestId("file-source-editor").filter({ visible: true }); await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown"); await source.locator(".cm-line").nth(2).click(); - const bar = page.getByTestId("file-source-status").filter({ visible: true }); + const bar = page.getByTestId("file-panel-bar").filter({ visible: true }); await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible(); await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible(); await capture(page, testInfo, "markdown-example-source-wide"); - // The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source. - await page.setViewportSize(COMPACT); - await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown"); - await filePanelSwitcher(page).click(); - await page.getByRole("menuitem", { name: "Source", exact: true }).click(); - await expect(filePanelSwitcher(page)).toHaveText("Source"); - await source.locator(".cm-line").nth(2).click(); - await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport(); - await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport(); - await capture(page, testInfo, "markdown-example-source-compact"); - await page.setViewportSize(WIDE); - await selectPanel(page, "Rich Markdown"); + await openWithFromTabMenu(page, "notes.md", "Rich Markdown — markdown-panel"); await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n"); await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText( "# External update", @@ -266,7 +253,6 @@ test("directory examples mount the PDF frame and write Markdown through the file await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden(); await page.setViewportSize(COMPACT); await expectMarkdownEditor(page, "Remembered panel"); - await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown"); } finally { await client.removePlugin("pdf-panel"); await client.removePlugin("markdown-panel"); diff --git a/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts b/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts index 792ace7700..0fc47d7a16 100644 --- a/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panel-picker.spec.ts @@ -30,17 +30,26 @@ async function openWithFromTabMenu(page: Page, name: string, option: string) { await page.getByRole("menuitem", { name: option }).click(); } -async function toggleAlwaysUseFromFileBar(page: Page) { - await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click(); - await page.getByRole("menuitem", { name: "Always use for .md" }).click(); +async function toggleAlwaysUseFromTabMenu(page: Page, name: string) { + await openWithFromTabMenu(page, name, "Always use for .md"); } -function filePanelSwitcher(page: Page) { - return page.getByTestId("file-panel-switcher").filter({ visible: true }); +/** The built-in panel's toolbar control between its Preview and Source views. */ +function viewControl(page: Page) { + return page.getByTestId("file-preview-mode").filter({ visible: true }); } -async function expectSwitcherPanels(page: Page, titles: string[]) { - await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles); +async function selectFileView(page: Page, view: "Preview" | "Source") { + const option = viewControl(page).getByRole("button", { name: view, exact: true }); + await option.click(); + await expect(option).toHaveAttribute("aria-selected", "true"); +} + +async function expectNoOpenWith(page: Page, name: string) { + await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Close", exact: true })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Open with…" })).toHaveCount(0); + await page.keyboard.press("Escape"); } async function capture(page: Page, testInfo: TestInfo, name: string) { @@ -49,7 +58,7 @@ async function capture(page: Page, testInfo: TestInfo, name: string) { await testInfo.attach(name, { path: file, contentType: "image/png" }); } -test("opens Markdown with the built-in panel and Source beside it without asking", async ({ +test("opens Markdown with the built-in panel and its Preview and Source without asking", async ({ page, }, testInfo) => { const workspace = await seedWorkspace({ @@ -62,19 +71,20 @@ test("opens Markdown with the built-in panel and Source beside it without asking await openFileExplorer(page); await openMarkdown(page, "notes.md"); await expectBuiltinMarkdown(page, "notes heading"); - await expectSwitcherPanels(page, ["Markdown", "Source"]); + await expect(viewControl(page).getByRole("button")).toHaveText(["Preview", "Source"]); await expect(filePanelPicker(page)).toBeHidden(); + await expectNoOpenWith(page, "notes.md"); await capture(page, testInfo, "file-panel-builtins-wide"); - await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click(); + await selectFileView(page, "Source"); await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible(); - await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click(); + await selectFileView(page, "Preview"); await expectBuiltinMarkdown(page, "notes heading"); await page.setViewportSize({ width: 390, height: 844 }); await expectBuiltinMarkdown(page, "notes heading"); await expect( - filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }), + viewControl(page).getByRole("button", { name: "Source", exact: true }), ).toBeInViewport({ ratio: 1 }); await expect(filePanelPicker(page)).toBeHidden(); await capture(page, testInfo, "file-panel-builtins-compact"); @@ -107,6 +117,7 @@ test("asks once which panel opens Markdown and remembers it when told to", async await expect(filePanelPicker(page)).toBeVisible(); await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked(); await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked(); + await expect(page.getByRole("radio")).toHaveCount(2); await expectBuiltinMarkdown(page, "first heading"); await capture(page, testInfo, "file-panel-picker-wide"); @@ -118,8 +129,8 @@ test("asks once which panel opens Markdown and remembers it when told to", async await expectPluginMarkdown(page, "second.md"); await expect(filePanelPicker(page)).toBeHidden(); - // Forgetting from the file bar keeps the tab on the plugin. - await toggleAlwaysUseFromFileBar(page); + // Forgetting from the tab menu keeps the tab on the plugin. + await toggleAlwaysUseFromTabMenu(page, "second.md"); await expect.poll(() => storedFilePanelAssociations(page)).toEqual({}); await expectPluginMarkdown(page, "second.md"); @@ -130,10 +141,14 @@ test("asks once which panel opens Markdown and remembers it when told to", async await expectPluginMarkdown(page, "third.md"); expect(await storedFilePanelAssociations(page)).toEqual({}); - // "Open with…" switches the open tab back to the built-in in place. + // "Open with…" switches the open tab back to the built-in in place, with its own views. await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION); await expectBuiltinMarkdown(page, "third heading"); await expectFileTabOpen(page, "third.md"); + await selectFileView(page, "Source"); + await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible(); + await selectFileView(page, "Preview"); + await expectBuiltinMarkdown(page, "third heading"); // Dismissing keeps the built-in; reopening the tab after a reload asks again. await openMarkdown(page, "fourth.md"); diff --git a/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts index d6b6f6c986..7f9350ed05 100644 --- a/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts @@ -7,7 +7,6 @@ import { BUILTIN_MARKDOWN_OPTION, MARKDOWN_PANEL_ID, PLUGIN_MARKDOWN_OPTION, - SOURCE_OPTION, expectPluginMarkdown, filePanelPicker, installMarkdownPanelPlugin, @@ -18,6 +17,7 @@ import { seedWorkspace } from "../support/helpers/seed-client"; import { openSettingsSection } from "../support/helpers/settings"; const ASK_EVERY_TIME = "Ask every time"; +const SOURCE_OPTION = "Source — Paseo"; function markdownRow(page: Page) { return page.getByTestId("file-panel-settings-row-ext:md"); @@ -69,7 +69,7 @@ test("Settings > Files chooses which panel opens .md and asks again on request", await markdownDropdown(page, ASK_EVERY_TIME).click(); await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible(); await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible(); - await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toHaveCount(0); await expectMenuOpen(page); await capture(page, testInfo, "file-panel-settings-wide-menu"); await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click(); @@ -94,11 +94,11 @@ test("Settings > Files chooses which panel opens .md and asks again on request", await expect(askAgain(page)).toBeInViewport({ ratio: 1 }); await capture(page, testInfo, "file-panel-settings-compact"); await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click(); - await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible(); await expectMenuOpen(page); await capture(page, testInfo, "file-panel-settings-compact-menu"); await page.keyboard.press("Escape"); - await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden(); + await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeHidden(); // Ask again forgets the choice; the next open asks. await askAgain(page).click(); diff --git a/packages/app/e2e/browser/plugin-file-panels.spec.ts b/packages/app/e2e/browser/plugin-file-panels.spec.ts index 74d4e41329..8567147cda 100644 --- a/packages/app/e2e/browser/plugin-file-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panels.spec.ts @@ -91,9 +91,13 @@ async function associateMarkdownWithPlugin(page: Page) { async function editMarkdownThroughPlugin(page: Page) { await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click(); } -async function selectSource(page: Page) { +/** Opens the tab with Paseo's Markdown panel from "Open with…" and shows its Source view. */ +async function openBuiltinMarkdownSource(page: Page, name: string) { + await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open with…" }).hover(); + await page.getByRole("menuitem", { name: "Markdown — Paseo" }).click(); await page - .getByTestId("file-panel-switcher") + .getByTestId("file-preview-mode") .filter({ visible: true }) .getByRole("button", { name: "Source", exact: true }) .click(); @@ -210,7 +214,7 @@ test("directory plugins render PDF bytes and write Markdown through the file han await page.getByRole("menuitem", { name: "Close", exact: true }).click(); await expect.poll(() => closePrompt).toContain("discard"); - await selectSource(page); + await openBuiltinMarkdownSource(page, "notes.md"); await expect( page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"), ).toHaveText("# Changed outside"); diff --git a/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts b/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts index 9cf67326be..29ae71d84d 100644 --- a/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts +++ b/packages/app/e2e/support/helpers/markdown-file-panel-plugin.ts @@ -11,7 +11,6 @@ export const MARKDOWN_PANEL_PLUGIN_ID = "file-panel-picker-e2e"; export const MARKDOWN_PANEL_ID = `plugin:${MARKDOWN_PANEL_PLUGIN_ID}/rich-markdown`; export const BUILTIN_MARKDOWN_OPTION = "Markdown — Paseo"; export const PLUGIN_MARKDOWN_OPTION = `Rich Markdown — ${MARKDOWN_PANEL_PLUGIN_ID}`; -export const SOURCE_OPTION = "Source — Paseo"; const SOURCE = `import React from "react"; import { Text, View } from "react-native"; diff --git a/packages/app/src/file-pane/bar.tsx b/packages/app/src/file-pane/bar.tsx deleted file mode 100644 index ab1fa41943..0000000000 --- a/packages/app/src/file-pane/bar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar"; -import { - FilePanelBarMenu, - FilePanelSwitcher, - useFilePanelChoiceSnapshot, - type FilePanelChoice, -} from "@/file-panels"; - -/** - * The file tab's bar: the switcher between the file's panels, "Open with…", and whether the panel - * has unsaved changes. Shown only when the file has more than one panel; the panel shows the rest. - */ -export function FilePanelBar({ choice, modified }: { choice: FilePanelChoice; modified: boolean }) { - const { t } = useTranslation(); - const { openWith } = useFilePanelChoiceSnapshot(choice); - if (!openWith) return null; - return ( - - - - - {modified ? ( - - ) : null} - - - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - chrome: { - flexShrink: 0, - }, - row: { - height: "100%", - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - }, - spacer: { flex: 1 }, - modifiedDot: { - width: 6, - height: 6, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.foregroundExtraMuted, - }, -})); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 68273491d7..606b27368e 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -15,7 +15,6 @@ import { useSessionStore } from "@/stores/session-store"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { formatFileSize } from "@/utils/format-file-size"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; -import { FilePanelBar } from "./bar"; /** * The file tab: stats the file, picks its panel and mounts it. The panel reads, saves and shows @@ -110,7 +109,6 @@ export function FilePane({ return ( - ; +} + +/** Markdown: the text file panel with the rendered document as its Preview. */ +function MarkdownPanel(props: BuiltinFilePanelProps) { + return ; +} + +/** HTML: the text file panel with the sandboxed page as its Preview. */ +function HtmlPanel(props: BuiltinFilePanelProps) { + return ; +} + +/** Shows Loading until the rendered Markdown has laid out once; later edits keep the content. */ +function MarkdownPreview({ text, target }: TextFilePreviewProps) { const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null); const [rendered, setRendered] = useState(false); const handleLayout = useCallback(() => setRendered(true), []); - if (text.status === "loading") return ; - if (text.status === "error") return ; return ( ; - if (text.status === "error") return ; +/** The page owns its scrolling, so there is no ScrollView around it. */ +function HtmlPreview({ text }: TextFilePreviewProps) { return ( - + ); } diff --git a/packages/app/src/file-panels/catalog.test.ts b/packages/app/src/file-panels/catalog.test.ts index 89a7e14cc7..31635e4a6d 100644 --- a/packages/app/src/file-panels/catalog.test.ts +++ b/packages/app/src/file-panels/catalog.test.ts @@ -25,8 +25,9 @@ function panelId(file: FilePanelFile, candidates: readonly FilePanelCandidate[] } describe("built-in file panels", () => { - it("opens each rendered extension with its panel and Source after it", () => { - expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown", "builtin:source"]); + it("opens each rendered extension with its panel alone, never with Source beside it", () => { + expect(candidateIds(textFile("README.md"))).toEqual(["builtin:markdown"]); + expect(candidateIds(textFile("plan.html"))).toEqual(["builtin:html"]); expect(panelId(textFile("notes.markdown"))).toBe("builtin:markdown"); expect(panelId(textFile("plan.html"))).toBe("builtin:html"); expect(panelId(textFile("docs/PLAN.HTML"))).toBe("builtin:html"); @@ -39,16 +40,29 @@ describe("built-in file panels", () => { } }); - it("puts plugin panels after the rendering built-ins and before Source", () => { + it("puts plugin panels after the built-in that claims the file, and before Source", () => { expect(candidateIds(textFile("README.md"), filePanelCatalog([richMarkdown]))).toEqual([ "builtin:markdown", richMarkdown.id, - "builtin:source", ]); const onlyPlugin = filePanelCatalog([{ ...richMarkdown, files: { extensions: ["txt"] } }]); + expect(candidateIds(textFile("notes.txt"), onlyPlugin)).toEqual([ + richMarkdown.id, + "builtin:source", + ]); expect(panelId(textFile("notes.txt"), onlyPlugin)).toBe(richMarkdown.id); }); + it("ignores a remembered Source for a file another built-in claims", () => { + const resolution = resolveFilePanel({ + file: textFile("README.md"), + candidates: builtins, + associations: { "ext:md": "builtin:source" }, + }); + expect(resolution.panel?.id).toBe("builtin:markdown"); + expect(resolution.associatedPanelId).toBeNull(); + }); + it("has no Source for files that are not text", () => { expect(candidateIds({ path: "archive.zip", kind: "binary" })).toEqual([]); }); diff --git a/packages/app/src/file-panels/catalog.ts b/packages/app/src/file-panels/catalog.ts index 6ff5cd8d5a..f77162645e 100644 --- a/packages/app/src/file-panels/catalog.ts +++ b/packages/app/src/file-panels/catalog.ts @@ -21,7 +21,10 @@ const renderingPanels = [ }, ] as const satisfies readonly FilePanelCandidate[]; -/** Paseo's editor. Every text file has it, so a text file always opens with some panel. */ +/** + * Paseo's editor, the floor for text files: every text file no other built-in claims has it, so a + * text file always opens with some panel. A built-in that claims a file owns its source view. + */ export const sourceFilePanel = { id: "builtin:source", builtin: true, @@ -34,7 +37,7 @@ export type SourceFilePanel = typeof sourceFilePanel; /** * Every panel a host offers, in resolver order: the built-ins that render a file, then plugin - * panels, then Source. Source is last so it is the default only when nothing else claims the file. + * panels, then Source. Source is last so a plugin panel is the default over it. */ export function filePanelCatalog( pluginPanels: readonly Plugin[], diff --git a/packages/app/src/file-panels/choice/hook.ts b/packages/app/src/file-panels/choice/hook.ts index 0e51a69c4b..2c2fafe388 100644 --- a/packages/app/src/file-panels/choice/hook.ts +++ b/packages/app/src/file-panels/choice/hook.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from import { usePaneContext } from "@/panels/pane-context"; import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes"; import { useFilePanelAssociations } from "../associations"; -import { sourceFilePanel } from "../catalog"; import { useFilePanels, type FilePanel } from "../panels"; import { resolveFilePanel, type FilePanelFileKind } from "../resolve"; import { chosenPanelFor, FilePanelChoiceModel, type FilePanelChoiceSnapshot } from "./model"; @@ -29,9 +28,7 @@ export function useFilePanelChoice(input: { const { serverId, workspaceId, tabId } = usePaneContext(); const candidates = useFilePanels(input.serverId); const { associations, remember } = useFilePanelAssociations(); - const [choice] = useState( - () => new FilePanelChoiceModel({ remember, linePanelId: sourceFilePanel.id }), - ); + const [choice] = useState(() => new FilePanelChoiceModel({ remember })); // From the subscribed snapshot: React Compiler caches reads off the stable model object. const chosenPanelId = chosenPanelFor(useFilePanelChoiceSnapshot(choice), targetKey); const resolution = useMemo( diff --git a/packages/app/src/file-panels/choice/model.test.ts b/packages/app/src/file-panels/choice/model.test.ts index e9e5e83042..73e98e91a1 100644 --- a/packages/app/src/file-panels/choice/model.test.ts +++ b/packages/app/src/file-panels/choice/model.test.ts @@ -8,6 +8,11 @@ const rich: FilePanelCandidate = { builtin: false, files: { extensions: ["md", "markdown"] }, }; +const richTypeScript: FilePanelCandidate = { + id: "plugin:ts/outline", + builtin: false, + files: { extensions: ["ts"] }, +}; const markdown = "builtin:markdown"; const source = sourceFilePanel.id; @@ -22,7 +27,6 @@ function openTab( if (panelId === null) delete associations[selectorKey]; else associations[selectorKey] = panelId; }, - linePanelId: source, }); let targetKey = `/repo:${options.path ?? "notes.md"}`; let presentable = options.presentable ?? true; @@ -70,11 +74,7 @@ describe("FilePanelChoiceModel", () => { selectedPanelId: markdown, remember: false, }); - expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([ - markdown, - rich.id, - source, - ]); + expect(tab.picker()?.candidates.map((candidate) => candidate.id)).toEqual([markdown, rich.id]); tab.model.dismissPicker(); expect(tab.picker()).toBeNull(); expect(tab.panel()).toBe(markdown); @@ -84,17 +84,13 @@ describe("FilePanelChoiceModel", () => { expect(openTab(associations).picker()).not.toBeNull(); }); - it("never asks between built-in panels, and switches between them", () => { + it("opens a file a built-in claims with that built-in alone and nothing to choose", () => { const tab = openTab({}, [...filePanelCatalog([])]); expect(tab.panel()).toBe(markdown); expect(tab.picker()).toBeNull(); - expect(tab.model.getSnapshot().openWith?.candidates.map((candidate) => candidate.id)).toEqual([ - markdown, - source, - ]); + expect(tab.model.getSnapshot().openWith).toBeNull(); tab.model.openWith(source); - expect(tab.panel()).toBe(source); - expect(tab.picker()).toBeNull(); + expect(tab.panel()).toBe(markdown); }); it("opens a file only Source claims with Source and nothing to choose", () => { @@ -133,7 +129,7 @@ describe("FilePanelChoiceModel", () => { it("does not ask with an association or while the tab is in the background", () => { expect(openTab({ "ext:md": rich.id }).picker()).toBeNull(); - expect(openTab({ "ext:md": source }).panel()).toBe(source); + expect(openTab({ "ext:md": source }).panel()).toBe(markdown); const background = openTab({}, undefined, { presentable: false }); expect(background.picker()).toBeNull(); @@ -141,21 +137,29 @@ describe("FilePanelChoiceModel", () => { expect(background.picker()).not.toBeNull(); }); - it("opens Source for a line target without asking, and again on each new one", () => { - const tab = openTab({}, undefined, { lineNavigation: 1 }); - expect(tab.panel()).toBe(source); + it("opens the built-in panel for a line target without asking, and again on each new one", () => { + const tab = openTab({ "ext:md": rich.id }, undefined, { lineNavigation: 1 }); + expect(tab.panel()).toBe(markdown); expect(tab.picker()).toBeNull(); tab.model.openWith(rich.id); expect(tab.panel()).toBe(rich.id); expect(tab.navigateToLine(1)).toBe(rich.id); - expect(tab.navigateToLine(2)).toBe(source); + expect(tab.navigateToLine(2)).toBe(markdown); }); - it("opens Source for the same line target in a retargeted tab", () => { + it("opens Source for a line target in a file only Source and a plugin claim", () => { + const tab = openTab({ "ext:ts": richTypeScript.id }, [...filePanelCatalog([richTypeScript])], { + path: "index.ts", + }); + expect(tab.panel()).toBe(richTypeScript.id); + expect(tab.navigateToLine(1)).toBe(source); + }); + + it("opens the built-in panel for the same line target in a retargeted tab", () => { const tab = openTab({}, undefined, { lineNavigation: 1 }); - tab.model.openWith(markdown); - expect(tab.retarget("other.md")).toBe(source); + tab.model.openWith(rich.id); + expect(tab.retarget("other.md")).toBe(markdown); }); it("starts a new open when the tab is retargeted", () => { diff --git a/packages/app/src/file-panels/choice/model.ts b/packages/app/src/file-panels/choice/model.ts index 52cc4072ce..c28daeca6e 100644 --- a/packages/app/src/file-panels/choice/model.ts +++ b/packages/app/src/file-panels/choice/model.ts @@ -30,15 +30,13 @@ export interface FilePanelChoiceSnapshot { /** The panel picked for this tab open. Resolution prefers it over the association. */ chosenPanelId: string | null; picker: FilePanelPickerState | null; - /** Present only when the file has more than one candidate; drives the bar's panel switcher. */ + /** Present only when the file has more than one candidate; drives "Open with…". */ openWith: FilePanelOpenWithState | null; } export interface FilePanelChoiceDeps { /** Writes (or clears, with null) the device-wide association for a selector key. */ remember(selectorKey: string, panelId: string | null): void; - /** The panel a line target opens: Source, the panel that shows lines. */ - linePanelId: string; } /** The tab choice that applies to `targetKey`; a choice never outlives its open. */ @@ -61,7 +59,8 @@ const EMPTY: FilePanelChoiceSnapshot = { * * A tab open is one target in one tab: the picker is offered at most once per open, the first * time the tab is on screen with a plugin panel among several candidates and nothing remembered. - * Built-in panels alone never ask. A line target opens the line panel the way "Open with…" does. + * Built-in panels alone never ask. A line target opens the file's built-in panel, which shows + * lines, the way "Open with…" does. * Retargeting the tab starts a new open. Choices made here apply to this tab; only "Don't ask me * again" and "Always use" write the association every later open reads. */ @@ -70,6 +69,8 @@ export class FilePanelChoiceModel { private snapshot: FilePanelChoiceSnapshot = EMPTY; private asked = false; private openedLineNavigation: number | null = null; + /** A line target arrived before the file resolved; it opens the built-in once it does. */ + private lineTargetPending = false; constructor(private readonly deps: FilePanelChoiceDeps) {} @@ -85,11 +86,17 @@ export class FilePanelChoiceModel { if (source.targetKey !== current.targetKey) { this.asked = false; this.openedLineNavigation = null; + this.lineTargetPending = false; current = { ...EMPTY, targetKey: source.targetKey }; } if (source.lineNavigation !== null && source.lineNavigation !== this.openedLineNavigation) { this.openedLineNavigation = source.lineNavigation; - current = { ...current, chosenPanelId: this.deps.linePanelId, picker: null }; + this.lineTargetPending = true; + } + if (this.lineTargetPending && source.resolution) { + this.lineTargetPending = false; + const builtin = source.resolution.candidates.find((candidate) => candidate.builtin); + if (builtin) current = { ...current, chosenPanelId: builtin.id, picker: null }; } const openWith = stableOpenWith(current.openWith, openWithState(source.resolution)); let picker = openWith ? current.picker : null; diff --git a/packages/app/src/file-panels/choice/open-with.tsx b/packages/app/src/file-panels/choice/open-with.tsx index b793b4f445..33a10aa254 100644 --- a/packages/app/src/file-panels/choice/open-with.tsx +++ b/packages/app/src/file-panels/choice/open-with.tsx @@ -1,17 +1,12 @@ import { useCallback, useMemo, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { withUnistyles } from "react-native-unistyles"; -import { Ellipsis } from "lucide-react-native"; import { MenuItem, MenuSeparator, MenuSubTrigger, type MenuPageDefinition, } from "@/components/ui/menu"; -import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu"; -import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; -import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; -import { useIsCompactFormFactor } from "@/constants/layout"; import type { PanelInstanceIdentity } from "@/panels/panel-instance-attributes"; import { PluginIconView } from "@/plugins/icons"; import type { Theme } from "@/styles/theme"; @@ -22,7 +17,6 @@ import { filePanelAlwaysUseLabel, filePanelIcon, filePanelOptionLabel } from "./ const OPEN_WITH_PAGE_ID = "filePanelOpenWith"; const MENU_ICON_SIZE = 16; -const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedPanelIcon = withUnistyles(PluginIconView); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -43,41 +37,6 @@ export function useFilePanelOpenWithMenu( return useOpenWithMenu(useTabFilePanelChoice(identity), testIDBase); } -/** - * The file bar's overflow: remembering the panel the bar's switcher shows. Renders nothing unless - * the file has more than one panel. - */ -export function FilePanelBarMenu({ choice }: { choice: FilePanelChoice }) { - const { t } = useTranslation(); - const compact = useIsCompactFormFactor(); - const { openWith } = useFilePanelChoiceSnapshot(choice); - if (!openWith) return null; - const label = t("panels.file.filePanels.actions"); - return ( - - - - - - - - - ); -} - function useOpenWithMenu( choice: FilePanelChoice | null, testIDBase: string, diff --git a/packages/app/src/file-panels/choice/presentation.ts b/packages/app/src/file-panels/choice/presentation.ts index ffe319b4fe..34ecc7265b 100644 --- a/packages/app/src/file-panels/choice/presentation.ts +++ b/packages/app/src/file-panels/choice/presentation.ts @@ -14,7 +14,7 @@ export function filePanelIcon(panel: FilePanel): PluginIcon { return panel.builtin ? BUILTIN_PRESENTATION[panel.id].icon : panel.contribution.icon; } -/** "Markdown", "Rich Markdown": the panel switcher's label. */ +/** "Markdown", "Rich Markdown": the panel's title. */ export function filePanelTitle(t: TFunction, panel: FilePanel): string { return panel.builtin ? t(`panels.file.filePanels.builtin.${BUILTIN_PRESENTATION[panel.id].title}`) diff --git a/packages/app/src/file-panels/choice/switcher-layout.test.ts b/packages/app/src/file-panels/choice/switcher-layout.test.ts deleted file mode 100644 index 196f03bf17..0000000000 --- a/packages/app/src/file-panels/choice/switcher-layout.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { FilePanelOpenWithState } from "./model"; -import { filePanelSwitcherState } from "./switcher-layout"; - -function openWith(count: number): FilePanelOpenWithState<{ id: string }> { - const candidates = Array.from({ length: count }, (_, index) => ({ id: `panel-${index}` })); - return { selectorKey: "ext:md", candidates, activePanelId: "panel-1", remembered: false }; -} - -describe("filePanelSwitcherState", () => { - it("shows nothing when the file has one panel or none", () => { - expect(filePanelSwitcherState(null, { compact: false })).toBeNull(); - }); - - it("sets up to three panels side by side and more behind a menu", () => { - const wide = { compact: false }; - expect(filePanelSwitcherState(openWith(2), wide)?.layout).toBe("segmented"); - expect(filePanelSwitcherState(openWith(3), wide)?.layout).toBe("segmented"); - expect(filePanelSwitcherState(openWith(4), wide)).toMatchObject({ - layout: "menu", - activePanelId: "panel-1", - candidates: openWith(4).candidates, - }); - }); - - it("sets two panels side by side on a compact screen and more behind a menu", () => { - const compact = { compact: true }; - expect(filePanelSwitcherState(openWith(2), compact)?.layout).toBe("segmented"); - expect(filePanelSwitcherState(openWith(3), compact)?.layout).toBe("menu"); - }); -}); diff --git a/packages/app/src/file-panels/choice/switcher-layout.ts b/packages/app/src/file-panels/choice/switcher-layout.ts deleted file mode 100644 index 16afae0fe9..0000000000 --- a/packages/app/src/file-panels/choice/switcher-layout.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { FilePanelOpenWithState } from "./model"; - -/** Up to this many panels sit side by side in the bar; more go behind a menu. */ -const SEGMENTED_PANEL_LIMIT = { wide: 3, compact: 2 }; - -export interface FilePanelSwitcherState { - layout: "segmented" | "menu"; - candidates: readonly Candidate[]; - activePanelId: string; -} - -/** The bar's panel switcher for a tab's choice; null when the file has one panel or none. */ -export function filePanelSwitcherState( - openWith: FilePanelOpenWithState | null, - { compact }: { compact: boolean }, -): FilePanelSwitcherState | null { - if (!openWith) return null; - const limit = compact ? SEGMENTED_PANEL_LIMIT.compact : SEGMENTED_PANEL_LIMIT.wide; - return { - layout: openWith.candidates.length > limit ? "menu" : "segmented", - candidates: openWith.candidates, - activePanelId: openWith.activePanelId, - }; -} diff --git a/packages/app/src/file-panels/choice/switcher.tsx b/packages/app/src/file-panels/choice/switcher.tsx deleted file mode 100644 index 75aef62727..0000000000 --- a/packages/app/src/file-panels/choice/switcher.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { Text } from "react-native"; -import { useTranslation } from "react-i18next"; -import { withUnistyles } from "react-native-unistyles"; -import { ChevronDown } from "lucide-react-native"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; -import { MenuItem } from "@/components/ui/menu"; -import { SegmentedControl } from "@/components/ui/segmented-control"; -import { - ToolbarLabelTriggerIcon, - isToolbarLabelTriggerHighlighted, - toolbarLabelTriggerStyle, - toolbarLabelTriggerTextStyle, -} from "@/components/ui/toolbar-label-trigger"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { PluginIconView } from "@/plugins/icons"; -import type { Theme } from "@/styles/theme"; -import type { FilePanel } from "../panels"; -import { useFilePanelChoiceSnapshot, type FilePanelChoice } from "./hook"; -import { filePanelIcon, filePanelTitle } from "./presentation"; -import { filePanelSwitcherState, type FilePanelSwitcherState } from "./switcher-layout"; - -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedPanelIcon = withUnistyles(PluginIconView); -const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -/** The file bar's panel switcher. Renders nothing unless the file has more than one panel. */ -export function FilePanelSwitcher({ choice }: { choice: FilePanelChoice }) { - const { openWith } = useFilePanelChoiceSnapshot(choice); - const compact = useIsCompactFormFactor(); - const switcher = filePanelSwitcherState(openWith, { compact }); - const select = useCallback((panelId: string) => choice.openWith(panelId), [choice]); - if (!switcher) return null; - return switcher.layout === "segmented" ? ( - - ) : ( - - ); -} - -interface SwitcherProps { - switcher: FilePanelSwitcherState; - onSelect(panelId: string): void; -} - -function SegmentedSwitcher({ switcher, onSelect }: SwitcherProps) { - const { t } = useTranslation(); - const options = useMemo( - () => - switcher.candidates.map((panel) => ({ - value: panel.id, - label: filePanelTitle(t, panel), - testID: `file-panel-switcher-${panel.id}`, - })), - [switcher.candidates, t], - ); - return ( - - ); -} - -function MenuSwitcher({ switcher, onSelect }: SwitcherProps) { - const { t } = useTranslation(); - const active = switcher.candidates.find((panel) => panel.id === switcher.activePanelId); - const activeTitle = active ? filePanelTitle(t, active) : ""; - return ( - - - {(state) => ( - <> - - {activeTitle} - - - - - - )} - - - {switcher.candidates.map((panel) => ( - - ))} - - - ); -} - -function SwitcherMenuItem({ - panel, - selected, - onSelect, -}: { - panel: FilePanel; - selected: boolean; - onSelect(panelId: string): void; -}) { - const { t } = useTranslation(); - const handleSelect = useCallback(() => onSelect(panel.id), [onSelect, panel.id]); - const leading = useMemo( - () => , - [panel], - ); - return ( - - {filePanelTitle(t, panel)} - - ); -} diff --git a/packages/app/src/file-panels/index.ts b/packages/app/src/file-panels/index.ts index 5219632fcb..2e2b156ebb 100644 --- a/packages/app/src/file-panels/index.ts +++ b/packages/app/src/file-panels/index.ts @@ -6,12 +6,7 @@ export { type FilePanelChoice, } from "./choice/hook"; export { FilePanelPicker } from "./choice/picker"; -export { FilePanelSwitcher } from "./choice/switcher"; -export { - FilePanelBarMenu, - useFilePanelOpenWithMenu, - type FilePanelOpenWithMenu, -} from "./choice/open-with"; +export { useFilePanelOpenWithMenu, type FilePanelOpenWithMenu } from "./choice/open-with"; export { DocumentFrame } from "./document-frame"; export { filePanelIcon, filePanelOptionLabel, filePanelSelectorLabel } from "./choice/presentation"; export { useFilePanelSettings, type FilePanelSettings } from "./settings/hook"; diff --git a/packages/app/src/file-panels/resolve.test.ts b/packages/app/src/file-panels/resolve.test.ts index b0712ff7c1..3c7372b441 100644 --- a/packages/app/src/file-panels/resolve.test.ts +++ b/packages/app/src/file-panels/resolve.test.ts @@ -70,6 +70,17 @@ describe("resolveFilePanel", () => { }); expect(resolve({ file: { ...file, kind: "binary" }, candidates: [every] }).panel).toBeNull(); }); + it("offers a built-in panel claiming every file only when no other built-in claims the file", () => { + const every: FilePanelCandidate = { ...markdown, id: "builtin:source", files: "all" }; + expect(resolve({ candidates: [markdown, alternative, every] }).candidates).toEqual([ + markdown, + alternative, + ]); + expect(resolve({ candidates: [alternative, every] }).candidates).toEqual([alternative, every]); + expect( + resolve({ file: { ...file, kind: "binary" }, candidates: [markdown, every] }).candidates, + ).toEqual([]); + }); it("passes over a built-in panel for another kind of file, even with an association", () => { expect( resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel, diff --git a/packages/app/src/file-panels/resolve.ts b/packages/app/src/file-panels/resolve.ts index 6b0f2ffbd1..1c9fefb78a 100644 --- a/packages/app/src/file-panels/resolve.ts +++ b/packages/app/src/file-panels/resolve.ts @@ -99,6 +99,14 @@ export function claimsFilePanelSelectorKey( : matchesName("", value, selector); } +/** + * A built-in panel claiming every file of its kind (Source) is the floor: a candidate only for + * files no other built-in panel claims, never an alternative beside one. + */ +function isBuiltinFloor(candidate: FilePanelCandidate): boolean { + return candidate.builtin && candidate.files === "all"; +} + /** Paseo asks which panel opens a file only when a plugin panel is one of several candidates. */ export function asksForFilePanel(candidates: readonly FilePanelCandidate[]): boolean { return candidates.length > 1 && candidates.some((candidate) => !candidate.builtin); @@ -112,11 +120,17 @@ export function resolveFilePanel(input: { choice?: string | null; }): FilePanelResolution { const { file, associations } = input; - const candidates = input.candidates.filter( + const matching = input.candidates.filter( (candidate) => (candidate.fileKind === undefined || candidate.fileKind === file.kind) && matchesFiles(file, candidate.files), ); + const claimedByBuiltin = matching.some( + (candidate) => candidate.builtin && candidate.files !== "all", + ); + const candidates = claimedByBuiltin + ? matching.filter((candidate) => !isBuiltinFloor(candidate)) + : matching; const filename = filePanelBasename(file.path); const matchedFilename = candidates.some((candidate) => claimsFilename(candidate.files, filename)); const selectorKey = matchedFilename diff --git a/packages/app/src/file-panels/settings/rows.test.ts b/packages/app/src/file-panels/settings/rows.test.ts index 170ffba04e..4cfc89ec75 100644 --- a/packages/app/src/file-panels/settings/rows.test.ts +++ b/packages/app/src/file-panels/settings/rows.test.ts @@ -39,12 +39,12 @@ describe("buildFilePanelSettingsRows", () => { expect(rows(catalog())).toEqual([]); }); - it("lists a key a plugin panel claims, built-in first and Source last", () => { + it("lists a key a plugin panel claims with the built-in first and no Source", () => { const plugin = richMarkdown("host-a"); expect(rows(catalog(plugin))).toEqual([ { selectorKey: "ext:md", - candidates: [builtinMarkdown, plugin, source], + candidates: [builtinMarkdown, plugin], associatedPanelId: null, }, ]); @@ -77,19 +77,25 @@ describe("buildFilePanelSettingsRows", () => { it("offers a panel installed on several hosts once, as the first host has it", () => { const first = richMarkdown("host-a"); const [row] = rows(catalog(first, richMarkdown("host-b"))); - expect(row?.candidates).toEqual([builtinMarkdown, first, source]); + expect(row?.candidates).toEqual([builtinMarkdown, first]); }); it("keeps a remembered key that only built-in panels claim", () => { expect(rows(catalog(), { "ext:markdown": source.id })).toEqual([ { selectorKey: "ext:markdown", - candidates: [builtinMarkdown, source], + candidates: [builtinMarkdown], associatedPanelId: source.id, }, ]); }); + it("offers only Source on a text key no other panel claims", () => { + expect(rows(catalog(), { "ext:ts": source.id })).toEqual([ + { selectorKey: "ext:ts", candidates: [source], associatedPanelId: source.id }, + ]); + }); + it("keeps a remembered key whose panel is on no connected host", () => { expect(rows(catalog(), { "ext:pdf": "plugin:pdf-panel/pdf" })).toEqual([ { selectorKey: "ext:pdf", candidates: [source], associatedPanelId: "plugin:pdf-panel/pdf" }, @@ -104,7 +110,7 @@ describe("buildFilePanelSettingsRows", () => { }; const keys = rows(catalog(named)).map((row) => row.selectorKey); expect(keys).toEqual(["name:README.md"]); - expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named, source]); + expect(rows(catalog(named))[0]?.candidates).toEqual([builtinMarkdown, named]); }); it("orders extensions before filenames, each alphabetically", () => { diff --git a/packages/app/src/file-panels/settings/rows.ts b/packages/app/src/file-panels/settings/rows.ts index b2f8bc1971..339e2b6562 100644 --- a/packages/app/src/file-panels/settings/rows.ts +++ b/packages/app/src/file-panels/settings/rows.ts @@ -40,10 +40,9 @@ export function buildFilePanelSettingsRows } /** - * The panels offered under a key, by the resolver's matching rule. A panel claiming every file of - * a kind (Source) is offered unless a built-in panel claiming the key reads another kind: without a - * file, that is the only knowledge of the key's kind. A remembered panel that does not fit the file - * when it opens is ignored there. + * The panels offered under a key, by the resolver's matching rule. The built-in floor (Source) is + * offered only when no built-in panel claims the key. A remembered panel that does not fit the + * file when it opens is ignored there. */ function offeredCandidates( candidates: readonly Candidate[], @@ -55,7 +54,7 @@ function offeredCandidates( ); return candidates.filter((candidate) => candidate.files === "all" - ? !claiming.some((claimant) => claimant.builtin && claimant.fileKind !== candidate.fileKind) + ? !claiming.some((claimant) => claimant.builtin) : claiming.includes(candidate), ); } diff --git a/packages/app/src/file-panels/source/panel.tsx b/packages/app/src/file-panels/source/panel.tsx index eb6179e08e..7339cc50e7 100644 --- a/packages/app/src/file-panels/source/panel.tsx +++ b/packages/app/src/file-panels/source/panel.tsx @@ -1,5 +1,13 @@ -import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; -import { useFileText } from "@getpaseo/plugin/client"; +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ComponentProps, + type ComponentType, +} from "react"; +import { useFileText, type PluginFileLocation } from "@getpaseo/plugin/client"; import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -11,12 +19,12 @@ import { formatFileSize } from "@/utils/format-file-size"; import type { Theme } from "@/styles/theme"; import { FilePanelLoading } from "../loading"; import type { BuiltinFilePanelProps } from "../panels"; -import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert"; +import type { FileConflictAlertState } from "./conflict-alert"; import { FileEditorModel, getFileConflictCallout } from "./editor/model"; import { FileEditorView } from "./editor/view"; import { selectSourcePresentation } from "./readonly/presentation"; import { FileSourceView } from "./readonly/view"; -import { SourceStatusRow } from "./status"; +import { TextFileToolbar, type TextFileView } from "./toolbar"; // CodeMirror and native source rendering consume appearance as props. Subscribe at the // rendering leaf so theme updates reconfigure it without replacing the editor/model. @@ -40,19 +48,49 @@ const ThemedFileSourceView = withUnistyles(FileSourceView, sourceAppearance); /** The daemon refuses to write larger files. */ const MAX_EDITABLE_BYTES = 1024 * 1024; -/** - * Paseo's editor for text files. Edits files up to 1 MiB on web; shows larger files and every file - * on native read-only, and refuses files above the display budget before reading them. - */ -export function SourcePanel(props: BuiltinFilePanelProps) { - const size = props.file.size; - const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" }); - if (presentation === "unsupported") return ; - if (isWeb && size <= MAX_EDITABLE_BYTES) return ; - return ; +/** A rendered view of the file's text: the panel's Preview, shown instead of its source. */ +export interface TextFilePreviewProps { + text: string; + target: BuiltinFilePanelProps["target"]; } -function TooLargeSource({ size }: { size: number }) { +type TextFilePanelProps = BuiltinFilePanelProps & { + /** The panel's Preview; with one, the toolbar switches between Preview and Source. */ + preview?: ComponentType; +}; + +/** + * What the Markdown, HTML and Source panels share: a toolbar over the file's Source view and, when + * the panel passes one, its Preview. Both views show the one read of the file and, while editing, the one editor buffer, so switching + * between them neither re-reads nor loses edits. Edits files up to 1 MiB on web; shows larger files + * and every file on native read-only, and refuses files above the display budget before reading. + */ +export function TextFilePanel(props: TextFilePanelProps) { + const size = props.file.size; + const presentation = selectSourcePresentation({ size, platform: isWeb ? "web" : "native" }); + if (presentation === "unsupported") return ; + if (isWeb && size <= MAX_EDITABLE_BYTES) return ; + return ; +} + +/** + * The view a panel with a preview shows. It opens on Preview, and on Source for a line target; + * each later line target switches to Source again. + */ +function useTextFileView( + location: PluginFileLocation, +): [TextFileView, (view: TextFileView) => void] { + const lineTarget = location.lineStart ? location.revision : null; + const [view, setView] = useState(lineTarget === null ? "preview" : "source"); + const [shownLineTarget, setShownLineTarget] = useState(lineTarget); + if (lineTarget !== null && lineTarget !== shownLineTarget) { + setShownLineTarget(lineTarget); + setView("source"); + } + return [view, setView]; +} + +function TooLargeTextFile({ size }: { size: number }) { const { t } = useTranslation(); return ( @@ -62,14 +100,16 @@ function TooLargeSource({ size }: { size: number }) { ); } -function ReadonlySource({ +function ReadonlyTextFile({ file, location, target, + preview: Preview, presentation, -}: BuiltinFilePanelProps & { presentation: "highlighted" | "plain" }) { +}: TextFilePanelProps & { presentation: "highlighted" | "plain" }) { const text = useFileText(file); const { t } = useTranslation(); + const [view, setView] = useTextFileView(location); const sourceLocation = useMemo( () => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }), [location.lineEnd, location.lineStart, target.path], @@ -82,25 +122,35 @@ function ReadonlySource({ ); } + const showPreview = Preview !== undefined && view === "preview"; return ( - - + {showPreview ? ( + + ) : ( + + )} ); } -function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) { +function EditableTextFile({ file, tab, location, target, preview: Preview }: TextFilePanelProps) { const { settings } = useAppSettings(); const { t } = useTranslation(); const [model] = useState(() => new FileEditorModel({ file })); - const [editorReady, setEditorReady] = useState(false); + const [view, setView] = useTextFileView(location); const [cursor, setCursor] = useState({ line: 1, column: 1 }); const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); @@ -130,7 +180,6 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) }, [model, snapshot.modified, t]); const handleOverwrite = useCallback(() => void model.overwrite(), [model]); const handleRetry = useCallback(() => void model.reload(), [model]); - const handleReady = useCallback(() => setEditorReady(true), []); const editorLocation = useMemo( () => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }), [location.lineEnd, location.lineStart, target.path], @@ -159,11 +208,23 @@ function EditableSource({ file, tab, location, target }: BuiltinFilePanelProps) onOverwrite: handleOverwrite, }; } + const showPreview = Preview !== undefined && view === "preview"; return ( - {conflict ? : null} - - + {showPreview ? ( + + ) : ( + - {editorReady ? null : } - - + )} + + ); +} + +/** The editor over the panel's buffer. Each mount shows Loading until the editor shows the text. */ +function SourceEditor(props: Omit, "onReady">) { + const [ready, setReady] = useState(false); + const handleReady = useCallback(() => setReady(true), []); + return ( + + + {ready ? null : } ); } diff --git a/packages/app/src/file-panels/source/status.tsx b/packages/app/src/file-panels/source/status.tsx deleted file mode 100644 index a3582f3c5d..0000000000 --- a/packages/app/src/file-panels/source/status.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { Text, View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import type { Theme } from "@/styles/theme"; -import { formatFileSize } from "@/utils/format-file-size"; -import type { FileEditorStatus } from "./editor/model"; - -const ThemedSpinner = withUnistyles(LoadingSpinner); -const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -/** Source's status row: the file's size and lines, the save state, the vim mode and the cursor. */ -export function SourceStatusRow({ - size, - lineCount, - editorStatus, - vimMode, - cursor, -}: { - size: number; - lineCount: number; - editorStatus?: FileEditorStatus; - vimMode?: string | null; - cursor?: { line: number; column: number }; -}) { - const { t } = useTranslation(); - return ( - - - - {formatFileSize(size)} - - - {t("panels.file.editor.lines", { count: lineCount })} - - - - {editorStatus === "dirty" ? ( - - ) : null} - {editorStatus === "saving" ? ( - <> - - {t("panels.file.editor.saving")} - - ) : null} - {editorStatus === "error" ? ( - {t("panels.file.editor.saveFailed")} - ) : null} - {vimMode ? ( - - {vimMode} - - ) : null} - {cursor ? ( - - Ln {cursor.line}, Col {cursor.column} - - ) : null} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - row: { - flexShrink: 0, - height: 28, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - metadata: { - flex: 1, - minWidth: 0, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - status: { - flexShrink: 0, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, - whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm }, - error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm }, - dirtyDot: { - width: 6, - height: 6, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.foregroundExtraMuted, - }, - vim: { - color: theme.colors.foregroundMuted, - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.sm, - }, -})); diff --git a/packages/app/src/file-panels/source/toolbar.tsx b/packages/app/src/file-panels/source/toolbar.tsx new file mode 100644 index 0000000000..43cb6f7291 --- /dev/null +++ b/packages/app/src/file-panels/source/toolbar.tsx @@ -0,0 +1,163 @@ +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import type { Theme } from "@/styles/theme"; +import { formatFileSize } from "@/utils/format-file-size"; +import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert"; +import type { FileEditorStatus } from "./editor/model"; + +const ThemedSpinner = withUnistyles(LoadingSpinner); +const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +export type TextFileView = "preview" | "source"; + +/** + * The text file panel's toolbar: the file's size and lines, the save state, the vim mode and the + * cursor, the Preview|Source control when the panel has a preview, and a conflict under it. + */ +export function TextFileToolbar({ + size, + lineCount, + view, + onViewChange, + editorStatus, + cursor, + vimMode, + conflict, +}: { + size: number; + lineCount: number; + view?: TextFileView; + onViewChange?(view: TextFileView): void; + editorStatus?: FileEditorStatus; + cursor?: { line: number; column: number }; + vimMode?: string | null; + conflict?: FileConflictAlertState | null; +}) { + const { t } = useTranslation(); + const views = [ + { + value: "preview" as const, + label: t("panels.file.editor.preview"), + testID: "file-mode-preview", + }, + { value: "source" as const, label: t("panels.file.editor.source"), testID: "file-mode-source" }, + ]; + return ( + + + + + + {formatFileSize(size)} + + + {t("panels.file.editor.lines", { count: lineCount })} + + + + {editorStatus === "dirty" ? ( + + ) : null} + {editorStatus === "saving" ? ( + <> + + {t("panels.file.editor.saving")} + + ) : null} + {editorStatus === "error" ? ( + {t("panels.file.editor.saveFailed")} + ) : null} + {vimMode ? ( + + {vimMode} + + ) : null} + {cursor ? ( + + Ln {cursor.line}, Col {cursor.column} + + ) : null} + + {view && onViewChange ? ( + + ) : null} + + + {conflict ? : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + chrome: { + flexShrink: 0, + }, + row: { + height: "100%", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + }, + metadata: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + status: { + flexShrink: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + secondary: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, + whisper: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.sm }, + error: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.sm }, + dirtyDot: { + width: 6, + height: 6, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.foregroundExtraMuted, + }, + vim: { + color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 388f9be16f..643bd44691 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1928,7 +1928,6 @@ export const ar: TranslationResources = { alwaysUseExtension: "استخدام دائمًا لـ .{{extension}}", alwaysUseFilename: "استخدام دائمًا لـ {{filename}}", option: "{{title}} — {{owner}}", - actions: "إجراءات الملف", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1947,6 +1946,8 @@ export const ar: TranslationResources = { notFound: "الملف غير موجود", failedToLoadPreview: "فشل تحميل معاينة الملف", editor: { + preview: "معاينة", + source: "المصدر", fileSize: "حجم الملف {{size}}", lines: "{{count}} سطر", editorStatus: "حالة المحرر {{status}}", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index d64c19871d..6224b848f1 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1953,7 +1953,6 @@ export const en = { alwaysUseExtension: "Always use for .{{extension}}", alwaysUseFilename: "Always use for {{filename}}", option: "{{title}} — {{owner}}", - actions: "File actions", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1972,6 +1971,8 @@ export const en = { notFound: "File not found", failedToLoadPreview: "Failed to load file preview", editor: { + preview: "Preview", + source: "Source", fileSize: "File size {{size}}", lines: "{{count}} lines", editorStatus: "Editor status {{status}}", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 27c4ab8404..b2718cd5a4 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1976,7 +1976,6 @@ export const es: TranslationResources = { alwaysUseExtension: "Usar siempre para .{{extension}}", alwaysUseFilename: "Usar siempre para {{filename}}", option: "{{title}} — {{owner}}", - actions: "Acciones del archivo", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1995,6 +1994,8 @@ export const es: TranslationResources = { notFound: "No se encontró el archivo", failedToLoadPreview: "No se pudo cargar la vista previa del archivo", editor: { + preview: "Vista previa", + source: "Código fuente", fileSize: "Tamaño {{size}}", lines: "{{count}} líneas", editorStatus: "Estado del editor: {{status}}", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 29464ca9e2..c750712bdd 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1974,7 +1974,6 @@ export const fr: TranslationResources = { alwaysUseExtension: "Toujours utiliser pour .{{extension}}", alwaysUseFilename: "Toujours utiliser pour {{filename}}", option: "{{title}} — {{owner}}", - actions: "Actions du fichier", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1993,6 +1992,8 @@ export const fr: TranslationResources = { notFound: "Fichier introuvable", failedToLoadPreview: "Impossible de charger l’aperçu du fichier", editor: { + preview: "Aperçu", + source: "Source", fileSize: "Taille du fichier : {{size}}", lines: "{{count}} lignes", editorStatus: "État de l’éditeur : {{status}}", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e3dba618ee..89ae1c5151 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1947,7 +1947,6 @@ export const ja: TranslationResources = { alwaysUseExtension: ".{{extension}} に常に使用", alwaysUseFilename: "{{filename}} に常に使用", option: "{{title}} — {{owner}}", - actions: "ファイルの操作", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1966,6 +1965,8 @@ export const ja: TranslationResources = { notFound: "ファイルが見つかりません", failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました", editor: { + preview: "プレビュー", + source: "ソース", fileSize: "ファイルサイズ {{size}}", lines: "{{count}} 行", editorStatus: "エディターの状態 {{status}}", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 0c82504aaa..6e96723260 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1938,7 +1938,6 @@ export const ko: TranslationResources = { alwaysUseExtension: ".{{extension}}에 항상 사용", alwaysUseFilename: "{{filename}}에 항상 사용", option: "{{title}} — {{owner}}", - actions: "파일 작업", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1957,6 +1956,8 @@ export const ko: TranslationResources = { notFound: "파일을 찾을 수 없습니다", failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다", editor: { + preview: "미리보기", + source: "소스", fileSize: "파일 크기 {{size}}", lines: "{{count}} 라인", editorStatus: "편집기 상태 {{status}}", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 842dea8f5e..0e09fd91dc 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1961,7 +1961,6 @@ export const ptBR: TranslationResources = { alwaysUseExtension: "Sempre usar para .{{extension}}", alwaysUseFilename: "Sempre usar para {{filename}}", option: "{{title}} — {{owner}}", - actions: "Ações do arquivo", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1980,6 +1979,8 @@ export const ptBR: TranslationResources = { notFound: "Arquivo não encontrado", failedToLoadPreview: "Falha ao carregar prévia do arquivo", editor: { + preview: "Prévia", + source: "Fonte", fileSize: "Tamanho {{size}}", lines: "{{count}} linhas", editorStatus: "Status do editor: {{status}}", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7a5881c1b7..95f9718313 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1960,7 +1960,6 @@ export const ru: TranslationResources = { alwaysUseExtension: "Всегда использовать для .{{extension}}", alwaysUseFilename: "Всегда использовать для {{filename}}", option: "{{title}} — {{owner}}", - actions: "Действия с файлом", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1979,6 +1978,8 @@ export const ru: TranslationResources = { notFound: "Файл не найден", failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.", editor: { + preview: "Предпросмотр", + source: "Исходный текст", fileSize: "Размер файла {{size}}", lines: "Строк: {{count}}", editorStatus: "Состояние редактора: {{status}}", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6cd2be6e81..aa7f64b2ef 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1906,7 +1906,6 @@ export const zhCN: TranslationResources = { alwaysUseExtension: "始终用于 .{{extension}}", alwaysUseFilename: "始终用于 {{filename}}", option: "{{title}} — {{owner}}", - actions: "文件操作", builtin: { owner: "Paseo", markdown: "Markdown", @@ -1925,6 +1924,8 @@ export const zhCN: TranslationResources = { notFound: "找不到文件", failedToLoadPreview: "加载文件预览失败", editor: { + preview: "预览", + source: "源代码", fileSize: "文件大小 {{size}}", lines: "{{count}} 行", editorStatus: "编辑器状态 {{status}}", diff --git a/plugin-examples/markdown-panel/README.md b/plugin-examples/markdown-panel/README.md index ab5e2b9941..512f5f9be3 100644 --- a/plugin-examples/markdown-panel/README.md +++ b/plugin-examples/markdown-panel/README.md @@ -15,8 +15,9 @@ The panel is a CodeMirror editor with Markdown highlighting, line numbers, undo, wrapping. It reads the file once with `readText`, saves 500 ms after the last edit with `writeText`, and reports unsaved edits with `tab.setModified`. When the file changes on disk with nothing unsaved, it reads again; with unsaved edits, or when a write rejects with -`PluginFileConflictError`, it offers **Reload** and **Overwrite**. The bar's switcher still offers -Paseo's Markdown and Source panels. Native shows selectable, read-only text through `useFileText`. +`PluginFileConflictError`, it offers **Reload** and **Overwrite**. **Open with…** in the tab menu +switches the tab back to Paseo's Markdown panel. Native shows selectable, read-only text through +`useFileText`. Paseo bundles the editor dependencies into this plugin. They are loaded only on web; the native path uses React Native primitives. DOM integration lives in `client/web.ts`. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 3729fa4e0e..273c32bc17 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1885,8 +1885,8 @@ Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC Register a component that renders a file as the body of its file tab. Your component receives a handle to the file on the selected host, including remote hosts. It reads the file, shows its own -loading state, and writes the file when the user edits it. The tab's bar shows the panel -switcher, the overflow menu, and a dot while your panel reports unsaved changes. +loading state, and writes the file when the user edits it. The tab shows a dot while your panel +reports unsaved changes. ```tsx import type { PluginClientContext } from "@getpaseo/plugin/client"; @@ -1906,13 +1906,13 @@ export default function contribute(client: PluginClientContext) { `addFilePanel` takes a `PluginFilePanelContribution` and returns an idempotent cleanup function. Paseo also removes the registration when the client entry is unloaded. -| Field | Required | Meaning | -| ----------- | -------- | -------------------------------------------------------------------------- | -| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. | -| `title` | Yes | Non-empty label in the panel switcher, the picker, and the Open with menu. | -| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). | -| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. | -| `Component` | Yes | Component receiving `PluginFilePanelProps`. | +| Field | Required | Meaning | +| ----------- | -------- | ------------------------------------------------------------------------ | +| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. | +| `title` | Yes | Non-empty label in the picker, the Open with menu, and Settings > Files. | +| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). | +| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. | +| `Component` | Yes | Component receiving `PluginFilePanelProps`. | Extensions have no leading dot and match without case sensitivity. Filenames match the exact basename, including case. Selectors contain no paths, globs, or wildcards. Use `extensions: []` @@ -2002,8 +2002,8 @@ A panel that holds unsaved edits reads with `readText()` once, then handles `wat 5. On `PluginFileConflictError`, show the same conflict. 6. On `{ type: "deleted" }` or `PluginFileDeletedError`, tell the user the file is gone. -Write pending edits before the component unmounts: switching panels or closing the tab unmounts -it. Paseo asks before closing a tab whose panel reports unsaved changes. The +Write pending edits before the component unmounts: opening the tab with another panel or closing +the tab unmounts it. Paseo asks before closing a tab whose panel reports unsaved changes. The [Markdown panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel) example implements these steps. @@ -2011,28 +2011,30 @@ example implements these steps. A plugin panel is a candidate for every file its selector matches, text or binary. The built-in panels require the host's file kind: Markdown, HTML, and **Source** open text files; Image opens -images. Candidates are ordered: the built-in Markdown, HTML, and Image panels, then plugin panels -in host catalog order, then **Source**, which every text file has. Source opens a file by default -only when nothing else claims it. +images. The Markdown and HTML panels each have a Preview and a Source view of the file, switched +in their toolbar. **Source**, Paseo's editor, is the built-in panel for text files that no other +built-in panel claims; it is never offered beside another built-in panel. Candidates are ordered: +the built-in panel that claims the file, then plugin panels in host catalog order, then Source +when no other built-in claims the file. A remembered association selects its panel when that panel is available on the current host. Otherwise Paseo opens the first candidate. When a plugin panel is among several candidates and no -association applies, Paseo asks once per tab open, over the panel it already opened. Built-in -panels alone never ask: a Markdown file opens with Markdown and offers Source in the switcher. With -a Markdown plugin installed, the picker offers **Markdown — Paseo**, **Rich Markdown — -markdown-panel**, and **Source — Paseo**. A plugin that claims a text extension competes with -Source, so its first open asks; a plugin that alone claims a binary file opens without asking. +association applies, Paseo asks once per tab open, over the panel it already opened. A file with +one candidate never asks. With a Markdown plugin installed, the picker offers **Markdown — Paseo** +and **Rich Markdown — markdown-panel**. A plugin that claims a text extension no built-in panel +claims competes with Source, so its first open asks; a plugin that alone claims a binary file +opens without asking. **Don't ask me again** remembers the choice on this device for the extension. When a candidate matches an exact filename, the choice applies to that basename instead. An unavailable association is ignored on that host and retained for hosts where its panel is installed. -Users switch the current tab with the bar's panel switcher, which shows up to three panels side by -side (two on a phone) and a menu beyond that, or with **Open with…** in the tab menu. That choice -wins over the remembered association for this tab until it is retargeted. **Always use**, in the -bar's overflow menu and in Open with, remembers the shown panel. Opening a file at a line, from a -link or a search result, switches the tab to Source. Files with no candidate show **Binary preview -unavailable**; binary files have no Source panel. +Users choose a panel three ways: the picker on first open, **Open with…** in the tab menu, and +**Settings > Files**. Open with switches the current tab and wins over the remembered association +for this tab until it is retargeted; its **Always use** remembers the shown panel. Opening a file +at a line, from a link or a search result, switches the tab to its built-in panel, which shows the +line in its Source view. Files with no candidate show **Binary preview unavailable**; binary files +have no Source panel. ### DocumentFrame diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index fb5d1c9f70..8865eb4310 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1637,7 +1637,7 @@ Agent 快照字段: ## 文件面板 {#file-panels} -注册一个组件,把文件渲染为文件标签页的主体。组件会收到所选主机上该文件的句柄,远程主机也一样。由组件读取文件、显示自己的加载状态,并在用户编辑时写回文件。标签页栏会显示面板切换器、溢出菜单,以及在面板报告有未保存更改时显示一个圆点。 +注册一个组件,把文件渲染为文件标签页的主体。组件会收到所选主机上该文件的句柄,远程主机也一样。由组件读取文件、显示自己的加载状态,并在用户编辑时写回文件。面板报告有未保存更改时,标签页会显示一个圆点。 ```tsx import type { PluginClientContext } from "@getpaseo/plugin/client"; @@ -1659,7 +1659,7 @@ export default function contribute(client: PluginClientContext) { | 字段 | 必填 | 含义 | | ----------- | ---- | -------------------------------------------------------------------- | | `id` | 是 | 插件内唯一的 ID,匹配 `^[a-z][a-z0-9-]*$`。 | -| `title` | 是 | 显示在面板切换器、选择器和 **打开方式…** 菜单中的非空标签。 | +| `title` | 是 | 显示在选择器、**打开方式…** 菜单和 **设置 > 文件** 中的非空标签。 | | `icon` | 是 | Lucide 名称、`{ svg }` 或组件。参见[图标](#icons)。 | | `files` | 是 | `{ extensions: readonly string[]; filenames?: readonly string[] }`。 | | `Component` | 是 | 接收 `PluginFilePanelProps` 的组件。 | @@ -1732,17 +1732,17 @@ hook 的重新读取会采用磁盘上的版本,因此下一次写入会覆盖 5. 遇到 `PluginFileConflictError` 时,显示同样的冲突。 6. 收到 `{ type: "deleted" }` 或遇到 `PluginFileDeletedError` 时,告诉用户文件已不存在。 -请在组件卸载之前写入待保存的编辑:切换面板或关闭标签页都会卸载它。如果面板报告有未保存的更改,Paseo 会在关闭标签页之前询问。[Markdown 面板](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel)示例实现了这些步骤。 +请在组件卸载之前写入待保存的编辑:用其他面板打开标签页或关闭标签页都会卸载它。如果面板报告有未保存的更改,Paseo 会在关闭标签页之前询问。[Markdown 面板](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel)示例实现了这些步骤。 ### 选择与打开方式 {#selection-and-open-with} -插件面板是其选择器匹配的每个文件的候选面板,无论是文本还是二进制文件。内置面板要求主机报告的文件类型:Markdown、HTML 和 **源代码** 打开文本文件,**图片** 打开图片。候选面板的顺序是:内置的 Markdown、HTML 和图片面板,然后按主机目录顺序排列的插件面板,最后是每个文本文件都有的 **源代码**。只有没有其他面板认领文件时,源代码才会默认打开它。 +插件面板是其选择器匹配的每个文件的候选面板,无论是文本还是二进制文件。内置面板要求主机报告的文件类型:Markdown、HTML 和 **源代码** 打开文本文件,**图片** 打开图片。Markdown 和 HTML 面板各自有文件的预览视图和源代码视图,在面板的工具栏中切换。**源代码** 是 Paseo 的编辑器,是没有其他内置面板认领的文本文件的内置面板;它从不与其他内置面板一起提供。候选面板的顺序是:认领该文件的内置面板,然后按主机目录顺序排列的插件面板,最后是没有其他内置面板认领该文件时的源代码。 -记住的关联在其面板于当前主机上可用时选择该面板。否则 Paseo 打开第一个候选面板。当插件面板是多个候选面板之一且没有适用的关联时,Paseo 每次打开标签页时询问一次,询问显示在它已经打开的面板上方。只有内置面板时从不询问:Markdown 文件用 Markdown 打开,并在切换器中提供源代码。安装了一个 Markdown 插件后,选择器会提供 **Markdown — Paseo**、**Rich Markdown — markdown-panel** 和 **源代码 — Paseo**。认领文本扩展名的插件会与源代码竞争,所以第一次打开时会询问;单独认领某个二进制文件的插件会直接打开,不询问。 +记住的关联在其面板于当前主机上可用时选择该面板。否则 Paseo 打开第一个候选面板。当插件面板是多个候选面板之一且没有适用的关联时,Paseo 每次打开标签页时询问一次,询问显示在它已经打开的面板上方。只有一个候选面板的文件从不询问。安装了一个 Markdown 插件后,选择器会提供 **Markdown — Paseo** 和 **Rich Markdown — markdown-panel**。认领某个没有内置面板认领的文本扩展名的插件会与源代码竞争,所以第一次打开时会询问;单独认领某个二进制文件的插件会直接打开,不询问。 **不再询问** 会在此设备上为该扩展名记住这个选择。当某个候选面板匹配了确切的文件名时,该选择改为应用于那个 basename。不可用的关联在该主机上会被忽略,并为安装了其面板的主机保留。 -用户可以用栏中的面板切换器切换当前标签页:它并排显示最多三个面板(手机上为两个),更多的面板放在菜单中;也可以用标签页菜单中的 **打开方式…**。对于这个标签页,该选择优先于记住的关联,直到标签页被重新定向。栏的溢出菜单和打开方式中的 **始终用于** 会记住当前显示的面板。从链接或搜索结果在某一行打开文件时,标签页会切换到源代码。没有候选面板的文件显示 **二进制预览不可用**;二进制文件没有源代码面板。 +用户有三种方式选择面板:第一次打开时的选择器、标签页菜单中的 **打开方式…**,以及 **设置 > 文件**。打开方式会切换当前标签页,并且对于这个标签页优先于记住的关联,直到标签页被重新定向;其中的 **始终用于** 会记住当前显示的面板。从链接或搜索结果在某一行打开文件时,标签页会切换到它的内置面板,并在源代码视图中显示该行。没有候选面板的文件显示 **二进制预览不可用**;二进制文件没有源代码面板。 ### DocumentFrame {#documentframe} From 4673ab1903cda8f34a99429f4764fa438f3364d8 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:08:33 +0200 Subject: [PATCH 25/62] fix(app): fall back to relay when the direct connection drops (#7616) The probe cycle reused the selected client's last heartbeat RTT even after that client disconnected. A dead direct connection kept reporting as available, so the app never switched to a reachable relay until it was restarted. Mark the selected connection unavailable while its client is not connected and let the existing failover pick another connection. Co-authored-by: 3ae3ae --- packages/app/src/runtime/host-runtime.test.ts | 44 +++++++++++++++++++ packages/app/src/runtime/host-runtime.ts | 8 ++++ 2 files changed, 52 insertions(+) diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 8f4d2acab5..0142548674 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1011,6 +1011,50 @@ describe("HostRuntimeController", () => { expect(activeClient.latencyMeasurements()).toEqual([]); }); + it.each([12, null])( + "falls back to relay when the direct client disconnects with heartbeat RTT %s", + async (heartbeatRtt) => { + useHostRuntimeClock(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6767": 12, + "relay:relay.paseo.sh:443": 65, + }; + const controller = new HostRuntimeController({ + host: makeHost(), + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); + expect(controller.getSnapshot().connectionStatus).toBe("online"); + + const directClient = clients[0]; + directClient.heartbeatReportsRtt(heartbeatRtt); + directClient.clearLatencyMeasurements(); + latencies["direct:lan:6767"] = new Error("LAN unreachable"); + directClient.setConnectionState({ status: "disconnected", reason: "network changed" }); + expect(controller.getSnapshot().connectionStatus).toBe("error"); + expect(directClient.getLastLivenessRttMs()).toBe(heartbeatRtt); + + await vi.advanceTimersByTimeAsync(2_000); + await controller.runProbeCycleNow(); + + const snapshot = controller.getSnapshot(); + expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443"); + expect(snapshot.connectionStatus).toBe("online"); + expect(snapshot.probeByConnectionId).toEqual( + new Map([ + ["direct:lan:6767", { status: "unavailable", latencyMs: null }], + ["relay:relay.paseo.sh:443", { status: "available", latencyMs: 65 }], + ]), + ); + expect(directClient.isDisposed()).toBe(true); + expect(directClient.latencyMeasurements()).toEqual([]); + await controller.stop(); + }, + ); + it("does not create a probe client for the selected connection while it reconnects", async () => { useHostRuntimeClock(); const relay: HostConnection = { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 152e3331e5..fd8c1499d0 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1110,6 +1110,14 @@ export class HostRuntimeController { shouldCloseClient = shouldCloseClient && !activated; if (activeClient) { + if (activeClient.getConnectionState().status !== "connected") { + probeByConnectionId.set(connection.id, { + status: "unavailable", + latencyMs: null, + }); + publishProbeState(); + return; + } const rttMs = activeClient.getLastLivenessRttMs(); if (!this.isCurrentProbeRequest(requestVersion)) { return; From 4574142314c49454e65e9e19ff7fbdff768f28d1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 14:36:22 +0700 Subject: [PATCH 26/62] fix(server): use only the configured models for manual metadata generation (#7611) Manual metadata generation is a non-empty agents.metadataGeneration.providers list, but the resolver always appended the built-in defaults and the current agent selection after it. When a manual model failed, the prompt was retried on providers the user never chose. Manual now resolves to exactly the configured list; Automatic is unchanged. Commit message and PR text generation swallowed failures without logging, so a broken manual model was invisible. The git metadata generator now logs the final failure with each attempt's provider, model and error, and the structured generation loop receives the session logger for per-attempt warnings, matching the branch name path. --- docs/data-model.md | 4 +- packages/app/src/i18n/resources/ar.ts | 2 +- packages/app/src/i18n/resources/en.ts | 2 +- packages/app/src/i18n/resources/es.ts | 2 +- packages/app/src/i18n/resources/fr.ts | 2 +- packages/app/src/i18n/resources/ja.ts | 2 +- packages/app/src/i18n/resources/ko.ts | 2 +- packages/app/src/i18n/resources/pt-BR.ts | 2 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 2 +- .../settings/metadata-generation-page.tsx | 2 +- .../server/agent/agent-response-loop.test.ts | 51 ++++++++++ .../src/server/agent/agent-response-loop.ts | 2 +- .../structured-generation-providers.test.ts | 21 +++-- .../agent/structured-generation-providers.ts | 24 +++-- packages/server/src/server/session.ts | 2 + .../checkout/git-metadata-generator.test.ts | 94 +++++++++++++++++-- .../checkout/git-metadata-generator.ts | 69 +++++++++----- .../worktree-branch-name-generator.test.ts | 51 +++++++++- public-docs/metadata-generation.md | 19 ++-- public-docs/metadata-generation.zh-CN.md | 19 ++-- 21 files changed, 300 insertions(+), 77 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index 1b578bb6d8..8bed344aa1 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -317,7 +317,7 @@ Paseo tools; otherwise the provider policy applies. Provider and global policy a session is created, resumed, imported, or reloaded, so configuration changes affect the next session rather than an already-running one. -`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. +`agents.metadataGeneration.providers` selects the models for daemon-side metadata tasks: commit messages, PR text, branch names, and generated titles. An empty list is Automatic; a non-empty list is Manual and only those models are tried. See [public-docs/metadata-generation.md](../public-docs/metadata-generation.md#how-a-model-is-chosen). ### Git process limits @@ -353,8 +353,6 @@ Environment variables override `config.json`: after changing `config.json`. Environment changes require a daemon restart; the launch environment remains authoritative during reload. -`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. - Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model. Set these to select OpenAI instead of local speech: diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 643bd44691..c44c7f8ffc 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2253,7 +2253,7 @@ export const ar: TranslationResources = { automaticHint: "يختار Paseo نموذجًا سريعًا متاحًا", preferredHint: "اختر النموذج الذي يستخدمه Paseo", model: "النموذج", - fallbackHint: "إذا لم يكن متاحًا، يستخدم Paseo نموذجًا آخر متاحًا", + modelHint: "يُستخدم هذا النموذج فقط. إذا فشل، لا يجرّب Paseo نموذجًا آخر", docs: "الوثائق", saveError: "تعذر تحديث إنشاء البيانات الوصفية", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 6224b848f1..d752c89372 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2342,7 +2342,7 @@ export const en = { automaticHint: "Paseo picks a fast available model", preferredHint: "Choose the model Paseo uses", model: "Model", - fallbackHint: "If it is unavailable, Paseo falls back to another available model", + modelHint: "Only this model is used. If it fails, Paseo does not try another model", docs: "Docs", saveError: "Unable to update metadata generation", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b2718cd5a4..f5eda9f6b9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2265,7 +2265,7 @@ export const es: TranslationResources = { automaticHint: "Paseo elige un modelo rápido disponible", preferredHint: "Elige el modelo que usa Paseo", model: "Modelo", - fallbackHint: "Si no está disponible, Paseo usa otro modelo disponible", + modelHint: "Solo se usa este modelo. Si falla, Paseo no prueba otro modelo", docs: "Documentación", saveError: "No se pudo actualizar la generación de metadatos", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c750712bdd..7ffeaf8d59 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2265,7 +2265,7 @@ export const fr: TranslationResources = { automaticHint: "Paseo choisit un modèle rapide disponible", preferredHint: "Choisissez le modèle utilisé par Paseo", model: "Modèle", - fallbackHint: "S’il est indisponible, Paseo utilise un autre modèle disponible", + modelHint: "Seul ce modèle est utilisé. S’il échoue, Paseo n’essaie pas d’autre modèle", docs: "Documentation", saveError: "Impossible de mettre à jour la génération de métadonnées", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 89ae1c5151..1ba8dac613 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2238,7 +2238,7 @@ export const ja: TranslationResources = { automaticHint: "Paseo が利用可能な高速モデルを選択します", preferredHint: "Paseo が使用するモデルを選択します", model: "モデル", - fallbackHint: "利用できない場合、Paseo は別の利用可能なモデルを使用します", + modelHint: "このモデルのみを使用します。失敗しても、Paseo は別のモデルを試しません", docs: "ドキュメント", saveError: "メタデータ生成を更新できません", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 6e96723260..f59d03a8e9 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2227,7 +2227,7 @@ export const ko: TranslationResources = { automaticHint: "Paseo가 사용 가능한 빠른 모델을 선택합니다", preferredHint: "Paseo에서 사용할 모델을 선택하세요", model: "모델", - fallbackHint: "사용할 수 없으면 Paseo가 다른 사용 가능한 모델을 사용합니다", + modelHint: "이 모델만 사용합니다. 실패해도 Paseo는 다른 모델을 시도하지 않습니다", docs: "문서", saveError: "메타데이터 생성을 업데이트할 수 없습니다", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 0e09fd91dc..8a98a32487 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2250,7 +2250,7 @@ export const ptBR: TranslationResources = { automaticHint: "O Paseo escolhe um modelo rápido disponível", preferredHint: "Escolha o modelo que o Paseo usa", model: "Modelo", - fallbackHint: "Se ele não estiver disponível, o Paseo usa outro modelo disponível", + modelHint: "Somente este modelo é usado. Se ele falhar, o Paseo não tenta outro modelo", docs: "Documentação", saveError: "Não foi possível atualizar a geração de metadados", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 95f9718313..b5e519baad 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2250,7 +2250,8 @@ export const ru: TranslationResources = { automaticHint: "Paseo выбирает быструю доступную модель", preferredHint: "Выберите модель, которую использует Paseo", model: "Модель", - fallbackHint: "Если она недоступна, Paseo использует другую доступную модель", + modelHint: + "Используется только эта модель. Если она не сработает, Paseo не будет пробовать другую модель", docs: "Документация", saveError: "Не удалось обновить настройки генерации метаданных", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index aa7f64b2ef..2d5fbca085 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2194,7 +2194,7 @@ export const zhCN: TranslationResources = { automaticHint: "Paseo 会选择一个可用的快速模型", preferredHint: "选择 Paseo 使用的模型", model: "模型", - fallbackHint: "如果不可用,Paseo 会改用其他可用模型", + modelHint: "只使用此模型。如果失败,Paseo 不会尝试其他模型", docs: "文档", saveError: "无法更新元数据生成设置", }, diff --git a/packages/app/src/screens/settings/metadata-generation-page.tsx b/packages/app/src/screens/settings/metadata-generation-page.tsx index be137726ba..c9a04e9b3e 100644 --- a/packages/app/src/screens/settings/metadata-generation-page.tsx +++ b/packages/app/src/screens/settings/metadata-generation-page.tsx @@ -139,7 +139,7 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) { {t("settings.metadataGeneration.model")} - {t("settings.metadataGeneration.fallbackHint")} + {t("settings.metadataGeneration.modelHint")} { expect(manager.checkedProviders).toEqual(["claude", "codex"]); }); + it("logs the provider, model and error of every failed attempt", async () => { + const warnings: Array<{ obj: object; msg?: string }> = []; + const runnerError = new Error("connection refused"); + const manager = createManager([ + { provider: "local-llm", available: true, error: null }, + { provider: "opencode", available: false, error: "not installed" }, + ]); + + await expect( + generateStructuredAgentResponseWithFallback({ + manager, + cwd: "/tmp/project", + prompt: "Return JSON", + schema, + schemaName: "BranchName", + providers: [ + { provider: "local-llm", model: "qwen-local" }, + { provider: "opencode", model: "minimax-m3" }, + ], + logger: { + info: () => undefined, + warn: (obj, msg) => warnings.push({ obj, msg }), + }, + runner: async () => { + throw runnerError; + }, + }), + ).rejects.toBeInstanceOf(StructuredAgentFallbackError); + + expect(warnings).toEqual([ + { + obj: { + err: runnerError, + provider: "local-llm", + model: "qwen-local", + schemaName: "BranchName", + }, + msg: "Structured generation: provider failed", + }, + { + obj: { + provider: "opencode", + model: "minimax-m3", + schemaName: "BranchName", + reason: "not installed", + }, + msg: "Structured generation: skipping unavailable provider", + }, + ]); + }); + it("throws a fallback error when all providers are unavailable or fail", async () => { const manager = createManager([ { provider: "claude", available: false, error: "missing auth" }, diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index a76f82fdaa..79f625b025 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -475,7 +475,7 @@ export async function generateStructuredAgentResponseWithFallback( }); logger?.warn( { err: error, provider: candidate.provider, model: candidate.model, schemaName }, - "Structured generation: provider failed, trying next", + "Structured generation: provider failed", ); } } diff --git a/packages/server/src/server/agent/structured-generation-providers.test.ts b/packages/server/src/server/agent/structured-generation-providers.test.ts index d34aff9640..0216fae643 100644 --- a/packages/server/src/server/agent/structured-generation-providers.test.ts +++ b/packages/server/src/server/agent/structured-generation-providers.test.ts @@ -18,7 +18,7 @@ class ProviderSnapshots { } describe("resolveStructuredGenerationProviders", () => { - test("tries the configured model before dynamically discovered fallbacks", async () => { + test("tries only the configured models when metadata generation is manual", async () => { const snapshots = new ProviderSnapshots([ { provider: "work-claude", @@ -28,6 +28,12 @@ describe("resolveStructuredGenerationProviders", () => { { provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true }, ], }, + { + provider: "opencode", + status: READY, + enabled: true, + models: [{ provider: "opencode", id: "minimax-m3", label: "MiniMax M3", isDefault: true }], + }, ]); const providers = await resolveStructuredGenerationProviders({ @@ -35,14 +41,18 @@ describe("resolveStructuredGenerationProviders", () => { providerSnapshotManager: snapshots, daemonConfig: { metadataGeneration: { - providers: [{ provider: "mock", model: "ten-second-stream" }], + providers: [ + { provider: "local-llm", model: "qwen-local" }, + { provider: "mock", model: "ten-second-stream" }, + ], }, }, + currentSelection: { provider: "opencode", model: "minimax-m3" }, }); expect(providers).toEqual([ + { provider: "local-llm", model: "qwen-local" }, { provider: "mock", model: "ten-second-stream" }, - { provider: "work-claude", model: "claude-haiku-2026" }, ]); expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); @@ -227,10 +237,7 @@ describe("resolveStructuredGenerationProviders", () => { }, }); - expect(providers).toEqual([ - { provider: "current-provider", model: "configured-model" }, - { provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" }, - ]); + expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]); expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); }); diff --git a/packages/server/src/server/agent/structured-generation-providers.ts b/packages/server/src/server/agent/structured-generation-providers.ts index 2a09ee1844..bf1af4f8fb 100644 --- a/packages/server/src/server/agent/structured-generation-providers.ts +++ b/packages/server/src/server/agent/structured-generation-providers.ts @@ -40,6 +40,10 @@ export interface ResolveStructuredGenerationProvidersOptions { }; } +/** + * A non-empty configured list means Manual: only those models are tried, in order. + * An empty list means Automatic: built-in defaults, then the current selection. + */ export async function resolveStructuredGenerationProviders( options: ResolveStructuredGenerationProvidersOptions, ): Promise { @@ -53,16 +57,18 @@ export async function resolveStructuredGenerationProviders( const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry])); const providers: StructuredGenerationProvider[] = []; - for (const configured of configuredProviders) { - const resolvedConfigured = resolveConfiguredCandidate( - configured, - modelEntries, - entriesByProvider, - ); - if (!resolvedConfigured) { - continue; + if (configuredProviders.length > 0) { + for (const configured of configuredProviders) { + const resolvedConfigured = resolveConfiguredCandidate( + configured, + modelEntries, + entriesByProvider, + ); + if (resolvedConfigured) { + providers.push(resolvedConfigured); + } } - providers.push(resolvedConfigured); + return dedupeProviders(providers); } for (const identifier of DEFAULT_STRUCTURED_GENERATION_PROVIDERS) { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f49191e59e..221d3148c8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -973,7 +973,9 @@ export class Session { providerSnapshotManager, readDaemonConfig: () => this.readStructuredGenerationDaemonConfig(), getFocusedSelection: (cwd) => this.getFocusedAgentSelectionForCwd(cwd), + logger: this.sessionLogger, }), + logger: this.sessionLogger, }), paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, diff --git a/packages/server/src/server/session/checkout/git-metadata-generator.test.ts b/packages/server/src/server/session/checkout/git-metadata-generator.test.ts index 9c30200402..a09f08f2fd 100644 --- a/packages/server/src/server/session/checkout/git-metadata-generator.test.ts +++ b/packages/server/src/server/session/checkout/git-metadata-generator.test.ts @@ -7,6 +7,7 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../../../utils/che import type { WorkspaceGitService } from "../../workspace-git-service.js"; import { createGitMetadataGenerator, + type GitMetadataGenerator, type StructuredTextGeneration, type StructuredTextGenerationRequest, } from "./git-metadata-generator.js"; @@ -38,6 +39,18 @@ function createGeneration(handler: (request: StructuredTextGenerationRequest = []; + return { + errors, + logger: { error: (obj: object, msg?: string) => errors.push({ obj, msg }) }, + }; +} + +const MANUAL_MODEL_FAILURE = new StructuredAgentFallbackError([ + { provider: "local-llm", model: "qwen-local", available: true, error: "connection refused" }, +]); + const DIFF_WITH_ONE_FILE: CheckoutDiffResult = { diff: "diff --git a/src/foo.ts b/src/foo.ts\n+added\n", structured: [ @@ -59,7 +72,11 @@ describe("createGitMetadataGenerator", () => { const { generation, generateCalls } = createGeneration(() => ({ message: "Fix the flaky retry test", })); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); const message = await generator.generateCommitMessage("/repo"); @@ -82,7 +99,11 @@ describe("createGitMetadataGenerator", () => { const { generation } = createGeneration(() => { throw new StructuredAgentFallbackError([]); }); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); await expect(generator.generateCommitMessage("/repo")).resolves.toBe("Update files"); }); @@ -95,7 +116,11 @@ describe("createGitMetadataGenerator", () => { validationErrors: ["message: required"], }); }); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); await expect(generator.generateCommitMessage("/repo")).resolves.toBe("Update files"); }); @@ -105,7 +130,11 @@ describe("createGitMetadataGenerator", () => { const { generation } = createGeneration(() => { throw new Error("network down"); }); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); await expect(generator.generateCommitMessage("/repo")).rejects.toThrow("network down"); }); @@ -116,7 +145,11 @@ describe("createGitMetadataGenerator", () => { title: "Add retry with backoff", body: "Retries transient failures up to twice.", })); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); const result = await generator.generatePullRequestText("/repo", "main"); @@ -140,11 +173,60 @@ describe("createGitMetadataGenerator", () => { const { generation } = createGeneration(() => { throw new StructuredAgentFallbackError([]); }); - const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation }); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger: createLogger().logger, + }); await expect(generator.generatePullRequestText("/repo")).resolves.toEqual({ title: "Update changes", body: "Automated PR generated by Paseo.", }); }); + + it.each([ + [ + "generateCommitMessage", + "CommitMessage", + (generator: GitMetadataGenerator) => generator.generateCommitMessage("/repo"), + ], + [ + "generatePullRequestText", + "PullRequest", + (generator: GitMetadataGenerator) => generator.generatePullRequestText("/repo", "main"), + ], + ] as const)("%s logs the failed provider, model and error", async (_name, schemaName, run) => { + const { diffSource } = createDiffSource(DIFF_WITH_ONE_FILE); + const { generation } = createGeneration(() => { + throw MANUAL_MODEL_FAILURE; + }); + const { logger, errors } = createLogger(); + const generator = createGitMetadataGenerator({ + workspaceGitService: diffSource, + generation, + logger, + }); + + await run(generator); + + expect(errors).toEqual([ + { + obj: { + err: MANUAL_MODEL_FAILURE, + cwd: "/repo", + schemaName, + attempts: [ + { + provider: "local-llm", + model: "qwen-local", + available: true, + error: "connection refused", + }, + ], + }, + msg: "Structured git metadata generation failed", + }, + ]); + }); }); diff --git a/packages/server/src/server/session/checkout/git-metadata-generator.ts b/packages/server/src/server/session/checkout/git-metadata-generator.ts index 06f276f133..722309213a 100644 --- a/packages/server/src/server/session/checkout/git-metadata-generator.ts +++ b/packages/server/src/server/session/checkout/git-metadata-generator.ts @@ -3,6 +3,7 @@ import { StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, + type StructuredGenerationLogger, } from "../../agent/agent-response-loop.js"; import type { AgentManager } from "../../agent/agent-manager.js"; import type { ProviderSnapshotManager } from "../../agent/provider-snapshot-manager.js"; @@ -37,7 +38,7 @@ export interface GitMetadataGenerator { * behaviour is unit-testable with a fake. Production wires * createAgentStructuredTextGeneration (resolve providers → run structured * generation); a failed generation throws StructuredAgent*Error, which the - * generator catches and turns into the product fallback text. + * generator logs and turns into the product fallback text. */ export interface StructuredTextGeneration { generate(request: StructuredTextGenerationRequest): Promise; @@ -51,6 +52,10 @@ export interface StructuredTextGenerationRequest { agentTitle: string; } +interface GitMetadataLogger { + error: (obj: object, msg?: string) => void; +} + type GitMetadataDiffSource = Pick; type CheckoutDiffOptions = Parameters[1]; type CheckoutDiff = Awaited>; @@ -90,8 +95,32 @@ interface PromptForDiffInput { export function createGitMetadataGenerator(deps: { workspaceGitService: GitMetadataDiffSource; generation: StructuredTextGeneration; + logger: GitMetadataLogger; }): GitMetadataGenerator { - const { workspaceGitService, generation } = deps; + const { workspaceGitService, generation, logger } = deps; + + async function generateOrFallback( + request: StructuredTextGenerationRequest, + fallback: T, + ): Promise { + try { + return await generation.generate(request); + } catch (error) { + if (!isStructuredGenerationFailure(error)) { + throw error; + } + logger.error( + { + err: error, + cwd: request.cwd, + schemaName: request.schemaName, + attempts: error instanceof StructuredAgentFallbackError ? error.attempts : undefined, + }, + "Structured git metadata generation failed", + ); + return fallback; + } + } async function buildPromptForDiff(input: PromptForDiffInput): Promise { const diff = await workspaceGitService.getCheckoutDiff(input.cwd, input.diffOptions); @@ -123,21 +152,17 @@ export function createGitMetadataGenerator(deps: { styleDefault: "Concise, imperative mood, no trailing period.", jsonFieldsHint: "Return JSON only with a single field 'message'.", }); - try { - const result = await generation.generate({ + const result = await generateOrFallback( + { cwd, prompt, schema: COMMIT_MESSAGE_SCHEMA, schemaName: "CommitMessage", agentTitle: "Commit generator", - }); - return result.message; - } catch (error) { - if (isStructuredGenerationFailure(error)) { - return COMMIT_MESSAGE_FALLBACK; - } - throw error; - } + }, + { message: COMMIT_MESSAGE_FALLBACK }, + ); + return result.message; }, async generatePullRequestText(cwd, baseRef) { @@ -150,20 +175,16 @@ export function createGitMetadataGenerator(deps: { styleDefault: "Clear, descriptive title; body explaining what changed and why.", jsonFieldsHint: "Return JSON only with fields 'title' and 'body'.", }); - try { - return await generation.generate({ + return generateOrFallback( + { cwd, prompt, schema: PULL_REQUEST_SCHEMA, schemaName: "PullRequest", agentTitle: "PR generator", - }); - } catch (error) { - if (isStructuredGenerationFailure(error)) { - return PULL_REQUEST_FALLBACK; - } - throw error; - } + }, + PULL_REQUEST_FALLBACK, + ); }, }; } @@ -180,6 +201,7 @@ export function createAgentStructuredTextGeneration(deps: { getFocusedSelection: ( cwd: string, ) => ResolveStructuredGenerationProvidersOptions["currentSelection"]; + logger: StructuredGenerationLogger; }): StructuredTextGeneration { return { async generate({ cwd, prompt, schema, schemaName, agentTitle }) { @@ -198,6 +220,7 @@ export function createAgentStructuredTextGeneration(deps: { maxRetries: 2, providers, persistSession: false, + logger: deps.logger, agentConfigOverrides: { title: agentTitle, internal: true, @@ -234,7 +257,9 @@ function diffChangeTypeFor(file: { isNew?: boolean; isDeleted?: boolean }): "A" return "M"; } -function isStructuredGenerationFailure(error: unknown): boolean { +function isStructuredGenerationFailure( + error: unknown, +): error is StructuredAgentResponseError | StructuredAgentFallbackError { return ( error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError ); diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts index caf0539880..8eb1625265 100644 --- a/packages/server/src/server/worktree-branch-name-generator.test.ts +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -4,7 +4,10 @@ import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { AgentManager } from "./agent/agent-manager.js"; -import type { StructuredAgentGenerationWithFallbackOptions } from "./agent/agent-response-loop.js"; +import { + StructuredAgentFallbackError, + type StructuredAgentGenerationWithFallbackOptions, +} from "./agent/agent-response-loop.js"; import { attemptFirstAgentBranchAutoName, type AttemptFirstAgentBranchAutoNameResult, @@ -227,6 +230,52 @@ describe("generateBranchNameFromFirstAgentContext", () => { ]); }); + test("tries only the manual model and logs its failure", async () => { + const logger = createLogger(); + const failure = new StructuredAgentFallbackError([ + { provider: "local-llm", model: "qwen-local", available: true, error: "connection refused" }, + ]); + const calls: StructuredAgentGenerationWithFallbackOptions[] = []; + + const result = await generateBranchNameFromFirstAgentContext({ + agentManager: {} as AgentManager, + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "opencode", + status: "ready" as const, + enabled: true, + models: [ + { provider: "opencode", id: "minimax-m3", label: "MiniMax M3", isDefault: true }, + ], + }, + ]), + }, + daemonConfig: { + metadataGeneration: { providers: [{ provider: "local-llm", model: "qwen-local" }] }, + }, + currentSelection: { provider: "opencode", model: "minimax-m3" }, + firstAgentContext: { prompt: "Fix the login flow" }, + logger, + deps: { + generateStructuredAgentResponseWithFallback: async (options) => { + calls.push(options as StructuredAgentGenerationWithFallbackOptions); + throw failure; + }, + }, + }); + + expect(result).toBeNull(); + expect(calls.map((call) => call.providers)).toEqual([ + [{ provider: "local-llm", model: "qwen-local" }], + ]); + expect(logger.error).toHaveBeenCalledWith( + { err: failure, attempts: failure.attempts }, + "Structured branch name generation failed", + ); + }); + test.each([ ["paseo.json missing", undefined], ["paseo.json exists but invalid JSON", "{ nope"], diff --git a/public-docs/metadata-generation.md b/public-docs/metadata-generation.md index 1d23ec661d..8941fa4c9b 100644 --- a/public-docs/metadata-generation.md +++ b/public-docs/metadata-generation.md @@ -21,29 +21,30 @@ A workspace title and its branch name are produced together from the same prompt ## How a model is chosen -You don't have to configure anything — Paseo picks a model automatically. It builds an ordered list of candidates and tries each one until a generation succeeds, so a slow or unavailable model falls through to the next. +Paseo has two modes. **Automatic** is the default and needs no configuration. **Manual** uses only the models you choose. -The candidate list is assembled in this order: +In Automatic mode, Paseo builds an ordered list of candidates and tries each one until a generation succeeds, so a slow or unavailable model falls through to the next: -1. **Providers you configured**, in the order you list them (see below). -2. **Built-in defaults**, matched against the models of the providers you have enabled: +1. **Built-in defaults**, matched against the models of the providers you have enabled: 1. a `haiku` model 2. `gpt-5.4-mini` (low reasoning) 3. `minimax-m3` 4. `nemotron-3-super` -3. **The model currently selected** for that agent or draft, as a last resort. +2. **The model currently selected** for that agent or draft, as a last resort. -Each default is a match on the model id or name, so the first enabled provider that ships a matching model wins. Anything that can't be resolved against an enabled provider is skipped. Duplicates are removed, then generation tries the list top to bottom. +Each default is a match on the model id or name, so the first enabled provider that ships a matching model wins. Defaults with no match are skipped. Duplicates are removed, then generation tries the list top to bottom. The default order prefers small, fast, cheap models for these short tasks before falling back to whatever you have selected. -The intent of the default order is to prefer small, fast, cheap models for these short tasks before falling back to whatever you have selected. +In Manual mode, Paseo tries only your configured models, in your order. It never adds the built-in defaults or the selected model, so your prompts and diffs only go to providers you chose. If every configured model fails, generation fails: workspace titles and branch names are not generated, and commits and pull requests get placeholder text. Pick a model that works for these tasks. + +Every failure is written to `$PASEO_HOME/daemon.log` (`~/.paseo/daemon.log` by default) with the provider, the model and the error. Look there when a manual model doesn't produce titles, branch names, commit messages or pull request text. ## Choose a model -Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, Paseo tries the model you choose first, then falls back to the remaining configured models and built-in candidates. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list. +Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, pick the model Paseo uses. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list. Switching back to Automatic clears the list. ## Configure a custom fallback order -To configure more than one preferred model or control the exact order, set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults. +To use more than one model in Manual mode, list them in `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Paseo tries your entries in order and nothing else. An empty list means Automatic. ```json { diff --git a/public-docs/metadata-generation.zh-CN.md b/public-docs/metadata-generation.zh-CN.md index 2b81411bb5..6669ab818c 100644 --- a/public-docs/metadata-generation.zh-CN.md +++ b/public-docs/metadata-generation.zh-CN.md @@ -19,29 +19,30 @@ Paseo 会生成以下几类元数据: ## 如何选择模型 {#how-a-model-is-chosen} -你无需进行任何配置,Paseo 会自动选择模型。它会构建一个有序的候选列表,逐个尝试,直到生成成功,因此慢或不可用的模型会让位给下一个。 +Paseo 有两种模式。**自动** 是默认模式,无需任何配置。**手动** 只使用你选择的模型。 -候选列表按以下顺序组成: +在自动模式下,Paseo 会构建一个有序的候选列表,逐个尝试,直到生成成功,因此慢或不可用的模型会让位给下一个: -1. **你配置的 Provider**,按你列出的顺序(见下文)。 -2. **内置默认值**,与你已启用的 Provider 的模型进行匹配: +1. **内置默认值**,与你已启用的 Provider 的模型进行匹配: 1. 一个 `haiku` 模型 2. `gpt-5.4-mini`(低推理) 3. `minimax-m3` 4. `nemotron-3-super` -3. 最后的备选:该 Agent 或草稿**当前选中的模型**。 +2. 最后的备选:该 Agent 或草稿**当前选中的模型**。 -每个默认值按模型 id 或名称匹配,因此第一个提供匹配模型的已启用 Provider 胜出。无法在已启用 Provider 中解析的条目会被跳过。去除重复项后,生成过程从上到下依次尝试。 +每个默认值按模型 id 或名称匹配,因此第一个提供匹配模型的已启用 Provider 胜出。没有匹配的默认值会被跳过。去除重复项后,生成过程从上到下依次尝试。默认顺序的意图是:对于这些简短任务,优先使用小、快、便宜的模型,最后才退回到你选中的模型。 -默认顺序的意图是:对于这些简短任务,优先使用小、快、便宜的模型,最后才退回到你选中的模型。 +在手动模式下,Paseo 只按你的顺序尝试你配置的模型。它不会加入内置默认值或当前选中的模型,因此你的提示词和 diff 只会发送给你选择的 Provider。如果所有配置的模型都失败,生成就会失败:不会生成工作区标题和分支名,提交和拉取请求会使用占位文本。请选择适合这些任务的模型。 + +每次失败都会写入 `$PASEO_HOME/daemon.log`(默认是 `~/.paseo/daemon.log`),包含 Provider、模型和错误。如果手动模型没有生成标题、分支名、提交信息或拉取请求文本,请查看这里。 ## 选择模型 {#choose-a-model} -打开 **设置 → 主机 → 元数据**,选择 **自动** 或 **手动**。在手动模式下,Paseo 会先尝试你选择的模型,然后退回到其余已配置的模型和内置候选。选择模型会替换 `agents.metadataGeneration.providers` 中的第一项,并保留列表的其余部分。 +打开 **设置 → 主机 → 元数据**,选择 **自动** 或 **手动**。在手动模式下,选择 Paseo 使用的模型。选择模型会替换 `agents.metadataGeneration.providers` 中的第一项,并保留列表的其余部分。切换回自动会清空该列表。 ## 配置自定义的回退顺序 {#configure-a-custom-fallback-order} -如果要配置多个首选模型或控制确切的顺序,请在 `~/.paseo/config.json` 中设置 `agents.metadataGeneration.providers`。你的条目会在内置默认值之前尝试。 +如果要在手动模式下使用多个模型,请在 `~/.paseo/config.json` 的 `agents.metadataGeneration.providers` 中列出它们。Paseo 按顺序尝试你的条目,不会尝试其他模型。空列表表示自动。 ```json { From 12e7a448f0f1a40cb78857cf1e29b385f71670ba Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 14:41:38 +0700 Subject: [PATCH 27/62] fix(app): open notification agents once in their current workspace (#7612) * fix(app): open notification agents once in their current workspace Keep tap ownership across Activity and React remounts, clear native responses, and resolve agent workspaces through existing navigation after startup settles. Co-authored-by: Kassam Khoja * fix(app): hydrate agent targets with the compact sidebar closed Hold directory demand for the agent route while resolving its workspace; the compact sidebar does not supply demand before it opens. * fix(app): keep deferred workspace opens in the layout model Key pending tab opens by workspace so redirects and retained screens cannot lose or consume another workspace's target. Legacy open URLs seed this state at their route boundary. * docs(app): document notification tap ownership and native replay * fix(ci): route feature-owned Electron sources to desktop checks * refactor(app): separate notification API from React integration * fix(app): consume pending opens before painting the restored tab A legacy open link showed the previously focused terminal for a frame before switching to the requested terminal. Seed and consume the pending open in layout effects so a ready target switches before paint. * fix(e2e): focus the visible retained terminal surface --------- Co-authored-by: Kassam Khoja --- .github/ci-paths.yml | 1 + CLAUDE.md | 1 + docs/expo-router.md | 25 +-- docs/notifications.md | 71 +++++++++ .../workspace-navigation-regression.spec.ts | 68 ++++++-- .../app/e2e/support/helpers/terminal-perf.ts | 7 +- .../e2e/support/helpers/workspace-layout.ts | 25 ++- packages/app/src/app/_layout.tsx | 136 ++-------------- .../src/app/h/[serverId]/agent/[agentId].tsx | 13 +- .../workspace/[workspaceId]/index.tsx | 106 +++---------- packages/app/src/fdroid/expo-notifications.ts | 2 + .../navigation/agent-route-resolution.test.ts | 16 ++ .../src/navigation/agent-route-resolution.ts | 7 +- packages/app/src/notification-open/index.ts | 6 + .../app/src/notification-open/model.test.ts | 150 ++++++++++++++++++ packages/app/src/notification-open/model.ts | 67 ++++++++ .../app/src/notification-open/react/index.tsx | 58 +++++++ .../src/notification-open/source.electron.ts | 42 +++++ .../src/notification-open/source.native.ts | 36 +++++ packages/app/src/notification-open/source.ts | 12 ++ .../app/src/notification-open/source.web.ts | 23 +++ .../screens/workspace/workspace-screen.tsx | 17 +- .../index.ts | 1 + .../navigation.test.ts | 17 +- .../navigation.ts | 21 +-- .../src/stores/workspace-layout-store.test.ts | 40 +++++ .../app/src/stores/workspace-layout-store.ts | 53 ++++++- .../utils/navigate-to-agent/resolve.test.ts | 10 +- .../src/utils/navigate-to-agent/resolve.ts | 4 +- .../src/utils/notification-routing.test.ts | 104 ------------ .../app/src/utils/notification-routing.ts | 42 ----- .../app/src/utils/os-notifications.test.ts | 9 +- packages/app/src/utils/os-notifications.ts | 28 +--- scripts/ci-workflow.test.mjs | 4 +- 34 files changed, 780 insertions(+), 442 deletions(-) create mode 100644 docs/notifications.md create mode 100644 packages/app/src/notification-open/index.ts create mode 100644 packages/app/src/notification-open/model.test.ts create mode 100644 packages/app/src/notification-open/model.ts create mode 100644 packages/app/src/notification-open/react/index.tsx create mode 100644 packages/app/src/notification-open/source.electron.ts create mode 100644 packages/app/src/notification-open/source.native.ts create mode 100644 packages/app/src/notification-open/source.ts create mode 100644 packages/app/src/notification-open/source.web.ts delete mode 100644 packages/app/src/utils/notification-routing.test.ts delete mode 100644 packages/app/src/utils/notification-routing.ts diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index 833820ff2d..87fc924b6b 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -41,6 +41,7 @@ server: desktop: - "packages/desktop/**" - "packages/app/src/desktop/**" + - "packages/app/src/**/*.electron.{ts,tsx}" - "packages/server/src/server/browser-tools/**" - "packages/app/e2e/support/**" - "packages/app/*config.{cjs,js,ts}" diff --git a/CLAUDE.md b/CLAUDE.md index 841a4c477e..79bdef92f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash | | [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu | | [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas | +| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA | | [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | | [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers | diff --git a/docs/expo-router.md b/docs/expo-router.md index cc65eb57a7..3bd6a43435 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -104,17 +104,24 @@ Do not collapse this topology with a catch-all, `getId`, or ## Agent Targets -Notifications and agent URLs enter the router with different authoritative -targets. +Notifications and agent URLs name a host and agent. Both use +`navigateToAgent()`, which reads the agent's current workspace from the store +or routes through `/h/[serverId]/agent/[agentId]`. The agent route waits for +the named host, demands its directory, and waits for workspace and layout +hydration before opening the tab. Keep the stable agent URL free of a workspace +hint. [Notifications](notifications.md) owns tap delivery and native lifecycle +constraints. -- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them - directly to the workspace with the agent open intent. -- Agent URLs carry only `serverId` and `agentId`. Route them through - `/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves - the agent's workspace from the host, and then opens the agent there. +When a workspace target cannot open yet, `navigateToWorkspace()` records a +pending open in the layout model under that host and workspace key. The owning +workspace screen consumes it once after hydration. Archived-workspace recovery +reads the same pending target, so startup redirects cannot discard it and a +retained screen cannot consume another workspace's request. -Both paths converge on `navigateToAgent()`. Do not make notification routing -guess a workspace, and do not add a workspace to the stable agent URL format. +Legacy `?open=` links seed this model state at the matched route boundary and +then lose the query parameter. Internal navigation does not use the query as a +handoff. Read matched route params locally; global search params let retained +workspace screens observe requests for other workspaces. ## Params diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000000..0e58ccafae --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,71 @@ +# Notifications + +Use `notification-open` for OS notification taps. It owns delivery, parsing, +process-local deduplication, startup readiness, and native acknowledgement. +Keep agent and workspace navigation in their existing navigation modules. +Import the pure API from `@/notification-open` and the app-shell component from +`@/notification-open/react`; senders must not load the React routing runtime. + +Agent payloads name a host and agent. Ignore their workspace hint when opening: +the agent can move after a notification is sent. Use `navigateToAgent`, which +uses the current store record or the agent route to resolve the workspace from +the host. Terminal payloads name their workspace and use `navigateToWorkspace`. + +See [agent targets and deferred opens](expo-router.md#agent-targets) for route +ownership and workspace recovery. Notification generation, presence, and push +eligibility remain daemon concerns. + +## Native delivery + +Keep the router instance at module scope. Android's `singleTask` Activity can +finish on Back while its JS context remains alive. A React ref is lost when the +root remounts; process-local handled IDs must survive that remount. + +Clear `lastNotificationResponse` after dispatch. Reading a launch response does +not acknowledge it. Without clearing it, a later Activity launch can reopen the +old agent. + +With expo-notifications 57, Android also replays its pending response list to +new native listeners. Clearing the last response does not drain that list. +Deduplication covers listener registration within one JS context. A development +JS reload creates another context and can replay a cold-launch response; do not +add persisted handled IDs to compensate. Recheck this limitation when upgrading +expo-notifications. + +iOS drains pending responses when its delegate first handles them. Push data +read by Expo arrives under `userInfo.body`; simulator payloads must match: + +```json +{ + "aps": { "alert": { "title": "Agent finished", "body": "Open the agent" }, "sound": "default" }, + "body": { + "serverId": "host", + "workspaceId": "workspace", + "agentId": "agent", + "reason": "finished" + } +} +``` + +An Android FCM launch intent needs `google.message_id` and a JSON `body` extra. +An arbitrary Activity launch with data extras does not exercise Expo's FCM path. + +Foreground pushes remain in Notification Center without a banner or sound. +This preserves notifications sent with stale presence information. + +## Startup and verification + +Wait for the root navigator, host registry, remembered selection, and startup +redirects before handing off a tap. A compact agent route must demand the host's +directory itself; its sidebar can be closed for the entire cold launch. + +Verify warm and cold taps, stale and missing workspace hints, two distinct taps, +and Android Back followed by launcher reopen or a different notification tap. Check the focused tab and every +workspace's saved layout, since a foreign pinned tab survives navigation. +Use the daemon's payload shape on both iOS and Android; a browser event alone +does not test native replay or acknowledgement. + +Existing foreign pins belong to the reconciliation fix in +[#6900](https://github.com/getpaseo/paseo/issues/6900). Desktop main-process window +selection is tracked separately in +[#6875](https://github.com/getpaseo/paseo/issues/6875). diff --git a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts index fdc0529451..7d8b484c77 100644 --- a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts @@ -11,6 +11,7 @@ import { expectWorkspaceTabVisible, openWorkspaceWithAgents, } from "../support/helpers/archive-tab"; +import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout"; import { clickNewChat } from "../support/helpers/launcher"; import { expectComposerVisible } from "../support/helpers/composer"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; @@ -93,10 +94,20 @@ async function closeFirstVisibleDraftTab(page: Page): Promise { await closeButton.first().click(); } +async function openWorkspaceTabUrl(page: Page, route: string): Promise { + await page.evaluate((url) => { + window.history.pushState(null, "", url); + window.dispatchEvent(new PopStateEvent("popstate")); + }, route); + await expectAppRoute(page, route.split("?")[0]); +} + test.describe("Workspace navigation regression", () => { test.describe.configure({ timeout: 240_000 }); - test("opens a notification's workspace on a different offline host", async ({ page }) => { + test("keeps an offline notification on the agent route until the host connects", async ({ + page, + }) => { const target = { serverId: "notification-offline-host", workspaceId: "notification-workspace", @@ -123,14 +134,33 @@ test.describe("Workspace navigation regression", () => { ); }, target); - await expectAppRoute( - page, - buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`), - { timeout: 30_000 }, - ); - await expect(page.getByText("Connecting", { exact: true })).toBeVisible(); - await expect(page.getByText("Notification Host", { exact: true })).toBeVisible(); - await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0); + await expectAppRoute(page, buildHostAgentDetailRoute(target.serverId, target.agentId), { + timeout: 30_000, + }); + await expect(page.getByTestId("agent-route-waiting-for-host")).toBeVisible(); + await expect(page.getByText(/Notification Host/).first()).toBeVisible(); + }); + + test("cold agent targets hydrate their workspace without opening the compact sidebar", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "notification-cold-compact-" }); + try { + const agent = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Cold notification agent", + }); + await page.setViewportSize({ width: 390, height: 844 }); + await gotoAppShell(page); + await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id)); + await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId), { + timeout: 30_000, + }); + await expectComposerVisible(page); + } finally { + await workspace.cleanup(); + } }); test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({ @@ -321,7 +351,7 @@ test.describe("Workspace navigation regression", () => { } }); - test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({ + test("a stale notification workspace preserves retained workspace selection and tabs", async ({ page, }) => { const serverId = getServerId(); @@ -435,7 +465,7 @@ test.describe("Workspace navigation regression", () => { }), ); }, - { agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId }, + { agentId: secondAgent.id, serverId, workspaceId: firstWorkspace.workspaceId }, ); await waitForWorkspaceTabsVisible(page); await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { @@ -463,6 +493,22 @@ test.describe("Workspace navigation regression", () => { await expect(secondDeckEntry).toBeHidden(); await expectWorkspaceDeckEntryCount(page, 2); + await expectAgentAbsentFromWorkspaceLayout(page, { + serverId, + workspaceId: firstWorkspace.workspaceId, + agentId: secondAgent.id, + }); + await openWorkspaceTabUrl( + page, + buildHostWorkspaceOpenRoute(serverId, firstWorkspace.workspaceId, `agent:${firstAgent.id}`), + ); + await expectWorkspaceTabVisible(page, firstAgent.id); + await expectAgentAbsentFromWorkspaceLayout(page, { + serverId, + workspaceId: secondWorkspace.workspaceId, + agentId: firstAgent.id, + }); + await page.reload(); await waitForSidebarHydration(page); await waitForWorkspaceTabsVisible(page); diff --git a/packages/app/e2e/support/helpers/terminal-perf.ts b/packages/app/e2e/support/helpers/terminal-perf.ts index ac41b7343b..5c00bed485 100644 --- a/packages/app/e2e/support/helpers/terminal-perf.ts +++ b/packages/app/e2e/support/helpers/terminal-perf.ts @@ -68,8 +68,8 @@ export async function navigateToTerminal( const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId); await page.goto(workspaceRoute); - // The workspace layout consumes `?open=...`, returns null during the effect, - // then replaces the URL with the clean workspace route after preparing the tab. + // The route seeds the legacy URL target into the pending-open model, then + // removes the query. Tab readiness below waits for that intent to be consumed. // On CI, Expo Router's rootNavigationState may take time to initialize, // so we allow a generous timeout here. const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId); @@ -86,7 +86,8 @@ export async function navigateToTerminal( await terminalTab.waitFor({ state: "visible", timeout: 30_000 }); await terminalTab.click(); - const terminalSurface = page.locator('[data-testid="terminal-surface"]'); + // Inactive terminal tabs retain their surfaces; interact with the selected tab. + const terminalSurface = page.getByTestId("terminal-surface").filter({ visible: true }); await terminalSurface.waitFor({ state: "visible", timeout: 15_000 }); // Wait for loading overlay to disappear (terminal attached) diff --git a/packages/app/e2e/support/helpers/workspace-layout.ts b/packages/app/e2e/support/helpers/workspace-layout.ts index 8fbc893f5f..80c2536fbf 100644 --- a/packages/app/e2e/support/helpers/workspace-layout.ts +++ b/packages/app/e2e/support/helpers/workspace-layout.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import { z } from "zod"; import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage"; import { @@ -61,3 +61,26 @@ export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: stri JSON.stringify(stored), ); } + +export async function expectAgentAbsentFromWorkspaceLayout( + page: Page, + input: { serverId: string; workspaceId: string; agentId: string }, +): Promise { + await expect + .poll(async () => { + const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state")); + if (!raw) throw new Error("Workspace layout was not persisted"); + const stored = z + .object({ state: WorkspaceLayoutPersistedStateSchema }) + .parse(JSON.parse(raw)); + const key = `${input.serverId}:${input.workspaceId}`; + const layout = stored.state.layoutByWorkspace[key]; + const pins = stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []; + // The persisted tree is validated above; inspect all panes including hidden ones. + return { + containsAgent: JSON.stringify(layout).includes(input.agentId), + pinned: pins.includes(input.agentId), + }; + }) + .toEqual({ containsAgent: false, pinned: false }); +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 559726d7c8..f24df99f26 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -7,7 +7,6 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; import { LucideProvider } from "lucide-react-native"; import * as Linking from "expo-linking"; -import * as Notifications from "expo-notifications"; import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router"; import { createContext, @@ -16,7 +15,6 @@ import { useContext, useEffect, useMemo, - useRef, useState, useSyncExternalStore, } from "react"; @@ -51,11 +49,7 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet"; -import { - getIsElectronRuntime, - HEADER_INNER_HEIGHT, - useIsCompactFormFactor, -} from "@/constants/layout"; +import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { canDesktopAppSidebarShare, resolveDesktopAppChromeLayout, @@ -97,7 +91,6 @@ import { KeyboardShiftProvider } from "@/keyboard/shift"; import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock"; import { useOpenProject } from "@/hooks/use-open-project"; import { useAppSettings } from "@/hooks/use-settings"; -import { useStableEvent } from "@/hooks/use-stable-event"; import { useOpenAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider"; import { @@ -132,14 +125,9 @@ import { parseHostWorkspaceRouteFromPathname, parseServerIdFromPathname, } from "@/utils/host-routes"; -import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; -import { navigateToAgent } from "@/utils/navigate-to-agent"; import { PluginCatalogSync } from "@/plugins"; -import { - ensureOsNotificationPermission, - WEB_NOTIFICATION_CLICK_EVENT, - type WebNotificationClickDetail, -} from "@/utils/os-notifications"; +import { NotificationOpenRouter } from "@/notification-open/react"; +import { ensureOsNotificationPermission } from "@/utils/os-notifications"; polyfillCrypto(); @@ -159,114 +147,6 @@ const HostRuntimeBootstrapContext = createContext({ startupBlocker: { kind: "none" }, }); -function PushNotificationRouter() { - const router = useRouter(); - const lastHandledIdRef = useRef(null); - const openNotification = useStableEvent((data: Record | undefined) => { - const target = resolveNotificationTarget(data); - const serverId = target.serverId; - const workspaceId = target.workspaceId; - const agentId = target.agentId; - if (serverId && workspaceId && agentId) { - navigateToAgent({ serverId, workspaceId, agentId, pin: true }); - return; - } - - router.navigate(buildNotificationRoute(data)); - }); - - useEffect(() => { - if (isWeb) { - let removeDesktopNotificationListener: (() => void) | null = null; - let cancelled = false; - - if (getIsElectronRuntime()) { - void ensureOsNotificationPermission(); - - const unlistenResult = getDesktopHost()?.events?.on?.( - "notification-click", - (payload: unknown) => { - const data = - typeof payload === "object" && - payload !== null && - "data" in payload && - typeof (payload as { data?: unknown }).data === "object" && - (payload as { data?: unknown }).data !== null - ? (payload as { data: Record }).data - : undefined; - openNotification(data); - }, - ); - - void Promise.resolve(unlistenResult).then((unlisten) => { - if (typeof unlisten !== "function") { - return; - } - if (cancelled) { - unlisten(); - return; - } - removeDesktopNotificationListener = unlisten; - return; - }); - } - - const openFromWebClick = (event: Event) => { - const customEvent = event as CustomEvent; - event.preventDefault(); - openNotification(customEvent.detail?.data); - }; - - window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener); - - return () => { - cancelled = true; - removeDesktopNotificationListener?.(); - window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener); - }; - } - - Notifications.setNotificationHandler({ - handleNotification: async () => ({ - // When the app is open, don't show OS banners. - shouldShowAlert: false, - shouldShowBanner: false, - shouldShowList: false, - shouldPlaySound: false, - shouldSetBadge: false, - }), - }); - - const openFromResponse = (response: Notifications.NotificationResponse) => { - const identifier = response.notification.request.identifier; - if (lastHandledIdRef.current === identifier) { - return; - } - lastHandledIdRef.current = identifier; - - const data = response.notification.request.content.data as - | Record - | undefined; - openNotification(data); - }; - - const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse); - - void Notifications.getLastNotificationResponseAsync().then((response) => { - if (response) { - openFromResponse(response); - } - return; - }); - - return () => { - subscription.remove(); - }; - }, [openNotification]); - - return null; -} - function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { const client = useHostRuntimeClient(daemon.serverId); @@ -945,6 +825,9 @@ function WorkspaceRouteNavigationBridge() { } function AppShell() { + useEffect(() => { + if (getDesktopHost()) void ensureOsNotificationPermission(); + }, []); return ( @@ -959,10 +842,15 @@ function AppShell() { ); } +function NotificationOpenBridge() { + const { startupBlocker } = useHostRuntimeBootstrapState(); + return ; +} + function RuntimeProviders({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 7f66a07b25..008999a540 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -4,6 +4,8 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo import { useFetchQuery } from "@/data/query"; import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution"; import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view"; +import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks"; +import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; import { useSessionStore } from "@/stores/session-store"; import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; @@ -83,7 +85,16 @@ function HostAgentReadyRouteContent() { lookupQuery.isSuccess, shouldLookupAgent, ]); + const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); + const hasHydratedLayout = useWorkspaceLayoutStoreHydrated(); + useEffect(() => { + if (!serverId) return; + // A compact app can open this route before its sidebar has demanded the directory. + return getHostRuntimeStore().acquireDirectoryDemand(serverId); + }, [serverId]); const resolution = resolveAgentRoute({ + hasHydratedWorkspaces, + hasHydratedLayout, serverId, agentId, cachedWorkspaceId: agentWorkspaceId, @@ -106,7 +117,7 @@ function HostAgentReadyRouteContent() { handledNavigationRef.current = navigationKey; if (resolution.kind === "resolved") { - navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId }); + navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId, pin: true }); return; } router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId)); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 80ba6df4d4..8f80f185c7 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; import { useNavigation } from "expo-router/react-navigation"; import { StyleSheet, View } from "react-native"; -import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router"; +import { useLocalSearchParams } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { RetainedPanel } from "@/components/retained-panel"; import { @@ -11,7 +11,7 @@ import { import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; -import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { areRetainedWorkspaceSelectionListsEqual, areWorkspaceSelectionsEqual, @@ -33,7 +33,7 @@ import { replaceBrowserRouteWithCanonicalHostWorkspaceRoute, stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit, } from "@/utils/host-route-browser"; -import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab"; import { isNative, isWeb } from "@/constants/platform"; import { RenderProfile } from "@/utils/render-profiler"; @@ -64,6 +64,8 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge return { kind: "draft", draftId: openIntent.draftId }; } +// COMPAT(workspace-open-url): added in v0.12.0, remove after 2027-01-22. +// Bookmarks and older clients still send ?open=; internal navigation uses pending model state. function stripOpenSearchParamFromBrowserUrl() { if (!isWeb || typeof window === "undefined") { return; @@ -81,7 +83,9 @@ function clearConsumedOpenIntent(input: { }) { input.navigation.setParams({ open: undefined }); if (isWeb) { - stripOpenSearchParamFromBrowserUrl(); + // Expo commits the incoming browser URL after setParams. Strip the consumed legacy input + // after that commit so it cannot be restored into the address bar and replayed on reload. + window.setTimeout(stripOpenSearchParamFromBrowserUrl, 0); } } @@ -95,15 +99,9 @@ export default function HostWorkspaceIndexRoute() { function HostWorkspaceRouteContent() { const navigation = useNavigation(); - const rootNavigationState = useRootNavigationState(); - const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated(); - const consumedIntentRef = useRef(null); - const [intentConsumed, setIntentConsumed] = useState(false); const params = useLocalSearchParams<{ serverId?: string | string[]; workspaceId?: string | string[]; - }>(); - const globalParams = useGlobalSearchParams<{ open?: string | string[]; }>(); const serverId = getParamValue(params.serverId); @@ -111,14 +109,7 @@ function HostWorkspaceRouteContent() { const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") : ""; - const openValue = getParamValue(globalParams.open); - const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); - const workspaceExists = useWorkspaceExists(serverId, workspaceId); - const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]); - const isAgentOpenIntent = openIntent?.kind === "agent"; - const isOpenIntentWaitingForWorkspace = Boolean( - isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists), - ); + const openValue = getParamValue(params.open); useEffect(() => { if (!serverId || !workspaceId) { return; @@ -126,74 +117,31 @@ function HostWorkspaceRouteContent() { stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit(); }, [serverId, workspaceId]); - useEffect(() => { - if (!openValue) { - return; - } - if (!rootNavigationState?.key) { - return; - } - if (!hasHydratedWorkspaceLayoutStore) { - return; - } - if (isOpenIntentWaitingForWorkspace) { - return; - } - - const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; - if (consumedIntentRef.current === consumptionKey) { - clearConsumedOpenIntent({ - navigation: navigation as unknown as { - setParams: (params: { open?: string | undefined }) => void; - }, - }); - setIntentConsumed(true); - return; - } - consumedIntentRef.current = consumptionKey; - + useLayoutEffect(() => { + if (!openValue || !serverId || !workspaceId) return; + const openIntent = parseWorkspaceOpenIntent(openValue); if (openIntent) { - prepareWorkspaceTab({ - serverId, - workspaceId, - target: getOpenIntentTarget(openIntent), - pin: openIntent.kind === "agent", - }); + prepareWorkspaceTab( + { + serverId, + workspaceId, + target: getOpenIntentTarget(openIntent), + pin: openIntent.kind === "agent", + }, + { openTab: useWorkspaceLayoutStore.getState().deferOpenTab }, + ); } - - // Expo Router's replace ignores query-param-only changes (findDivergentState - // skips search params). Strip ?open from the browser URL directly so the - // address bar reflects the clean workspace route. clearConsumedOpenIntent({ navigation: navigation as unknown as { setParams: (params: { open?: string | undefined }) => void; }, }); + }, [navigation, openValue, serverId, workspaceId]); - setIntentConsumed(true); - }, [ - hasHydratedWorkspaceLayoutStore, - isOpenIntentWaitingForWorkspace, - navigation, - openIntent, - openValue, - rootNavigationState?.key, - serverId, - workspaceId, - ]); - - if ( - openValue && - !isOpenIntentWaitingForWorkspace && - (!intentConsumed || !hasHydratedWorkspaceLayoutStore) - ) { - return null; - } - - return ; + return ; } -function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { +function WorkspaceDeck() { const activeSelection = useActiveWorkspaceSelection(); const [retainedSelections, setRetainedSelections] = useState(() => activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [], @@ -264,7 +212,6 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { key={getWorkspaceSelectionKey(selection)} selection={selection} active={active} - recoveryRequested={recoveryRequested} onUnmountInactive={unmountWorkspaceSelection} /> ); @@ -277,12 +224,10 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { function WorkspaceDeckEntry({ selection, active, - recoveryRequested, onUnmountInactive, }: { selection: ActiveWorkspaceSelection; active: boolean; - recoveryRequested: boolean; onUnmountInactive: (selection: ActiveWorkspaceSelection) => void; }) { const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId); @@ -312,7 +257,6 @@ function WorkspaceDeckEntry({ serverId={selection.serverId} workspaceId={selection.workspaceId} isRouteFocused={active} - recoveryRequested={active && recoveryRequested} /> ); diff --git a/packages/app/src/fdroid/expo-notifications.ts b/packages/app/src/fdroid/expo-notifications.ts index dfd4908c2c..3a8e30fc96 100644 --- a/packages/app/src/fdroid/expo-notifications.ts +++ b/packages/app/src/fdroid/expo-notifications.ts @@ -38,3 +38,5 @@ export function addNotificationResponseReceivedListener() { export async function getLastNotificationResponseAsync() { return null; } + +export function clearLastNotificationResponse(): void {} diff --git a/packages/app/src/navigation/agent-route-resolution.test.ts b/packages/app/src/navigation/agent-route-resolution.test.ts index 99fb95ec45..b87b375aed 100644 --- a/packages/app/src/navigation/agent-route-resolution.test.ts +++ b/packages/app/src/navigation/agent-route-resolution.test.ts @@ -5,6 +5,8 @@ const VALID_ROUTE = { serverId: "server-1", agentId: "agent-1", cachedWorkspaceId: null, + hasHydratedWorkspaces: true, + hasHydratedLayout: true, } as const; describe("resolveAgentRoute", () => { @@ -32,6 +34,20 @@ describe("resolveAgentRoute", () => { }, ); + it.each([ + { hasHydratedWorkspaces: false, hasHydratedLayout: true }, + { hasHydratedWorkspaces: true, hasHydratedLayout: false }, + ])("waits for workspace and layout hydration: %j", (hydration) => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + ...hydration, + connectionStatus: "online", + lookup: { kind: "found", workspaceId: "workspace-2" }, + }), + ).toEqual({ kind: "fetchingAgent" }); + }); + it("fetches the agent after its target host connects", () => { expect( resolveAgentRoute({ diff --git a/packages/app/src/navigation/agent-route-resolution.ts b/packages/app/src/navigation/agent-route-resolution.ts index 88fb848c5b..0e5f9f6e11 100644 --- a/packages/app/src/navigation/agent-route-resolution.ts +++ b/packages/app/src/navigation/agent-route-resolution.ts @@ -24,13 +24,18 @@ export function resolveAgentRoute(input: { cachedWorkspaceId: string | null | undefined; connectionStatus: HostRuntimeConnectionStatus; lookup: AgentRouteLookup; + hasHydratedWorkspaces: boolean; + hasHydratedLayout: boolean; }): AgentRouteResolution { if (!input.serverId || !input.agentId) { return { kind: "invalid" }; } + const hydrationPending = !input.hasHydratedWorkspaces || !input.hasHydratedLayout; + if (hydrationPending && input.connectionStatus === "online") return { kind: "fetchingAgent" }; + const cachedWorkspaceId = normalizeWorkspaceOpaqueId(input.cachedWorkspaceId); - if (cachedWorkspaceId) { + if (cachedWorkspaceId && !hydrationPending) { return { kind: "resolved", workspaceId: cachedWorkspaceId }; } diff --git a/packages/app/src/notification-open/index.ts b/packages/app/src/notification-open/index.ts new file mode 100644 index 0000000000..cd5a9c93bb --- /dev/null +++ b/packages/app/src/notification-open/index.ts @@ -0,0 +1,6 @@ +export { + createNotificationOpenRouter, + parseNotificationOpenTarget, + WEB_NOTIFICATION_CLICK_EVENT, +} from "./model"; +export type { NotificationOpenSource, NotificationTap } from "./source"; diff --git a/packages/app/src/notification-open/model.test.ts b/packages/app/src/notification-open/model.test.ts new file mode 100644 index 0000000000..ffcb516b0d --- /dev/null +++ b/packages/app/src/notification-open/model.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { createNotificationOpenRouter, parseNotificationOpenTarget } from "./model"; +import type { NotificationOpenSource, NotificationTap } from "./source"; + +class FakeSource implements NotificationOpenSource { + listeners = new Set<(tap: NotificationTap) => void>(); + acknowledged: string[] = []; + launchReads = 0; + constructor(readonly launch: NotificationTap | null) {} + subscribe(onTap: (tap: NotificationTap) => void) { + this.listeners.add(onTap); + return () => { + this.listeners.delete(onTap); + }; + } + async takeLaunchTap() { + this.launchReads++; + return this.launch; + } + acknowledge(tap: NotificationTap) { + this.acknowledged.push(tap.id); + } + tap(tap: NotificationTap) { + for (const listener of this.listeners) listener(tap); + } +} +const launch: NotificationTap = { + id: "one", + data: { serverId: "host", workspaceId: "stale", agentId: "agent" }, +}; +async function flush() { + for (let i = 0; i < 10; i++) await Promise.resolve(); +} + +describe("notification open", () => { + it.each([ + [undefined, null], + [null, null], + [[], null], + [{ agentId: "a" }, null], + [{ serverId: " " }, null], + [{ serverId: " h " }, { kind: "host", serverId: "h" }], + [ + { serverId: "h", agentId: "a" }, + { kind: "agent", serverId: "h", agentId: "a" }, + ], + [ + { serverId: "h", workspaceId: "stale", agentId: "a" }, + { kind: "agent", serverId: "h", agentId: "a" }, + ], + [ + { serverId: "h", workspaceId: "w", terminalId: "t", agentId: "a" }, + { kind: "terminal", serverId: "h", workspaceId: "w", terminalId: "t" }, + ], + ])("parses %j", (data, expected) => { + expect(parseNotificationOpenTarget(data)).toEqual(expected); + }); + + function setup(whenReadyToNavigate = () => Promise.resolve()) { + const source = new FakeSource(launch); + const agents: unknown[] = []; + const routes: string[] = []; + const terminals: unknown[] = []; + const router = createNotificationOpenRouter({ + source, + openAgent: (target) => { + agents.push(target); + }, + openTerminal: (target) => { + terminals.push(target); + }, + openRoute: (route) => { + routes.push(route); + }, + whenReadyToNavigate, + }); + return { source, agents, routes, terminals, router }; + } + + it("takes the launch tap once across remounts", async () => { + const { source, agents, router } = setup(); + router.start()(); + const stop = router.start(); + await flush(); + expect(source.launchReads).toBe(1); + expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]); + stop(); + }); + + it("ignores listener replay of the launch tap after remount", async () => { + const { source, agents, router } = setup(); + const stop = router.start(); + await flush(); + stop(); + const stopAgain = router.start(); + source.tap(launch); + await flush(); + expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]); + expect(source.acknowledged).toEqual(["one"]); + stopAgain(); + }); + + it("waits for readiness before dispatch and acknowledgement", async () => { + let ready = () => {}; + const readiness = new Promise((resolve) => { + ready = resolve; + }); + const { source, agents, router } = setup(() => readiness); + const stop = router.start(); + await flush(); + expect(agents).toEqual([]); + expect(source.acknowledged).toEqual([]); + ready(); + await flush(); + expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]); + expect(source.acknowledged).toEqual(["one"]); + stop(); + }); + + it("acknowledges each distinct tap including invalid targets", async () => { + const { source, agents, router } = setup(); + const stop = router.start(); + await flush(); + source.tap({ id: "two", data: { serverId: "host", agentId: "second" } }); + source.tap({ id: "invalid", data: undefined }); + await flush(); + expect(agents).toEqual([ + { serverId: "host", agentId: "agent" }, + { serverId: "host", agentId: "second" }, + ]); + expect(source.acknowledged).toEqual(["one", "two", "invalid"]); + stop(); + }); + + it("dispatches terminals and hosts through their navigation capabilities", async () => { + const { source, routes, terminals, router } = setup(); + const stop = router.start(); + source.tap({ + id: "terminal", + data: { serverId: "host", workspaceId: "ws", terminalId: "term" }, + }); + source.tap({ id: "host", data: { serverId: "host" } }); + await flush(); + expect(routes).toEqual(["/h/host"]); + expect(terminals).toEqual([ + { kind: "terminal", serverId: "host", workspaceId: "ws", terminalId: "term" }, + ]); + stop(); + }); +}); diff --git a/packages/app/src/notification-open/model.ts b/packages/app/src/notification-open/model.ts new file mode 100644 index 0000000000..79c40394bb --- /dev/null +++ b/packages/app/src/notification-open/model.ts @@ -0,0 +1,67 @@ +import { buildHostRootRoute } from "@/utils/host-routes"; +import type { NotificationOpenSource, NotificationTap } from "./source"; + +export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click"; + +export type NotificationOpenTarget = + | { kind: "agent"; serverId: string; agentId: string } + | { kind: "terminal"; serverId: string; workspaceId: string; terminalId: string } + | { kind: "host"; serverId: string }; + +function readId(data: object, key: string): string { + const value: unknown = Reflect.get(data, key); + return typeof value === "string" ? value.trim() : ""; +} + +export function parseNotificationOpenTarget(data: unknown): NotificationOpenTarget | null { + if (typeof data !== "object" || data === null || Array.isArray(data)) return null; + const serverId = readId(data, "serverId"); + if (!serverId) return null; + const workspaceId = readId(data, "workspaceId"); + const terminalId = readId(data, "terminalId"); + if (workspaceId && terminalId) return { kind: "terminal", serverId, workspaceId, terminalId }; + const agentId = readId(data, "agentId"); + if (agentId) return { kind: "agent", serverId, agentId }; + return { kind: "host", serverId }; +} + +interface NotificationOpenRouterDeps { + source: NotificationOpenSource; + openAgent: (target: { serverId: string; agentId: string }) => void; + openRoute: (route: string) => void; + openTerminal: (target: Extract) => void; + whenReadyToNavigate: () => Promise; +} + +export function createNotificationOpenRouter(deps: NotificationOpenRouterDeps) { + const handledIds = new Set(); + let launchTaken = false; + async function receive(tap: NotificationTap) { + if (handledIds.has(tap.id)) return; + handledIds.add(tap.id); + await deps.whenReadyToNavigate(); + const target = parseNotificationOpenTarget(tap.data); + if (target?.kind === "agent") { + deps.openAgent({ serverId: target.serverId, agentId: target.agentId }); + } else if (target?.kind === "terminal") { + deps.openTerminal(target); + } else if (target?.kind === "host") { + deps.openRoute(buildHostRootRoute(target.serverId)); + } + deps.source.acknowledge(tap); + } + return { + start() { + const unsubscribe = deps.source.subscribe((tap) => { + void receive(tap); + }); + if (!launchTaken) { + launchTaken = true; + void deps.source.takeLaunchTap().then((tap) => { + return tap ? receive(tap) : undefined; + }); + } + return unsubscribe; + }, + }; +} diff --git a/packages/app/src/notification-open/react/index.tsx b/packages/app/src/notification-open/react/index.tsx new file mode 100644 index 0000000000..87507a36cf --- /dev/null +++ b/packages/app/src/notification-open/react/index.tsx @@ -0,0 +1,58 @@ +import { useEffect } from "react"; +import { router, usePathname, useRootNavigationState, type Href } from "expo-router"; +import { useHostRegistryStatus } from "@/runtime/host-runtime"; +import { + navigateToWorkspace, + useIsLastWorkspaceSelectionHydrated, +} from "@/stores/navigation-active-workspace-store"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { createNotificationOpenRouter } from "../model"; +import { createNotificationOpenSource } from "../source"; + +let ready = false; +const waiters = new Set<() => void>(); +const notificationRouter = createNotificationOpenRouter({ + source: createNotificationOpenSource(), + openAgent: (target) => navigateToAgent({ ...target, pin: true }), + openTerminal: ({ serverId, workspaceId, terminalId }) => + navigateToWorkspace({ + serverId, + workspaceId, + target: { kind: "terminal", terminalId }, + }), + openRoute: (route) => router.navigate(route as Href), + whenReadyToNavigate: () => + ready + ? Promise.resolve() + : new Promise((resolve) => { + waiters.add(resolve); + }), +}); + +export function NotificationOpenRouter({ startupBlocked }: { startupBlocked: boolean }) { + const root = useRootNavigationState(); + const pathname = usePathname(); + const registryStatus = useHostRegistryStatus(); + const selectionHydrated = useIsLastWorkspaceSelectionHydrated(); + // Index routes redirect during startup. Let their final leaf commit before dispatching a tap. + const atStartupIndex = pathname === "/" || /^\/h\/[^/]+\/?$/.test(pathname); + const canNavigate = Boolean( + root?.key && + registryStatus === "ready" && + selectionHydrated && + !startupBlocked && + !atStartupIndex, + ); + useEffect(() => { + ready = canNavigate; + if (ready) { + for (const resolve of waiters) resolve(); + waiters.clear(); + } + return () => { + ready = false; + }; + }, [canNavigate]); + useEffect(() => notificationRouter.start(), []); + return null; +} diff --git a/packages/app/src/notification-open/source.electron.ts b/packages/app/src/notification-open/source.electron.ts new file mode 100644 index 0000000000..a6fd0a61fd --- /dev/null +++ b/packages/app/src/notification-open/source.electron.ts @@ -0,0 +1,42 @@ +import { getDesktopHost } from "@/desktop/host"; +import { createNotificationOpenSource as createWebSource } from "./source.web"; +import type { NotificationOpenSource } from "./source"; + +export function createNotificationOpenSource(): NotificationOpenSource { + const web = createWebSource(); + let nextId = 0; + return { + ...web, + subscribe(onTap) { + const stopWeb = web.subscribe(onTap); + let disposed = false; + let stopDesktop: (() => void) | undefined; + const subscription = getDesktopHost()?.events?.on?.( + "notification-click", + (payload: unknown) => { + const data = + typeof payload === "object" && payload !== null && "data" in payload + ? payload.data + : undefined; + onTap({ + id: `electron:${++nextId}`, + data: + typeof data === "object" && data !== null + ? Object.fromEntries(Object.entries(data)) + : undefined, + }); + }, + ); + void Promise.resolve(subscription).then((stop) => { + if (disposed) stop?.(); + else stopDesktop = stop; + return; + }); + return () => { + disposed = true; + stopWeb(); + stopDesktop?.(); + }; + }, + }; +} diff --git a/packages/app/src/notification-open/source.native.ts b/packages/app/src/notification-open/source.native.ts new file mode 100644 index 0000000000..0700d58c81 --- /dev/null +++ b/packages/app/src/notification-open/source.native.ts @@ -0,0 +1,36 @@ +import * as Notifications from "expo-notifications"; +import type { NotificationOpenSource, NotificationTap } from "./source"; + +function toTap(response: Notifications.NotificationResponse): NotificationTap { + return { + id: response.notification.request.identifier, + data: response.notification.request.content.data, + }; +} + +export function createNotificationOpenSource(): NotificationOpenSource { + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: false, + shouldShowBanner: false, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), + }); + return { + subscribe(onTap) { + const subscription = Notifications.addNotificationResponseReceivedListener((response) => + onTap(toTap(response)), + ); + return () => subscription.remove(); + }, + async takeLaunchTap() { + const response = await Notifications.getLastNotificationResponseAsync(); + return response ? toTap(response) : null; + }, + acknowledge() { + Notifications.clearLastNotificationResponse(); + }, + }; +} diff --git a/packages/app/src/notification-open/source.ts b/packages/app/src/notification-open/source.ts new file mode 100644 index 0000000000..b88b1880e0 --- /dev/null +++ b/packages/app/src/notification-open/source.ts @@ -0,0 +1,12 @@ +export interface NotificationTap { + id: string; + data: Record | undefined; +} + +export interface NotificationOpenSource { + subscribe(onTap: (tap: NotificationTap) => void): () => void; + takeLaunchTap(): Promise; + acknowledge(tap: NotificationTap): void; +} + +export { createNotificationOpenSource } from "./source.native"; diff --git a/packages/app/src/notification-open/source.web.ts b/packages/app/src/notification-open/source.web.ts new file mode 100644 index 0000000000..31b9aef065 --- /dev/null +++ b/packages/app/src/notification-open/source.web.ts @@ -0,0 +1,23 @@ +import { WEB_NOTIFICATION_CLICK_EVENT } from "./model"; +import type { NotificationOpenSource } from "./source"; +import { isWeb } from "@/constants/platform"; + +export function createNotificationOpenSource(): NotificationOpenSource { + let nextId = 0; + return { + subscribe(onTap) { + if (!isWeb) return () => {}; + function receive(event: Event) { + if (!(event instanceof CustomEvent)) return; + event.preventDefault(); + onTap({ id: `web:${++nextId}`, data: event.detail?.data }); + } + window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, receive); + return () => window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, receive); + }, + async takeLaunchTap() { + return null; + }, + acknowledge() {}, + }; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 530c1a07d0..df9646f93d 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -93,6 +93,7 @@ import { type SheetTab, type WorkspaceTabPlacement, useWorkspaceLayoutStore, + usePendingWorkspaceOpen, useWorkspaceLayoutStoreHydrated, } from "@/stores/workspace-layout-store"; import { @@ -271,12 +272,10 @@ interface WorkspaceScreenProps { serverId: string; workspaceId: string; isRouteFocused?: boolean; - recoveryRequested?: boolean; } type WorkspaceScreenContentProps = WorkspaceScreenProps & { isRouteFocused: boolean; - recoveryRequested: boolean; }; function trimNonEmpty(value: string | null | undefined): string | null { @@ -419,7 +418,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ serverId, workspaceId, isRouteFocused, - recoveryRequested, }: WorkspaceScreenProps) { const navigationFocused = useIsFocused(); useEffect(() => { @@ -433,7 +431,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ serverId={serverId} workspaceId={workspaceId} isRouteFocused={isRouteFocused ?? navigationFocused} - recoveryRequested={recoveryRequested ?? false} /> ); }); @@ -748,9 +745,9 @@ function useResolvedWorkspaceRouteState(input: { function shouldInspectWorkspaceRecovery( hasHydratedWorkspaces: boolean, workspace: WorkspaceDescriptor | null, - recoveryRequested: boolean, + pendingOpenTarget: WorkspaceTabTarget | null, ): boolean { - return recoveryRequested && hasHydratedWorkspaces && workspace === null; + return pendingOpenTarget?.kind === "agent" && hasHydratedWorkspaces && workspace === null; } function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { @@ -1015,7 +1012,6 @@ function WorkspaceScreenContent({ serverId, workspaceId, isRouteFocused, - recoveryRequested, }: WorkspaceScreenContentProps) { const { t } = useTranslation(); const _insets = useSafeAreaInsets(); @@ -1149,13 +1145,18 @@ function WorkspaceScreenContent({ const hasHydratedWorkspaces = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, ); + const pendingOpenTarget = usePendingWorkspaceOpen({ + workspaceKey: persistenceKey, + hasHydratedWorkspaces, + workspaceExists: workspaceDescriptor !== null, + }); const workspaceRecovery = useWorkspaceRecovery({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, enabled: shouldInspectWorkspaceRecovery( hasHydratedWorkspaces, workspaceDescriptor, - recoveryRequested, + pendingOpenTarget, ), }); diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index be001c5f2d..0404ea34ed 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -38,6 +38,7 @@ function navigateDeps(): NavigateToWorkspaceDeps { getSessionAgents: (serverId) => useSessionStore.getState().sessions[serverId]?.agents.values() ?? [], isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(), + deferOpenTab: (input) => useWorkspaceLayoutStore.getState().deferOpenTab(input), openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input), rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection), navigateToRoute: (route) => { diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index 3902ba116c..055049baee 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -21,7 +21,12 @@ function createFakeDeps(overrides: Partial = {}) { const navigations: string[] = []; const remembered: ActiveWorkspaceSelection[] = []; const openedTabs: RecordedTab[] = []; + const deferredTabs: unknown[] = []; const deps: NavigateToWorkspaceDeps = { + deferOpenTab: (input) => { + deferredTabs.push(input); + return null; + }, getSessionWorkspaces: () => null, getSessionAgents: () => [] as Agent[], isWorkspaceLayoutHydrated: () => true, @@ -33,7 +38,7 @@ function createFakeDeps(overrides: Partial = {}) { navigateToRoute: (route) => navigations.push(route), ...overrides, }; - return { deps, navigations, remembered, openedTabs }; + return { deps, navigations, remembered, openedTabs, deferredTabs }; } function createLastSelectionDeps( @@ -139,7 +144,7 @@ describe("workspace navigation", () => { }); it("defers an agent tab until a missing workspace is recovered", () => { - const { deps, navigations, openedTabs } = createFakeDeps({ + const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({ getSessionWorkspaces: () => new Map(), }); @@ -153,7 +158,8 @@ describe("workspace navigation", () => { ); expect(openedTabs).toEqual([]); - expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]); + expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]); + expect(deferredTabs).toHaveLength(1); }); it("defers an agent tab until persisted workspace layout has hydrated", () => { @@ -161,7 +167,7 @@ describe("workspace navigation", () => { id: "workspace-a", workspaceDirectory: "/repo/workspace-a", } as WorkspaceDescriptor; - const { deps, navigations, openedTabs } = createFakeDeps({ + const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({ getSessionWorkspaces: () => new Map([[workspace.id, workspace]]), isWorkspaceLayoutHydrated: () => false, }); @@ -177,7 +183,8 @@ describe("workspace navigation", () => { ); expect(openedTabs).toEqual([]); - expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]); + expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]); + expect(deferredTabs).toHaveLength(1); }); it("reads the active workspace from the current route", () => { diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index f8bb8ccf9f..f234b1c2a6 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -1,7 +1,6 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; import { pickAttentionAgent } from "@/utils/agent-attention"; import { - buildHostWorkspaceOpenRoute, buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment, parseHostWorkspaceRouteFromPathname, @@ -13,6 +12,7 @@ import { import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab"; +import type { OpenWorkspaceTabInput } from "@/stores/workspace-layout-store"; import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions"; export interface RouteSelectionInput { @@ -35,6 +35,7 @@ export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps { getSessionWorkspaces: (serverId: string) => Map | null | undefined; getSessionAgents: (serverId: string) => Iterable; isWorkspaceLayoutHydrated: () => boolean; + deferOpenTab: (input: OpenWorkspaceTabInput) => null; rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void; navigateToRoute: (route: string) => void; } @@ -91,13 +92,10 @@ export function navigateToWorkspace( workspaces, workspaceId: input.workspaceId, }); - const shouldDeferAgentOpen = Boolean( - input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()), - ); + const shouldDeferOpen = !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated(); if (input.target) { - if (!shouldDeferAgentOpen) { - prepareWorkspaceTab({ ...input, target: input.target }, deps); - } + const openTab = shouldDeferOpen ? deps.deferOpenTab : deps.openTab; + prepareWorkspaceTab({ ...input, target: input.target }, { openTab }); } else { const workspaceAgents = resolvedWorkspaceId ? Array.from(deps.getSessionAgents(input.serverId)).filter( @@ -114,14 +112,7 @@ export function navigateToWorkspace( } } - const route = - input.target?.kind === "agent" && shouldDeferAgentOpen - ? buildHostWorkspaceOpenRoute( - input.serverId, - input.workspaceId, - `agent:${input.target.agentId}`, - ) - : buildHostWorkspaceRoute(input.serverId, input.workspaceId); + const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId); deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId }); deps.navigateToRoute(route); return route; diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index a989dc0dd3..cfac2f999a 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -5131,3 +5131,43 @@ describe("compact workspace sheet", () => { expect(received).toEqual([[], ["child"], []]); }); }); + +describe("pending workspace opens", () => { + it("retains an open across hydration and consumes it once only for its workspace", async () => { + const values = new Map(); + const store = createWorkspaceLayoutStore(undefined, { + getItem: async (key) => values.get(key) ?? null, + setItem: async (key, value) => { + values.set(key, value); + }, + removeItem: async (key) => { + values.delete(key); + }, + }); + store.getState().deferOpenTab({ + workspaceKey: "server:beta", + target: { kind: "agent", agentId: "b" }, + intent: "reveal", + pin: true, + }); + await store.persist.rehydrate(); + expect(store.getState().consumePendingOpen("server:alpha")).toBeNull(); + expect( + store + .getState() + .getWorkspaceTabs("server:alpha") + .filter((tab) => tab.target.kind === "agent"), + ).toEqual([]); + expect(store.getState().consumePendingOpen("server:beta")).toBe("agent_b"); + expect(store.getState().consumePendingOpen("server:beta")).toBeNull(); + expect( + store + .getState() + .getWorkspaceTabs("server:beta") + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.target), + ).toEqual([{ kind: "agent", agentId: "b" }]); + expect(store.getState().pinnedAgentIdsByWorkspace["server:beta"]).toEqual(new Set(["b"])); + expect(store.getState().pendingOpenByWorkspace).toEqual({}); + }); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 01c932605e..4091b7d53c 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1,5 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useState } from "react"; import { create } from "zustand"; import { persist, type StateStorage } from "zustand/middleware"; import type { z } from "zod"; @@ -130,6 +130,9 @@ export type WorkspaceTabDestination = interface WorkspaceLayoutStore { /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; + pendingOpenByWorkspace: Record; + deferOpenTab: (input: OpenWorkspaceTabInput) => null; + consumePendingOpen: (workspaceKey: string) => string | null; /** * Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The * list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount @@ -1020,6 +1023,26 @@ export function createWorkspaceLayoutStore( } return tabId; }, + pendingOpenByWorkspace: {}, + deferOpenTab: (input) => { + set((state) => ({ + pendingOpenByWorkspace: { + ...state.pendingOpenByWorkspace, + [input.workspaceKey]: input, + }, + })); + return null; + }, + consumePendingOpen: (workspaceKey) => { + const pending = get().pendingOpenByWorkspace[workspaceKey]; + if (!pending) return null; + set((state) => { + const { [workspaceKey]: _pending, ...pendingOpenByWorkspace } = + state.pendingOpenByWorkspace; + return { pendingOpenByWorkspace }; + }); + return get().openTab(pending); + }, layoutMode: "split-panes", sheetsByWorkspace: {}, layoutByWorkspace: {}, @@ -2160,6 +2183,7 @@ export function createWorkspaceLayoutStore( set((state) => { const hasAny = + normalizedWorkspaceKey in state.pendingOpenByWorkspace || normalizedWorkspaceKey in state.layoutByWorkspace || normalizedWorkspaceKey in state.splitSizesByWorkspace || normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace || @@ -2173,6 +2197,8 @@ export function createWorkspaceLayoutStore( if (!hasAny) { return state; } + const { [normalizedWorkspaceKey]: _pendingOpen, ...pendingOpenByWorkspace } = + state.pendingOpenByWorkspace; const { [normalizedWorkspaceKey]: _autoOpened, ...pullRequestTabAutoOpenedByWorkspace @@ -2199,6 +2225,7 @@ export function createWorkspaceLayoutStore( state.sidePaneIdByWorkspace; return { ...withSheets(state, normalizedWorkspaceKey, NO_SHEETS), + pendingOpenByWorkspace, pullRequestTabAutoOpenedByWorkspace, layoutByWorkspace, splitSizesByWorkspace, @@ -2348,3 +2375,27 @@ export function useWorkspaceLayoutStoreHydrated(): boolean { return hasHydrated; } + +interface PendingWorkspaceOpenReadiness { + workspaceKey: string | null; + hasHydratedWorkspaces: boolean; + workspaceExists: boolean; +} + +export function usePendingWorkspaceOpen({ + workspaceKey, + hasHydratedWorkspaces, + workspaceExists, +}: PendingWorkspaceOpenReadiness): WorkspaceTabTarget | null { + const workspaceReady = hasHydratedWorkspaces && workspaceExists; + const pending = useWorkspaceLayoutStore((state) => + workspaceKey ? state.pendingOpenByWorkspace[workspaceKey] : undefined, + ); + const layoutHydrated = useWorkspaceLayoutStoreHydrated(); + useLayoutEffect(() => { + if (workspaceKey && workspaceReady && layoutHydrated && pending) { + useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey); + } + }, [workspaceKey, workspaceReady, layoutHydrated, pending]); + return pending ? pending.target : null; +} diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts index a9c6bc46c9..f768edfce5 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -62,7 +62,7 @@ describe("resolveNavigateToAgent", () => { ]); }); - it("uses the input workspaceId without reading the nav target", () => { + it("uses the input workspaceId only when the store has no record", () => { const readTargets: { serverId: string; agentId: string }[] = []; const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null }); deps.readAgentNavTarget = (input) => { @@ -75,7 +75,7 @@ describe("resolveNavigateToAgent", () => { deps, ); - expect(readTargets).toEqual([]); + expect(readTargets).toEqual([{ serverId: SERVER_ID, agentId: AGENT_ID }]); expect(tabNavigations).toEqual([ { serverId: SERVER_ID, @@ -86,6 +86,12 @@ describe("resolveNavigateToAgent", () => { ]); }); + it("prefers the current agent workspace over a stale input workspace", () => { + const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: "current" }); + resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: "stale" }, deps); + expect(tabNavigations[0]?.workspaceId).toBe("current"); + }); + it("falls back to the host agent route when the agent has no workspaceId", () => { const { deps, hostNavigations, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null, diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts index a6ba8c155d..842760d793 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -26,8 +26,8 @@ export function resolveNavigateToAgent( deps: NavigateToAgentDeps, ): string { const agentWorkspaceId = - input.workspaceId ?? - deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId; + deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }) + .agentWorkspaceId ?? input.workspaceId; const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId); if (!workspaceId) { diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts deleted file mode 100644 index 92fdad43f4..0000000000 --- a/packages/app/src/utils/notification-routing.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing"; - -describe("resolveNotificationTarget", () => { - it("extracts non-empty server and agent ids", () => { - expect( - resolveNotificationTarget({ - serverId: " server-123 ", - agentId: " agent-456 ", - }), - ).toEqual({ - serverId: "server-123", - agentId: "agent-456", - workspaceId: null, - terminalId: null, - }); - }); - - it("returns null for missing/empty ids", () => { - expect(resolveNotificationTarget({ serverId: "", agentId: " " })).toEqual({ - serverId: null, - agentId: null, - workspaceId: null, - terminalId: null, - }); - expect(resolveNotificationTarget(undefined)).toEqual({ - serverId: null, - agentId: null, - workspaceId: null, - terminalId: null, - }); - }); - - it("does not treat cwd as a workspace id alias", () => { - expect( - resolveNotificationTarget({ - serverId: "srv-1", - agentId: "agent-1", - cwd: "/tmp/repo", - }), - ).toEqual({ - serverId: "srv-1", - agentId: "agent-1", - workspaceId: null, - terminalId: null, - }); - }); -}); - -describe("buildNotificationRoute", () => { - it("routes to the agent path when workspace id is present", () => { - expect( - buildNotificationRoute({ - serverId: "srv-1", - agentId: "agent-1", - workspaceId: "ws-main", - }), - ).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1"); - }); - - it("does not treat an incomplete notification as an agent URL", () => { - expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe("/h/srv-1"); - }); - - it("routes to the workspace terminal tab when workspace and terminal ids are present", () => { - expect( - buildNotificationRoute({ - serverId: "srv-1", - workspaceId: "ws-main", - terminalId: "term-1", - }), - ).toBe("/h/srv-1/workspace/ws-main?open=terminal%3Aterm-1"); - }); - - it("falls back to host root for a terminal without a workspace id", () => { - expect( - buildNotificationRoute({ - serverId: "srv-1", - terminalId: "term-1", - cwd: "/tmp/repo", - }), - ).toBe("/h/srv-1"); - }); - - it("falls back to host root when only serverId is present", () => { - expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only"); - }); - - it("falls back to root when no server id is present", () => { - expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/"); - expect(buildNotificationRoute(undefined)).toBe("/"); - }); - - it("encodes path segments", () => { - expect( - buildNotificationRoute({ - serverId: "srv/with/slash", - workspaceId: "workspace-1", - agentId: "agent with space", - }), - ).toBe("/h/srv%2Fwith%2Fslash/workspace/workspace-1?open=agent%3Aagent%20with%20space"); - }); -}); diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts deleted file mode 100644 index 04c78f591a..0000000000 --- a/packages/app/src/utils/notification-routing.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Href } from "expo-router"; -import { buildHostRootRoute, buildHostWorkspaceOpenRoute } from "@/utils/host-routes"; - -type NotificationData = Record | null | undefined; -type NotificationRoute = Extract; - -function readNonEmptyString(data: NotificationData, key: string): string | null { - const value = data?.[key]; - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export function resolveNotificationTarget(data: NotificationData): { - serverId: string | null; - agentId: string | null; - workspaceId: string | null; - terminalId: string | null; -} { - return { - serverId: readNonEmptyString(data, "serverId"), - agentId: readNonEmptyString(data, "agentId"), - workspaceId: readNonEmptyString(data, "workspaceId"), - terminalId: readNonEmptyString(data, "terminalId"), - }; -} - -export function buildNotificationRoute(data: NotificationData): NotificationRoute { - const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data); - if (serverId && workspaceId && agentId) { - return buildHostWorkspaceOpenRoute(serverId, workspaceId, `agent:${agentId}`); - } - if (serverId && workspaceId && terminalId) { - return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`); - } - if (serverId) { - return buildHostRootRoute(serverId); - } - return "/" as const; -} diff --git a/packages/app/src/utils/os-notifications.test.ts b/packages/app/src/utils/os-notifications.test.ts index 84a8a65bff..234e5f0eb4 100644 --- a/packages/app/src/utils/os-notifications.test.ts +++ b/packages/app/src/utils/os-notifications.test.ts @@ -1,3 +1,4 @@ +import { WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; interface MockNotificationOptions { @@ -132,7 +133,7 @@ describe("sendOsNotification", () => { (globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent; (globalThis as { location?: unknown }).location = { assign }; - const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } = await loadModuleForPlatform("web"); + const { sendOsNotification } = await loadModuleForPlatform("web"); const sent = await sendOsNotification({ title: "Agent finished", @@ -159,7 +160,7 @@ describe("sendOsNotification", () => { expect(assign).not.toHaveBeenCalled(); }); - it("falls back to route navigation when no listener handles the click", async () => { + it("leaves navigation to the app when no listener handles the click", async () => { const created: MockNotificationInstance[] = []; class MockNotification implements MockNotificationInstance { @@ -207,9 +208,7 @@ describe("sendOsNotification", () => { expect(clicked.clickListeners).toHaveLength(1); clicked.clickListeners[0]?.({} as Event); - expect(assign).toHaveBeenCalledWith( - "/h/srv%20with%20space/workspace/workspace-1?open=agent%3Aagent%2F1", - ); + expect(assign).not.toHaveBeenCalled(); }); it("returns false when the Notification API is unavailable", async () => { diff --git a/packages/app/src/utils/os-notifications.ts b/packages/app/src/utils/os-notifications.ts index 3d5823b906..c0c73d333d 100644 --- a/packages/app/src/utils/os-notifications.ts +++ b/packages/app/src/utils/os-notifications.ts @@ -1,6 +1,6 @@ import { Asset } from "expo-asset"; import { getDesktopHost } from "@/desktop/host"; -import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing"; +import { parseNotificationOpenTarget, WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open"; import { isNative } from "@/constants/platform"; interface OsNotificationPayload { @@ -17,8 +17,6 @@ interface WebNotificationInstance { addEventListener: (type: "click", listener: (event: Event) => void) => void; } -export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click"; - let permissionRequest: Promise | null = null; let notificationIconUrl: string | null | undefined; @@ -98,8 +96,7 @@ export async function ensureOsNotificationPermission(): Promise { } function hasNotificationClickTarget(data: Record | undefined): boolean { - const target = resolveNotificationTarget(data); - return target.serverId !== null || target.agentId !== null || target.workspaceId !== null; + return parseNotificationOpenTarget(data) !== null; } function getWebNotificationIconUrl(): string | undefined { @@ -135,31 +132,12 @@ function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boole return !dispatch(event); } -function fallbackNavigateToNotificationTarget(data: Record | undefined): void { - const route = buildNotificationRoute(data); - const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } }) - .location; - if (!location) { - return; - } - if (typeof location.assign === "function") { - location.assign(route); - return; - } - if (typeof location.href === "string") { - location.href = route; - } -} - function attachWebClickHandler( notification: WebNotificationInstance, data: Record | undefined, ): void { notification.addEventListener("click", () => { - const handledByApp = dispatchWebNotificationClick({ data }); - if (!handledByApp) { - fallbackNavigateToNotificationTarget(data); - } + dispatchWebNotificationClick({ data }); }); } diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 0ec90d2f8b..7401206108 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -188,6 +188,7 @@ test("PR routing declares stable behavior ownership", () => { desktop: [ "packages/desktop/**", "packages/app/src/desktop/**", + "packages/app/src/**/*.electron.{ts,tsx}", "packages/server/src/server/browser-tools/**", "packages/app/e2e/support/**", "packages/app/*config.{cjs,js,ts}", @@ -251,13 +252,11 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => { const filters = loadFilters(filtersPath); const browserSpecs = filesUnder("packages/app/e2e", (path) => path.endsWith(".spec.ts")); const desktopSpecs = filesUnder("packages/desktop/e2e", (path) => path.endsWith(".spec.ts")); - const electronModules = filesUnder("packages/app/src", (path) => /\.electron\.tsx?$/.test(path)); assert.ok(browserSpecs.length > 0); assert.ok(desktopSpecs.length > 0); assert.ok(browserSpecs.every((path) => path.startsWith("packages/app/e2e/browser/"))); assert.ok(desktopSpecs.every((path) => path.startsWith("packages/desktop/e2e/"))); - assert.ok(electronModules.every((path) => path.startsWith("packages/app/src/desktop/"))); const desktopPackage = JSON.parse(readFileSync(desktopPackagePath, "utf8")); assert.match(desktopPackage.scripts.test, /--exclude ["']e2e\/\*\*["']/); @@ -277,6 +276,7 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => { assert.deepEqual(filters.desktop, [ "packages/desktop/**", "packages/app/src/desktop/**", + "packages/app/src/**/*.electron.{ts,tsx}", "packages/server/src/server/browser-tools/**", "packages/app/e2e/support/**", "packages/app/*config.{cjs,js,ts}", From c3c6a936a423c97b88f96b3c4dad9ba03430a451 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 16:32:10 +0700 Subject: [PATCH 28/62] List plugin header buttons in the workspace menu on phones and narrow windows (#7630) * fix(app): list plugin header buttons in the compact workspace menu The compact header rewrite left plugin header buttons with no home, so phones and narrow windows had no way to reach them. They are now rows of the compact workspace menu, with menus and popovers as pages of its sheet, the way workspace scripts moved there. The header overflow and the workspace menu share one row/page builder. Each row and page resolves its own plugin installation's runtime: the old overflow routed every row through the first entry's host client. Closes #7601 * test(app): wait for agent control rows before keying the Command Center Two Command Center agent-control tests typed a query and pressed Enter or ArrowDown immediately. The agent's mode and model rows register after the panel opens, so on a slow runner the keys landed while only file search had matched and the mode never changed. The CI trace for the failure shows no Mode or Model rows at the moment of the key press. The tests now wait until both groups are listed, which keeps what they check: the best match wins, and a stale arrow-key selection is dropped. --- docs/plugins.md | 9 +- .../command-center-agent-controls.spec.ts | 7 +- .../app/e2e/browser/plugin-buttons.spec.ts | 7 +- .../helpers/command-center-agent-controls.ts | 16 ++ .../app/e2e/support/helpers/plugin-buttons.ts | 198 ++++++++++++++++- packages/app/src/plugins/buttons/view.tsx | 201 +++++++++++------- packages/app/src/plugins/index.ts | 1 + .../workspace/workspace-header-menu.tsx | 15 +- .../screens/workspace/workspace-screen.tsx | 1 + public-docs/plugins/reference.md | 6 +- public-docs/plugins/reference.zh-CN.md | 5 +- 11 files changed, 373 insertions(+), 93 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 30d99a00a8..bf83669d55 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -587,11 +587,14 @@ the author API, selection, `DocumentFrame`, and the version requirement. The exa Header buttons and composer pills share the client-only descriptor and registration lifecycle in `packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/reference.md#header-buttons) owns the author API and placement rules. Keep presentation policy in this module so another -placement can reuse behavior without copying registration or action state. +placement can reuse behavior without copying registration or action state. The header overflow +and the compact workspace menu share one builder that turns buttons into menu rows and pages; the +workspace menu reaches it through `usePluginWorkspaceMenu`. Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state, -query, and toast providers inside the surface content, including overflow pages from different -plugins. Providers only around the trigger do not reach those bodies. +query, and toast providers inside the surface content. A host menu can hold buttons from several +installations, so each of its rows and pages resolves its own installation's runtime. Providers +only around the trigger do not reach those bodies. Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned `subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon diff --git a/packages/app/e2e/browser/command-center-agent-controls.spec.ts b/packages/app/e2e/browser/command-center-agent-controls.spec.ts index 658e0e1123..a2082ac09b 100644 --- a/packages/app/e2e/browser/command-center-agent-controls.spec.ts +++ b/packages/app/e2e/browser/command-center-agent-controls.spec.ts @@ -5,6 +5,7 @@ import { applyCommandCenterAgentControls, chooseCommandCenterAgentControl, openCommandCenterForAgent, + searchAgentControls, expectCommandCenterAgentControlSelected, expectFocusedAgentControls, expectWorkspaceAgentConfiguration, @@ -111,8 +112,8 @@ test.describe("Command Center agent controls", () => { }); try { await workspace.client.setAgentMode(workspace.agentId, "approval-test"); - const input = await openCommandCenterForAgent(page, workspace); - await input.fill("load"); + await openCommandCenterForAgent(page, workspace); + await searchAgentControls(page, "load"); await page.keyboard.press("Enter"); await expectWorkspaceAgentConfiguration(workspace, { @@ -184,7 +185,7 @@ test.describe("Command Center agent controls", () => { try { await workspace.client.setAgentMode(workspace.agentId, "approval-test"); const input = await openCommandCenterForAgent(page, workspace); - await input.fill("loa"); + await searchAgentControls(page, "loa"); // Moves the highlight onto a model row, which still matches after the next keystroke. await page.keyboard.press("ArrowDown"); await input.fill("load"); diff --git a/packages/app/e2e/browser/plugin-buttons.spec.ts b/packages/app/e2e/browser/plugin-buttons.spec.ts index d3503f56ed..fe4227a8c3 100644 --- a/packages/app/e2e/browser/plugin-buttons.spec.ts +++ b/packages/app/e2e/browser/plugin-buttons.spec.ts @@ -4,13 +4,14 @@ import { withButtonShowcase } from "../support/helpers/plugin-buttons"; test("plugin header and composer buttons share actions, menus, content, and updates", async ({ page, -}) => { - test.setTimeout(180_000); - await withButtonShowcase(page, async (buttons) => { +}, info) => { + test.setTimeout(240_000); + await withButtonShowcase(page, info, async (buttons) => { await buttons.verifyObservationLifetime(); await buttons.openWideMenusAndPopovers(); await buttons.runAndUpdateActions(); await buttons.openCompactSheets(); + await buttons.useCompactWorkspaceMenu(); await buttons.hideAndUnloadOpenButtons(); }); }); diff --git a/packages/app/e2e/support/helpers/command-center-agent-controls.ts b/packages/app/e2e/support/helpers/command-center-agent-controls.ts index c460154d25..6b51b84609 100644 --- a/packages/app/e2e/support/helpers/command-center-agent-controls.ts +++ b/packages/app/e2e/support/helpers/command-center-agent-controls.ts @@ -22,6 +22,22 @@ export async function openCommandCenterForAgent( return panel.getByTestId("command-center-input"); } +/** + * Types a query that matches both the agent's modes and its models, and waits until rows from both + * groups are listed. The agent's controls register after the panel opens; a key pressed before + * then lands on whatever else matched, such as file search. + */ +export async function searchAgentControls(page: Page, query: string): Promise { + const panel = page.getByTestId("command-center-panel"); + await panel.getByTestId("command-center-input").fill(query); + await expect(panel.getByRole("button", { name: /^Mode › / }).first()).toBeVisible({ + timeout: 30_000, + }); + await expect(panel.getByRole("button", { name: /^Model › / }).first()).toBeVisible({ + timeout: 30_000, + }); +} + export async function chooseCommandCenterAgentControl(input: { page: Page; query: string; diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts index f3279ce7ef..2418f284d2 100644 --- a/packages/app/e2e/support/helpers/plugin-buttons.ts +++ b/packages/app/e2e/support/helpers/plugin-buttons.ts @@ -1,11 +1,17 @@ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { Locator } from "@playwright/test"; +import type { Locator, TestInfo } from "@playwright/test"; import { test, expect, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; import { buildAgentRoute } from "./mock-agent"; import { openCommandCenter } from "./command-center"; +import { + closeCompactWorkspaceMenu, + compactWorkspaceMenu, + goBackInMenu, + openCompactWorkspaceMenu, +} from "./compact-workspace-scripts"; import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { seedWorkspace } from "./seed-client"; import { pluginRequirements } from "./plugin-fixture"; @@ -60,7 +66,7 @@ export default function contribute(client) { let finishReview = () => {}; let attempts = 0; const action = { kind: "action", async onPress() { - if (++attempts === 1) throw new Error("Deployment failed. Try again."); + if (++attempts % 2 === 1) throw new Error("Deployment failed. Try again."); await new Promise((resolve) => { finish = resolve; }); deploy.update({ label: "Deployed" }); } }; @@ -165,6 +171,13 @@ async function command(page: Page, title: string) { await expect(panel).not.toBeVisible(); } +/** The command center opens from the sidebar, which compact layouts keep closed. */ +async function commandFromCompact(page: Page, title: string) { + await page.setViewportSize(WIDE); + await command(page, title); + await page.setViewportSize(COMPACT); +} + async function expectSurfaceVisible( page: Page, subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }), @@ -176,6 +189,61 @@ async function expectDetails(page: Page) { await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport(); } +function workspaceMenuRow(page: Page, title: string): Locator { + return compactWorkspaceMenu(page).getByRole("menuitem", { name: title, exact: true }); +} + +async function expectWorkspaceMenuRows(page: Page, titles: readonly string[]) { + const rows = compactWorkspaceMenu(page).getByRole("menuitem"); + for (const [index, title] of titles.entries()) { + await expect(rows.nth(index)).toHaveAccessibleName(title); + } +} + +async function chooseInWorkspaceMenu(page: Page, title: string) { + await workspaceMenuRow(page, title).click(); +} + +async function expectWorkspaceMenuClosed(page: Page) { + await expect(compactWorkspaceMenu(page)).toBeHidden({ timeout: 10_000 }); +} + +async function expectCompactDetails(page: Page) { + const menu = compactWorkspaceMenu(page); + await expect(menu.getByText("All systems operational", { exact: true })).toBeInViewport(); + await expect(menu.getByText("Presentation: compact", { exact: true })).toBeVisible(); +} + +/** Resolves once `subject` renders the same pixels twice in a row: entering animations are done. */ +async function expectStill(subject: Locator) { + let previous: Buffer | null = null; + await expect + .poll( + async () => { + const current = await subject.screenshot(); + const still = previous !== null && current.equals(previous); + previous = current; + return still; + }, + { intervals: [150] }, + ) + .toBe(true); +} + +/** Attaches the screen once per colour scheme, for review alongside the run. */ +async function captureSchemes(page: Page, info: TestInfo, name: string, subject: Locator) { + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await expectStill(subject); + const file = info.outputPath(`${name}-${colorScheme}.png`); + await info.attach(`${name}-${colorScheme}`, { + body: await page.screenshot({ path: file }), + contentType: "image/png", + }); + } + await page.emulateMedia({ colorScheme: "light" }); +} + async function expectHeaderTextTruncated(page: Page) { const title = page.getByTestId("workspace-header-title"); const subtitle = page.getByTestId("workspace-header-subtitle"); @@ -204,10 +272,12 @@ async function expectHeaderTextTruncated(page: Page) { export async function withButtonShowcase( page: Page, + info: TestInfo, run: (buttons: { openWideMenusAndPopovers(): Promise; runAndUpdateActions(): Promise; openCompactSheets(): Promise; + useCompactWorkspaceMenu(): Promise; hideAndUnloadOpenButtons(): Promise; verifyObservationLifetime(): Promise; }) => Promise, @@ -284,6 +354,12 @@ export async function withButtonShowcase( .getByTestId("plugin-button-chevron"), ).toHaveCount(0); await expectSurfaceVisible(page); + await captureSchemes( + page, + info, + "desktop-wide-header", + page.getByRole("toolbar", { name: "Workspace actions", exact: true }), + ); await press(page, "Header checks"); await expect( page.getByRole("menuitem", { name: "Run checks", exact: true }), @@ -303,6 +379,12 @@ export async function withButtonShowcase( await expect( page.getByRole("menuitem", { name: "Production locked", exact: true }), ).toBeDisabled(); + await captureSchemes( + page, + info, + "desktop-overflow", + page.getByRole("menuitem", { name: "Production locked", exact: true }), + ); await expectSurfaceVisible( page, page.getByRole("menuitem", { name: "Production locked", exact: true }), @@ -372,7 +454,19 @@ export async function withButtonShowcase( await expectSurfaceVisible(page); await command(page, "Enable example buttons"); await command(page, "Use icon-only header"); + await expect( + page.getByRole("button", { name: "Deploy application", exact: true }), + ).toHaveText(""); await expectSurfaceVisible(page); + await expect( + page.getByText("Deployment failed. Try again.", { exact: true }), + ).toHaveCount(0); + await captureSchemes( + page, + info, + "desktop-icon-only-header", + page.getByRole("toolbar", { name: "Workspace actions", exact: true }), + ); await command(page, "Hide review button"); await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount( 0, @@ -383,7 +477,7 @@ export async function withButtonShowcase( ); }), openCompactSheets: () => - test.step("compact header omits plugin actions while composer triggers open sheets", async () => { + test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => { await page.setViewportSize(COMPACT); await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0); await expect( @@ -422,6 +516,98 @@ export async function withButtonShowcase( ); await choose(page, "Run checks"); }), + useCompactWorkspaceMenu: () => + test.step("compact workspace menu carries header buttons with their actions, menus, and content", async () => { + await openCompactWorkspaceMenu(page); + await expectWorkspaceMenuRows(page, [ + "Deploy application", + "Header checks", + "Header status", + "Open deployment logs", + "Production locked", + "Copy workspace path", + ]); + await expect(workspaceMenuRow(page, "Production locked")).toBeDisabled(); + // Deploy lost its label to "Use icon-only header" and logs never had one; rows read titles. + await expect(workspaceMenuRow(page, "Deploy application")).toHaveText( + "Deploy application", + ); + await expect(workspaceMenuRow(page, "Open deployment logs")).toHaveText( + "Open deployment logs", + ); + await captureSchemes(page, info, "compact-root-icon-only", compactWorkspaceMenu(page)); + + await showcase.setTitle("Compact checks"); + await expect(workspaceMenuRow(page, "Deploy application")).toHaveCount(0); + await showcase.setTitle("Button showcase"); + await expect(workspaceMenuRow(page, "Deploy application")).toBeVisible(); + + await chooseInWorkspaceMenu(page, "Deploy application"); + await expectWorkspaceMenuClosed(page); + await expect( + page.getByText("Deployment failed. Try again.", { exact: true }), + ).toBeVisible(); + await openCompactWorkspaceMenu(page); + await chooseInWorkspaceMenu(page, "Deploy application"); + await expectWorkspaceMenuClosed(page); + await openCompactWorkspaceMenu(page); + await expect(workspaceMenuRow(page, "Deploy application")).toBeDisabled(); + await captureSchemes(page, info, "compact-row-states", compactWorkspaceMenu(page)); + await closeCompactWorkspaceMenu(page); + await commandFromCompact(page, "Finish deployment"); + await openCompactWorkspaceMenu(page); + await expect(workspaceMenuRow(page, "Deploy application")).toBeEnabled(); + await captureSchemes(page, info, "compact-root", compactWorkspaceMenu(page)); + + await chooseInWorkspaceMenu(page, "Header checks"); + await expectWorkspaceMenuRows(page, ["Run checks", "Environment", "Deployment details"]); + await captureSchemes(page, info, "compact-header-checks", compactWorkspaceMenu(page)); + await goBackInMenu(page); + await expect(workspaceMenuRow(page, "Header status")).toBeVisible(); + await chooseInWorkspaceMenu(page, "Header checks"); + await chooseInWorkspaceMenu(page, "Environment"); + await expectWorkspaceMenuRows(page, ["Staging", "Production"]); + await expect(workspaceMenuRow(page, "Production")).toBeDisabled(); + await captureSchemes(page, info, "compact-environment", compactWorkspaceMenu(page)); + await goBackInMenu(page); + await expect(workspaceMenuRow(page, "Run checks")).toBeVisible(); + await goBackInMenu(page); + await expect(workspaceMenuRow(page, "Header checks")).toBeVisible(); + + await chooseInWorkspaceMenu(page, "Header checks"); + await chooseInWorkspaceMenu(page, "Deployment details"); + await expectCompactDetails(page); + await captureSchemes( + page, + info, + "compact-deployment-details", + compactWorkspaceMenu(page), + ); + await goBackInMenu(page); + await expect(workspaceMenuRow(page, "Run checks")).toBeVisible(); + await chooseInWorkspaceMenu(page, "Deployment details"); + await expectCompactDetails(page); + await press(page, "Close deployment details"); + await expectWorkspaceMenuClosed(page); + + await openCompactWorkspaceMenu(page); + await chooseInWorkspaceMenu(page, "Header status"); + await expectCompactDetails(page); + await captureSchemes(page, info, "compact-header-status", compactWorkspaceMenu(page)); + await press(page, "Fail button content"); + await expect( + compactWorkspaceMenu(page).getByText("Plugin failed: Button content failed", { + exact: true, + }), + ).toBeVisible(); + await goBackInMenu(page); + await expect(workspaceMenuRow(page, "Header status")).toBeVisible(); + await chooseInWorkspaceMenu(page, "Open deployment logs"); + await expectCompactDetails(page); + await captureSchemes(page, info, "compact-logs-popover", compactWorkspaceMenu(page)); + await press(page, "Close deployment details"); + await expectWorkspaceMenuClosed(page); + }), hideAndUnloadOpenButtons: () => test.step("hiding an open button and unloading a plugin remove its surfaces", async () => { await page.setViewportSize(WIDE); @@ -454,6 +640,12 @@ export async function withButtonShowcase( 0, ); await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0); + await page.setViewportSize(COMPACT); + await openCompactWorkspaceMenu(page); + await expect(workspaceMenuRow(page, "Header checks")).toHaveCount(0); + await expect(workspaceMenuRow(page, "Header status")).toHaveCount(0); + await closeCompactWorkspaceMenu(page); + await page.setViewportSize(WIDE); await showcase.setTitle("Healthy app after plugin unload"); await expect(page.getByTestId("workspace-header-title")).toHaveText( "Healthy app after plugin unload", diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index 2cd3e1bd0f..3db4381597 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -4,7 +4,13 @@ import type { } from "./validation"; import type { PluginButtonIcon, PluginHostProps } from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; -import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react"; +import { + useCallback, + useMemo, + useSyncExternalStore, + type ReactElement, + type ReactNode, +} from "react"; import { Pressable, Text, View, useWindowDimensions } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native"; @@ -180,7 +186,7 @@ function ButtonBody({ behavior={item.behavior} disabled={view.entry.pending || view.entry.button.disabled || item.disabled === true} path={itemPath} - pageId={buttonPageId(view, itemPath)} + pageId={buttonPageId(view.entry.key, itemPath)} /> ); }); @@ -204,14 +210,19 @@ function ButtonSurfaceBody({ ); } -function buttonPageId(view: ButtonView, path: readonly string[]): string { - return [view.entry.key, ...path].join("/"); +// Prefixed so plugin pages can share a surface with the host's own pages. +function buttonPageId(key: number, path: readonly string[]): string { + return ["plugin-button", key, ...path].join("/"); } +type RenderButtonPage = (behavior: PluginButtonBehavior, path: readonly string[]) => ReactNode; + +/** A page for every menu and popover item below `behavior`, depth first. */ function buttonPages( - view: ButtonView, + key: number, behavior: PluginButtonBehavior, - path: readonly string[] = [], + path: readonly string[], + render: RenderButtonPage, ): MenuPageDefinition[] { if (behavior.kind !== "menu") return []; return behavior.items.flatMap((item) => { @@ -220,12 +231,12 @@ function buttonPages( const itemPath = [...path, item.id]; return [ { - id: buttonPageId(view, itemPath), + id: buttonPageId(key, itemPath), title: item.title, hoverIntent: item.behavior.kind === "menu", - content: , + content: render(item.behavior, itemPath), }, - ...buttonPages(view, item.behavior, itemPath), + ...buttonPages(key, item.behavior, itemPath, render), ]; }); } @@ -304,7 +315,13 @@ function ButtonControl({ view }: { view: ButtonView }) { {contents} ); - const pages = useMemo(() => buttonPages(view, button.behavior), [view, button.behavior]); + const pages = useMemo( + () => + buttonPages(entry.key, button.behavior, ROOT_PATH, (behavior, path) => ( + + )), + [view, entry.key, button.behavior], + ); return ( @@ -424,82 +441,112 @@ function PluginButtonHost({ const ThemedPluginButton = withUnistyles(PluginButtonHost); -// Overflow rows keep their own plugin runtime; a menu can contain several installations. -function OverflowButton({ view }: { view: ButtonView }) { - const { button } = view.entry; - return ( - +/** + * Each row and page of a shared menu resolves its own installation's runtime: one menu can hold + * buttons from several plugins, and none of them may run through another's host client. + */ +function useMenuButtonView(entry: RegisteredPluginButton, theme: PluginTheme): ButtonView | null { + const client = useHostRuntimeClient(entry.installation.serverId); + const toast = useToast(); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const serverId = entry.installation.serverId; + const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + return useMemo( + () => createButtonView({ entry, client, toast, compact, hostLabel, theme }), + [entry, client, toast, compact, hostLabel, theme], ); } -function OverflowPages({ - entries, - compact, - hostLabel, +function MenuButtonRow({ entry, theme }: { entry: RegisteredPluginButton; theme: PluginTheme }) { + const view = useMenuButtonView(entry, theme); + if (!view) return null; + const { button } = entry; + return ( + + + + + + ); +} + +function MenuButtonPage({ + entry, + behavior, + path, theme, }: { - entries: readonly RegisteredPluginButton[]; - compact: boolean; - hostLabel: string; + entry: RegisteredPluginButton; + behavior: PluginButtonBehavior; + path: readonly string[]; theme: PluginTheme; }) { - // The header belongs to one host, but each button keeps its installation's query cache and RPCs. - const client = useHostRuntimeClient(entries[0].installation.serverId); - const toast = useToast(); - const menuContent = useMemo(() => { - const pages: MenuPageDefinition[] = []; - const rows: ReactNode[] = []; - for (const entry of entries) { - const view = createButtonView({ entry, client, toast, compact, hostLabel, theme }); - if (!view) continue; - rows.push( - - - - - , - ); - if (entry.button.behavior.kind === "action") continue; - pages.push( - { - id: buttonPageId(view, []), - title: entry.button.title, - hoverIntent: false, - content: ( - - ), - }, - ...buttonPages(view, entry.button.behavior), - ); - } - return { pages, rows }; - }, [entries, client, toast, theme, hostLabel, compact]); + const view = useMenuButtonView(entry, theme); + if (!view) return null; + return ; +} + +const ThemedMenuButtonRow = withUnistyles(MenuButtonRow); +const ThemedMenuButtonPage = withUnistyles(MenuButtonPage); + +export interface PluginButtonsMenu { + /** One row per button, in registration order. Render them on the menu's root page. */ + rows: ReactElement[]; + /** The pages those rows lead to. Pass them to the menu's surface. */ + pages: MenuPageDefinition[]; +} + +function buttonsMenu(entries: readonly RegisteredPluginButton[]): PluginButtonsMenu { + const rows = entries.map((entry) => ( + + )); + const pages = entries.flatMap((entry): MenuPageDefinition[] => { + const { behavior } = entry.button; + if (behavior.kind === "action") return []; + const render: RenderButtonPage = (pageBehavior, path) => ( + + ); + return [ + { + id: buttonPageId(entry.key, ROOT_PATH), + title: entry.button.title, + hoverIntent: false, + content: render(behavior, ROOT_PATH), + }, + ...buttonPages(entry.key, behavior, ROOT_PATH, render), + ]; + }); + return { rows, pages }; +} + +function OverflowSurface({ entries }: { entries: readonly RegisteredPluginButton[] }) { const { t } = useTranslation(); + const menu = useMemo(() => buttonsMenu(entries), [entries]); return ( - {menuContent.rows} + {menu.rows} ); } -const ThemedOverflowPages = withUnistyles(OverflowPages); - function useButtons(serverId: string, workspaceId: string, agentId: string | null) { const entries = useSyncExternalStore( pluginButtonStore.subscribe, @@ -545,6 +592,15 @@ export function PluginComposerPills({ )); } +/** + * A workspace's header buttons as rows and pages of a host menu. Compact headers have no room for + * the buttons themselves, so the workspace menu carries them instead. + */ +export function usePluginWorkspaceMenu(serverId: string, workspaceId: string): PluginButtonsMenu { + const entries = useButtons(serverId, workspaceId, null); + return useMemo(() => buttonsMenu(entries), [entries]); +} + export function PluginHeaderButtons({ serverId, workspaceId, @@ -590,12 +646,7 @@ export function PluginHeaderButtons({ > - + ) : null} diff --git a/packages/app/src/plugins/index.ts b/packages/app/src/plugins/index.ts index e221f4208f..ac3500e7e9 100644 --- a/packages/app/src/plugins/index.ts +++ b/packages/app/src/plugins/index.ts @@ -7,4 +7,5 @@ export { PluginComposerPills, PluginHeaderButtons, useHasPluginComposerPills, + usePluginWorkspaceMenu, } from "./buttons/view"; diff --git a/packages/app/src/screens/workspace/workspace-header-menu.tsx b/packages/app/src/screens/workspace/workspace-header-menu.tsx index 74a95c7548..b8f4739cfb 100644 --- a/packages/app/src/screens/workspace/workspace-header-menu.tsx +++ b/packages/app/src/screens/workspace/workspace-header-menu.tsx @@ -31,6 +31,7 @@ import { useWorkspaceScriptsMenuPages, type UseWorkspaceScriptsMenuPagesInput, } from "@/screens/workspace/scripts"; +import { usePluginWorkspaceMenu } from "@/plugins"; const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedCopy = withUnistyles(Copy); @@ -206,6 +207,7 @@ function HeaderMenuProfileItem({ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspaceActions { normalizedServerId: string; + normalizedWorkspaceId: string; scripts: UseWorkspaceScriptsMenuPagesInput; showCreateBrowserTab: boolean; createTerminalDisabled: boolean; @@ -218,10 +220,12 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace * the rarer launches stay here alongside the workspace actions. * * The header has no room for a scripts button either, so scripts are a page of this menu, and the - * ⋯ carries a dot while a running service is unhealthy. + * ⋯ carries a dot while a running service is unhealthy. Plugin header buttons are rows here for the + * same reason. */ export function WorkspaceHeaderMenuMobile({ normalizedServerId, + normalizedWorkspaceId, scripts, showCreateBrowserTab, createTerminalDisabled, @@ -242,6 +246,11 @@ export function WorkspaceHeaderMenuMobile({ router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href); }, [normalizedServerId, router]); const scriptsMenu = useWorkspaceScriptsMenuPages(scripts); + const pluginMenu = usePluginWorkspaceMenu(normalizedServerId, normalizedWorkspaceId); + const pages = useMemo( + () => [...scriptsMenu.pages, ...pluginMenu.pages], + [scriptsMenu.pages, pluginMenu.pages], + ); return ( @@ -266,10 +275,12 @@ export function WorkspaceHeaderMenuMobile({ align="start" width={220} testID="workspace-header-menu" - pages={scriptsMenu.pages} + pages={pages} sheetTitle={t("workspace.header.actions.workspaceActions")} > {scriptsMenu.trigger} + {pluginMenu.rows} + {pluginMenu.rows.length > 0 ? : null} {showCreateBrowserTab ? ( Date: Thu, 22 Oct 2026 12:07:39 +0200 Subject: [PATCH 29/62] Keep Claude session listing from loading whole histories into memory (#7626) * fix(claude): stream and cache session import descriptors * fix(claude): bound and schedule import reads Reject JSONL records over 4 MiB explicitly to preserve metadata. Limit shared reads to four globally and one per listing for fairness. Deduplicate reads by cacheKey with independent subscriber cancellation. Set a 45-second provider deadline before the 60-second client timeout. Clone returned descriptors to isolate overlapping callers. Validation: 97 focused tests passed; independent review approved. Detected-By: user * fix(claude): parallelize resilient session scans Use up to four workers per listing with a shared four-read limit and bounded FIFO queuing. Preserve cache-key deduplication, subscriber cancellation, and candidate order. Log and skip oversized or unreadable transcripts so healthy sessions remain visible. Propagate programming and abort errors. Detected-By: user * fix(claude): surface session import read failures Skip expected candidate read failures and return healthy sessions. Distinguish unreadable metadata from parsed transcripts, and raise AggregateError when every candidate is unreadable. Propagate systemic file errors and narrow discovery error suppression. Detected-By: user * fix(claude): keep large sessions importable while streaming history Preserve import metadata for transcripts containing image records above 4 MiB. Stream complete records instead of rejecting otherwise listable sessions, and cover overlapping requests across aliased provider roots under a constrained heap. Co-authored-by: Josh Lane * test(app): wait for active agent controls and submitted turns Observe the mode control before testing Command Center keyboard selection. Observe a running turn before waiting for its completion, so the rewind test cannot mistake the previous idle state and end marker for the new turn. --------- Co-authored-by: Josh Lane Co-authored-by: Josh Lane Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../e2e/browser/agent-message-rewind.spec.ts | 2 + .../helpers/command-center-agent-controls.ts | 3 + .../claude/agent.import-large-record.test.ts | 53 ++ .../agent/providers/claude/agent.test.ts | 584 ++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 181 +++++- .../claude/import-descriptor-scheduler.ts | 92 +++ 6 files changed, 890 insertions(+), 25 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude/agent.import-large-record.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/import-descriptor-scheduler.ts diff --git a/packages/app/e2e/browser/agent-message-rewind.spec.ts b/packages/app/e2e/browser/agent-message-rewind.spec.ts index 4875cc31c1..8d4e773259 100644 --- a/packages/app/e2e/browser/agent-message-rewind.spec.ts +++ b/packages/app/e2e/browser/agent-message-rewind.spec.ts @@ -21,8 +21,10 @@ async function completeSubmittedTurn( const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }); await expect(userMessage).toBeVisible(); + await expectAgentReadyToInterrupt(page); const finish = await agent.client.waitForFinish(agent.agentId, 30_000); expect(finish.status).toBe("idle"); + await expectAgentIdle(page); await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible(); await expect(userMessage).toHaveAttribute("aria-busy", "false"); return userMessage; diff --git a/packages/app/e2e/support/helpers/command-center-agent-controls.ts b/packages/app/e2e/support/helpers/command-center-agent-controls.ts index 6b51b84609..f819cc98e9 100644 --- a/packages/app/e2e/support/helpers/command-center-agent-controls.ts +++ b/packages/app/e2e/support/helpers/command-center-agent-controls.ts @@ -18,6 +18,9 @@ export async function openCommandCenterForAgent( workspace: Pick, ): Promise { await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId }); + await expect( + page.getByRole("button", { name: /^Select agent mode \(/ }).filter({ visible: true }), + ).toBeVisible({ timeout: 30_000 }); const panel = await openCommandCenter(page); return panel.getByTestId("command-center-input"); } diff --git a/packages/server/src/server/agent/providers/claude/agent.import-large-record.test.ts b/packages/server/src/server/agent/providers/claude/agent.import-large-record.test.ts new file mode 100644 index 0000000000..f0fb26ecc3 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.import-large-record.test.ts @@ -0,0 +1,53 @@ +import { test, expect } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import pino from "pino"; +import { ClaudeAgentClient } from "./agent.js"; + +test("lists a session containing a large image record and retains the final prompt", async () => { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-import-image-")); + try { + const project = path.join(configDir, "projects", "image-project"); + await fs.mkdir(project, { recursive: true }); + const first = { + type: "user", + sessionId: "image-session", + cwd: "/image-project", + message: { content: "Describe the image" }, + }; + const image = { + type: "user", + sessionId: "image-session", + cwd: "/image-project", + message: { + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "a".repeat(5 * 1024 * 1024) }, + }, + ], + }, + }; + const last = { type: "user", message: { content: "What changed?" } }; + await fs.writeFile( + path.join(project, "image-session.jsonl"), + [first, image, last].map((record) => JSON.stringify(record)).join("\n"), + ); + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + }); + const sessions = await client.listImportableSessions({ limit: 1 }); + expect(sessions).toEqual([ + expect.objectContaining({ + providerHandleId: "image-session", + title: "Describe the image", + firstPromptPreview: "Describe the image", + lastPromptPreview: "What changed?", + }), + ]); + } finally { + await fs.rm(configDir, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index d6b869f1e0..2b2c725269 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1,6 +1,9 @@ +import { execFile } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { promisify } from "node:util"; +import pino from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { PermissionResult, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; @@ -15,9 +18,11 @@ import { resolveClaudeCodeVersion, toClaudeSdkMcpConfig, } from "./agent.js"; +import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { streamSession } from "../test-utils/session-stream-adapter.js"; import type { + ImportableProviderSession, AgentPromptInput, AgentSession, AgentTimelineItem, @@ -1740,6 +1745,404 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => { }); describe("ClaudeAgentClient.listImportableSessions", () => { + async function descriptorFixture(count: number) { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-errors-")); + const projectDir = path.join(configDir, "projects", "errors"); + await fs.mkdir(projectDir, { recursive: true }); + const files: string[] = []; + const descriptors: ImportableProviderSession[] = []; + const logs: unknown[] = []; + for (let index = 0; index < count; index += 1) { + const file = path.join(projectDir, `${index}.jsonl`); + const timestamp = new Date(Date.UTC(2026, 0, 1) + (count - index) * 1000); + await fs.writeFile( + file, + JSON.stringify({ + type: "user", + sessionId: `session-${index}`, + cwd: "/errors", + message: { content: `Prompt ${index}` }, + }), + ); + await fs.utimes(file, timestamp, timestamp); + files.push(file); + descriptors.push({ + providerHandleId: `session-${index}`, + cwd: "/errors", + title: `Prompt ${index}`, + firstPromptPreview: `Prompt ${index}`, + lastPromptPreview: `Prompt ${index}`, + lastActivityAt: timestamp, + }); + } + const options = { + logger: pino({ level: "warn" }, { write: (line: string) => logs.push(JSON.parse(line)) }), + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + }; + return { + files, + descriptors, + logs, + options, + cleanup: () => fs.rm(configDir, { recursive: true, force: true }), + }; + } + + const cannotEnforceFilePermissions = process.platform === "win32" || process.getuid?.() === 0; + const discoveryPermissionCases = [ + { label: "project enumeration", mode: 0, deniedSuffix: "", existingSuffix: "" }, + { + label: "candidate stat", + mode: 0o400, + deniedSuffix: "hidden.jsonl", + existingSuffix: "0.jsonl", + }, + ]; + test.skipIf(cannotEnforceFilePermissions).each(discoveryPermissionCases)( + "logs denied $label while discovering a healthy project", + async ({ mode, deniedSuffix }) => { + const fixture = await descriptorFixture(1); + const projectsRoot = path.dirname(path.dirname(fixture.files[0]!)); + const deniedProject = path.join(projectsRoot, "denied-project"); + await fs.mkdir(deniedProject); + try { + await fs.writeFile(path.join(deniedProject, "hidden.jsonl"), '{"type":"user"}'); + await fs.chmod(deniedProject, mode); + const client = new ClaudeAgentClient(fixture.options); + await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual( + fixture.descriptors, + ); + expect(fixture.logs).toEqual([ + expect.objectContaining({ + path: path.join(deniedProject, deniedSuffix), + err: expect.objectContaining({ code: "EACCES" }), + msg: "Skipping Claude session discovery path with denied access", + }), + ]); + } finally { + await fs.chmod(deniedProject, 0o700); + await fixture.cleanup(); + } + }, + ); + + test.skipIf(cannotEnforceFilePermissions).each(discoveryPermissionCases)( + "surfaces a provider error when $label denies discovery in every project", + async ({ mode, deniedSuffix, existingSuffix }) => { + const fixture = await descriptorFixture(1); + const existingProject = path.dirname(fixture.files[0]!); + const deniedProject = path.join(path.dirname(existingProject), "denied-project"); + await fs.mkdir(deniedProject); + try { + await fs.writeFile(path.join(deniedProject, "hidden.jsonl"), '{"type":"user"}'); + await fs.chmod(existingProject, mode); + await fs.chmod(deniedProject, mode); + const client = new ClaudeAgentClient(fixture.options); + await expect(client.listImportableSessions({ limit: 2 })).rejects.toMatchObject({ + name: "AggregateError", + message: expect.stringContaining("no transcripts could be discovered"), + errors: expect.arrayContaining([ + expect.objectContaining({ + code: "EACCES", + path: path.join(existingProject, existingSuffix), + }), + expect.objectContaining({ + code: "EACCES", + path: path.join(deniedProject, deniedSuffix), + }), + ]), + }); + expect(fixture.logs).toHaveLength(2); + expect(fixture.logs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: path.join(existingProject, existingSuffix), + err: expect.objectContaining({ code: "EACCES" }), + }), + expect.objectContaining({ + path: path.join(deniedProject, deniedSuffix), + err: expect.objectContaining({ code: "EACCES" }), + }), + ]), + ); + } finally { + await fs.chmod(existingProject, 0o700); + await fs.chmod(deniedProject, 0o700); + await fixture.cleanup(); + } + }, + ); + + test.skipIf(cannotEnforceFilePermissions)( + "isolates an actual access-denied transcript while returning a healthy session", + async () => { + const fixture = await descriptorFixture(2); + try { + await fs.chmod(fixture.files[0]!, 0); + const client = new ClaudeAgentClient(fixture.options); + await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual([ + fixture.descriptors[1], + ]); + expect(fixture.logs).toEqual([ + expect.objectContaining({ + path: fixture.files[0], + err: expect.objectContaining({ code: "EACCES" }), + msg: "Skipping Claude transcript with unreadable import metadata", + }), + ]); + } finally { + await fixture.cleanup(); + } + }, + ); + + test.skipIf(cannotEnforceFilePermissions)( + "surfaces a provider error when every actual candidate is access denied", + async () => { + const fixture = await descriptorFixture(2); + try { + await Promise.all(fixture.files.map((file) => fs.chmod(file, 0))); + const client = new ClaudeAgentClient(fixture.options); + await expect(client.listImportableSessions({ limit: 2 })).rejects.toMatchObject({ + name: "AggregateError", + errors: fixture.files.map((file) => + expect.objectContaining({ code: "EACCES", path: file }), + ), + message: expect.stringContaining( + "all 2 candidate transcripts have unreadable import metadata", + ), + }); + expect(fixture.logs).toHaveLength(2); + } finally { + await fixture.cleanup(); + } + }, + ); + + test("returns an empty listing for readable transcripts without import metadata", async () => { + const fixture = await descriptorFixture(2); + try { + await Promise.all(fixture.files.map((file) => fs.writeFile(file, '{"type":"assistant"}'))); + const client = new ClaudeAgentClient(fixture.options); + await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual([]); + expect(fixture.logs).toEqual([]); + } finally { + await fixture.cleanup(); + } + }); + + test("refreshes descriptors after append, rewrite, replacement, and deletion", async () => { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-cache-")); + try { + const projectDir = path.join(configDir, "projects", "changing-transcript"); + await fs.mkdir(projectDir, { recursive: true }); + const sessionFile = path.join(projectDir, "changing.jsonl"); + const initial = `${JSON.stringify({ + type: "user", + sessionId: "changing-session", + cwd: "/changing-project", + message: { content: "First prompt" }, + })}\n`; + function title(customTitle: string): string { + return `${JSON.stringify({ type: "custom-title", customTitle })}\n`; + } + await fs.writeFile(sessionFile, initial + title("First title")); + const timestamp = new Date("2026-06-01T12:00:00.000Z"); + await fs.utimes(sessionFile, timestamp, timestamp); + + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + }); + const first = await client.listImportableSessions({ limit: 1 }); + expect(first.map((session) => session.title)).toEqual(["First title"]); + // Callers must not be able to mutate a cached descriptor, including its Date. + first[0]!.title = "Changed by caller"; + first[0]!.lastActivityAt.setTime(0); + const repeated = await Promise.all([ + client.listImportableSessions({ limit: 1 }), + client.listImportableSessions({ limit: 1 }), + ]); + expect(repeated.map((sessions) => [sessions[0]!.title, sessions[0]!.lastActivityAt])).toEqual( + [ + ["First title", timestamp], + ["First title", timestamp], + ], + ); + + repeated[0]![0]!.title = "Changed by overlapping caller"; + repeated[0]![0]!.lastActivityAt.setTime(0); + expect([repeated[1]![0]!.title, repeated[1]![0]!.lastActivityAt]).toEqual([ + "First title", + timestamp, + ]); + + await fs.appendFile(sessionFile, title("After append")); + expect( + (await client.listImportableSessions({ limit: 1 })).map((session) => session.title), + ).toEqual(["After append"]); + + // Same-length rewrites with a restored mtime still invalidate the descriptor. + await fs.writeFile(sessionFile, initial + title("Other title")); + await fs.utimes(sessionFile, timestamp, timestamp); + expect( + (await client.listImportableSessions({ limit: 1 })).map((session) => session.title), + ).toEqual(["Other title"]); + + const replacement = path.join(projectDir, "replacement.tmp"); + await fs.writeFile(replacement, initial + title("Final title")); + await fs.utimes(replacement, timestamp, timestamp); + await fs.rename(replacement, sessionFile); + expect( + (await client.listImportableSessions({ limit: 1 })).map((session) => session.title), + ).toEqual(["Final title"]); + + await fs.unlink(sessionFile); + await expect(client.listImportableSessions({ limit: 1 })).resolves.toEqual([]); + } finally { + await fs.rm(configDir, { recursive: true, force: true }); + } + }); + + test.each([ + { providers: 1, requestsPerProvider: 4 }, + { providers: 5, requestsPerProvider: 3 }, + ])( + "discovers large histories with $providers providers and $requestsPerProvider requests each", + async ({ providers, requestsPerProvider }) => { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-heap-")); + try { + const projectDir = path.join(configDir, "projects", "large-transcript"); + await fs.mkdir(projectDir, { recursive: true }); + const sessionFile = path.join(projectDir, "large.jsonl"); + const transcript = await fs.open(sessionFile, "w"); + try { + await transcript.writeFile( + `${JSON.stringify({ + type: "user", + sessionId: "large-session", + cwd: "/large-project", + message: { content: "First prompt" }, + })}\r\n`, + ); + const block = `${JSON.stringify({ type: "assistant", text: "x".repeat(1024) })}\n`.repeat( + 1024, + ); + for (let index = 0; index < 128; index += 1) { + await transcript.writeFile(block); + } + // A malformed record must not hide the final unterminated record. + await transcript.writeFile( + `incomplete json\n${JSON.stringify({ + type: "user", + message: { content: "Last prompt 🐢" }, + })}`, + ); + } finally { + await transcript.close(); + } + + const { stdout } = await promisify(execFile)( + process.execPath, + [ + "--max-old-space-size=96", + "--import", + import.meta.resolve("tsx"), + "--input-type=module", + "--eval", + ` + import { deepStrictEqual } from "node:assert"; + import { ClaudeAgentClient } from ${JSON.stringify(new URL("./agent.ts", import.meta.url).href)}; + import pino from ${JSON.stringify(import.meta.resolve("pino"))}; + import { mkdir, symlink } from "node:fs/promises"; + import path from "node:path"; + const clients = []; + for (let index = 0; index < ${providers}; index++) { + const providerConfigDir = path.join(${JSON.stringify(configDir)}, "alias-" + index); + await mkdir(providerConfigDir); + await symlink(path.join(${JSON.stringify(configDir)}, "projects"), path.join(providerConfigDir, "projects"), "junction"); + clients.push(new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } }, + })); + } + const listings = await Promise.all(clients.flatMap(client => + Array.from({ length: ${requestsPerProvider} }, () => client.listImportableSessions({ limit: 1 })) + )); + const sessions = listings[0]; + for (const listing of listings) deepStrictEqual(listing, sessions); + console.log(JSON.stringify(sessions)); + `, + ], + { timeout: 20_000 }, + ); + + expect(JSON.parse(stdout)).toEqual([ + { + providerHandleId: "large-session", + cwd: "/large-project", + title: "First prompt", + firstPromptPreview: "First prompt", + lastPromptPreview: "Last prompt 🐢", + lastActivityAt: (await fs.stat(sessionFile)).mtime.toISOString(), + }, + ]); + } finally { + await fs.rm(configDir, { recursive: true, force: true }); + } + }, + ); + + test("keeps metadata and UTF-8 across read chunk boundaries", async () => { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-limit-")); + try { + const projectDir = path.join(configDir, "projects", "boundary"); + await fs.mkdir(projectDir, { recursive: true }); + const prefix = '{"padding":"'; + // Split the four-byte turtle across a 64 KiB input chunk boundary. + const padding = "x".repeat(64 * 1024 - Buffer.byteLength(prefix) - 1); + const suffix = + '","type":"user","sessionId":"boundary-session","cwd":"/boundary","message":{"content":"Hello 🐢"}}'; + const remaining = 4 * 1024 * 1024 - Buffer.byteLength(prefix + padding + "🐢" + suffix); + await fs.writeFile( + path.join(projectDir, "boundary.jsonl"), + prefix + padding + "🐢" + "x".repeat(remaining) + suffix, + ); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + }); + expect( + (await client.listImportableSessions({ limit: 1 })).map((session) => ({ + providerHandleId: session.providerHandleId, + cwd: session.cwd, + title: session.title, + firstPromptPreview: session.firstPromptPreview, + lastPromptPreview: session.lastPromptPreview, + })), + ).toEqual([ + { + providerHandleId: "boundary-session", + cwd: "/boundary", + title: "Hello 🐢", + firstPromptPreview: "Hello 🐢", + lastPromptPreview: "Hello 🐢", + }, + ]); + } finally { + await fs.rm(configDir, { recursive: true, force: true }); + } + }); + + test("rejects a cancelled listing before doing filesystem work", async () => { + const controller = new AbortController(); + controller.abort(new Error("Picker closed")); + const client = new ClaudeAgentClient({ logger: createTestLogger() }); + await expect(client.listImportableSessions({ signal: controller.signal })).rejects.toThrow( + "Picker closed", + ); + }); + test("uses the latest native custom title and leaves fixture mtimes unchanged", async () => { const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-")); const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; @@ -3695,3 +4098,184 @@ describe("Claude question permission notifications", () => { expect(request.description).toBeUndefined(); }); }); + +describe("Claude import descriptor scheduling", () => { + function controlledReads() { + const started: string[] = []; + const reads = new Map< + string, + { resolve: (value: string) => void; reject: (reason: unknown) => void } + >(); + const signals = new Map(); + const sharedScheduler = createImportDescriptorScheduler( + (candidate: { cacheKey: string }, signal) => { + started.push(candidate.cacheKey); + signals.set(candidate.cacheKey, signal); + return new Promise((resolve, reject) => { + reads.set(candidate.cacheKey, { resolve, reject }); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }, + ); + function scheduler(candidates: string[], signal: AbortSignal): Promise { + return sharedScheduler( + candidates.map((cacheKey) => ({ cacheKey })), + signal, + ); + } + return { scheduler, started, reads, signals }; + } + + test("shares a cold read across overlapping listings and leaves capacity for unrelated work", async () => { + const { scheduler, started, reads } = controlledReads(); + const signal = new AbortController().signal; + const overlapping = Array.from({ length: 4 }, () => scheduler(["shared"], signal)); + await vi.waitFor(() => expect(started).toEqual(["shared"])); + const unrelated = scheduler(["unrelated"], signal); + await vi.waitFor(() => expect(started).toEqual(["shared", "unrelated"])); + reads.get("unrelated")!.resolve("unrelated descriptor"); + await expect(unrelated).resolves.toEqual(["unrelated descriptor"]); + reads.get("shared")!.resolve("shared descriptor"); + await expect(Promise.all(overlapping)).resolves.toEqual( + Array.from({ length: 4 }, () => ["shared descriptor"]), + ); + // A settled entry must not become an accidental second cache. + const later = scheduler(["shared"], signal); + await vi.waitFor(() => expect(started).toEqual(["shared", "unrelated", "shared"])); + reads.get("shared")!.resolve("updated descriptor"); + await expect(later).resolves.toEqual(["updated descriptor"]); + }); + + test("cancelling the initiating listing keeps its other subscriber alive", async () => { + const { scheduler, started, reads } = controlledReads(); + const initiator = new AbortController(); + const first = scheduler(["shared"], initiator.signal); + const firstRejected = expect(first).rejects.toThrow("First picker closed"); + const second = scheduler(["shared"], new AbortController().signal); + await vi.waitFor(() => expect(started).toEqual(["shared"])); + initiator.abort(new Error("First picker closed")); + await firstRejected; + reads.get("shared")!.resolve("shared descriptor"); + await expect(second).resolves.toEqual(["shared descriptor"]); + expect(started).toEqual(["shared"]); + }); + + test("the final subscriber cancellation aborts the read and allows a fresh subscription", async () => { + const { scheduler, started, signals } = controlledReads(); + const firstController = new AbortController(); + const secondController = new AbortController(); + const first = scheduler(["shared"], firstController.signal); + const second = scheduler(["shared"], secondController.signal); + const firstRejected = expect(first).rejects.toThrow("First picker closed"); + const secondRejected = expect(second).rejects.toThrow("Final picker closed"); + await vi.waitFor(() => expect(started).toEqual(["shared"])); + firstController.abort(new Error("First picker closed")); + await firstRejected; + expect(signals.get("shared")!.aborted).toEqual(false); + secondController.abort(new Error("Final picker closed")); + await secondRejected; + expect(signals.get("shared")!.aborted).toEqual(true); + const retryController = new AbortController(); + const retry = scheduler(["shared"], retryController.signal); + const retryRejected = expect(retry).rejects.toThrow("Retry closed"); + await vi.waitFor(() => expect(started).toEqual(["shared", "shared"])); + retryController.abort(new Error("Retry closed")); + await retryRejected; + expect(signals.get("shared")!.aborted).toEqual(true); + }); + + test("runs four reads per listing and gives a later small listing a bounded turn", async () => { + const { scheduler, started, reads, signals } = controlledReads(); + const controller = new AbortController(); + const large = scheduler( + Array.from({ length: 500 }, (_, index) => `large-${index}`), + controller.signal, + ); + const largeRejected = expect(large).rejects.toThrow("Fairness check complete"); + await vi.waitFor(() => expect(started).toEqual(["large-0", "large-1", "large-2", "large-3"])); + const small = scheduler(["small"], new AbortController().signal); + reads.get("large-0")!.resolve("first descriptor"); + await vi.waitFor(() => + expect(started).toEqual(["large-0", "large-1", "large-2", "large-3", "small"]), + ); + reads.get("small")!.resolve("small descriptor"); + await expect(small).resolves.toEqual(["small descriptor"]); + controller.abort(new Error("Fairness check complete")); + await largeRejected; + expect(signals.get("large-1")!.aborted).toEqual(true); + expect(signals.get("large-2")!.aborted).toEqual(true); + expect(signals.get("large-3")!.aborted).toEqual(true); + expect(started.length).toBeLessThanOrEqual(6); + }); + + test("refills four workers while retaining candidate order after out-of-order completions", async () => { + const { scheduler, started, reads } = controlledReads(); + const listing = scheduler(["0", "1", "2", "3", "4", "5"], new AbortController().signal); + await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3"])); + reads.get("3")!.resolve("descriptor-3"); + await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3", "4"])); + reads.get("2")!.resolve("descriptor-2"); + await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3", "4", "5"])); + reads.get("5")!.resolve("descriptor-5"); + reads.get("4")!.resolve("descriptor-4"); + reads.get("1")!.resolve("descriptor-1"); + reads.get("0")!.resolve("descriptor-0"); + await expect(listing).resolves.toEqual([ + "descriptor-0", + "descriptor-1", + "descriptor-2", + "descriptor-3", + "descriptor-4", + "descriptor-5", + ]); + }); + + test("an unexpected read failure cancels sibling workers without starting remaining candidates", async () => { + const { scheduler, started, reads, signals } = controlledReads(); + const listing = scheduler(["0", "1", "2", "3", "4", "5"], new AbortController().signal); + const rejected = expect(listing).rejects.toThrow("Unexpected read failure"); + await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3"])); + reads.get("0")!.reject(new TypeError("Unexpected read failure")); + await rejected; + expect(signals.get("1")!.aborted).toEqual(true); + expect(signals.get("2")!.aborted).toEqual(true); + expect(signals.get("3")!.aborted).toEqual(true); + expect(started).toEqual(["0", "1", "2", "3"]); + }); + + test("bounds global reads and skips cancelled queued candidates", async () => { + const { scheduler, started, reads } = controlledReads(); + const controllers = Array.from({ length: 5 }, () => new AbortController()); + const listings = controllers.map((controller, index) => + scheduler([`${index}-first`], controller.signal), + ); + // Attach handlers before aborting any promises. + function errorMessage(error: Error): string { + return error.message; + } + const settled = listings.map((listing) => listing.catch(errorMessage)); + await vi.waitFor(() => expect(started).toEqual(["0-first", "1-first", "2-first", "3-first"])); + controllers[4]!.abort(new Error("Queued picker closed")); + await expect(settled[4]).resolves.toEqual("Queued picker closed"); + controllers[0]!.abort(new Error("Active picker closed")); + await expect(settled[0]).resolves.toEqual("Active picker closed"); + reads.get("1-first")!.reject(new Error("Stop 1")); + reads.get("2-first")!.reject(new Error("Stop 2")); + reads.get("3-first")!.reject(new Error("Stop 3")); + await expect(Promise.all(settled)).resolves.toEqual([ + "Active picker closed", + "Stop 1", + "Stop 2", + "Stop 3", + "Queued picker closed", + ]); + expect(started).toEqual(["0-first", "1-first", "2-first", "3-first"]); + // A cancelled queue entry must not consume a later listing's read slot. + const later = scheduler(["later"], new AbortController().signal); + await vi.waitFor(() => + expect(started).toEqual(["0-first", "1-first", "2-first", "3-first", "later"]), + ); + reads.get("later")!.resolve("later descriptor"); + await expect(later).resolves.toEqual(["later descriptor"]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 3d59851038..681988a813 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -4,6 +4,8 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; import path from "node:path"; +import { LRUCache } from "lru-cache"; +import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js"; import { type AgentDefinition, type CanUseTool, @@ -467,6 +469,10 @@ interface ClaudeAgentClientOptions { rewindSdk?: ClaudeRewindSdk; } +type ClaudeImportDescriptorResult = + | { type: "parsed"; descriptor: ImportableProviderSession | null } + | { type: "unreadable"; error: Error }; + interface ClaudeAgentSessionOptions { defaults?: { agents?: Record }; runtimeSettings?: ProviderRuntimeSettings; @@ -1571,6 +1577,15 @@ export class ClaudeAgentClient implements AgentClient { private readonly resolveBinary: () => Promise; private readonly resolveVersion: (signal?: AbortSignal) => Promise; private readonly rewindSdk: ClaudeRewindSdk; + private readonly scheduleImportDescriptors = createImportDescriptorScheduler( + (candidate: ClaudeSessionCandidate, signal) => this.readImportDescriptor(candidate, signal), + ); + private readonly importDescriptorCache = new LRUCache({ + max: 500, + maxSize: 8 * 1024 * 1024, + sizeCalculation: (descriptor, key) => + 128 + 2 * (JSON.stringify(descriptor).length + key.length), + }); constructor(options: ClaudeAgentClientOptions) { this.discoverModels = options.discoverModels ?? discoverClaudeModels; @@ -1714,26 +1729,73 @@ export class ClaudeAgentClient implements AgentClient { } async listImportableSessions( - options?: ListImportableSessionsOptions, + options?: ListImportableSessionsOptions & { signal?: AbortSignal }, ): Promise { + // Leave time to deliver the provider error before the client RPC expires at 60 seconds. + const deadline = AbortSignal.timeout(45_000); + const signal = options?.signal ? AbortSignal.any([options.signal, deadline]) : deadline; + signal.throwIfAborted(); const configDir = claudeConfigDir(this.buildProviderEnv()); const sessionsRoot = options?.cwd ? claudeProjectDirSync(options.cwd, { configDir }) : path.join(configDir, "projects"); - if (!(await pathExists(sessionsRoot))) { - return []; - } + const rootExists = await pathExists(sessionsRoot); + signal.throwIfAborted(); + if (!rootExists) return []; const limit = options?.limit ?? 20; const scanLimit = Math.min(options?.scanLimit ?? limit * 3, 500); const candidates = await collectRecentClaudeSessions(sessionsRoot, scanLimit, { rootIsProjectDir: Boolean(options?.cwd), + logger: this.logger, }); - const parsed = await Promise.all( - candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)), - ); + const results = await this.scheduleImportDescriptors(candidates, signal); + const failures = results.filter((result) => result.type === "unreadable"); + if (failures.length > 0 && failures.length === results.length) { + const errors = failures.map((result) => result.error); + throw new AggregateError( + errors, + `Cannot list Claude sessions: all ${failures.length} candidate transcripts have unreadable import metadata. ${errors[0]!.message}`, + ); + } + const parsed = results.filter((result) => result.type === "parsed"); return parsed + .map((result) => result.descriptor) .filter((session): session is ImportableProviderSession => session !== null) - .slice(0, limit); + .slice(0, limit) + .map((session) => structuredClone(session)); + } + + private async readImportDescriptor( + candidate: ClaudeSessionCandidate, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const cached = this.importDescriptorCache.get(candidate.cacheKey); + if (cached) return { type: "parsed", descriptor: structuredClone(cached) }; + + let descriptor: ImportableProviderSession | null; + try { + descriptor = await parseClaudeSessionDescriptor(candidate.path, candidate.mtime, signal); + } catch (error) { + signal.throwIfAborted(); + if (!(error instanceof Error) || !isExpectedClaudeImportFileError(error)) throw error; + this.logger.warn( + { err: error, path: candidate.path }, + "Skipping Claude transcript with unreadable import metadata", + ); + return { type: "unreadable", error }; + } + if (descriptor) { + const current = await fsPromises.stat(candidate.path).catch((error: unknown) => { + if (!isExpectedClaudeImportFileError(error)) throw error; + return null; + }); + signal.throwIfAborted(); + if (current && claudeSessionCacheKey(candidate.path, current) === candidate.cacheKey) { + this.importDescriptorCache.set(candidate.cacheKey, structuredClone(descriptor)); + } + } + return { type: "parsed", descriptor: descriptor ? structuredClone(descriptor) : null }; } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { @@ -6540,29 +6602,69 @@ function createAsyncMessageInput(): AsyncMessageInput { interface ClaudeSessionCandidate { path: string; mtime: Date; + cacheKey: string; +} + +function claudeSessionCacheKey(filePath: string, stats: fs.Stats): string { + return `${filePath}\0${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`; +} + +function isMissingClaudeImportFile(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} + +function isExpectedClaudeImportFileError(error: unknown): boolean { + if (!(error instanceof Error) || !("code" in error)) return false; + return ( + error.code === "ENOENT" || + error.code === "ENOTDIR" || + error.code === "EACCES" || + error.code === "EPERM" + ); } async function pathExists(target: string): Promise { try { await fsPromises.access(target); return true; - } catch { + } catch (error) { + if (!isMissingClaudeImportFile(error)) throw error; return false; } } +interface ClaudeSessionDiscoveryOptions { + rootIsProjectDir: boolean; + logger: Logger; +} + async function collectRecentClaudeSessions( root: string, limit: number, - options?: { rootIsProjectDir?: boolean }, + options: ClaudeSessionDiscoveryOptions, ): Promise { + const permissionErrors: Error[] = []; + function recordDiscoveryFailure(error: unknown, targetPath: string): void { + if (isMissingClaudeImportFile(error)) return; + if (!(error instanceof Error) || !isExpectedClaudeImportFileError(error)) throw error; + permissionErrors.push(error); + options.logger.warn( + { err: error, path: targetPath }, + "Skipping Claude session discovery path with denied access", + ); + } let rootEntries: string[]; try { rootEntries = await fsPromises.readdir(root); - } catch { + } catch (error) { + if (!isMissingClaudeImportFile(error)) throw error; return []; } - const fileEntries = options?.rootIsProjectDir + const fileEntries = options.rootIsProjectDir ? rootEntries.filter((file) => file.endsWith(".jsonl")).map((file) => path.join(root, file)) : ( await Promise.all( @@ -6575,7 +6677,8 @@ async function collectRecentClaudeSessions( return files .filter((file) => file.endsWith(".jsonl")) .map((file) => path.join(projectPath, file)); - } catch { + } catch (error) { + recordDiscoveryFailure(error, projectPath); return [] as string[]; } }), @@ -6585,8 +6688,14 @@ async function collectRecentClaudeSessions( fileEntries.map(async (fullPath) => { try { const fileStats = await fsPromises.stat(fullPath); - return { path: fullPath, mtime: fileStats.mtime }; - } catch { + if (!fileStats.isFile()) return null; + return { + path: fullPath, + mtime: fileStats.mtime, + cacheKey: claudeSessionCacheKey(fullPath, fileStats), + }; + } catch (error) { + recordDiscoveryFailure(error, fullPath); return null; } }), @@ -6594,6 +6703,12 @@ async function collectRecentClaudeSessions( const candidates: ClaudeSessionCandidate[] = statResults.filter( (entry): entry is ClaudeSessionCandidate => entry !== null, ); + if (candidates.length === 0 && permissionErrors.length > 0) { + throw new AggregateError( + permissionErrors, + `Cannot discover Claude sessions: access was denied to ${permissionErrors.length} paths and no transcripts could be discovered. ${permissionErrors[0]!.message}`, + ); + } return candidates.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()).slice(0, limit); } @@ -6665,14 +6780,8 @@ function applyClaudeSessionEntryToAccumulator( async function parseClaudeSessionDescriptor( filePath: string, mtime: Date, + signal: AbortSignal, ): Promise { - let content: string; - try { - content = await fsPromises.readFile(filePath, "utf8"); - } catch { - return null; - } - const acc: ClaudeSessionDescriptorAccumulator = { sessionId: null, cwd: null, @@ -6683,16 +6792,38 @@ async function parseClaudeSessionDescriptor( lastPromptPreview: null, }; - for (const line of content.split(/\r?\n/)) { - if (!line || !shouldParseClaudeSessionDescriptorLine(line, acc)) continue; + // Keep only the current record, including image records larger than a read chunk. + const record: Buffer[] = []; + const stream = fs.createReadStream(filePath, { highWaterMark: 64 * 1024, signal }); + function applyRecord(): void { + const line = Buffer.concat(record).toString("utf8"); + if (!line || !shouldParseClaudeSessionDescriptorLine(line, acc)) return; let entry: unknown; try { entry = JSON.parse(line); } catch { - continue; + return; } applyClaudeSessionEntryToAccumulator(entry, acc); } + try { + for await (const chunk of stream) { + signal.throwIfAborted(); + let start = 0; + while (start < chunk.length) { + const newline = chunk.indexOf(10, start); + const end = newline === -1 ? chunk.length : newline; + record.push(chunk.subarray(start, end)); + if (newline === -1) break; + applyRecord(); + record.length = 0; + start = newline + 1; + } + } + if (record.length > 0) applyRecord(); + } finally { + stream.destroy(); + } const { sessionId, cwd } = acc; diff --git a/packages/server/src/server/agent/providers/claude/import-descriptor-scheduler.ts b/packages/server/src/server/agent/providers/claude/import-descriptor-scheduler.ts new file mode 100644 index 0000000000..41766e2d9b --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/import-descriptor-scheduler.ts @@ -0,0 +1,92 @@ +import pLimit from "p-limit"; + +interface ImportDescriptorCandidate { + cacheKey: string; +} + +interface PendingDescriptor { + controller: AbortController; + subscribers: number; + settled: boolean; + promise: Promise; +} + +export function createImportDescriptorScheduler< + Candidate extends ImportDescriptorCandidate, + Descriptor, +>( + read: (candidate: Candidate, signal: AbortSignal) => Promise, +): (candidates: Candidate[], signal: AbortSignal) => Promise { + const limit = pLimit(4); + const reads = new Map>(); + + function acquire(candidate: Candidate): PendingDescriptor { + let pending = reads.get(candidate.cacheKey); + if (!pending) { + const controller = new AbortController(); + const entry: PendingDescriptor = { + controller, + subscribers: 0, + settled: false, + promise: limit(() => { + controller.signal.throwIfAborted(); + return read(candidate, controller.signal); + }).finally(() => { + entry.settled = true; + if (reads.get(candidate.cacheKey) === entry) reads.delete(candidate.cacheKey); + }), + }; + reads.set(candidate.cacheKey, entry); + pending = entry; + } + pending.subscribers += 1; + return pending; + } + + function release(candidate: Candidate, pending: PendingDescriptor): void { + pending.subscribers -= 1; + // The initiating listing has no special ownership of a shared read. + if (pending.subscribers === 0 && !pending.settled) { + if (reads.get(candidate.cacheKey) === pending) reads.delete(candidate.cacheKey); + pending.controller.abort(); + } + } + + return async (candidates, signal) => { + signal.throwIfAborted(); + const controller = new AbortController(); + signal = AbortSignal.any([signal, controller.signal]); + let onAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + }); + const results: Descriptor[] = []; + let nextIndex = 0; + async function worker(): Promise { + while (nextIndex < candidates.length) { + signal.throwIfAborted(); + const index = nextIndex++; + const candidate = candidates[index]!; + // Each worker queues only its next read. Later listings enter the + // shared FIFO after at most four outstanding reads from this listing. + const pending = acquire(candidate); + try { + results[index] = await Promise.race([pending.promise, aborted]); + } finally { + release(candidate, pending); + } + } + } + try { + const workers = Array.from({ length: Math.min(4, candidates.length) }, () => worker()); + await Promise.all(workers); + return results; + } catch (error) { + controller.abort(error); + throw error; + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } + }; +} From 9d908d678b117e2cde6329cfe197502ad4c156ef Mon Sep 17 00:00:00 2001 From: Gerard Balaoro Date: Thu, 22 Oct 2026 18:40:01 +0800 Subject: [PATCH 30/62] feat(terminal): support image paste into agent TUIs, local and remote (#3795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(terminal): support image paste into agent TUIs, local and remote (#3725) Codex and OpenCode receive image pastes by binding Ctrl+V (\x16) and reading the OS clipboard of the machine they run on; no image bytes travel through the pty. Paseo's terminal swallowed that keystroke and only ever pasted text, so image paste silently did nothing. - web/Electron runtime: forward \x16 when the clipboard holds no text, keep the existing text-paste path otherwise (bash quoted-insert stays intact) - new terminal.clipboard.write_image RPC ships client clipboard bytes to the daemon, which writes them onto its host clipboard (osascript / wl-copy / xclip / Set-Clipboard) so remote clients work like local ones; gated by server_info.features.terminalClipboardImage with a bare-\x16 fallback for older daemons - clipboard tools are waited on via their exit event, not stream close: wl-copy and xclip fork a selection owner that inherits our pipes, so waiting for stream closure hung every Linux write until timeout - headless hosts without a clipboard get a temp-file fallback: the daemon materializes the image under os.tmpdir() and returns its path; the client injects it as bracket-pasted text - all four renderers covered (web DOM, Electron, native grid, WebView bridge); mobile Paste button enables for image clipboards and toasts failures in all 10 locales * fix(terminal): bind image paste to private retained files * fix(terminal): recover interrupted image metadata safely * fix(terminal): retire image files with background lifecycle maintenance * fix(terminal): check live paste modes and refresh native bridge * fix(terminal): order image paste input and isolate failures * fix(terminal): normalize paste and release failed creation reservations * fix(terminal): validate missing Windows launch paths before spawn * fix(terminal): keep input live during cancellable image uploads * test(terminal): follow streamed input-mode updates * feat(terminal): upload dropped images through the paste flow * feat(terminal): paste and drop images through file uploads Terminal image paste and drop now reuse the existing upload path (client.uploadFile -> file.upload.request -> FileUploadStore) and paste the quoted host path through the pane's input, bracketed when the app asked for it. This replaces the PR's daemon-side image store, RPCs, paste permits and file lifecycle, so the daemon, protocol and client diffs for this feature are empty. - terminal/image-paste owns a per-pane serial queue: gesture order, keystrokes stay live, Ctrl+C and pane/connection teardown cancel pending pastes, and a cancelled path is never pasted. - Clipboard text wins over an image; any image/* type is accepted up to the composer's 50MB upload limit. - Host paths are quoted for the host OS by path shape. - One failure toast; i18n keeps two keys. - Browser e2e covers two panes pasting distinct PNGs, drop, ordering, Ctrl+C cancel and disconnect against a real daemon. Co-authored-by: Gerard Balaoro * fix(terminal): translate the disconnected image upload error * feat(terminal): report image uploads through toasts Show a lasting "Uploading image…" toast while an image uploads, replace it with "Image uploaded" or a failure toast that carries the reason, and dismiss it when Ctrl+C cancels the upload. Toast show now returns a dismiss scoped to that toast, so the pane can clear its own toast without removing a newer one. * build(terminal): regenerate the WebView HTML for the toast events * fix(terminal): keep i18n out of the terminal WebView bundle The image paste queue imported i18n for its size-limit message, which pulled every translation into the WebView HTML. The queue now reports a typed TerminalImageTooLargeError and the pane words it. * refactor(terminal): route dropped files by host locality and share the drop overlay A dropped file pastes its own path only when the host is the desktop app's own daemon (useIsLocalDaemon); anywhere else the host cannot read the client's disk, so the file uploads through client.uploadFile and the host path is pasted. File type no longer plays a part: images and other files take the same route, and clipboard images always upload because they have no path. The terminal pane now sits in the shared FileDropZone, so dragging files over it shows the agent panels' "Drop files here" overlay. The emulator's own drag listeners, blue tint and 180 ms timeout are gone. FileDropSink.onFiles is optional so a sink can take raw files without persisting raster images as attachments. terminal/image-paste becomes terminal/file-paste; the queue, callbacks, toasts and i18n keys say "file". * test(terminal): keep the fake desktop bridge in an e2e support helper The CI contract test forbids browser specs from installing paseoDesktop directly; the local-host drop scenario now imports it from support. --------- Co-authored-by: Mohamed Boudra --- .github/workflows/ci.yml | 4 +- .../browser/terminal-protocol-query.spec.ts | 269 ++++++++++++++++++ .../e2e/support/helpers/file-upload-gate.ts | 44 +++ .../e2e/support/helpers/local-desktop-host.ts | 38 +++ packages/app/package.json | 1 + .../scripts/build-terminal-webview-html.mjs | 18 +- packages/app/src/attachments/upload-limit.ts | 2 + .../file-drop/file-drop-backdrop.tsx | 2 +- .../app/src/components/file-drop/types.ts | 6 +- .../file-drop/use-drop-listeners.ts | 14 +- .../terminal-copy-paste-actions.tsx | 7 +- .../components/terminal-emulator-contract.ts | 2 + .../terminal-emulator-webview.native.tsx | 9 + .../app/src/components/terminal-emulator.tsx | 138 +-------- packages/app/src/components/terminal-pane.tsx | 155 +++++++++- .../app/src/components/toast-host.test.tsx | 35 +++ packages/app/src/components/toast-host.tsx | 16 +- packages/app/src/composer/actions.ts | 3 +- .../use-load-older-agent-history.test.ts | 1 + packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + .../activity.integration.test.tsx | 2 +- .../terminal/drop/terminal-file-drop.test.ts | 128 +++------ .../src/terminal/drop/terminal-file-drop.ts | 117 +++----- .../src/terminal/drop/terminal-path.test.ts | 26 ++ .../app/src/terminal/drop/terminal-path.ts | 40 +++ .../app/src/terminal/file-paste/index.test.ts | 173 +++++++++++ packages/app/src/terminal/file-paste/index.ts | 146 ++++++++++ .../terminal-emulator-runtime.browser.test.ts | 117 +++++++- .../runtime/terminal-emulator-runtime.ts | 92 +++++- .../terminal/runtime/terminal-paste.test.ts | 6 +- .../src/terminal/runtime/terminal-paste.ts | 6 +- .../terminal-emulator-webview-entry.ts | 25 ++ .../webview/terminal-emulator-webview-html.ts | 2 +- .../webview/terminal-find.browser.test.ts | 33 ++- 42 files changed, 1356 insertions(+), 348 deletions(-) create mode 100644 packages/app/e2e/support/helpers/file-upload-gate.ts create mode 100644 packages/app/e2e/support/helpers/local-desktop-host.ts create mode 100644 packages/app/src/attachments/upload-limit.ts create mode 100644 packages/app/src/components/toast-host.test.tsx create mode 100644 packages/app/src/terminal/drop/terminal-path.test.ts create mode 100644 packages/app/src/terminal/drop/terminal-path.ts create mode 100644 packages/app/src/terminal/file-paste/index.test.ts create mode 100644 packages/app/src/terminal/file-paste/index.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e632cb4b96..099f9efc0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -415,7 +415,9 @@ jobs: run: npm run build:app-deps - name: Run app unit tests - run: npm run test --workspace=@getpaseo/app + run: | + npm run check:terminal-webview --workspace=@getpaseo/app + npm run test --workspace=@getpaseo/app - uses: actions/setup-java@v4 with: diff --git a/packages/app/e2e/browser/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts index 023bf12b9d..8f297dfb17 100644 --- a/packages/app/e2e/browser/terminal-protocol-query.spec.ts +++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts @@ -1,6 +1,10 @@ +import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import path from "node:path"; +import type { Page } from "@playwright/test"; import { test, expect } from "../support/fixtures"; +import { holdFileUploadResponse } from "../support/helpers/file-upload-gate"; +import { installLocalDesktopHost } from "../support/helpers/local-desktop-host"; import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf"; @@ -26,12 +30,125 @@ process.stdin.on("data", (chunk) => { setTimeout(finish, 700); `; +// A TUI stand-in: turns on bracketed paste, reads every quoted path in each +// bracketed paste, and echoes typed lines, so the PTY output proves what reached +// the terminal and where the file was read from. +const FILE_RECEIVER_SCRIPT = String.raw` +const fs = require("node:fs"); +const crypto = require("node:crypto"); +let buffer = ""; +process.stdin.setRawMode(true); +process.stdin.resume(); +process.stdout.write("\x1b[?2004hFILE_RECEIVER_READY\r\n"); +process.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + for (;;) { + const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer); + if (!paste) break; + for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) { + const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex"); + const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local"; + process.stdout.write("FILE_READ:" + digest + ":" + location + "\r\n"); + } + buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length); + } + if (buffer.includes("\x03")) { + process.stdout.write("INTERRUPTED\r\n"); + buffer = buffer.replaceAll("\x03", ""); + } + const enter = buffer.indexOf("\r"); + if (enter >= 0 && !buffer.includes("'")) { + process.stdout.write("TYPED:" + buffer.slice(0, enter) + "\r\n"); + buffer = buffer.slice(enter + 1); + } +}); +`; + +function pngBytes(seed: number): Buffer { + return Buffer.concat([Buffer.from("iVBORw0KGgo=", "base64"), Buffer.from([seed])]); +} + +function fileRead(bytes: Buffer, location: "upload" | "local"): string { + const digest = createHash("sha256").update(bytes).digest("hex"); + return `FILE_READ:${digest}:${location}`; +} + +function terminalInput(page: Page) { + return page.locator('[data-testid="terminal-surface"] .xterm-helper-textarea'); +} + +async function pasteImage(page: Page, bytes: Buffer): Promise { + await terminalInput(page).evaluate( + (element, data) => { + (element as HTMLTextAreaElement).focus(); + const clipboardData = new DataTransfer(); + clipboardData.items.add(new File([new Uint8Array(data)], "image.png", { type: "image/png" })); + element.dispatchEvent( + new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }), + ); + }, + [...bytes], + ); +} + +interface DroppedFile { + name: string; + type: string; + bytes: Buffer; +} + +async function dragTransfer(page: Page, files: DroppedFile[]) { + return await page.evaluateHandle( + (entries) => { + const dataTransfer = new DataTransfer(); + for (const entry of entries) { + dataTransfer.items.add( + new File([new Uint8Array(entry.bytes)], entry.name, { type: entry.type }), + ); + } + return dataTransfer; + }, + files.map((file) => ({ ...file, bytes: [...file.bytes] })), + ); +} + +async function dragFilesOver(page: Page, files: DroppedFile[]): Promise { + const dataTransfer = await dragTransfer(page, files); + await terminalInput(page).dispatchEvent("dragenter", { dataTransfer }); + await terminalInput(page).dispatchEvent("dragover", { dataTransfer }); + await dataTransfer.dispose(); +} + +async function dropFiles(page: Page, files: DroppedFile[]): Promise { + const dataTransfer = await dragTransfer(page, files); + await terminalInput(page).dispatchEvent("drop", { dataTransfer }); + await dataTransfer.dispose(); +} + +/** The shared "Drop files here" overlay of the zone around the terminal. */ +function terminalDropOverlay(page: Page) { + return page + .getByTestId("terminal-surface") + .locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]") + .getByTestId("file-drop-backdrop"); +} + +/** + * Makes the app treat the e2e daemon as the desktop app's own daemon, so dropped + * files resolve to paths inside `directory` instead of uploading. + */ +async function typeLine(page: Page, text: string): Promise { + await terminalInput(page).pressSequentially(text); + await terminalInput(page).press("Enter"); +} + test.describe("Terminal protocol queries", () => { let harness: TerminalE2EHarness; test.beforeAll(async () => { harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" }); await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT); + await writeFile(path.join(harness.tempRepo.path, "file-receiver.cjs"), FILE_RECEIVER_SCRIPT); }); test.afterAll(async () => { @@ -58,4 +175,156 @@ test.describe("Terminal protocol queries", () => { await harness.killTerminal(terminalInstance.id); } }); + + async function openFileReceiver(page: Page, name: string) { + const terminal = await harness.createTerminal({ + name, + command: process.execPath, + args: ["file-receiver.cjs"], + }); + await harness.openTerminal(page, { terminalId: terminal.id }); + await expectTerminalOutput(terminal.id, "FILE_RECEIVER_READY"); + return terminal; + } + + // Reads the PTY itself, so output from one pane cannot satisfy another's check. + async function terminalOutput(terminalId: string): Promise { + const capture = await harness.client.captureTerminal(terminalId); + return capture.lines.join("").replace(/\s/g, ""); + } + + async function expectTerminalOutput(terminalId: string, text: string): Promise { + await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text); + } + + test("pastes images from two panes into their own terminals as uploaded host files", async ({ + page, + }) => { + const secondPage = await page.context().newPage(); + const first = await openFileReceiver(page, "image-paste-a"); + const second = await openFileReceiver(secondPage, "image-paste-b"); + try { + await Promise.all([pasteImage(page, pngBytes(1)), pasteImage(secondPage, pngBytes(2))]); + + await expectTerminalOutput(first.id, fileRead(pngBytes(1), "upload")); + await expectTerminalOutput(second.id, fileRead(pngBytes(2), "upload")); + expect(await terminalOutput(first.id)).not.toContain(fileRead(pngBytes(2), "upload")); + expect(await terminalOutput(second.id)).not.toContain(fileRead(pngBytes(1), "upload")); + } finally { + await secondPage.close(); + await harness.killTerminal(first.id); + await harness.killTerminal(second.id); + } + }); + + test("uploads dropped files of any type when the host is another machine", async ({ page }) => { + const terminal = await openFileReceiver(page, "file-drop-remote"); + const files = [ + { name: "shot.png", type: "image/png", bytes: pngBytes(3) }, + { name: "notes.txt", type: "text/plain", bytes: Buffer.from("remote notes") }, + ]; + try { + await dragFilesOver(page, files); + await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "1"); + await expect(terminalDropOverlay(page)).toHaveText("Drop files here"); + + await dropFiles(page, files); + await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "0"); + + await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload")); + await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload")); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("pastes dropped files' own paths when the host is this machine", async ({ page }) => { + const files = [ + { name: "local-shot.png", type: "image/png", bytes: pngBytes(9) }, + { name: "local-notes.txt", type: "text/plain", bytes: Buffer.from("local notes") }, + ]; + for (const file of files) { + await writeFile(path.join(harness.tempRepo.path, file.name), file.bytes); + } + await installLocalDesktopHost(page, harness.tempRepo.path); + const terminal = await openFileReceiver(page, "file-drop-local"); + try { + await dropFiles(page, files); + + await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local")); + await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local")); + expect(await terminalOutput(terminal.id)).not.toContain(":upload"); + await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("lands image pastes in gesture order while typing stays live", async ({ page }) => { + const upload = await holdFileUploadResponse(page); + const terminal = await openFileReceiver(page, "image-order"); + try { + await pasteImage(page, pngBytes(4)); + await upload.waitUntilHeld(); + await pasteImage(page, pngBytes(5)); + await expect(page.getByTestId("terminal-file-uploading")).toHaveText("Uploading file…"); + + await typeLine(page, "describe"); + await expectTerminalOutput(terminal.id, "TYPED:describe"); + + upload.release(); + await expectTerminalOutput(terminal.id, fileRead(pngBytes(5), "upload")); + const output = await terminalOutput(terminal.id); + expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeGreaterThanOrEqual(0); + expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeLessThan( + output.indexOf(fileRead(pngBytes(5), "upload")), + ); + await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); + await expect(page.getByTestId("app-toast-message")).toHaveText("File uploaded"); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("Ctrl+C abandons an image paste that is still uploading", async ({ page }) => { + const upload = await holdFileUploadResponse(page); + const terminal = await openFileReceiver(page, "image-cancel"); + try { + await pasteImage(page, pngBytes(6)); + await upload.waitUntilHeld(); + + await terminalInput(page).press("Control+c"); + await expectTerminalOutput(terminal.id, "INTERRUPTED"); + await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); + upload.release(); + + await pasteImage(page, pngBytes(7)); + await expectTerminalOutput(terminal.id, fileRead(pngBytes(7), "upload")); + expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(6), "upload")); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("a disconnect during upload reports one failure and input keeps working", async ({ + page, + }) => { + const upload = await holdFileUploadResponse(page); + const terminal = await openFileReceiver(page, "image-disconnect"); + try { + await pasteImage(page, pngBytes(8)); + await upload.waitUntilHeld(); + + await upload.disconnect(); + await expect(page.getByTestId("app-toast-message")).toHaveText( + "Failed to paste file: Host is not connected", + ); + + await typeLine(page, "after"); + await expectTerminalOutput(terminal.id, "TYPED:after"); + expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(8), "upload")); + } finally { + await harness.killTerminal(terminal.id); + } + }); }); diff --git a/packages/app/e2e/support/helpers/file-upload-gate.ts b/packages/app/e2e/support/helpers/file-upload-gate.ts new file mode 100644 index 0000000000..c33b49f637 --- /dev/null +++ b/packages/app/e2e/support/helpers/file-upload-gate.ts @@ -0,0 +1,44 @@ +import type { Page, WebSocketRoute } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +type WebSocketMessage = string | Buffer; + +function isFileUploadResponse(message: WebSocketMessage): boolean { + if (typeof message !== "string") return false; + try { + const envelope = JSON.parse(message) as { type?: unknown; message?: { type?: unknown } }; + return (envelope.message ?? envelope).type === "file.upload.response"; + } catch { + return false; + } +} + +/** + * Holds the daemon's first `file.upload.response`, so the upload is stored on + * the host but the app still sees it in flight. + */ +export async function holdFileUploadResponse(page: Page) { + let held: { browser: WebSocketRoute; message: WebSocketMessage } | null = null; + let resolveHeld: (() => void) | null = null; + const heldResponse = new Promise((resolve) => { + resolveHeld = resolve; + }); + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((message) => server.send(message)); + server.onMessage((message) => { + if (held || !isFileUploadResponse(message)) { + browser.send(message); + return; + } + held = { browser, message }; + resolveHeld?.(); + }); + }); + return { + waitUntilHeld: () => heldResponse, + release: () => held?.browser.send(held.message), + /** Drops the app's connection while the upload is in flight. */ + disconnect: () => held?.browser.close({ code: 1001, reason: "Upload gate disconnect" }), + }; +} diff --git a/packages/app/e2e/support/helpers/local-desktop-host.ts b/packages/app/e2e/support/helpers/local-desktop-host.ts new file mode 100644 index 0000000000..863857e947 --- /dev/null +++ b/packages/app/e2e/support/helpers/local-desktop-host.ts @@ -0,0 +1,38 @@ +import type { Page } from "@playwright/test"; + +// Poses as the desktop app running the e2e daemon as its own managed daemon, so +// `useIsLocalDaemon` reports the host as this machine. `directory` is where the +// desktop bridge claims dropped files live on disk. +export async function installLocalDesktopHost(page: Page, directory: string): Promise { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set"); + } + await page.addInitScript( + (localHost: { serverId: string; directory: string }) => { + (window as unknown as { paseoDesktop: unknown }).paseoDesktop = { + platform: "linux", + invoke: async (command: string) => { + if (command === "desktop_daemon_status") { + return { + serverId: localHost.serverId, + status: "running", + listen: null, + hostname: null, + pid: null, + home: "", + version: null, + desktopManaged: true, + error: null, + }; + } + return null; + }, + getPendingOpenProject: async () => null, + events: { on: async () => () => undefined }, + webUtils: { getPathForFile: (file: File) => `${localHost.directory}/${file.name}` }, + }; + }, + { serverId, directory }, + ); +} diff --git a/packages/app/package.json b/packages/app/package.json index dc3deb047d..5eaea08838 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -36,6 +36,7 @@ "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs", + "check:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs --check", "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs", "dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug", "dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios", diff --git a/packages/app/scripts/build-terminal-webview-html.mjs b/packages/app/scripts/build-terminal-webview-html.mjs index d3b02336b2..238b2fd6fe 100644 --- a/packages/app/scripts/build-terminal-webview-html.mjs +++ b/packages/app/scripts/build-terminal-webview-html.mjs @@ -47,6 +47,7 @@ const aliasPlugin = { }; const result = await esbuild.build({ + absWorkingDir: repoRoot, entryPoints: [entry], bundle: true, write: false, @@ -85,5 +86,18 @@ const contents = `// Generated by packages/app/scripts/build-terminal-webview-ht export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)}; `; -await fs.writeFile(output, contents); -console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`); +if (process.argv.includes("--check")) { + // Compare parsed modules so repository formatting does not create false drift. + const options = { loader: "ts", format: "esm" }; + const expected = await esbuild.transform(contents, options); + const actual = await esbuild.transform(await fs.readFile(output, "utf8"), options); + if (actual.code !== expected.code) { + throw new Error( + "Stale terminal WebView HTML. Run npm run build:terminal-webview --workspace=@getpaseo/app", + ); + } + console.log("Terminal WebView HTML is current"); +} else { + await fs.writeFile(output, contents); + console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`); +} diff --git a/packages/app/src/attachments/upload-limit.ts b/packages/app/src/attachments/upload-limit.ts new file mode 100644 index 0000000000..8a81043f4b --- /dev/null +++ b/packages/app/src/attachments/upload-limit.ts @@ -0,0 +1,2 @@ +/** Largest file the app sends through `client.uploadFile`. */ +export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; diff --git a/packages/app/src/components/file-drop/file-drop-backdrop.tsx b/packages/app/src/components/file-drop/file-drop-backdrop.tsx index bafc9c0227..fa381bf545 100644 --- a/packages/app/src/components/file-drop/file-drop-backdrop.tsx +++ b/packages/app/src/components/file-drop/file-drop-backdrop.tsx @@ -34,7 +34,7 @@ export function FileDropBackdrop() { // non-animated children (applying themed Unistyles styles to an Animated.View crashes // on theme change — see docs/unistyles.md). return ( - + diff --git a/packages/app/src/components/file-drop/types.ts b/packages/app/src/components/file-drop/types.ts index 89bbbca7c4..e9b80a00be 100644 --- a/packages/app/src/components/file-drop/types.ts +++ b/packages/app/src/components/file-drop/types.ts @@ -13,11 +13,11 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem; /** * What a consumer (e.g. a composer) registers to receive files dropped onto the - * surrounding FileDropZone. Raster images arrive already persisted via `onFiles`; - * everything else arrives raw via `onGenericFiles`. + * surrounding FileDropZone. Every file arrives raw via `onGenericFiles`; a sink that + * also provides `onFiles` gets its raster images persisted as attachments. */ export interface FileDropSink { - onFiles: (images: ImageAttachment[]) => void; + onFiles?: (images: ImageAttachment[]) => void; onGenericFiles?: (items: DroppedItem[]) => void; onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void; } diff --git a/packages/app/src/components/file-drop/use-drop-listeners.ts b/packages/app/src/components/file-drop/use-drop-listeners.ts index 947c95d91e..2e3dc320b4 100644 --- a/packages/app/src/components/file-drop/use-drop-listeners.ts +++ b/packages/app/src/components/file-drop/use-drop-listeners.ts @@ -148,8 +148,9 @@ export function useDropListeners({ sink.onGenericFiles(items); } - const imagePaths = payload.paths.filter(isRasterImagePath); - if (imagePaths.length === 0) { + const onFiles = sink.onFiles; + const imagePaths = onFiles ? payload.paths.filter(isRasterImagePath) : []; + if (!onFiles || imagePaths.length === 0) { return; } @@ -162,7 +163,7 @@ export function useDropListeners({ // composer the user dropped on (matches the web path below). No post-persist busy // re-check: a mixed drop's own generic upload flips the busy flag, and re-checking // would discard the image from the same drop. - sink.onFiles(attachments); + onFiles(attachments); return; }) .catch((error) => { @@ -262,16 +263,17 @@ export function useDropListeners({ sink.onGenericFiles(genericItems); } - const imageFiles = files.filter(isRasterImageFile); + const onFiles = sink.onFiles; + const imageFiles = onFiles ? files.filter(isRasterImageFile) : []; - if (imageFiles.length === 0) return; + if (!onFiles || imageFiles.length === 0) return; try { const attachments = await Promise.all(imageFiles.map(fileToImageAttachment)); // No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag, // and re-checking would discard the image from the same drop. The guard at drop start // already rejects drops that begin while busy. - sink.onFiles(attachments); + onFiles(attachments); } catch (error) { console.error("[useDropListeners] Failed to process dropped files:", error); } diff --git a/packages/app/src/components/terminal-copy-paste-actions.tsx b/packages/app/src/components/terminal-copy-paste-actions.tsx index 26bba48aad..bf5f86eedb 100644 --- a/packages/app/src/components/terminal-copy-paste-actions.tsx +++ b/packages/app/src/components/terminal-copy-paste-actions.tsx @@ -3,17 +3,18 @@ import { Pressable, Text, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; export interface TerminalPasteActionProps { - hasClipboardText: boolean; + /** Clipboard holds text or an image, so a paste attempt can do something. */ + hasClipboardContent: boolean; onPaste: () => void; } -export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) { +export function TerminalPasteAction({ hasClipboardContent, onPaste }: TerminalPasteActionProps) { return ( diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts index 3575e21500..c4c7080b83 100644 --- a/packages/app/src/components/terminal-emulator-contract.ts +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -12,6 +12,7 @@ import type { TerminalLocalFileLinkTarget, } from "../terminal/local-links/terminal-local-link-provider"; import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection"; +import type { TerminalFile } from "../terminal/file-paste"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; @@ -75,4 +76,5 @@ export interface TerminalEmulatorProps { pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; resizeRequestToken?: number; + onFilePaste?: (file: Promise) => void; } diff --git a/packages/app/src/components/terminal-emulator-webview.native.tsx b/packages/app/src/components/terminal-emulator-webview.native.tsx index dbaa0175d4..163e335381 100644 --- a/packages/app/src/components/terminal-emulator-webview.native.tsx +++ b/packages/app/src/components/terminal-emulator-webview.native.tsx @@ -27,6 +27,7 @@ import type { TerminalLocalFileLinkSource, TerminalLocalFileLinkTarget, } from "../terminal/local-links/terminal-local-link-provider"; +import { terminalFileFromBase64 } from "../terminal/file-paste"; import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { openExternalUrl } from "../utils/open-external-url"; @@ -95,6 +96,7 @@ type BridgeOutboundMessage = | { type: "pendingModifiersConsumed"; streamKey: string } | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState } | { type: "openExternalUrl"; streamKey: string; url: string } + | { type: "filePaste"; streamKey: string; fileName: string; mimeType: string; data: string } | { type: "resolveLocalFileLink"; streamKey: string; @@ -181,6 +183,7 @@ export default function WebViewTerminalEmulator({ onResolveLocalFileLink, onOpenLocalFileLink, onRendererReadyChange, + onFilePaste, pendingModifiers = { ctrl: false, shift: false, alt: false }, focusRequestToken = 0, resizeRequestToken = 0, @@ -230,6 +233,7 @@ export default function WebViewTerminalEmulator({ onRendererReadyChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, onSwipeLeft, onSwipeRight, }); @@ -245,6 +249,7 @@ export default function WebViewTerminalEmulator({ onRendererReadyChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, onSwipeLeft, onSwipeRight, }; @@ -590,6 +595,10 @@ export default function WebViewTerminalEmulator({ callbacksRef.current.onFindResult?.(message.result); return; } + if (message.type === "filePaste") { + callbacksRef.current.onFilePaste?.(Promise.resolve(terminalFileFromBase64(message))); + return; + } handleTerminalMessage(message); }, [handleLifecycleMessage, handleTerminalMessage, streamKey], diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index be12355bea..3d272fb75e 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -11,9 +11,7 @@ import { useImperativeHandle, useMemo, useRef, - useState, type CSSProperties, - type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type Ref, } from "react"; @@ -29,6 +27,7 @@ import { type TerminalOutputData, } from "../terminal/runtime/terminal-emulator-runtime"; import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste"; +import type { TerminalFile } from "../terminal/file-paste"; import type { TerminalLocalFileLinkSource, TerminalLocalFileLinkTarget, @@ -37,13 +36,6 @@ import type { TerminalClipboardWriter } from "../terminal/native-renderer/termin import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; import { openExternalUrl } from "../utils/open-external-url"; import { focusWithRetries } from "../utils/web-focus"; -import { - extractTerminalDropPaths, - isTerminalDragLeaveOutside, - isTerminalFileDrag, - prepareDroppedPathsForTerminal, -} from "../terminal/drop/terminal-file-drop"; -import { getDesktopHost } from "@/desktop/host"; export interface TerminalEmulatorHandle { find?: TerminalFindHandle; @@ -145,6 +137,7 @@ interface TerminalEmulatorProps { disposition: "main" | "side", ) => Promise | void; onRendererReadyChange?: (change: TerminalRendererReadyChange) => void; + onFilePaste?: (file: Promise) => void; pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; resizeRequestToken?: number; @@ -191,6 +184,7 @@ export default function TerminalEmulator({ onResolveLocalFileLink, onOpenLocalFileLink, onRendererReadyChange, + onFilePaste, pendingModifiers = { ctrl: false, shift: false, alt: false }, focusRequestToken = 0, resizeRequestToken = 0, @@ -220,6 +214,7 @@ export default function TerminalEmulator({ onInputModeChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, }); mountCallbacksRef.current = { onFindRequest, @@ -231,13 +226,12 @@ export default function TerminalEmulator({ onInputModeChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, }; const initialSnapshotRef = useRef(initialSnapshot); initialSnapshotRef.current = initialSnapshot; const pendingModifiersRef = useRef(pendingModifiers); pendingModifiersRef.current = pendingModifiers; - const [isDropActive, setIsDropActive] = useState(false); - const dropActiveTimeoutRef = useRef | null>(null); const domBridgeRef = useRef(null); const pasteText = useCallback((text: string) => { @@ -506,11 +500,13 @@ export default function TerminalEmulator({ onInputModeChange, onResolveLocalFileLink, onOpenLocalFileLink, + onFilePaste, onOpenExternalUrl: openExternalUrl, }, }); }, [ onFindRequest, + onFilePaste, onFindResult, onInput, onInputModeChange, @@ -582,111 +578,6 @@ export default function TerminalEmulator({ [showTerminalContextMenu], ); - const clearDropActiveTimeout = useCallback(() => { - if (dropActiveTimeoutRef.current === null) { - return; - } - clearTimeout(dropActiveTimeoutRef.current); - dropActiveTimeoutRef.current = null; - }, []); - - const clearTerminalDropActive = useCallback(() => { - clearDropActiveTimeout(); - setIsDropActive(false); - }, [clearDropActiveTimeout]); - - const keepTerminalDropActive = useCallback(() => { - clearDropActiveTimeout(); - setIsDropActive(true); - dropActiveTimeoutRef.current = setTimeout(() => { - dropActiveTimeoutRef.current = null; - setIsDropActive(false); - }, 180); - }, [clearDropActiveTimeout]); - - useEffect(() => { - const root = rootRef.current; - if (!root) { - return () => {}; - } - - const handleDragEnter = (event: DragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - keepTerminalDropActive(); - }; - - const handleDragOver = (event: DragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = "copy"; - } - keepTerminalDropActive(); - }; - - const handleDrop = (event: DragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - clearTerminalDropActive(); - - const bridge = getDesktopHost(); - const paths = extractTerminalDropPaths(event.dataTransfer, bridge); - if (paths.length === 0) { - return; - } - - runtimeRef.current?.focus(); - mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge)); - }; - - root.addEventListener("dragenter", handleDragEnter, { capture: true }); - root.addEventListener("dragover", handleDragOver, { capture: true }); - root.addEventListener("drop", handleDrop, { capture: true }); - window.addEventListener("dragend", clearTerminalDropActive); - window.addEventListener("drop", clearTerminalDropActive); - - return () => { - root.removeEventListener("dragenter", handleDragEnter, { capture: true }); - root.removeEventListener("dragover", handleDragOver, { capture: true }); - root.removeEventListener("drop", handleDrop, { capture: true }); - window.removeEventListener("dragend", clearTerminalDropActive); - window.removeEventListener("drop", clearTerminalDropActive); - clearDropActiveTimeout(); - }; - }, [clearDropActiveTimeout, clearTerminalDropActive, keepTerminalDropActive]); - - const handleRootDragLeave = useCallback( - (event: ReactDragEvent) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - if ( - !isTerminalDragLeaveOutside({ - currentTarget: event.currentTarget, - relatedTarget: event.relatedTarget, - }) - ) { - return; - } - clearTerminalDropActive(); - }, - [clearTerminalDropActive], - ); - const rootDivStyle = useMemo( () => ({ position: "relative", @@ -702,19 +593,6 @@ export default function TerminalEmulator({ }), [xtermTheme.background], ); - const dropOverlayStyle = useMemo( - () => ({ - position: "absolute", - inset: 0, - zIndex: 9, - border: "1px solid rgba(78, 161, 255, 0.72)", - backgroundColor: "rgba(78, 161, 255, 0.16)", - opacity: isDropActive ? 1 : 0, - pointerEvents: "none", - transition: "opacity 120ms ease-out", - }), - [isDropActive], - ); return (
-
); } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 4b37470b30..6c1561bc5b 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -43,7 +43,23 @@ import { shouldShowTerminalPasteAction, type TerminalVirtualKeyboardControl, } from "@/terminal/runtime/terminal-virtual-keyboard"; -import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste"; +import { encodeTerminalPaste, pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste"; +import { + TerminalFilePasteQueue, + TerminalFileTooLargeError, + terminalFileFromBase64, + type TerminalFile, +} from "@/terminal/file-paste"; +import { useToast } from "@/contexts/toast-context"; +import type { DismissToast } from "@/components/toast-host"; +import { FileDropZone } from "@/components/file-drop/file-drop-zone"; +import { useFileDrop } from "@/components/file-drop/use-file-drop"; +import type { DroppedItem } from "@/components/file-drop/types"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; +import { getDesktopHost } from "@/desktop/host"; +import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop"; +import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path"; +import { toErrorMessage } from "@/utils/error-messages"; import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; import { EMPTY_FOCUS_CLAIM_STATE, @@ -122,6 +138,24 @@ type PendingTerminalInput = input: TerminalKeyInput; }; +async function readNativeClipboardImage(): Promise { + const image = await Clipboard.getImageAsync({ format: "png" }); + if (!image) { + return null; + } + return terminalFileFromBase64({ + fileName: "clipboard.png", + mimeType: "image/png", + data: image.data.slice(image.data.indexOf(",") + 1), + }); +} + +/** Receives files dropped on the pane's FileDropZone; the pane routes them by host locality. */ +function TerminalFileDropSink({ onFiles }: { onFiles: (items: DroppedItem[]) => void }) { + useFileDrop({ onGenericFiles: onFiles }); + return null; +} + function terminalScopeKey(input: { serverId: string; cwd: string }): string { return `${input.serverId}:${input.cwd}`; } @@ -292,9 +326,47 @@ export function TerminalPane({ const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); const [hasSelection, setHasSelection] = useState(false); const [hasClipboardText, setHasClipboardText] = useState(false); + const [hasClipboardImage, setHasClipboardImage] = useState(false); const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false); const [focusRequestToken, setFocusRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0); + const toast = useToast(); + const dismissUploadingToastRef = useRef(null); + const showFileUploading = useStableEvent((uploading: boolean) => { + dismissUploadingToastRef.current?.(); + dismissUploadingToastRef.current = uploading + ? toast.show(t("workspace.terminal.uploadingFile"), { + durationMs: null, + testID: "terminal-file-uploading", + }) + : null; + }); + const reportFileUploaded = useStableEvent(() => { + toast.show(t("workspace.terminal.fileUploaded"), { variant: "success" }); + }); + const reportFilePasteFailure = useStableEvent((error: unknown) => { + const reason = + error instanceof TerminalFileTooLargeError + ? t("composer.errors.fileTooLarge", { size: "50MB", fileName: error.fileName }) + : toErrorMessage(error); + toast.error(t("workspace.terminal.pasteFileFailed", { reason })); + }); + const [filePastes] = useState( + () => + new TerminalFilePasteQueue({ + onUploadingChange: showFileUploading, + onUploaded: reportFileUploaded, + onFailure: reportFilePasteFailure, + }), + ); + useEffect(() => () => void filePastes.cancel(), [filePastes, terminalStreamKey]); + useEffect(() => { + if (!isConnected && filePastes.cancel()) { + reportFilePasteFailure(new Error(t("workspace.terminal.hostDisconnected"))); + } + }, [filePastes, isConnected, reportFilePasteFailure, t]); + // Only the desktop app's own daemon can read files on this machine by path. + const isLocalHost = useIsLocalDaemon(serverId); useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection); const emulatorRef = useRef(null); const findRef = useRef(null); @@ -325,10 +397,15 @@ export function TerminalPane({ return; } try { - const hasText = await Clipboard.hasStringAsync(); + const [hasText, hasImage] = await Promise.all([ + Clipboard.hasStringAsync(), + Clipboard.hasImageAsync(), + ]); setHasClipboardText(hasText); + setHasClipboardImage(hasImage); } catch { setHasClipboardText(false); + setHasClipboardImage(false); } }, [showVirtualKeyBar]); @@ -705,6 +782,9 @@ export function TerminalPane({ alt: boolean; meta?: boolean; }): boolean => { + if (input.ctrl && input.key.toLowerCase() === "c") { + filePastes.cancel(); + } if (!client || !terminalIdRef.current) { enqueuePendingTerminalInput({ type: "key", @@ -740,7 +820,7 @@ export function TerminalPane({ } return true; }, - [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput], + [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, filePastes], ); const handleTerminalData = useCallback( @@ -748,6 +828,10 @@ export function TerminalPane({ if (data.length === 0) { return; } + // Ctrl+C abandons file pastes that have not landed yet. + if (data.includes("\x03")) { + filePastes.cancel(); + } if (hasPendingTerminalModifiers(modifiers)) { const pendingResolution = resolvePendingModifierDataInput({ @@ -793,12 +877,59 @@ export function TerminalPane({ clearPendingModifiers, client, dispatchTerminalInputEntry, + filePastes, modifiers, sendTerminalKey, enqueuePendingTerminalInput, ], ); + const pasteTerminalText = useStableEvent((text: string) => { + const entry: PendingTerminalInput = { + type: "data", + data: encodeTerminalPaste({ + text, + bracketedPaste: inputModeRef.current.bracketedPaste ?? false, + }), + }; + if (!dispatchTerminalInputEntry(entry)) { + enqueuePendingTerminalInput(entry); + } + }); + + const handleFilePaste = useStableEvent((file: Promise) => { + filePastes.add(file, { + upload: async (upload) => { + if (!client || !isConnected) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + const response = await client.uploadFile(upload); + if (!response.file) { + throw new Error(response.error ?? "Upload failed"); + } + return response.file.path; + }, + paste: pasteTerminalText, + }); + }); + + const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => { + const bridge = getDesktopHost(); + const paths: string[] = []; + for (const item of items) { + const delivery = routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge }); + if (delivery.kind === "path") { + paths.push(delivery.path); + } else { + handleFilePaste(delivery.read()); + } + } + if (paths.length > 0) { + pasteTerminalText(prepareDroppedPathsForTerminal(paths)); + } + requestTerminalFocus(); + }); + const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => { const nextSize = { rows: input.rows, cols: input.cols }; const claim = resolveTerminalResizeClaim({ @@ -876,11 +1007,15 @@ export function TerminalPane({ const handleTerminalPaste = useCallback(() => { requestTerminalReflow(); - void pasteTerminalClipboard({ + const image = pasteTerminalClipboard({ clipboard: { readText: () => Clipboard.getStringAsync() }, terminal: { paste: (text) => emulatorRef.current?.paste(text) }, - }).then(() => refreshClipboardAvailability()); - }, [requestTerminalReflow, refreshClipboardAvailability]); + }) + .catch(() => false) + .then((pastedText) => (pastedText ? null : readNativeClipboardImage())); + handleFilePaste(image); + void image.catch(() => null).then(() => refreshClipboardAvailability()); + }, [handleFilePaste, requestTerminalReflow, refreshClipboardAvailability]); const handleTerminalCopy = useCallback(() => { void emulatorRef.current @@ -1032,7 +1167,7 @@ export function TerminalPane({ return showPasteAction ? ( ) : null; @@ -1065,7 +1200,8 @@ export function TerminalPane({ return ( - + + ) : null} - + {streamError ? ( diff --git a/packages/app/src/components/toast-host.test.tsx b/packages/app/src/components/toast-host.test.tsx new file mode 100644 index 0000000000..32f1bfb8a7 --- /dev/null +++ b/packages/app/src/components/toast-host.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useToastHost } from "./toast-host"; + +describe("useToastHost", () => { + it("dismisses the toast that show returned the dismiss for", () => { + const { result } = renderHook(() => useToastHost()); + + let dismiss = () => {}; + act(() => { + dismiss = result.current.api.show("Uploading image…", { durationMs: null }); + }); + expect(result.current.toast?.content).toBe("Uploading image…"); + + act(() => dismiss()); + expect(result.current.toast).toBeNull(); + }); + + it("leaves a newer toast in place when an older one is dismissed", () => { + const { result } = renderHook(() => useToastHost()); + + let dismissUploading = () => {}; + act(() => { + dismissUploading = result.current.api.show("Uploading image…", { durationMs: null }); + }); + act(() => { + result.current.api.show("Image uploaded", { variant: "success" }); + }); + + act(() => dismissUploading()); + expect(result.current.toast?.content).toBe("Image uploaded"); + }); +}); diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 6b9009dd6a..d9c8001f50 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -43,8 +43,11 @@ export interface ToastState { testID?: string; } +/** Dismisses the toast it was returned for; a no-op once another toast replaced it. */ +export type DismissToast = () => void; + export interface ToastApi { - show: (content: ReactNode, options?: ToastShowOptions) => void; + show: (content: ReactNode, options?: ToastShowOptions) => DismissToast; copied: (label?: string) => void; error: (message: string) => void; } @@ -52,6 +55,7 @@ export interface ToastApi { type ToastViewportPlacement = "app-shell" | "panel"; const DEFAULT_DURATION_MS = 2200; +const noopDismiss: DismissToast = () => {}; const TOAST_MAX_WIDTH = 480; const toastEntering = FadeIn.duration(140); const toastExiting = FadeOut.duration(140); @@ -73,10 +77,10 @@ export function useToastHost(): { const [toast, setToast] = useState(null); const idRef = useRef(0); - const show = useCallback((content: ReactNode, options?: ToastShowOptions) => { + const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => { const nativeMessage = typeof content === "string" ? content.trim() : null; if (!content || nativeMessage === "") { - return; + return noopDismiss; } const variant = options?.variant ?? "default"; @@ -87,12 +91,13 @@ export function useToastHost(): { const duration = durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG; ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP); - return; + return noopDismiss; } idRef.current += 1; + const id = idRef.current; setToast({ - id: idRef.current, + id, content, nativeMessage, icon: options?.icon, @@ -100,6 +105,7 @@ export function useToastHost(): { durationMs, testID: options?.testID, }); + return () => setToast((current) => (current?.id === id ? null : current)); }, []); const api = useMemo( diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 7e197b4f13..f810ef19c6 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -1,4 +1,5 @@ import type { SelectedFile } from "@/attachments/selected-file"; +import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { @@ -131,7 +132,7 @@ export async function uploadFileAttachments(input: { for (const file of input.files) { const bytes = await file.readBytes(); - if (bytes.byteLength > 50 * 1024 * 1024) { + if (bytes.byteLength > FILE_UPLOAD_MAX_BYTES) { throw new Error( i18n.t("composer.errors.fileTooLarge", { size: "50MB", fileName: file.fileName }), ); diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 292b54ab87..1da68b4edf 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -56,6 +56,7 @@ function createToast(): FakeToast { shown, show: (message, options) => { shown.push({ message, options }); + return () => {}; }, copied: () => {}, error: () => {}, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c44c7f8ffc..c3ee235c29 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -615,6 +615,9 @@ export const ar: TranslationResources = { hostDisconnected: "Host غير متصل", updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", unableToSubscribe: "غير قادر على الاشتراك في المحطة", + fileUploaded: "تم رفع الملف", + pasteFileFailed: "تعذر لصق الملف: {{reason}}", + uploadingFile: "جارٍ رفع الملف…", }, sheet: { openAsTab: "فتح كعلامة تبويب", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index d752c89372..6649bd8bde 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -612,6 +612,9 @@ export const en = { hostDisconnected: "Host is not connected", updateHost: "Update the host to use the native terminal renderer.", unableToSubscribe: "Unable to subscribe to terminal", + fileUploaded: "File uploaded", + pasteFileFailed: "Failed to paste file: {{reason}}", + uploadingFile: "Uploading file…", }, sheet: { openAsTab: "Open as tab", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index f5eda9f6b9..81dd72cec0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -621,6 +621,9 @@ export const es: TranslationResources = { hostDisconnected: "Hostno está conectado", updateHost: "Actualiza el host para usar el renderizador de terminal nativo.", unableToSubscribe: "No se puede suscribir al terminal", + fileUploaded: "Archivo subido", + pasteFileFailed: "No se pudo pegar el archivo: {{reason}}", + uploadingFile: "Subiendo archivo…", }, sheet: { openAsTab: "Abrir como pestaña", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 7ffeaf8d59..52d8a9206a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -621,6 +621,9 @@ export const fr: TranslationResources = { hostDisconnected: "L’hôte n’est pas connecté", updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.", unableToSubscribe: "Impossible de s’abonner au terminal", + fileUploaded: "Fichier envoyé", + pasteFileFailed: "Impossible de coller le fichier : {{reason}}", + uploadingFile: "Envoi du fichier…", }, sheet: { openAsTab: "Ouvrir dans un onglet", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 1ba8dac613..0dd14ea5a3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -621,6 +621,9 @@ export const ja: TranslationResources = { hostDisconnected: "ホストが接続されていません", updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", unableToSubscribe: "ターミナルに接続できません", + fileUploaded: "ファイルをアップロードしました", + pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}", + uploadingFile: "ファイルをアップロード中…", }, sheet: { openAsTab: "タブで開く", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index f59d03a8e9..0bfb1be599 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -616,6 +616,9 @@ export const ko: TranslationResources = { hostDisconnected: "호스트가 연결되어 있지 않습니다", updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.", unableToSubscribe: "터미널을 구독할 수 없습니다", + fileUploaded: "파일을 업로드했습니다", + pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}", + uploadingFile: "파일 업로드 중…", }, sheet: { openAsTab: "탭으로 열기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8a98a32487..295c1acfaf 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -620,6 +620,9 @@ export const ptBR: TranslationResources = { hostDisconnected: "Host não está conectado", updateHost: "Atualize o host para usar o renderizador de terminal nativo.", unableToSubscribe: "Não foi possível assinar o terminal", + fileUploaded: "Arquivo enviado", + pasteFileFailed: "Falha ao colar o arquivo: {{reason}}", + uploadingFile: "Enviando arquivo…", }, sheet: { openAsTab: "Abrir como aba", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index b5e519baad..36dd91a9c5 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -622,6 +622,9 @@ export const ru: TranslationResources = { hostDisconnected: "Хост не подключён", updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.", unableToSubscribe: "Не удалось подключиться к терминалу", + fileUploaded: "Файл загружен", + pasteFileFailed: "Не удалось вставить файл: {{reason}}", + uploadingFile: "Загрузка файла…", }, sheet: { openAsTab: "Открыть во вкладке", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 2d5fbca085..7afb29ca30 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -615,6 +615,9 @@ export const zhCN: TranslationResources = { hostDisconnected: "Host 未连接", updateHost: "请更新主机以使用原生终端渲染器。", unableToSubscribe: "无法订阅 Terminal", + fileUploaded: "文件已上传", + pasteFileFailed: "无法粘贴文件:{{reason}}", + uploadingFile: "正在上传文件…", }, sheet: { openAsTab: "作为标签页打开", diff --git a/packages/app/src/panels/pull-request/activity.integration.test.tsx b/packages/app/src/panels/pull-request/activity.integration.test.tsx index 2f2b97aab2..0cb403f9b7 100644 --- a/packages/app/src/panels/pull-request/activity.integration.test.tsx +++ b/packages/app/src/panels/pull-request/activity.integration.test.tsx @@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client"; import { waitFor } from "@testing-library/dom"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); const noop = () => {}; -const toastApi = { show: noop, error: noop, copied: noop }; +const toastApi = { show: () => noop, error: noop, copied: noop }; const roots: ReturnType[] = []; function render(children: ReactNode) { const container = document.createElement("div"); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.test.ts b/packages/app/src/terminal/drop/terminal-file-drop.test.ts index 01147eb7b9..5a439f316b 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.test.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.test.ts @@ -1,22 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import type { DesktopHostBridge } from "@/desktop/host"; -import { - extractTerminalDropPaths, - isTerminalDragLeaveOutside, - isTerminalFileDrag, - prepareDroppedPathForTerminal, - prepareDroppedPathsForTerminal, -} from "./terminal-file-drop"; +import { routeTerminalDrop } from "./terminal-file-drop"; -function dataTransfer(input: { types?: string[]; files?: File[] }): DataTransfer { - return { - types: input.types ?? [], - files: input.files ?? [], - } as unknown as DataTransfer; -} - -function fakeFile(input: { name: string; legacyPath?: string }): File { - const file = { name: input.name } as unknown as File; +function fakeFile(input: { name: string; type?: string; legacyPath?: string }): File { + const file = { name: input.name, type: input.type ?? "" } as unknown as File; if (input.legacyPath !== undefined) { Object.defineProperty(file, "path", { configurable: true, @@ -26,45 +13,34 @@ function fakeFile(input: { name: string; legacyPath?: string }): File { return file; } -function makeNode(children: readonly EventTarget[] = []): EventTarget { - return { - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - contains: (other: EventTarget | null) => other !== null && children.includes(other), - } as unknown as EventTarget; -} - -describe("terminal file drop", () => { - it("detects file drags", () => { - expect(isTerminalFileDrag(dataTransfer({ types: ["Files"] }))).toBe(true); - expect(isTerminalFileDrag(dataTransfer({ types: ["text/plain"] }))).toBe(false); - expect(isTerminalFileDrag(null)).toBe(false); - }); - - it("keeps drag highlight active when moving between terminal children", () => { - const child = makeNode(); - const outside = makeNode(); - const root = makeNode([child]); - - expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: child })).toBe(false); - expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: outside })).toBe(true); - expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: null })).toBe(true); - expect(isTerminalDragLeaveOutside({ currentTarget: null, relatedTarget: child })).toBe(true); - }); - - it("extracts paths through Electron webUtils", () => { - const file = fakeFile({ name: "photo.png" }); - const getPathForFile = vi.fn(() => "/Users/me/Desktop/photo.png"); - const bridge: DesktopHostBridge = { webUtils: { getPathForFile } }; +describe("terminal file drop routing", () => { + const notes = fakeFile({ name: "notes.txt", type: "text/plain" }); + const electron: DesktopHostBridge = { + webUtils: { getPathForFile: (file) => `/Users/me/Desktop/${file.name}` }, + }; + it("pastes the file's own path when the host is the client's machine", () => { expect( - extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge), - ).toEqual(["/Users/me/Desktop/photo.png"]); - expect(getPathForFile).toHaveBeenCalledWith(file); + routeTerminalDrop({ kind: "web-file", file: notes }, { hostIsLocal: true, bridge: electron }), + ).toEqual({ kind: "path", path: "/Users/me/Desktop/notes.txt" }); }); - it("falls back to legacy Electron file paths", () => { + it("uploads the same file when the host is another machine", () => { + expect( + routeTerminalDrop( + { kind: "web-file", file: notes }, + { hostIsLocal: false, bridge: electron }, + ), + ).toMatchObject({ kind: "upload" }); + }); + + it("uploads browser files, which have no path on any machine", () => { + expect( + routeTerminalDrop({ kind: "web-file", file: notes }, { hostIsLocal: true, bridge: null }), + ).toMatchObject({ kind: "upload" }); + }); + + it("falls back to legacy Electron file paths on a local host", () => { const file = fakeFile({ name: "photo.png", legacyPath: "/tmp/legacy-photo.png" }); const bridge: DesktopHostBridge = { webUtils: { @@ -74,45 +50,21 @@ describe("terminal file drop", () => { }, }; - expect( - extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge), - ).toEqual(["/tmp/legacy-photo.png"]); + expect(routeTerminalDrop({ kind: "web-file", file }, { hostIsLocal: true, bridge })).toEqual({ + kind: "path", + path: "/tmp/legacy-photo.png", + }); }); - it("drops browser files that have no filesystem path", () => { - const file = fakeFile({ name: "photo.png" }); + it("routes desktop paths by host locality too", () => { + const item = { kind: "desktop-path", path: "/tmp/report.pdf" } as const; - expect( - extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), null), - ).toEqual([]); - }); - - it("prepares POSIX paths with conservative escaping", () => { - const bridge: DesktopHostBridge = { platform: "darwin" }; - - expect(prepareDroppedPathForTerminal("/tmp/my image.png", bridge)).toBe("'/tmp/my image.png'"); - expect(prepareDroppedPathForTerminal("/tmp/a$(touch bad).png", bridge)).toBe( - "'/tmp/a(touch bad).png'", - ); - expect(prepareDroppedPathForTerminal("/tmp/it's.png", bridge)).toBe("'/tmp/it\\'s.png'"); - }); - - it("prepares Windows paths with space quoting", () => { - const bridge: DesktopHostBridge = { platform: "win32" }; - - expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo.png", bridge)).toBe( - "C:\\Users\\me\\photo.png", - ); - expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo one.png", bridge)).toBe( - '"C:\\Users\\me\\photo one.png"', - ); - }); - - it("joins multiple dropped paths for one terminal input", () => { - const bridge: DesktopHostBridge = { platform: "darwin" }; - - expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"], bridge)).toBe( - "'/tmp/a.png' '/tmp/b c.png'", - ); + expect(routeTerminalDrop(item, { hostIsLocal: true, bridge: null })).toEqual({ + kind: "path", + path: "/tmp/report.pdf", + }); + expect(routeTerminalDrop(item, { hostIsLocal: false, bridge: null })).toMatchObject({ + kind: "upload", + }); }); }); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.ts b/packages/app/src/terminal/drop/terminal-file-drop.ts index 44dab5b144..18221271e5 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.ts @@ -1,13 +1,16 @@ +import { getMimeTypeFromPath } from "@/attachments/file-types"; +import { readDesktopFileBytes } from "@/attachments/selected-file"; +import type { DroppedItem } from "@/components/file-drop/types"; import type { DesktopHostBridge } from "@/desktop/host"; - -const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g; +import { readTerminalFile, type TerminalFile } from "../file-paste"; function getLegacyFilePath(file: File): string | null { const path = Reflect.get(file, "path"); return typeof path === "string" && path.length > 0 ? path : null; } -function getFilePath(file: File, bridge: DesktopHostBridge | null): string | null { +/** Where the client's own filesystem holds a dropped file; null for files that are only bytes. */ +function getClientFilePath(file: File, bridge: DesktopHostBridge | null): string | null { const getPathForFile = bridge?.webUtils?.getPathForFile; if (typeof getPathForFile === "function") { try { @@ -22,87 +25,39 @@ function getFilePath(file: File, bridge: DesktopHostBridge | null): string | nul return getLegacyFilePath(file); } -export function isTerminalFileDrag(dataTransfer: DataTransfer | null): boolean { - return Boolean(dataTransfer && Array.from(dataTransfer.types).includes("Files")); +function fileNameFromPath(path: string): string { + return path.split(/[/\\]/).findLast((segment) => segment.length > 0) ?? path; } -interface ContainsTarget { - contains: (other: EventTarget | null) => boolean; +async function readDesktopFile(path: string): Promise { + return { + fileName: fileNameFromPath(path), + mimeType: getMimeTypeFromPath(path), + bytes: await readDesktopFileBytes(path), + }; } -function asContainsTarget(value: EventTarget | null): ContainsTarget | null { - if (!value || typeof (value as Partial).contains !== "function") { - return null; +/** How a dropped item reaches the terminal. */ +export type TerminalDropDelivery = + | { kind: "path"; path: string } + | { kind: "upload"; read: () => Promise }; + +/** + * A dropped file pastes its own path only when the host is the machine the file + * sits on. Anywhere else the host cannot read the client's disk, so the bytes are + * uploaded and the host path is pasted instead. The file's type plays no part. + */ +export function routeTerminalDrop( + item: DroppedItem, + input: { hostIsLocal: boolean; bridge: DesktopHostBridge | null }, +): TerminalDropDelivery { + if (item.kind === "desktop-path") { + return input.hostIsLocal + ? { kind: "path", path: item.path } + : { kind: "upload", read: () => readDesktopFile(item.path) }; } - return value as unknown as ContainsTarget; -} - -export function isTerminalDragLeaveOutside(input: { - currentTarget: EventTarget | null; - relatedTarget: EventTarget | null; -}): boolean { - const currentTarget = asContainsTarget(input.currentTarget); - if (!currentTarget || !input.relatedTarget) { - return true; - } - return !currentTarget.contains(input.relatedTarget); -} - -export function extractTerminalDropPaths( - dataTransfer: DataTransfer | null, - bridge: DesktopHostBridge | null, -): string[] { - if (!dataTransfer) { - return []; - } - - const paths: string[] = []; - for (const file of Array.from(dataTransfer.files)) { - const path = getFilePath(file, bridge); - if (path) { - paths.push(path); - } - } - return paths; -} - -function escapeNonWindowsPath(path: string): string { - let nextPath = path; - if (nextPath.includes("\\")) { - nextPath = nextPath.replace(/\\/g, "\\\\"); - } - - nextPath = nextPath.replace(DANGEROUS_NON_WINDOWS_PATH_CHARS, ""); - - if (nextPath.includes("'") && nextPath.includes('"')) { - return `$'${nextPath.replace(/'/g, "\\'")}'`; - } - if (nextPath.includes("'")) { - return `'${nextPath.replace(/'/g, "\\'")}'`; - } - return `'${nextPath}'`; -} - -function escapeWindowsPath(path: string): string { - if (!path.includes(" ")) { - return path; - } - return `"${path}"`; -} - -export function prepareDroppedPathForTerminal( - path: string, - bridge: DesktopHostBridge | null, -): string { - if (bridge?.platform === "win32") { - return escapeWindowsPath(path); - } - return escapeNonWindowsPath(path); -} - -export function prepareDroppedPathsForTerminal( - paths: readonly string[], - bridge: DesktopHostBridge | null, -): string { - return paths.map((path) => prepareDroppedPathForTerminal(path, bridge)).join(" "); + const clientPath = input.hostIsLocal ? getClientFilePath(item.file, input.bridge) : null; + return clientPath + ? { kind: "path", path: clientPath } + : { kind: "upload", read: () => readTerminalFile(item.file) }; } diff --git a/packages/app/src/terminal/drop/terminal-path.test.ts b/packages/app/src/terminal/drop/terminal-path.test.ts new file mode 100644 index 0000000000..6c4b580e48 --- /dev/null +++ b/packages/app/src/terminal/drop/terminal-path.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { prepareDroppedPathsForTerminal, prepareTerminalPath } from "./terminal-path"; + +describe("terminal path quoting", () => { + it("prepares POSIX paths with conservative escaping", () => { + expect(prepareTerminalPath("/tmp/my image.png")).toBe("'/tmp/my image.png'"); + expect(prepareTerminalPath("/tmp/a$(touch bad).png")).toBe("'/tmp/a(touch bad).png'"); + expect(prepareTerminalPath("/tmp/it's.png")).toBe("'/tmp/it\\'s.png'"); + }); + + it("prepares Windows paths with space quoting", () => { + expect(prepareTerminalPath("C:\\Users\\me\\photo.png")).toBe("C:\\Users\\me\\photo.png"); + expect(prepareTerminalPath("C:\\Users\\me\\photo one.png")).toBe( + '"C:\\Users\\me\\photo one.png"', + ); + expect(prepareTerminalPath("\\\\server\\share\\photo one.png")).toBe( + '"\\\\server\\share\\photo one.png"', + ); + }); + + it("joins multiple dropped paths for one terminal input", () => { + expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"])).toBe( + "'/tmp/a.png' '/tmp/b c.png'", + ); + }); +}); diff --git a/packages/app/src/terminal/drop/terminal-path.ts b/packages/app/src/terminal/drop/terminal-path.ts new file mode 100644 index 0000000000..bc29edd54b --- /dev/null +++ b/packages/app/src/terminal/drop/terminal-path.ts @@ -0,0 +1,40 @@ +// Bundled into the terminal WebView as well as the app: keep this module dependency-free. +const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g; + +function escapeNonWindowsPath(path: string): string { + let nextPath = path; + if (nextPath.includes("\\")) { + nextPath = nextPath.replace(/\\/g, "\\\\"); + } + + nextPath = nextPath.replace(DANGEROUS_NON_WINDOWS_PATH_CHARS, ""); + + if (nextPath.includes("'") && nextPath.includes('"')) { + return `$'${nextPath.replace(/'/g, "\\'")}'`; + } + if (nextPath.includes("'")) { + return `'${nextPath.replace(/'/g, "\\'")}'`; + } + return `'${nextPath}'`; +} + +function escapeWindowsPath(path: string): string { + if (!path.includes(" ")) { + return path; + } + return `"${path}"`; +} + +const WINDOWS_PATH = /^(?:[A-Za-z]:[\\/]|\\\\)/; + +/** Quotes a path for the shell of the machine it belongs to, identified by the path's shape. */ +export function prepareTerminalPath(path: string): string { + if (WINDOWS_PATH.test(path)) { + return escapeWindowsPath(path); + } + return escapeNonWindowsPath(path); +} + +export function prepareDroppedPathsForTerminal(paths: readonly string[]): string { + return paths.map(prepareTerminalPath).join(" "); +} diff --git a/packages/app/src/terminal/file-paste/index.test.ts b/packages/app/src/terminal/file-paste/index.test.ts new file mode 100644 index 0000000000..63a1e5e73d --- /dev/null +++ b/packages/app/src/terminal/file-paste/index.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit"; +import { TerminalFilePasteQueue, type TerminalFile, type TerminalFilePasteTarget } from "./index"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function file(fileName: string, bytes: number[] = [1, 2, 3]): TerminalFile { + return { fileName, mimeType: "image/png", bytes: new Uint8Array(bytes) }; +} + +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +class FakeHost implements TerminalFilePasteTarget { + readonly uploads: TerminalFile[] = []; + readonly pasted: string[] = []; + private readonly results = new Map>(); + + holdUpload(fileName: string): ReturnType> { + const result = deferred(); + this.results.set(fileName, result.promise); + return result; + } + + async upload(input: TerminalFile): Promise { + this.uploads.push(input); + return this.results.get(input.fileName) ?? `/host/uploads/${input.fileName}`; + } + + paste(text: string): void { + this.pasted.push(text); + } +} + +function createQueue() { + const events = { uploading: [] as boolean[], uploaded: 0, failures: [] as string[] }; + const queue = new TerminalFilePasteQueue({ + onUploadingChange: (uploading) => { + events.uploading.push(uploading); + }, + onUploaded: () => { + events.uploaded += 1; + }, + onFailure: (error) => { + events.failures.push(error instanceof Error ? error.message : String(error)); + }, + }); + return { queue, events, host: new FakeHost() }; +} + +describe("terminal file paste queue", () => { + it("pastes each uploaded host path in gesture order", async () => { + const { queue, host, events } = createQueue(); + const firstRead = deferred(); + const firstUpload = host.holdUpload("a.png"); + + queue.add(firstRead.promise, host); + queue.add(Promise.resolve(file("b.png")), host); + await settle(); + expect(host.uploads).toEqual([]); + + firstRead.resolve(file("a.png")); + await settle(); + expect(host.uploads.map((upload) => upload.fileName)).toEqual(["a.png"]); + + firstUpload.resolve("/host/uploads/a.png"); + await settle(); + expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]); + expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] }); + }); + + it("quotes Windows host paths for the host shell", async () => { + const { queue, host } = createQueue(); + host.holdUpload("shot.png").resolve("C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"); + + queue.add(Promise.resolve(file("shot.png")), host); + await settle(); + + expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']); + }); + + it("uploads under a file name that needs no shell quoting", async () => { + const { queue, host } = createQueue(); + + queue.add(Promise.resolve(file("Screen Shot #1 (it's $HOME).png")), host); + await settle(); + + expect(host.uploads.map((upload) => upload.fileName)).toEqual([ + "Screen_Shot__1__it_s__HOME_.png", + ]); + }); + + it("does nothing for a gesture that carried no file", async () => { + const { queue, host, events } = createQueue(); + + queue.add(Promise.resolve(null), host); + await settle(); + + expect(host.uploads).toEqual([]); + expect(host.pasted).toEqual([]); + expect(events).toEqual({ uploading: [], uploaded: 0, failures: [] }); + }); + + it("reports a failed upload and keeps pasting later files", async () => { + const { queue, host, events } = createQueue(); + host.holdUpload("a.png").reject(new Error("disk full")); + + queue.add(Promise.resolve(file("a.png")), host); + queue.add(Promise.resolve(file("b.png")), host); + await settle(); + + expect(events.failures).toEqual(["disk full"]); + expect(events.uploaded).toBe(1); + expect(host.pasted).toEqual(["'/host/uploads/b.png'"]); + }); + + it("reports unreadable and oversized files without uploading them", async () => { + const { queue, host, events } = createQueue(); + const oversized = { + ...file("big.png"), + bytes: new Uint8Array(FILE_UPLOAD_MAX_BYTES + 1), + }; + + queue.add(Promise.reject(new Error("unreadable")), host); + queue.add(Promise.resolve(oversized), host); + await settle(); + + expect(events.failures).toEqual(["unreadable", "big.png exceeds the upload limit"]); + expect(host.uploads).toEqual([]); + }); + + it("never pastes the path of an upload cancelled mid-flight", async () => { + const { queue, host, events } = createQueue(); + const upload = host.holdUpload("a.png"); + + queue.add(Promise.resolve(file("a.png")), host); + queue.add(Promise.resolve(file("b.png")), host); + await settle(); + expect(queue.cancel()).toBe(true); + expect(events.uploading).toEqual([true, false]); + + upload.resolve("/host/uploads/a.png"); + await settle(); + expect(host.pasted).toEqual([]); + expect(host.uploads.map((entry) => entry.fileName)).toEqual(["a.png"]); + expect(events.failures).toEqual([]); + expect(events.uploaded).toBe(0); + }); + + it("does not hold new pastes behind a cancelled upload that never settles", async () => { + const { queue, host, events } = createQueue(); + host.holdUpload("stuck.png"); + + queue.add(Promise.resolve(file("stuck.png")), host); + await settle(); + queue.cancel(); + queue.add(Promise.resolve(file("next.png")), host); + await settle(); + + expect(host.pasted).toEqual(["'/host/uploads/next.png'"]); + expect(events.uploading).toEqual([true, false, true, false]); + expect(queue.cancel()).toBe(false); + }); +}); diff --git a/packages/app/src/terminal/file-paste/index.ts b/packages/app/src/terminal/file-paste/index.ts new file mode 100644 index 0000000000..b94990b1bc --- /dev/null +++ b/packages/app/src/terminal/file-paste/index.ts @@ -0,0 +1,146 @@ +// Bundled into the terminal WebView as well as the app: keep i18n and React out. +import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit"; +import { prepareTerminalPath } from "../drop/terminal-path"; + +/** File bytes from a paste or drop gesture, before they become a file on the host. */ +export interface TerminalFile { + fileName: string; + mimeType: string; + bytes: Uint8Array; +} + +/** A file over the upload limit; the pane words it for the user. */ +export class TerminalFileTooLargeError extends Error { + constructor(readonly fileName: string) { + super(`${fileName} exceeds the upload limit`); + } +} + +/** The pane's host and terminal: where a file is stored and where its path is pasted. */ +export interface TerminalFilePasteTarget { + /** Stores the file on the host and returns its host path. */ + upload(file: TerminalFile): Promise; + paste(text: string): void; +} + +interface TerminalFilePasteEvents { + onUploadingChange(uploading: boolean): void; + onUploaded(): void; + onFailure(error: unknown): void; +} + +/** + * Turns a pane's file gestures into pasted host paths, one at a time and in + * gesture order. Ordinary input never waits on it. + */ +export class TerminalFilePasteQueue { + private tail: Promise = Promise.resolve(); + private generation = 0; + private pending = 0; + private uploading = false; + + constructor(private readonly events: TerminalFilePasteEvents) {} + + /** Queues a gesture. `file` resolves to null when the gesture carried no file. */ + add(file: Promise, target: TerminalFilePasteTarget): void { + const generation = this.generation; + this.pending += 1; + this.tail = this.tail.then(() => this.paste(file, target, generation)); + } + + /** Drops queued and in-flight gestures. Returns whether any were dropped. */ + cancel(): boolean { + const dropped = this.pending > 0; + this.generation += 1; + this.pending = 0; + // A cancelled upload can stay unsettled until its connection resolves. + this.tail = Promise.resolve(); + this.setUploading(false); + return dropped; + } + + private async paste( + read: Promise, + target: TerminalFilePasteTarget, + generation: number, + ): Promise { + const isCurrent = () => generation === this.generation; + try { + const file = await read; + if (!file || !isCurrent()) { + return; + } + if (file.bytes.byteLength > FILE_UPLOAD_MAX_BYTES) { + throw new TerminalFileTooLargeError(file.fileName); + } + this.setUploading(true); + const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) }); + if (isCurrent()) { + target.paste(prepareTerminalPath(path)); + this.events.onUploaded(); + } + } catch (error) { + if (isCurrent()) { + this.events.onFailure(error); + } + } finally { + if (isCurrent()) { + this.pending -= 1; + this.setUploading(false); + } + } + } + + private setUploading(uploading: boolean): void { + if (this.uploading === uploading) { + return; + } + this.uploading = uploading; + this.events.onUploadingChange(uploading); + } +} + +// The host path is pasted into a shell or TUI; keep its last segment unquotable. +function toShellSafeFileName(fileName: string): string { + return fileName.replace(/[^\w.-]/g, "_"); +} + +export async function readTerminalFile(file: File): Promise { + return { + fileName: file.name, + mimeType: file.type, + bytes: new Uint8Array(await file.arrayBuffer()), + }; +} + +export function terminalFileFromBase64(input: { + fileName: string; + mimeType: string; + data: string; +}): TerminalFile { + const binary = atob(input.data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return { fileName: input.fileName, mimeType: input.mimeType, bytes }; +} + +const BASE64_CHUNK_SIZE = 0x8000; + +export function terminalFileToBase64(file: TerminalFile): string { + let binary = ""; + for (let offset = 0; offset < file.bytes.length; offset += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode(...file.bytes.subarray(offset, offset + BASE64_CHUNK_SIZE)); + } + return btoa(binary); +} + +/** File extension for a clipboard image, which arrives as bytes with only a MIME type. */ +export function imageExtension(mimeType: string): string { + const subtype = mimeType.split("/")[1]?.split(/[+;]/)[0] ?? ""; + if (subtype === "jpeg") { + return "jpg"; + } + return subtype || "png"; +} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index b173545abc..5dcefb8ebf 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -1,7 +1,12 @@ import { page, userEvent } from "@vitest/browser/context"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; -import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; +import type { TerminalFile } from "../file-paste"; +import { + encodeTerminalOutput, + TerminalEmulatorRuntime, + type TerminalClipboard, +} from "./terminal-emulator-runtime"; vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: class WebglAddon { @@ -41,6 +46,36 @@ interface MountedTerminal { terminalKeys: TerminalKeyRecord[]; inputModeChanges: TerminalInputModeState[]; openedUrls: string[]; + clipboard: FakeClipboard; + filePastes: Promise[]; +} + +class FakeClipboard implements TerminalClipboard { + text = ""; + image: { mimeType: string; bytes: number[] } | null = null; + imageReads = 0; + + async readText(): Promise { + return this.text; + } + + async read(): Promise[]> { + this.imageReads += 1; + const image = this.image; + if (!image) { + return []; + } + return [ + { + types: ["text/html", image.mimeType], + getType: async (type: string) => new Blob([new Uint8Array(image.bytes)], { type }), + }, + ]; + } + + async writeText(text: string): Promise { + this.text = text; + } } const mountedTerminals: MountedTerminal[] = []; @@ -100,8 +135,11 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { const terminalKeys: TerminalKeyRecord[] = []; const inputModeChanges: TerminalInputModeState[] = []; const openedUrls: string[] = []; + const clipboard = new FakeClipboard(); + const filePastes: Promise[] = []; const runtime = new TerminalEmulatorRuntime( input.isMac === undefined ? undefined : { isMac: input.isMac }, + clipboard, ); runtime.setCallbacks({ callbacks: { @@ -120,6 +158,9 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { onOpenExternalUrl: (url) => { openedUrls.push(url); }, + onFilePaste: (file) => { + filePastes.push(file); + }, }, }); runtime.mount({ @@ -143,6 +184,8 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { terminalKeys, inputModeChanges, openedUrls, + clipboard, + filePastes, }; mountedTerminals.push(mounted); return mounted; @@ -413,6 +456,78 @@ describe("terminal emulator runtime in a real browser", () => { expect(mounted.inputs).toEqual(["legacy renderer paste"]); }); + it("pastes clipboard text on Ctrl+V without reading the clipboard image", async () => { + const mounted = createTerminalHost({ width: 720, height: 360, isMac: false }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + mounted.clipboard.text = "clipboard text"; + mounted.clipboard.image = { mimeType: "image/png", bytes: [1, 2, 3] }; + + dispatchTerminalKey({ host: mounted.host, key: "v", ctrlKey: true }); + + expect(mounted.filePastes).toHaveLength(1); + await expect(mounted.filePastes[0]).resolves.toBeNull(); + expect(mounted.inputs).toEqual(["clipboard text"]); + expect(mounted.clipboard.imageReads).toBe(0); + }); + + it("hands the clipboard image to onFilePaste on Ctrl+V when there is no text", async () => { + const mounted = createTerminalHost({ width: 720, height: 360, isMac: false }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + mounted.clipboard.image = { mimeType: "image/jpeg", bytes: [1, 2, 3] }; + + dispatchTerminalKey({ host: mounted.host, key: "v", ctrlKey: true }); + + expect(mounted.filePastes).toHaveLength(1); + await expect(mounted.filePastes[0]).resolves.toEqual({ + fileName: "clipboard.jpg", + mimeType: "image/jpeg", + bytes: new Uint8Array([1, 2, 3]), + }); + expect(mounted.inputs).toEqual([]); + }); + + it("takes file-only paste events at xterm's input before xterm pastes empty text", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + const transfer = new DataTransfer(); + transfer.items.add(new File([new Uint8Array([1, 2, 3])], "shot.png", { type: "image/png" })); + const event = new ClipboardEvent("paste", { + clipboardData: transfer, + bubbles: true, + cancelable: true, + }); + + mounted.host.querySelector(".xterm-helper-textarea")!.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(mounted.filePastes).toHaveLength(1); + await expect(mounted.filePastes[0]).resolves.toEqual({ + fileName: "shot.png", + mimeType: "image/png", + bytes: new Uint8Array([1, 2, 3]), + }); + await nextFrame(); + expect(mounted.inputs).toEqual([]); + }); + + it("leaves paste events with text to xterm", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + const transfer = new DataTransfer(); + transfer.setData("text/plain", "echo hi"); + transfer.items.add(new File([new Uint8Array([1])], "shot.png", { type: "image/png" })); + + mounted.host + .querySelector(".xterm-helper-textarea")! + .dispatchEvent( + new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }), + ); + + await waitFor({ predicate: () => mounted.inputs.length > 0 }); + expect(mounted.inputs).toEqual(["echo hi"]); + expect(mounted.filePastes).toEqual([]); + }); + it("refreshes visible rows on a forced same-size resize", async () => { await page.viewport(900, 600); const mounted = createTerminalHost({ width: 720, height: 360 }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index cc4462ff7b..0a55cd7126 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -33,6 +33,7 @@ import { import { isFindShortcut, type FindShortcutPlatform } from "@/pane-find/find-shortcut"; import { isMacUserAgent } from "@/utils/mac-user-agent"; import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-font"; +import { imageExtension, readTerminalFile, type TerminalFile } from "../file-paste"; export type TerminalOutputData = Uint8Array; @@ -83,6 +84,8 @@ export interface TerminalEmulatorRuntimeCallbacks { disposition: "main" | "side", ) => Promise | void; onInputModeChange?: (state: TerminalInputModeState) => Promise | void; + /** A paste gesture that can carry a file; resolves to null when it carried none. */ + onFilePaste?: (file: Promise) => void; } export interface TerminalResizeEvent { @@ -122,6 +125,7 @@ interface TerminalEmulatorRuntimeDisposables { clearFitTimeouts: () => void; removeFontListeners: () => void; removeTouchListeners: () => void; + removeFilePasteListener: () => void; restoreDocumentStyles: () => void; restoreViewportStyles: () => void; disposeFitAddon: () => void; @@ -180,8 +184,51 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme { }; } +export interface TerminalClipboard { + readText(): Promise; + read(): Promise[]>; + writeText(text: string): Promise; +} + +const browserClipboard: TerminalClipboard = { + readText: () => navigator.clipboard.readText(), + read: () => navigator.clipboard.read(), + writeText: (text) => navigator.clipboard.writeText(text), +}; + +async function readClipboardImage(clipboard: TerminalClipboard): Promise { + for (const item of await clipboard.read()) { + const mimeType = item.types.find((type) => type.startsWith("image/")); + if (mimeType) { + const blob = await item.getType(mimeType); + return { + fileName: `clipboard.${imageExtension(mimeType)}`, + mimeType, + bytes: new Uint8Array(await blob.arrayBuffer()), + }; + } + } + return null; +} + +/** The file of a paste that carries no text. xterm would paste it as empty text. */ +function findFileOnlyPaste(transfer: DataTransfer | null): File | null { + if (!transfer || transfer.getData("text/plain").length > 0) { + return null; + } + for (const item of Array.from(transfer.items)) { + if (item.kind === "file") { + return item.getAsFile(); + } + } + return null; +} + export class TerminalEmulatorRuntime { - constructor(private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }) {} + constructor( + private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }, + private readonly clipboard: TerminalClipboard = browserClipboard, + ) {} private callbacks: TerminalEmulatorRuntimeCallbacks = {}; private pendingModifiers: PendingTerminalModifiers = { @@ -327,19 +374,14 @@ export class TerminalEmulatorRuntime { // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT if (key === "c" && terminal.hasSelection()) { - void navigator.clipboard.writeText(terminal.getSelection()); + void this.clipboard.writeText(terminal.getSelection()); return false; } - // Ctrl+V: paste from clipboard into terminal + // Ctrl+V: paste clipboard text, or the clipboard image when there is no text if (key === "v") { event.preventDefault(); - void navigator.clipboard.readText().then((text) => { - if (text) { - terminal.paste(text); - } - return; - }); + this.pasteClipboard(terminal); return false; } @@ -666,6 +708,20 @@ export class TerminalEmulatorRuntime { this.attachKeyEventHandler(terminal); + // Cmd+V and context-menu paste arrive as DOM paste events. Take file-only + // pastes before xterm's own listener consumes them as empty text. + const filePasteHandler = (event: ClipboardEvent): void => { + const onFilePaste = this.callbacks.onFilePaste; + const file = findFileOnlyPaste(event.clipboardData); + if (!onFilePaste || !file) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onFilePaste(readTerminalFile(file)); + }; + input.host.addEventListener("paste", filePasteHandler, true); + const removeTouchListeners = this.setupTouchScrollHandlers({ root: input.root, host: input.host, @@ -755,6 +811,9 @@ export class TerminalEmulatorRuntime { fontSet?.removeEventListener?.("loadingdone", fontReadyHandler); }, removeTouchListeners, + removeFilePasteListener: () => { + input.host.removeEventListener("paste", filePasteHandler, true); + }, restoreDocumentStyles, restoreViewportStyles, disposeFitAddon: () => { @@ -784,6 +843,7 @@ export class TerminalEmulatorRuntime { disposables.clearFitTimeouts(); disposables.removeFontListeners(); disposables.removeTouchListeners(); + disposables.removeFilePasteListener(); disposables.disposeFitAddon(); disposables.disposeWebglAddon(); disposables.disposeTerminal(); @@ -824,6 +884,20 @@ export class TerminalEmulatorRuntime { this.terminal?.paste(text); } + private pasteClipboard(terminal: Pick): void { + const text = this.clipboard.readText().catch(() => ""); + const onFilePaste = this.callbacks.onFilePaste; + // Report the gesture now so it keeps its place among earlier file pastes. + const file = text.then((value) => { + if (value) { + terminal.paste(value); + return null; + } + return onFilePaste ? readClipboardImage(this.clipboard).catch(() => null) : null; + }); + onFilePaste?.(file); + } + renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void { if (!input.state) { this.clear(input); diff --git a/packages/app/src/terminal/runtime/terminal-paste.test.ts b/packages/app/src/terminal/runtime/terminal-paste.test.ts index 48832003c9..b052a62c94 100644 --- a/packages/app/src/terminal/runtime/terminal-paste.test.ts +++ b/packages/app/src/terminal/runtime/terminal-paste.test.ts @@ -27,22 +27,24 @@ describe("terminal paste encoding", () => { it("reads clipboard through the explicit paste adapter", async () => { const pasted: string[] = []; - await pasteTerminalClipboard({ + const result = await pasteTerminalClipboard({ clipboard: { readText: async () => "printf one\nprintf two" }, terminal: { paste: (text) => pasted.push(text) }, }); expect(pasted).toEqual(["printf one\nprintf two"]); + expect(result).toBe(true); }); it("does not dispatch terminal input when clipboard is empty", async () => { const pasted: string[] = []; - await pasteTerminalClipboard({ + const result = await pasteTerminalClipboard({ clipboard: { readText: async () => "" }, terminal: { paste: (text) => pasted.push(text) }, }); expect(pasted).toEqual([]); + expect(result).toBe(false); }); }); diff --git a/packages/app/src/terminal/runtime/terminal-paste.ts b/packages/app/src/terminal/runtime/terminal-paste.ts index f02d301e07..8830ff0b6f 100644 --- a/packages/app/src/terminal/runtime/terminal-paste.ts +++ b/packages/app/src/terminal/runtime/terminal-paste.ts @@ -28,11 +28,13 @@ export function encodeTerminalPaste(input: TerminalPasteInput): string { return `${BRACKETED_PASTE_START}${payload}${BRACKETED_PASTE_END}`; } -export async function pasteTerminalClipboard(input: PasteTerminalClipboardInput): Promise { +/** Pastes clipboard text. Returns whether there was text to paste. */ +export async function pasteTerminalClipboard(input: PasteTerminalClipboardInput): Promise { const text = await input.clipboard.readText(); if (text.length === 0) { - return; + return false; } input.terminal.paste(text); + return true; } diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts index 50f080b37c..d1825d7eb7 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts @@ -12,6 +12,7 @@ import type { TerminalLocalFileLinkSource, TerminalLocalFileLinkTarget, } from "../local-links/terminal-local-link-provider"; +import { terminalFileToBase64, type TerminalFile } from "../file-paste"; interface MountMessage { type: "mount"; @@ -76,6 +77,7 @@ type OutboundMessage = | { type: "pendingModifiersConsumed"; streamKey: string } | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState } | { type: "openExternalUrl"; streamKey: string; url: string } + | { type: "filePaste"; streamKey: string; fileName: string; mimeType: string; data: string } | { type: "resolveLocalFileLink"; streamKey: string; @@ -182,6 +184,7 @@ class TerminalWebViewBridge { number, (target: TerminalLocalFileLinkTarget | null) => void >(); + private filePastes: Promise = Promise.resolve(); private swipeGesturesEnabled = false; private trackingSwipe = false; private activePointerId: number | null = null; @@ -353,6 +356,7 @@ class TerminalWebViewBridge { target, disposition, }), + onFilePaste: (file) => this.forwardFilePaste(message.streamKey, file), }, }); runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers }); @@ -394,6 +398,27 @@ class TerminalWebViewBridge { } } + // Forward in gesture order: an earlier file can finish reading after a later one. + private forwardFilePaste(streamKey: string, file: Promise): void { + this.filePastes = this.filePastes.then(() => this.sendFilePaste(streamKey, file)); + } + + private async sendFilePaste( + streamKey: string, + file: Promise, + ): Promise { + const read = await file.catch(() => null); + if (read && this.matches(streamKey)) { + sendToNative({ + type: "filePaste", + streamKey, + fileName: read.fileName, + mimeType: read.mimeType, + data: terminalFileToBase64(read), + }); + } + } + private matches(streamKey: string): boolean { return this.streamKey === streamKey; } diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index c7fc27652e..e7a9cfd8d1 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/terminal/webview/terminal-find.browser.test.ts b/packages/app/src/terminal/webview/terminal-find.browser.test.ts index 37e83c6ba9..24176331d2 100644 --- a/packages/app/src/terminal/webview/terminal-find.browser.test.ts +++ b/packages/app/src/terminal/webview/terminal-find.browser.test.ts @@ -7,6 +7,8 @@ interface Message { streamKey?: string; result?: { resultCount: number; resultIndex: number }; data?: string; + fileName?: string; + mimeType?: string; } interface TerminalFrameWindow extends Window { __PASEO_TERMINAL_WEBVIEW_RECEIVE__(message: unknown): void; @@ -15,7 +17,7 @@ interface TerminalFrameWindow extends Window { let frame: HTMLIFrameElement; afterEach(() => frame?.remove()); -test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => { +async function mountWebView() { const messages: Message[] = []; frame = document.createElement("iframe"); frame.style.width = "500px"; @@ -42,6 +44,11 @@ test("the generated WebView searches its mounted stream and keeps Find commands await expect .poll(() => messages.some((m) => m.type === "rendererReady" && m.streamKey === "owned")) .toBe(true); + return { win, send, messages }; +} + +test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => { + const { win, send, messages } = await mountWebView(); send({ type: "writeOutput", text: "first a.b\r\n" + "padding\r\n".repeat(30) + "last A.B" }); await expect .poll(() => { @@ -71,3 +78,27 @@ test("the generated WebView searches its mounted stream and keeps Find commands expect(messages.filter((m) => m.type === "input")).toEqual([]); send({ type: "unmount" }); }); + +test("the generated WebView forwards file-only pastes to native as file bytes", async () => { + const { win, send, messages } = await mountWebView(); + const clipboardData = new DataTransfer(); + clipboardData.items.add( + new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" }), + ); + + win.document + .querySelector(".xterm-helper-textarea")! + .dispatchEvent(new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true })); + + await expect + .poll(() => messages.find((m) => m.type === "filePaste")) + .toEqual({ + type: "filePaste", + streamKey: "owned", + fileName: "image.png", + mimeType: "image/png", + data: "iVBORw==", + }); + expect(messages.filter((m) => m.type === "input")).toEqual([]); + send({ type: "unmount" }); +}); From 6376f2bf409405b9857c03262381655433a6d0ee Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 19:47:28 +0700 Subject: [PATCH 31/62] Remove the WebView terminal renderer: xterm on web, native grid on mobile (#7639) * refactor(terminal): remove the WebView terminal renderer Native uses the native grid renderer only; web keeps xterm. The legacy renderer setting, its diagnostics toggle, the esbuild WebView bundle and its CI drift check are gone. * test(terminal): target the visible terminal surface in the file paste spec A second page opening a terminal in the same workspace adds a retained, hidden surface to the first page, so the unscoped locator matched two textareas and failed strict mode. * test(server): wait for the shortstat warm instead of polling 500ms The background warm shells out to git; on Windows CI that regularly outlasts the 20x25ms poll and fails the test. Use the completion callback the warm already exposes and keep the non-blocking check. --- .github/workflows/ci.yml | 4 +- .../browser/terminal-protocol-query.spec.ts | 11 +- packages/app/package.json | 4 +- .../scripts/build-terminal-webview-html.mjs | 103 --- .../terminal-emulator-webview.native.tsx | 724 ------------------ .../components/terminal-emulator.native.tsx | 10 +- .../terminal-webview-message-routing.test.ts | 43 -- .../terminal-webview-message-routing.ts | 17 - .../terminal-webview-readiness.test.ts | 64 -- .../components/terminal-webview-readiness.ts | 13 - .../src/hooks/use-settings/storage.test.ts | 20 - .../app/src/hooks/use-settings/storage.ts | 3 - packages/app/src/i18n/resources.test.ts | 3 - packages/app/src/i18n/resources/ar.ts | 5 - packages/app/src/i18n/resources/en.ts | 5 - packages/app/src/i18n/resources/es.ts | 5 - packages/app/src/i18n/resources/fr.ts | 5 - packages/app/src/i18n/resources/ja.ts | 5 - packages/app/src/i18n/resources/ko.ts | 5 - packages/app/src/i18n/resources/pt-BR.ts | 5 - packages/app/src/i18n/resources/ru.ts | 5 - packages/app/src/i18n/resources/zh-CN.ts | 5 - packages/app/src/screens/settings-screen.tsx | 36 +- packages/app/src/terminal/file-paste/index.ts | 11 - .../terminal-renderer-capability.test.ts | 27 +- .../terminal-renderer-capability.ts | 6 +- .../terminal-emulator-webview-entry.ts | 593 -------------- .../webview/terminal-emulator-webview-html.ts | 5 - .../webview/terminal-find.browser.test.ts | 104 --- .../server/src/utils/checkout-git.test.ts | 18 +- 30 files changed, 26 insertions(+), 1838 deletions(-) delete mode 100644 packages/app/scripts/build-terminal-webview-html.mjs delete mode 100644 packages/app/src/components/terminal-emulator-webview.native.tsx delete mode 100644 packages/app/src/components/terminal-webview-message-routing.test.ts delete mode 100644 packages/app/src/components/terminal-webview-message-routing.ts delete mode 100644 packages/app/src/components/terminal-webview-readiness.test.ts delete mode 100644 packages/app/src/components/terminal-webview-readiness.ts delete mode 100644 packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts delete mode 100644 packages/app/src/terminal/webview/terminal-emulator-webview-html.ts delete mode 100644 packages/app/src/terminal/webview/terminal-find.browser.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 099f9efc0b..e632cb4b96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -415,9 +415,7 @@ jobs: run: npm run build:app-deps - name: Run app unit tests - run: | - npm run check:terminal-webview --workspace=@getpaseo/app - npm run test --workspace=@getpaseo/app + run: npm run test --workspace=@getpaseo/app - uses: actions/setup-java@v4 with: diff --git a/packages/app/e2e/browser/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts index 8f297dfb17..d5a0071189 100644 --- a/packages/app/e2e/browser/terminal-protocol-query.spec.ts +++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts @@ -73,8 +73,14 @@ function fileRead(bytes: Buffer, location: "upload" | "local"): string { return `FILE_READ:${digest}:${location}`; } +// Inactive terminal tabs retain their surfaces, and another page opening a terminal in +// the same workspace adds a tab here too. Only the selected tab's surface is visible. +function terminalSurface(page: Page) { + return page.getByTestId("terminal-surface").filter({ visible: true }); +} + function terminalInput(page: Page) { - return page.locator('[data-testid="terminal-surface"] .xterm-helper-textarea'); + return terminalSurface(page).locator(".xterm-helper-textarea"); } async function pasteImage(page: Page, bytes: Buffer): Promise { @@ -127,8 +133,7 @@ async function dropFiles(page: Page, files: DroppedFile[]): Promise { /** The shared "Drop files here" overlay of the zone around the terminal. */ function terminalDropOverlay(page: Page) { - return page - .getByTestId("terminal-surface") + return terminalSurface(page) .locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]") .getByTestId("file-drop-backdrop"); } diff --git a/packages/app/package.json b/packages/app/package.json index 5eaea08838..bc992b5d6c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -7,7 +7,7 @@ "start": "npm run start:expo", "start:expo": "cross-env APP_VARIANT=development expo start", "reset-project": "node ./scripts/reset-project.js", - "eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview", + "eas-build-post-install": "npm --prefix ../.. run build:app-deps", "android": "npm run dev:android", "android:development": "npm run dev:android", "android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release", @@ -35,8 +35,6 @@ "profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs", "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", - "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs", - "check:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs --check", "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs", "dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug", "dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios", diff --git a/packages/app/scripts/build-terminal-webview-html.mjs b/packages/app/scripts/build-terminal-webview-html.mjs deleted file mode 100644 index 238b2fd6fe..0000000000 --- a/packages/app/scripts/build-terminal-webview-html.mjs +++ /dev/null @@ -1,103 +0,0 @@ -import esbuild from "esbuild"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const appRoot = path.resolve(__dirname, ".."); -const repoRoot = path.resolve(appRoot, "../.."); -const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts"); -const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts"); - -async function resolveTsPath(basePath) { - const candidates = [ - basePath, - `${basePath}.ts`, - `${basePath}.tsx`, - `${basePath}.js`, - `${basePath}.jsx`, - path.join(basePath, "index.ts"), - path.join(basePath, "index.tsx"), - path.join(basePath, "index.js"), - path.join(basePath, "index.jsx"), - ]; - for (const candidate of candidates) { - try { - const stat = await fs.stat(candidate); - if (stat.isFile()) return candidate; - } catch { - // try next candidate - } - } - return basePath; -} - -const aliasPlugin = { - name: "paseo-alias", - setup(build) { - build.onResolve({ filter: /^@\// }, async (args) => ({ - path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))), - })); - build.onResolve({ filter: /^@server\// }, async (args) => ({ - path: await resolveTsPath( - path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)), - ), - })); - }, -}; - -const result = await esbuild.build({ - absWorkingDir: repoRoot, - entryPoints: [entry], - bundle: true, - write: false, - format: "iife", - platform: "browser", - target: ["ios15", "chrome100"], - loader: { - ".css": "text", - }, - plugins: [aliasPlugin], - logLevel: "info", -}); - -const js = result.outputFiles[0]?.text; -if (!js) { - throw new Error("terminal webview bundle produced no JavaScript"); -} - -const html = ` - - - - - - - - -`; - -const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs. -// Do not edit by hand. - -export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)}; -`; - -if (process.argv.includes("--check")) { - // Compare parsed modules so repository formatting does not create false drift. - const options = { loader: "ts", format: "esm" }; - const expected = await esbuild.transform(contents, options); - const actual = await esbuild.transform(await fs.readFile(output, "utf8"), options); - if (actual.code !== expected.code) { - throw new Error( - "Stale terminal WebView HTML. Run npm run build:terminal-webview --workspace=@getpaseo/app", - ); - } - console.log("Terminal WebView HTML is current"); -} else { - await fs.writeFile(output, contents); - console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`); -} diff --git a/packages/app/src/components/terminal-emulator-webview.native.tsx b/packages/app/src/components/terminal-emulator-webview.native.tsx deleted file mode 100644 index 163e335381..0000000000 --- a/packages/app/src/components/terminal-emulator-webview.native.tsx +++ /dev/null @@ -1,724 +0,0 @@ -import { - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, - type ComponentProps, -} from "react"; -import { - Keyboard, - StyleSheet, - View, - type GestureResponderEvent, - type StyleProp, - type ViewStyle, -} from "react-native"; -import { WebView, type WebViewMessageEvent } from "react-native-webview"; -import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; -import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; -import type { - TerminalOutputData, - TerminalFindResult, -} from "../terminal/runtime/terminal-emulator-runtime"; -import type { - TerminalLocalFileLinkSource, - TerminalLocalFileLinkTarget, -} from "../terminal/local-links/terminal-local-link-provider"; -import { terminalFileFromBase64 } from "../terminal/file-paste"; -import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html"; -import type { PendingTerminalModifiers } from "../utils/terminal-keys"; -import { openExternalUrl } from "../utils/open-external-url"; -import type { TerminalEmulatorHandle, TerminalEmulatorProps } from "./terminal-emulator-contract"; -import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing"; -import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness"; - -type BridgeInboundMessage = - | { - type: "mount"; - streamKey: string; - initialSnapshot: TerminalState | null; - scrollbackLines: number; - theme: ITheme; - fontFamily?: string; - fontSize?: number; - pendingModifiers: PendingTerminalModifiers; - swipeGesturesEnabled: boolean; - } - | { type: "unmount"; streamKey: string } - | { type: "writeOutput"; streamKey: string; text: string } - | { type: "restoreOutput"; streamKey: string; text: string } - | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } - | { type: "paste"; streamKey: string; text: string } - | { type: "clear"; streamKey: string } - | { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" } - | { type: "clearFind"; streamKey: string } - | { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } } - | { type: "focus"; streamKey: string; forceRefocus?: boolean } - | { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean } - | { type: "setTheme"; streamKey: string; theme: ITheme } - | { type: "setScrollback"; streamKey: string; lines: number } - | { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number } - | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } - | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean } - | { - type: "resolveLocalFileLinkResponse"; - streamKey: string; - requestId: number; - target: TerminalLocalFileLinkTarget | null; - }; - -type BridgeOutboundMessage = - | { type: "bridgeReady" } - | { type: "rendererReady"; streamKey: string; isReady: boolean } - | { type: "input"; streamKey: string; data: string } - | { type: "findRequest"; streamKey: string } - | { type: "findResult"; streamKey: string; result: TerminalFindResult } - | { - type: "resize"; - streamKey: string; - rows: number; - cols: number; - shouldClaim?: boolean; - forceClaim?: boolean; - } - | { - type: "terminalKey"; - streamKey: string; - key: string; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta: boolean; - } - | { type: "pendingModifiersConsumed"; streamKey: string } - | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState } - | { type: "openExternalUrl"; streamKey: string; url: string } - | { type: "filePaste"; streamKey: string; fileName: string; mimeType: string; data: string } - | { - type: "resolveLocalFileLink"; - streamKey: string; - requestId: number; - source: TerminalLocalFileLinkSource; - } - | { - type: "openLocalFileLink"; - streamKey: string; - target: TerminalLocalFileLinkTarget; - disposition: "main" | "side"; - } - | { type: "swipeLeft"; streamKey: string } - | { type: "swipeRight"; streamKey: string } - | { type: "debug"; message: string; details?: unknown }; - -const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml }; -const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"]; -const BRIDGE_READY_TIMEOUT_MS = 2_500; -const RENDERER_READY_TIMEOUT_MS = 2_500; -const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8; -type WebViewProps = ComponentProps; -interface PendingTerminalTap { - startX: number; - startY: number; - moved: boolean; -} - -function buildThemeKey(theme: ITheme): string { - return JSON.stringify(theme); -} - -function serializeForInjectedJavaScript(message: BridgeInboundMessage): string { - return JSON.stringify(message).replace(/<\/script/gi, "<\\/script"); -} - -function createMountMessage(input: { - streamKey: string; - initialSnapshot: TerminalState | null; - scrollbackLines: number; - theme: ITheme; - fontFamily?: string; - fontSize?: number; - pendingModifiers: PendingTerminalModifiers; - swipeGesturesEnabled: boolean; -}): BridgeInboundMessage { - return { - type: "mount", - streamKey: input.streamKey, - initialSnapshot: input.initialSnapshot, - scrollbackLines: input.scrollbackLines, - theme: input.theme, - fontFamily: input.fontFamily, - fontSize: input.fontSize, - pendingModifiers: input.pendingModifiers, - swipeGesturesEnabled: input.swipeGesturesEnabled, - }; -} - -export default function WebViewTerminalEmulator({ - ref, - streamKey, - testId = "terminal-surface", - xtermTheme = { - background: "#0b0b0b", - foreground: "#e6e6e6", - cursor: "#e6e6e6", - }, - scrollbackLines, - fontFamily, - fontSize, - swipeGesturesEnabled = false, - onSwipeLeft, - onSwipeRight, - initialSnapshot = null, - onFindRequest, - onFindResult, - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onRendererReadyChange, - onFilePaste, - pendingModifiers = { ctrl: false, shift: false, alt: false }, - focusRequestToken = 0, - resizeRequestToken = 0, -}: TerminalEmulatorProps) { - const webViewRef = useRef(null); - const [webViewEpoch, setWebViewEpoch] = useState(0); - const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0); - const bridgeReadyRef = useRef(false); - const bridgeReadyVersionRef = useRef(0); - const rendererReadyVersionRef = useRef(0); - const pendingMessagesRef = useRef([]); - const outputDecoderRef = useRef(new TextDecoder()); - const mountRequestedStreamKeyRef = useRef(null); - const rendererReadyStreamKeyRef = useRef(null); - const bridgeReadyTimeoutRef = useRef | null>(null); - const rendererReadyTimeoutRef = useRef | null>(null); - const pendingTapRef = useRef(null); - const mountConfigRef = useRef({ - streamKey, - initialSnapshot, - scrollbackLines, - theme: xtermTheme, - fontFamily, - fontSize, - pendingModifiers, - swipeGesturesEnabled, - }); - mountConfigRef.current = { - streamKey, - initialSnapshot, - scrollbackLines, - theme: xtermTheme, - fontFamily, - fontSize, - pendingModifiers, - swipeGesturesEnabled, - }; - const callbacksRef = useRef({ - onFindRequest, - onFindResult, - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onRendererReadyChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onFilePaste, - onSwipeLeft, - onSwipeRight, - }); - callbacksRef.current = { - onFindRequest, - onFindResult, - onInput, - onFocus, - onResize, - onTerminalKey, - onPendingModifiersConsumed, - onInputModeChange, - onRendererReadyChange, - onResolveLocalFileLink, - onOpenLocalFileLink, - onFilePaste, - onSwipeLeft, - onSwipeRight, - }; - - const clearBridgeReadyTimeout = useCallback(() => { - if (bridgeReadyTimeoutRef.current === null) return; - clearTimeout(bridgeReadyTimeoutRef.current); - bridgeReadyTimeoutRef.current = null; - }, []); - - const clearRendererReadyTimeout = useCallback(() => { - if (rendererReadyTimeoutRef.current === null) return; - clearTimeout(rendererReadyTimeoutRef.current); - rendererReadyTimeoutRef.current = null; - }, []); - - const resetWebViewDocument = useCallback(() => { - clearBridgeReadyTimeout(); - clearRendererReadyTimeout(); - bridgeReadyRef.current = false; - pendingMessagesRef.current = []; - mountRequestedStreamKeyRef.current = null; - rendererReadyStreamKeyRef.current = null; - callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); - setWebViewEpoch((value) => value + 1); - }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]); - - const scheduleBridgeReadyWatchdog = useCallback(() => { - clearBridgeReadyTimeout(); - const expectedBridgeReadyVersion = bridgeReadyVersionRef.current; - bridgeReadyTimeoutRef.current = setTimeout(() => { - bridgeReadyTimeoutRef.current = null; - if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) { - return; - } - resetWebViewDocument(); - }, BRIDGE_READY_TIMEOUT_MS); - }, [clearBridgeReadyTimeout, resetWebViewDocument]); - - const scheduleRendererReadyWatchdog = useCallback(() => { - clearRendererReadyTimeout(); - const expectedRendererReadyVersion = rendererReadyVersionRef.current; - rendererReadyTimeoutRef.current = setTimeout(() => { - rendererReadyTimeoutRef.current = null; - if ( - !shouldResetForMissingRendererReady({ - expectedReadyVersion: expectedRendererReadyVersion, - currentReadyVersion: rendererReadyVersionRef.current, - expectedStreamKey: streamKey, - mountRequestedStreamKey: mountRequestedStreamKeyRef.current, - rendererReadyStreamKey: rendererReadyStreamKeyRef.current, - }) - ) { - return; - } - resetWebViewDocument(); - }, RENDERER_READY_TIMEOUT_MS); - }, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]); - - const flushPendingMessages = useCallback(() => { - if (!bridgeReadyRef.current || !webViewRef.current) return; - const pending = pendingMessagesRef.current.splice(0); - for (const message of pending) { - const payload = serializeForInjectedJavaScript(message); - webViewRef.current.injectJavaScript( - `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, - ); - } - }, []); - - const sendToWebView = useCallback((message: BridgeInboundMessage) => { - if (!bridgeReadyRef.current || !webViewRef.current) { - pendingMessagesRef.current.push(message); - return; - } - const payload = serializeForInjectedJavaScript(message); - webViewRef.current.injectJavaScript( - `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, - ); - }, []); - - useImperativeHandle( - ref, - (): TerminalEmulatorHandle => ({ - find: { - setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }), - search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }), - clear: () => sendToWebView({ type: "clearFind", streamKey }), - }, - writeOutput: (data: TerminalOutputData) => { - const output = outputDecoderRef.current.decode(data, { stream: true }); - if (output.length === 0) { - return; - } - sendToWebView({ type: "writeOutput", streamKey, text: output }); - }, - restoreOutput: (data: TerminalOutputData) => { - outputDecoderRef.current.decode(); - const text = outputDecoderRef.current.decode(data, { stream: false }); - if (text.length === 0) { - return; - } - sendToWebView({ type: "restoreOutput", streamKey, text }); - }, - renderSnapshot: (state: TerminalState | null) => { - outputDecoderRef.current.decode(); - sendToWebView({ type: "renderSnapshot", streamKey, state }); - }, - paste: (text: string) => { - sendToWebView({ type: "paste", streamKey, text }); - }, - copySelection: async () => "", - clear: () => { - outputDecoderRef.current.decode(); - sendToWebView({ type: "clear", streamKey }); - }, - claimSize: () => { - sendToWebView({ type: "resize", streamKey, forceClaim: true }); - }, - showKeyboard: () => { - sendToWebView({ type: "focus", streamKey, forceRefocus: true }); - webViewRef.current?.requestFocus(); - }, - blur: () => { - webViewRef.current?.injectJavaScript( - "window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;", - ); - Keyboard.dismiss(); - }, - }), - [sendToWebView, streamKey], - ); - - useEffect(() => { - outputDecoderRef.current.decode(); - }, [streamKey]); - - useEffect(() => { - if (bridgeReadyVersion <= 0) return; - const mountMessage = createMountMessage(mountConfigRef.current); - mountRequestedStreamKeyRef.current = streamKey; - rendererReadyStreamKeyRef.current = null; - sendToWebView(mountMessage); - flushPendingMessages(); - scheduleRendererReadyWatchdog(); - }, [ - bridgeReadyVersion, - flushPendingMessages, - scheduleRendererReadyWatchdog, - sendToWebView, - streamKey, - ]); - - const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]); - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme }); - }, [sendToWebView, streamKey, themeKey, xtermTheme]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines }); - }, [scrollbackLines, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize }); - }, [fontFamily, fontSize, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers }); - }, [pendingModifiers, sendToWebView, streamKey]); - - useEffect(() => { - if (!mountRequestedStreamKeyRef.current) return; - sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled }); - }, [sendToWebView, streamKey, swipeGesturesEnabled]); - - useEffect(() => { - if (focusRequestToken <= 0) return; - sendToWebView({ type: "resize", streamKey, forceClaim: true }); - sendToWebView({ type: "focus", streamKey }); - webViewRef.current?.requestFocus(); - }, [focusRequestToken, sendToWebView, streamKey]); - - useEffect(() => { - if (resizeRequestToken <= 0) return; - sendToWebView({ type: "resize", streamKey, forceClaim: false, shouldClaim: false }); - }, [resizeRequestToken, sendToWebView, streamKey]); - - useEffect(() => { - return () => { - if (mountRequestedStreamKeyRef.current) { - const previousStreamKey = mountRequestedStreamKeyRef.current; - callbacksRef.current.onRendererReadyChange?.({ - streamKey: previousStreamKey, - isReady: false, - }); - sendToWebView({ type: "unmount", streamKey: previousStreamKey }); - } - bridgeReadyRef.current = false; - pendingMessagesRef.current = []; - mountRequestedStreamKeyRef.current = null; - rendererReadyStreamKeyRef.current = null; - clearBridgeReadyTimeout(); - clearRendererReadyTimeout(); - }; - }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]); - - const handleLifecycleMessage = useCallback( - (message: BridgeOutboundMessage): boolean => { - if (message.type === "bridgeReady") { - bridgeReadyRef.current = true; - bridgeReadyVersionRef.current += 1; - clearBridgeReadyTimeout(); - setBridgeReadyVersion((value) => value + 1); - return true; - } - if (message.type === "rendererReady") { - if (message.streamKey === mountRequestedStreamKeyRef.current) { - rendererReadyStreamKeyRef.current = message.isReady ? message.streamKey : null; - if (message.isReady) { - rendererReadyVersionRef.current += 1; - clearRendererReadyTimeout(); - } - } - callbacksRef.current.onRendererReadyChange?.({ - streamKey: message.streamKey, - isReady: message.isReady, - }); - return true; - } - return false; - }, - [clearBridgeReadyTimeout, clearRendererReadyTimeout], - ); - - const resolveLocalFileLink = useCallback( - async (message: Extract) => { - try { - const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ?? - null); - sendToWebView({ - type: "resolveLocalFileLinkResponse", - streamKey: message.streamKey, - requestId: message.requestId, - target, - }); - } catch { - sendToWebView({ - type: "resolveLocalFileLinkResponse", - streamKey: message.streamKey, - requestId: message.requestId, - target: null, - }); - } - }, - [sendToWebView], - ); - - const handleTerminalMessage = useCallback( - ( - message: Exclude, - ) => { - if (message.type === "resolveLocalFileLink") { - void resolveLocalFileLink(message); - return; - } - if (message.type === "openLocalFileLink") { - callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition); - return; - } - switch (message.type) { - case "input": - callbacksRef.current.onInput?.(message.data); - break; - case "resize": - callbacksRef.current.onResize?.({ - rows: message.rows, - cols: message.cols, - shouldClaim: message.shouldClaim !== false, - forceClaim: message.forceClaim, - }); - break; - case "terminalKey": - callbacksRef.current.onTerminalKey?.({ - key: message.key, - ctrl: message.ctrl, - shift: message.shift, - alt: message.alt, - meta: message.meta, - }); - break; - case "pendingModifiersConsumed": - callbacksRef.current.onPendingModifiersConsumed?.(); - break; - case "inputModeChange": - callbacksRef.current.onInputModeChange?.(message.state); - break; - case "openExternalUrl": - void openExternalUrl(message.url); - break; - case "swipeLeft": - callbacksRef.current.onSwipeLeft?.(); - break; - case "swipeRight": - callbacksRef.current.onSwipeRight?.(); - break; - case "debug": - break; - } - }, - [resolveLocalFileLink], - ); - - const handleMessage = useCallback( - (event: WebViewMessageEvent) => { - let message: BridgeOutboundMessage; - try { - message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage; - } catch { - return; - } - - if (message.type === "bridgeReady" || message.type === "rendererReady") { - handleLifecycleMessage(message); - return; - } - if ( - !shouldHandleTerminalBridgeMessage(message, { - desiredStreamKey: streamKey, - mountedStreamKey: mountRequestedStreamKeyRef.current, - }) - ) { - return; - } - if (message.type === "findRequest") { - callbacksRef.current.onFindRequest?.(); - return; - } - if (message.type === "findResult") { - callbacksRef.current.onFindResult?.(message.result); - return; - } - if (message.type === "filePaste") { - callbacksRef.current.onFilePaste?.(Promise.resolve(terminalFileFromBase64(message))); - return; - } - handleTerminalMessage(message); - }, - [handleLifecycleMessage, handleTerminalMessage, streamKey], - ); - - const handleLoadStart = useCallback>(() => { - bridgeReadyRef.current = false; - mountRequestedStreamKeyRef.current = null; - rendererReadyStreamKeyRef.current = null; - scheduleBridgeReadyWatchdog(); - }, [scheduleBridgeReadyWatchdog]); - - const handleContentProcessDidTerminate = useCallback< - NonNullable - >(() => { - resetWebViewDocument(); - }, [resetWebViewDocument]); - - const handleRenderProcessGone = useCallback< - NonNullable - >(() => { - resetWebViewDocument(); - }, [resetWebViewDocument]); - - const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => { - pendingTapRef.current = { - startX: event.nativeEvent.pageX, - startY: event.nativeEvent.pageY, - moved: false, - }; - }, []); - - const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => { - const pendingTap = pendingTapRef.current; - if (!pendingTap) return; - const dx = event.nativeEvent.pageX - pendingTap.startX; - const dy = event.nativeEvent.pageY - pendingTap.startY; - if ( - Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX || - Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX - ) { - pendingTap.moved = true; - } - }, []); - - const handleWebViewTouchEnd = useCallback(() => { - const pendingTap = pendingTapRef.current; - pendingTapRef.current = null; - if (!pendingTap || pendingTap.moved) { - return; - } - webViewRef.current?.requestFocus(); - callbacksRef.current.onFocus?.(); - sendToWebView({ type: "focus", streamKey, forceRefocus: true }); - }, [sendToWebView, streamKey]); - - const handleWebViewTouchCancel = useCallback(() => { - pendingTapRef.current = null; - }, []); - - const backgroundColor = xtermTheme.background ?? "#0b0b0b"; - const rootStyle = useMemo>( - () => [styles.root, { backgroundColor }], - [backgroundColor], - ); - const webViewStyle = useMemo>( - () => [styles.webView, { backgroundColor }], - [backgroundColor], - ); - const webViewContainerStyle = useMemo>( - () => [styles.webViewContainer, { backgroundColor }], - [backgroundColor], - ); - - return ( - - - - ); -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - minHeight: 0, - minWidth: 0, - overflow: "hidden", - }, - webView: { - flex: 1, - }, - webViewContainer: { - flex: 1, - }, -}); diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx index 052d7a02f0..0635291c4c 100644 --- a/packages/app/src/components/terminal-emulator.native.tsx +++ b/packages/app/src/components/terminal-emulator.native.tsx @@ -2,10 +2,8 @@ import { useEffect } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import { useAppSettings } from "../hooks/use-settings"; import { resolveTerminalRendererSelection } from "../terminal/native-renderer/terminal-renderer-capability"; import NativeGridTerminalEmulator from "./terminal-emulator-native-grid.native"; -import WebViewTerminalEmulator from "./terminal-emulator-webview.native"; import type { TerminalEmulatorProps } from "./terminal-emulator-contract"; export type { TerminalEmulatorHandle } from "./terminal-emulator-contract"; @@ -25,10 +23,7 @@ function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: Termina } export default function TerminalEmulator(props: TerminalEmulatorProps) { - const { settings } = useAppSettings(); - const { useLegacyTerminalRenderer } = settings; const rendererSelection = resolveTerminalRendererSelection({ - useLegacyTerminalRenderer, supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay, }); @@ -36,10 +31,7 @@ export default function TerminalEmulator(props: TerminalEmulatorProps) { return ; } - const Renderer = - rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator; - - return ; + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/components/terminal-webview-message-routing.test.ts b/packages/app/src/components/terminal-webview-message-routing.test.ts deleted file mode 100644 index 7a08aa19d9..0000000000 --- a/packages/app/src/components/terminal-webview-message-routing.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing"; - -describe("terminal WebView message routing", () => { - it("rejects terminal messages from a stale stream", () => { - expect( - shouldHandleTerminalBridgeMessage( - { type: "input", streamKey: "terminal-a" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(false); - expect( - shouldHandleTerminalBridgeMessage( - { type: "resize", streamKey: "terminal-a" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(false); - }); - - it("rejects the old WebView stream while React is switching callbacks", () => { - expect( - shouldHandleTerminalBridgeMessage( - { type: "input", streamKey: "terminal-a" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-a" }, - ), - ).toBe(false); - }); - - it("accepts messages from the mounted stream and unscoped diagnostics", () => { - expect( - shouldHandleTerminalBridgeMessage( - { type: "input", streamKey: "terminal-b" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(true); - expect( - shouldHandleTerminalBridgeMessage( - { type: "debug" }, - { desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" }, - ), - ).toBe(true); - }); -}); diff --git a/packages/app/src/components/terminal-webview-message-routing.ts b/packages/app/src/components/terminal-webview-message-routing.ts deleted file mode 100644 index c23c6b7244..0000000000 --- a/packages/app/src/components/terminal-webview-message-routing.ts +++ /dev/null @@ -1,17 +0,0 @@ -interface TerminalBridgeMessageIdentity { - type: string; - streamKey?: string; -} - -export function shouldHandleTerminalBridgeMessage( - message: TerminalBridgeMessageIdentity, - stream: { - desiredStreamKey: string; - mountedStreamKey: string | null; - }, -): boolean { - return ( - message.streamKey === undefined || - (message.streamKey === stream.desiredStreamKey && message.streamKey === stream.mountedStreamKey) - ); -} diff --git a/packages/app/src/components/terminal-webview-readiness.test.ts b/packages/app/src/components/terminal-webview-readiness.test.ts deleted file mode 100644 index 00db851676..0000000000 --- a/packages/app/src/components/terminal-webview-readiness.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness"; - -describe("legacy terminal WebView readiness", () => { - it("resets when a requested mount never reports renderer readiness", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-a", - rendererReadyStreamKey: null, - }), - ).toBe(true); - }); - - it("keeps the document after the requested renderer confirms readiness", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-a", - rendererReadyStreamKey: "terminal-a", - }), - ).toBe(false); - }); - - it("ignores a watchdog from an earlier ready generation", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 1, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-a", - rendererReadyStreamKey: null, - }), - ).toBe(false); - }); - - it("does not let readiness from a stale stream satisfy the requested mount", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-b", - mountRequestedStreamKey: "terminal-b", - rendererReadyStreamKey: "terminal-a", - }), - ).toBe(true); - }); - - it("does not reset a newer mount from an expired watchdog", () => { - expect( - shouldResetForMissingRendererReady({ - expectedReadyVersion: 0, - currentReadyVersion: 0, - expectedStreamKey: "terminal-a", - mountRequestedStreamKey: "terminal-b", - rendererReadyStreamKey: null, - }), - ).toBe(false); - }); -}); diff --git a/packages/app/src/components/terminal-webview-readiness.ts b/packages/app/src/components/terminal-webview-readiness.ts deleted file mode 100644 index a0cf804156..0000000000 --- a/packages/app/src/components/terminal-webview-readiness.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function shouldResetForMissingRendererReady(input: { - expectedReadyVersion: number; - currentReadyVersion: number; - expectedStreamKey: string; - mountRequestedStreamKey: string | null; - rendererReadyStreamKey: string | null; -}): boolean { - return ( - input.currentReadyVersion === input.expectedReadyVersion && - input.mountRequestedStreamKey === input.expectedStreamKey && - input.rendererReadyStreamKey !== input.expectedStreamKey - ); -} diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 323868280f..dadb912a24 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -389,26 +389,6 @@ describe("loadAppSettingsFromStorage", () => { expect(legacySide.openInSidePane).not.toHaveProperty("pullRequests"); }); - it("uses the native terminal renderer by default", async () => { - const deps = makeDeps(); - - const result = await loadAppSettingsFromStorage(deps); - - expect(result.useLegacyTerminalRenderer).toBe(false); - }); - - it("loads the per-device legacy terminal renderer preference", async () => { - const deps = makeDeps({ - storage: createInMemoryKeyValueStorage({ - [APP_SETTINGS_KEY]: JSON.stringify({ useLegacyTerminalRenderer: true }), - }), - }); - - const result = await loadAppSettingsFromStorage(deps); - - expect(result.useLegacyTerminalRenderer).toBe(true); - }); - it("loads configured terminal scrollback lines from app settings", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index d3b918755f..4fe08bb6d5 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -81,7 +81,6 @@ export interface AppSettings { sendBehavior: SendBehavior; serviceUrlBehavior: ServiceUrlBehavior; terminalScrollbackLines: number; - useLegacyTerminalRenderer: boolean; uiFontFamily: string; // "" = platform default UI stack monoFontFamily: string; // "" = platform default mono stack uiBaseFontSize: number; // clamped px, platform default 14 or 15 @@ -142,7 +141,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { sendBehavior: "steer", serviceUrlBehavior: "ask", terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES, - useLegacyTerminalRenderer: false, uiFontFamily: "", monoFontFamily: "", uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE, @@ -224,7 +222,6 @@ const StoredAppSettingsSchema = z MIN_TERMINAL_SCROLLBACK_LINES, MAX_TERMINAL_SCROLLBACK_LINES, ).catch(DEFAULT_TERMINAL_SCROLLBACK_LINES), - useLegacyTerminalRenderer: z.boolean().catch(false), uiFontFamily: sanitizedFontFamily().catch(""), monoFontFamily: sanitizedFontFamily().catch(""), uiBaseFontSize: clampedNumber(MIN_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE) diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index c837815a7c..18ba3f041e 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -369,9 +369,6 @@ describe("translation resources", () => { it("includes Settings expansion keys for the Batch 3A migration", () => { expect(en.settings.diagnostics.title).toBe("Diagnostics"); - expect(en.settings.diagnostics.legacyTerminalRenderer.label).toBe( - "Use legacy terminal renderer", - ); expect(en.settings.about.title).toBe("About"); expect(en.settings.about.releaseChannel.label).toBe("Release channel"); expect(en.settings.appearance.theme.title).toBe("Theme"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c3ee235c29..e28613bbff 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2332,11 +2332,6 @@ export const ar: TranslationResources = { }, diagnostics: { title: "التشخيص", - legacyTerminalRenderer: { - label: "استخدام عارض الطرفية السابق", - description: "استخدام طرفية WebView السابقة بعد إعادة فتح الطرفية", - accessibilityLabel: "استخدام عارض الطرفية السابق", - }, testAudio: "اختبار الصوت", playTest: "لعب الاختبار", playing: "جارٍ اللعب...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 6649bd8bde..e056fdc855 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2420,11 +2420,6 @@ export const en = { }, diagnostics: { title: "Diagnostics", - legacyTerminalRenderer: { - label: "Use legacy terminal renderer", - description: "Use the previous WebView terminal after reopening a terminal", - accessibilityLabel: "Use legacy terminal renderer", - }, testAudio: "Test audio", playTest: "Play test", playing: "Playing...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 81dd72cec0..6d5f422e3a 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2348,11 +2348,6 @@ export const es: TranslationResources = { }, diagnostics: { title: "Diagnóstico", - legacyTerminalRenderer: { - label: "Usar el renderizador de terminal anterior", - description: "Usa el terminal WebView anterior al volver a abrir un terminal", - accessibilityLabel: "Usar el renderizador de terminal anterior", - }, testAudio: "audio de prueba", playTest: "Prueba de juego", playing: "Jugando...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 52d8a9206a..3159dd4b61 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2346,11 +2346,6 @@ export const fr: TranslationResources = { }, diagnostics: { title: "Diagnostic", - legacyTerminalRenderer: { - label: "Utiliser l’ancien rendu du terminal", - description: "Utilise l’ancien terminal WebView après la réouverture d’un terminal", - accessibilityLabel: "Utiliser l’ancien rendu du terminal", - }, testAudio: "Tester le son", playTest: "Lancer le test", playing: "Lecture…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 0dd14ea5a3..1fcbbc7ad6 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2316,11 +2316,6 @@ export const ja: TranslationResources = { }, diagnostics: { title: "診断", - legacyTerminalRenderer: { - label: "以前のターミナルレンダラーを使用", - description: "ターミナルを開き直した後、以前の WebView ターミナルを使用します", - accessibilityLabel: "以前のターミナルレンダラーを使用", - }, testAudio: "音声をテスト", playTest: "テスト再生", playing: "再生中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 0bfb1be599..ea823a94ce 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2307,11 +2307,6 @@ export const ko: TranslationResources = { }, diagnostics: { title: "진단", - legacyTerminalRenderer: { - label: "레거시 터미널 렌더러 사용", - description: "터미널을 다시 열 때 이전 WebView 터미널을 사용합니다.", - accessibilityLabel: "레거시 터미널 렌더러 사용", - }, testAudio: "오디오 테스트", playTest: "테스트 재생", playing: "재생 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 295c1acfaf..de85b202c8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2331,11 +2331,6 @@ export const ptBR: TranslationResources = { }, diagnostics: { title: "Diagnósticos", - legacyTerminalRenderer: { - label: "Usar renderizador de terminal anterior", - description: "Usa o terminal WebView anterior após reabrir um terminal", - accessibilityLabel: "Usar renderizador de terminal anterior", - }, testAudio: "Testar áudio", playTest: "Reproduzir teste", playing: "Reproduzindo...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 36dd91a9c5..5e70b0c1f4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2333,11 +2333,6 @@ export const ru: TranslationResources = { }, diagnostics: { title: "Диагностика", - legacyTerminalRenderer: { - label: "Использовать прежний рендерер терминала", - description: "Использовать прежний WebView-терминал после повторного открытия", - accessibilityLabel: "Использовать прежний рендерер терминала", - }, testAudio: "Проверка звука", playTest: "Воспроизвести тестовый звук", playing: "Воспроизведение...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 7afb29ca30..96758eaca7 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2271,11 +2271,6 @@ export const zhCN: TranslationResources = { }, diagnostics: { title: "诊断", - legacyTerminalRenderer: { - label: "使用旧版终端渲染器", - description: "重新打开终端后使用之前的 WebView 终端", - accessibilityLabel: "使用旧版终端渲染器", - }, testAudio: "测试音频", playTest: "播放测试", playing: "正在播放...", diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 3e3a4ef553..b90f5ccd24 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -81,7 +81,6 @@ import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts- import { EditorSection } from "@/screens/settings/editor-section"; import { FilesSection } from "@/screens/settings/files-section"; import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; import { CommunityLinks } from "@/components/community-links"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; @@ -136,7 +135,7 @@ import { } from "@/utils/host-routes"; import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; -import { isNative, isWeb } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; // --------------------------------------------------------------------------- // View model @@ -383,8 +382,6 @@ function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps) } interface DiagnosticsSectionProps { - useLegacyTerminalRenderer: boolean; - onUseLegacyTerminalRendererChange: (value: boolean) => void; voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; playbackTestResult: string | null; @@ -392,8 +389,6 @@ interface DiagnosticsSectionProps { } function DiagnosticsSection({ - useLegacyTerminalRenderer, - onUseLegacyTerminalRendererChange, voiceAudioEngine, isPlaybackTestRunning, playbackTestResult, @@ -407,26 +402,6 @@ function DiagnosticsSection({ return ( - {isNative ? ( - - - - {t("settings.diagnostics.legacyTerminalRenderer.label")} - - - {t("settings.diagnostics.legacyTerminalRenderer.description")} - - - - - ) : null} {t("settings.diagnostics.app.rowTitle")} @@ -1159,13 +1134,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti [updateSettings], ); - const handleUseLegacyTerminalRendererChange = useCallback( - (useLegacyTerminalRenderer: boolean) => { - void updateSettings({ useLegacyTerminalRenderer }); - }, - [updateSettings], - ); - const handlePlaybackTest = useCallback(async () => { if (!voiceAudioEngine || isPlaybackTestRunning) { return; @@ -1390,8 +1358,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti case "diagnostics": return ( { it("requires input mode replay before attaching the native renderer", () => { - expect( - resolveTerminalRendererSelection({ - useLegacyTerminalRenderer: false, - supportsTerminalInputModeReplay: false, - }), - ).toBe("update-host"); + expect(resolveTerminalRendererSelection({ supportsTerminalInputModeReplay: false })).toBe( + "update-host", + ); }); it("allows the native renderer when the daemon advertises input mode replay", () => { - expect( - resolveTerminalRendererSelection({ - useLegacyTerminalRenderer: false, - supportsTerminalInputModeReplay: true, - }), - ).toBe("native-grid"); - }); - - it("keeps the explicit legacy renderer available with older daemons", () => { - expect( - resolveTerminalRendererSelection({ - useLegacyTerminalRenderer: true, - supportsTerminalInputModeReplay: false, - }), - ).toBe("webview"); + expect(resolveTerminalRendererSelection({ supportsTerminalInputModeReplay: true })).toBe( + "native-grid", + ); }); }); diff --git a/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts index 5c5c7ca608..8e4b08baf1 100644 --- a/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts +++ b/packages/app/src/terminal/native-renderer/terminal-renderer-capability.ts @@ -1,11 +1,7 @@ -export type TerminalRendererSelection = "native-grid" | "webview" | "update-host"; +export type TerminalRendererSelection = "native-grid" | "update-host"; export function resolveTerminalRendererSelection(input: { - useLegacyTerminalRenderer: boolean; supportsTerminalInputModeReplay: boolean; }): TerminalRendererSelection { - if (input.useLegacyTerminalRenderer) { - return "webview"; - } return input.supportsTerminalInputModeReplay ? "native-grid" : "update-host"; } diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts deleted file mode 100644 index d1825d7eb7..0000000000 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ /dev/null @@ -1,593 +0,0 @@ -import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime"; -import type { ITheme } from "@xterm/xterm"; -import xtermCss from "@xterm/xterm/css/xterm.css"; -import type { TerminalState } from "@getpaseo/protocol/messages"; -import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; -import type { PendingTerminalModifiers } from "@/utils/terminal-keys"; -import { - encodeTerminalOutput, - TerminalEmulatorRuntime, -} from "../runtime/terminal-emulator-runtime"; -import type { - TerminalLocalFileLinkSource, - TerminalLocalFileLinkTarget, -} from "../local-links/terminal-local-link-provider"; -import { terminalFileToBase64, type TerminalFile } from "../file-paste"; - -interface MountMessage { - type: "mount"; - streamKey: string; - initialSnapshot: TerminalState | null; - scrollbackLines: number; - theme: ITheme; - fontFamily?: string; - fontSize?: number; - pendingModifiers: PendingTerminalModifiers; - swipeGesturesEnabled: boolean; -} - -type InboundMessage = - | MountMessage - | { type: "unmount"; streamKey: string } - | { type: "writeOutput"; streamKey: string; text: string } - | { type: "restoreOutput"; streamKey: string; text: string } - | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } - | { type: "paste"; streamKey: string; text: string } - | { type: "clear"; streamKey: string } - | { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" } - | { type: "clearFind"; streamKey: string } - | { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } } - | { type: "focus"; streamKey: string; forceRefocus?: boolean } - | { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean } - | { type: "setTheme"; streamKey: string; theme: ITheme } - | { type: "setScrollback"; streamKey: string; lines: number } - | { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number } - | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } - | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean } - | { - type: "resolveLocalFileLinkResponse"; - streamKey: string; - requestId: number; - target: TerminalLocalFileLinkTarget | null; - }; - -type OutboundMessage = - | { type: "bridgeReady" } - | { type: "rendererReady"; streamKey: string; isReady: boolean } - | { type: "input"; streamKey: string; data: string } - | { type: "findRequest"; streamKey: string } - | { type: "findResult"; streamKey: string; result: TerminalFindResult } - | { - type: "resize"; - streamKey: string; - rows: number; - cols: number; - shouldClaim?: boolean; - forceClaim?: boolean; - } - | { - type: "terminalKey"; - streamKey: string; - key: string; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta: boolean; - } - | { type: "pendingModifiersConsumed"; streamKey: string } - | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState } - | { type: "openExternalUrl"; streamKey: string; url: string } - | { type: "filePaste"; streamKey: string; fileName: string; mimeType: string; data: string } - | { - type: "resolveLocalFileLink"; - streamKey: string; - requestId: number; - source: TerminalLocalFileLinkSource; - } - | { - type: "openLocalFileLink"; - streamKey: string; - target: TerminalLocalFileLinkTarget; - disposition: "main" | "side"; - } - | { type: "swipeLeft"; streamKey: string } - | { type: "swipeRight"; streamKey: string } - | { type: "debug"; message: string; details?: unknown }; - -declare global { - interface Window { - ReactNativeWebView?: { - postMessage?: (data: string) => void; - }; - __PASEO_TERMINAL_WEBVIEW_RECEIVE__?: (message: InboundMessage) => void; - __PASEO_TERMINAL_WEBVIEW_BLUR__?: () => void; - } -} - -const sendToNative = (message: OutboundMessage): void => { - window.ReactNativeWebView?.postMessage?.(JSON.stringify(message)); -}; - -const TERMINAL_BACKGROUND_CSS_VAR = "--paseo-terminal-background"; -const DEFAULT_TERMINAL_BACKGROUND = "#0b0b0b"; -const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8; - -function getTerminalBackground(theme: ITheme): string { - return theme.background ?? DEFAULT_TERMINAL_BACKGROUND; -} - -const installStyles = (): void => { - const style = document.createElement("style"); - style.textContent = ` -${xtermCss} -:root { - ${TERMINAL_BACKGROUND_CSS_VAR}: transparent; -} -html, -body, -#terminal-root { - width: 100%; - height: 100%; - margin: 0; - padding: 0; - overflow: hidden; - overscroll-behavior: none; - background: var(${TERMINAL_BACKGROUND_CSS_VAR}); -} -#terminal-root { - display: flex; - min-width: 0; - min-height: 0; -} -#terminal-host { - flex: 1; - min-width: 0; - min-height: 0; - width: 100%; - height: 100%; - overflow: hidden; - background: var(${TERMINAL_BACKGROUND_CSS_VAR}); -} -#terminal-root .xterm, -#terminal-root .xterm-screen, -#terminal-root .xterm-viewport { - background-color: var(${TERMINAL_BACKGROUND_CSS_VAR}) !important; -} -[data-terminal-scrollbar-root="true"] .xterm-viewport { - scrollbar-width: none; - -ms-overflow-style: none; -} -[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar { - width: 0; - height: 0; -} -#terminal-root .xterm .xterm-helper-textarea { - opacity: 0.01; - width: 1px; - height: 1px; - min-width: 1px; - min-height: 1px; - color: transparent; - background: transparent; - caret-color: transparent; - z-index: 5 !important; -} -`; - document.head.appendChild(style); -}; - -class TerminalWebViewBridge { - private runtime: TerminalEmulatorRuntime | null = null; - private streamKey: string | null = null; - private nextLocalFileLinkRequestId = 1; - private readonly pendingLocalFileLinkResolutions = new Map< - number, - (target: TerminalLocalFileLinkTarget | null) => void - >(); - private filePastes: Promise = Promise.resolve(); - private swipeGesturesEnabled = false; - private trackingSwipe = false; - private activePointerId: number | null = null; - private startX = 0; - private startY = 0; - private firedSwipe = false; - private tapTouchIdentifier: number | null = null; - private tapStartX = 0; - private tapStartY = 0; - private tapMoved = false; - - constructor( - private readonly root: HTMLDivElement, - private readonly host: HTMLDivElement, - ) { - this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true }); - this.root.addEventListener("touchstart", this.handleTouchStart, { passive: true }); - this.root.addEventListener("touchmove", this.handleTouchMove, { passive: true }); - this.root.addEventListener("touchend", this.handleTouchEnd, { passive: true }); - this.root.addEventListener("touchcancel", this.handleTouchCancel, { passive: true }); - this.root.addEventListener("pointermove", this.handlePointerMove, { passive: false }); - this.root.addEventListener("pointerup", this.handlePointerUp, { passive: true }); - this.root.addEventListener("pointercancel", this.handlePointerUp, { passive: true }); - } - - receive = (message: InboundMessage): void => { - try { - this.receiveUnsafe(message); - } catch (error) { - sendToNative({ - type: "debug", - message: "terminal webview receive failed", - details: error instanceof Error ? { message: error.message, stack: error.stack } : error, - }); - } - }; - - private receiveUnsafe(message: InboundMessage): void { - if (message.type === "mount") { - this.mount(message); - return; - } - if (message.type === "unmount") { - this.unmount(message.streamKey); - return; - } - if (!this.matches(message.streamKey)) { - return; - } - if (message.type === "resolveLocalFileLinkResponse") { - this.resolveLocalFileLinkRequest(message.requestId, message.target); - return; - } - if (message.type === "findWidgetSize") { - this.runtime?.find.setWidgetSize(message.size); - return; - } - if (message.type === "find") { - this.runtime?.find.search(message.query, message.direction); - return; - } - if (message.type === "clearFind") { - this.runtime?.find.clear(); - return; - } - this.receiveMounted(message); - } - - private receiveMounted( - message: Exclude< - InboundMessage, - MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" } - >, - ): void { - if (this.receiveConfigurationMessage(message)) return; - - switch (message.type) { - case "writeOutput": - this.runtime?.write({ data: encodeTerminalOutput(message.text) }); - break; - case "restoreOutput": - this.runtime?.restoreOutput({ data: encodeTerminalOutput(message.text) }); - break; - case "renderSnapshot": - this.runtime?.renderSnapshot({ state: message.state }); - break; - case "paste": - this.runtime?.paste(message.text); - break; - case "clear": - this.runtime?.clear(); - break; - case "focus": - this.runtime?.focus({ forceRefocus: message.forceRefocus }); - break; - case "resize": - this.runtime?.resize({ - shouldClaim: message.shouldClaim ?? true, - forceClaim: message.forceClaim, - }); - break; - } - } - - private receiveConfigurationMessage( - message: Exclude< - InboundMessage, - MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" } - >, - ): boolean { - switch (message.type) { - case "setTheme": - this.applyThemeBackground(message.theme); - this.runtime?.setTheme({ theme: message.theme }); - return true; - case "setScrollback": - this.runtime?.setScrollback({ lines: message.lines }); - return true; - case "setFont": - this.runtime?.setFont({ fontFamily: message.fontFamily, fontSize: message.fontSize }); - return true; - case "setPendingModifiers": - this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers }); - return true; - case "setSwipeGesturesEnabled": - this.swipeGesturesEnabled = message.enabled; - return true; - default: - return false; - } - } - - private mount(message: MountMessage): void { - this.unmount(this.streamKey); - this.streamKey = message.streamKey; - this.swipeGesturesEnabled = message.swipeGesturesEnabled; - this.applyThemeBackground(message.theme); - - const runtime = new TerminalEmulatorRuntime(); - this.runtime = runtime; - runtime.setCallbacks({ - callbacks: { - onFindRequest: () => sendToNative({ type: "findRequest", streamKey: message.streamKey }), - onFindResult: (result) => - sendToNative({ type: "findResult", streamKey: message.streamKey, result }), - onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }), - onResize: ({ rows, cols, shouldClaim, forceClaim }) => - sendToNative({ - type: "resize", - streamKey: message.streamKey, - rows, - cols, - shouldClaim, - forceClaim, - }), - onTerminalKey: (input) => - sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }), - onPendingModifiersConsumed: () => - sendToNative({ type: "pendingModifiersConsumed", streamKey: message.streamKey }), - onInputModeChange: (state) => - sendToNative({ type: "inputModeChange", streamKey: message.streamKey, state }), - onOpenExternalUrl: (url) => - sendToNative({ type: "openExternalUrl", streamKey: message.streamKey, url }), - onResolveLocalFileLink: (source) => this.requestLocalFileLinkResolution(source), - onOpenLocalFileLink: (target, disposition) => - sendToNative({ - type: "openLocalFileLink", - streamKey: message.streamKey, - target, - disposition, - }), - onFilePaste: (file) => this.forwardFilePaste(message.streamKey, file), - }, - }); - runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers }); - runtime.mount({ - root: this.root, - host: this.host, - initialSnapshot: message.initialSnapshot, - scrollback: message.scrollbackLines, - theme: message.theme, - fontFamily: message.fontFamily, - fontSize: message.fontSize, - }); - sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true }); - } - - private applyThemeBackground(theme: ITheme): void { - const background = getTerminalBackground(theme); - document.documentElement.style.setProperty(TERMINAL_BACKGROUND_CSS_VAR, background); - document.body.style.backgroundColor = background; - this.root.style.backgroundColor = background; - this.host.style.backgroundColor = background; - } - - blur = (): void => { - this.runtime?.blur(); - }; - - private unmount(streamKey: string | null): void { - if (!this.runtime) { - return; - } - const previousStreamKey = this.streamKey; - this.runtime.unmount(); - this.runtime = null; - this.streamKey = null; - this.resolveAllLocalFileLinkRequests(null); - if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) { - sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false }); - } - } - - // Forward in gesture order: an earlier file can finish reading after a later one. - private forwardFilePaste(streamKey: string, file: Promise): void { - this.filePastes = this.filePastes.then(() => this.sendFilePaste(streamKey, file)); - } - - private async sendFilePaste( - streamKey: string, - file: Promise, - ): Promise { - const read = await file.catch(() => null); - if (read && this.matches(streamKey)) { - sendToNative({ - type: "filePaste", - streamKey, - fileName: read.fileName, - mimeType: read.mimeType, - data: terminalFileToBase64(read), - }); - } - } - - private matches(streamKey: string): boolean { - return this.streamKey === streamKey; - } - - private requestLocalFileLinkResolution( - source: TerminalLocalFileLinkSource, - ): Promise { - const streamKey = this.streamKey; - if (!streamKey) { - return Promise.resolve(null); - } - - const requestId = this.nextLocalFileLinkRequestId++; - return new Promise((resolve) => { - this.pendingLocalFileLinkResolutions.set(requestId, resolve); - sendToNative({ - type: "resolveLocalFileLink", - streamKey, - requestId, - source, - }); - }); - } - - private resolveLocalFileLinkRequest( - requestId: number, - target: TerminalLocalFileLinkTarget | null, - ): void { - const resolve = this.pendingLocalFileLinkResolutions.get(requestId); - if (!resolve) { - return; - } - this.pendingLocalFileLinkResolutions.delete(requestId); - resolve(target); - } - - private resolveAllLocalFileLinkRequests(target: TerminalLocalFileLinkTarget | null): void { - const requests = Array.from(this.pendingLocalFileLinkResolutions.values()); - this.pendingLocalFileLinkResolutions.clear(); - for (const resolve of requests) { - resolve(target); - } - } - - private handlePointerDown = (event: PointerEvent): void => { - if (!event.isPrimary) { - return; - } - if (!this.swipeGesturesEnabled) { - return; - } - this.trackingSwipe = true; - this.firedSwipe = false; - this.activePointerId = event.pointerId; - this.startX = event.clientX; - this.startY = event.clientY; - }; - - private handleTouchStart = (event: TouchEvent): void => { - if (event.touches.length !== 1) { - this.resetTap(); - return; - } - const touch = event.touches[0]; - this.tapTouchIdentifier = touch.identifier; - this.tapStartX = touch.clientX; - this.tapStartY = touch.clientY; - this.tapMoved = false; - }; - - private handleTouchMove = (event: TouchEvent): void => { - const touch = this.findTrackedTouch(event.touches); - if (!touch) { - return; - } - const dx = touch.clientX - this.tapStartX; - const dy = touch.clientY - this.tapStartY; - if ( - Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX || - Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX - ) { - this.tapMoved = true; - } - }; - - private handleTouchEnd = (event: TouchEvent): void => { - const completedTap = Boolean(this.findTrackedTouch(event.changedTouches) && !this.tapMoved); - this.resetTap(); - if (completedTap) { - this.runtime?.focus({ forceRefocus: true }); - } - }; - - private handleTouchCancel = (): void => { - this.resetTap(); - }; - - private handlePointerMove = (event: PointerEvent): void => { - if (!this.trackingSwipe || this.firedSwipe || !this.streamKey) { - return; - } - if (this.activePointerId !== null && event.pointerId !== this.activePointerId) { - return; - } - - const dx = event.clientX - this.startX; - const dy = event.clientY - this.startY; - const absDx = Math.abs(dx); - const absDy = Math.abs(dy); - if (absDy >= 12 && absDy > absDx) { - this.resetSwipe(); - return; - } - if (absDx < 22 || (absDy !== 0 && absDx / absDy < 1.2)) { - return; - } - - this.firedSwipe = true; - sendToNative({ type: dx > 0 ? "swipeRight" : "swipeLeft", streamKey: this.streamKey }); - if (event.cancelable) event.preventDefault(); - }; - - private handlePointerUp = (event: PointerEvent): void => { - if (this.activePointerId !== null && event.pointerId !== this.activePointerId) { - return; - } - this.resetSwipe(); - }; - - private resetSwipe(): void { - this.trackingSwipe = false; - this.activePointerId = null; - this.startX = 0; - this.startY = 0; - this.firedSwipe = false; - } - - private findTrackedTouch(touches: TouchList): Touch | null { - if (this.tapTouchIdentifier === null) { - return null; - } - for (let index = 0; index < touches.length; index += 1) { - const touch = touches.item(index); - if (touch?.identifier === this.tapTouchIdentifier) { - return touch; - } - } - return null; - } - - private resetTap(): void { - this.tapTouchIdentifier = null; - this.tapStartX = 0; - this.tapStartY = 0; - this.tapMoved = false; - } -} - -installStyles(); - -const root = document.createElement("div"); -root.id = "terminal-root"; -root.dataset.terminalScrollbarRoot = "true"; -const host = document.createElement("div"); -host.id = "terminal-host"; -root.appendChild(host); -document.body.appendChild(root); - -const bridge = new TerminalWebViewBridge(root, host); -window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ = bridge.receive; -window.__PASEO_TERMINAL_WEBVIEW_BLUR__ = bridge.blur; -sendToNative({ type: "bridgeReady" }); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts deleted file mode 100644 index e7a9cfd8d1..0000000000 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Generated by packages/app/scripts/build-terminal-webview-html.mjs. -// Do not edit by hand. - -export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/terminal/webview/terminal-find.browser.test.ts b/packages/app/src/terminal/webview/terminal-find.browser.test.ts deleted file mode 100644 index 24176331d2..0000000000 --- a/packages/app/src/terminal/webview/terminal-find.browser.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Terminal } from "@xterm/xterm"; -import { afterEach, expect, test } from "vitest"; -import { terminalEmulatorWebViewHtml } from "./terminal-emulator-webview-html"; - -interface Message { - type: string; - streamKey?: string; - result?: { resultCount: number; resultIndex: number }; - data?: string; - fileName?: string; - mimeType?: string; -} -interface TerminalFrameWindow extends Window { - __PASEO_TERMINAL_WEBVIEW_RECEIVE__(message: unknown): void; - __paseoTerminal?: Terminal; -} -let frame: HTMLIFrameElement; -afterEach(() => frame?.remove()); - -async function mountWebView() { - const messages: Message[] = []; - frame = document.createElement("iframe"); - frame.style.width = "500px"; - frame.style.height = "300px"; - document.body.append(frame); - const win = frame.contentWindow as TerminalFrameWindow; - Object.assign(win, { - ReactNativeWebView: { postMessage: (raw: string) => messages.push(JSON.parse(raw)) }, - }); - win.document.open(); - win.document.write(terminalEmulatorWebViewHtml); - win.document.close(); - await expect.poll(() => messages.some((m) => m.type === "bridgeReady")).toBe(true); - const send = (message: object) => - win.__PASEO_TERMINAL_WEBVIEW_RECEIVE__({ streamKey: "owned", ...message }); - send({ - type: "mount", - initialSnapshot: null, - scrollbackLines: 1000, - theme: { background: "#ffffff", foreground: "#111111" }, - pendingModifiers: { ctrl: false, shift: false, alt: false }, - swipeGesturesEnabled: false, - }); - await expect - .poll(() => messages.some((m) => m.type === "rendererReady" && m.streamKey === "owned")) - .toBe(true); - return { win, send, messages }; -} - -test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => { - const { win, send, messages } = await mountWebView(); - send({ type: "writeOutput", text: "first a.b\r\n" + "padding\r\n".repeat(30) + "last A.B" }); - await expect - .poll(() => { - const b = win.__paseoTerminal?.buffer.active; - return b - ? Array.from({ length: b.length }, (_, i) => b.getLine(i)?.translateToString(true)).join( - "\n", - ) - : ""; - }) - .toContain("last A.B"); - send({ type: "findWidgetSize", size: { width: 356, height: 58 } }); - send({ type: "find", query: "a.b" }); - await expect - .poll(() => messages.findLast((m) => m.type === "findResult")?.result) - .toMatchObject({ resultCount: 2, resultIndex: 1 }); - send({ type: "find", query: "a.b", direction: "previous" }); - await expect - .poll(() => messages.findLast((m) => m.type === "findResult")?.result) - .toMatchObject({ resultCount: 2, resultIndex: 0 }); - send({ type: "find", streamKey: "stale", query: "absent" }); - expect(win.__paseoTerminal!.getSelection()).toBe("a.b"); - send({ type: "clearFind" }); - await expect - .poll(() => messages.findLast((m) => m.type === "findResult")?.result) - .toMatchObject({ resultCount: 0 }); - expect(messages.filter((m) => m.type === "input")).toEqual([]); - send({ type: "unmount" }); -}); - -test("the generated WebView forwards file-only pastes to native as file bytes", async () => { - const { win, send, messages } = await mountWebView(); - const clipboardData = new DataTransfer(); - clipboardData.items.add( - new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" }), - ); - - win.document - .querySelector(".xterm-helper-textarea")! - .dispatchEvent(new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true })); - - await expect - .poll(() => messages.find((m) => m.type === "filePaste")) - .toEqual({ - type: "filePaste", - streamKey: "owned", - fileName: "image.png", - mimeType: "image/png", - data: "iVBORw==", - }); - expect(messages.filter((m) => m.type === "input")).toEqual([]); - send({ type: "unmount" }); -}); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 0ef41b3b6f..ff13dc2ad8 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1589,19 +1589,15 @@ const x = 1; it("warms shortstat cache in the background without blocking listing callers", async () => { expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined(); - warmCheckoutShortstatInBackground(repoDir); + const warmed = new Promise((resolve) => { + warmCheckoutShortstatInBackground(repoDir, undefined, resolve); + }); + // The warm hands off to git and returns; listing callers see no value yet. + expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined(); + await warmed; // A repo with no origin/main computes to null, but null should still be cached. - for (let attempts = 0; attempts < 20; attempts += 1) { - const cached = getCachedCheckoutShortstat(repoDir); - if (cached !== undefined) { - expect(cached).toBeNull(); - return; - } - await sleep(25); - } - - throw new Error("shortstat background warm did not populate cache in time"); + expect(getCachedCheckoutShortstat(repoDir)).toBeNull(); }); it("commits messages with quotes safely", async () => { From 91d749f82c8a4388521b47966e6c2f8297097386 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 19:48:34 +0700 Subject: [PATCH 32/62] fix(server): preserve workspace PR status across reconnects (#7638) Workspace runtime snapshots lived inside refcounted watch targets and vanished when the last session disconnected. Keep the last observed git and forge data in daemon memory so reconnecting clients receive it before refresh completes, while watch lifetime continues to govern refresh only. --- docs/architecture.md | 7 + .../opencode/bridge-input-errors.test.ts | 4 +- packages/server/src/server/bootstrap.ts | 17 +- .../src/server/checkout-diff-manager.test.ts | 7 +- .../src/server/checkout-diff-manager.ts | 12 +- .../src/server/paseo-worktree-service.test.ts | 2 +- packages/server/src/server/session.test.ts | 133 +++++---- packages/server/src/server/session.ts | 13 +- .../session.workspace-git-watch.test.ts | 6 +- ...on.workspace-resolution-invariants.test.ts | 3 +- .../src/server/session.workspaces.test.ts | 256 +++++++++++++++++- .../workspace-provisioning-service.test.ts | 5 - .../workspace-provisioning-service.ts | 2 +- .../workspace-scripts-service.test.ts | 14 +- .../workspace-scripts-service.ts | 10 +- .../snapshot-mutation-ownership.test.ts | 2 + .../test-utils/workspace-git-service-stub.ts | 2 +- .../websocket-server.browser-tools.test.ts | 2 + .../websocket-server.notifications.test.ts | 2 + .../websocket-server.relay-reconnect.test.ts | 2 + ...cket-server.terminal-notifications.test.ts | 2 + .../server/src/server/websocket-server.ts | 4 +- .../server/src/server/wire-compat.test.ts | 6 +- ...it-service.observation.integration.test.ts | 6 +- .../workspace-git-service.observation.test.ts | 41 ++- .../workspace-git-service.primitive.test.ts | 19 +- .../src/server/workspace-git-service.test.ts | 44 ++- .../src/server/workspace-git-service.ts | 11 +- .../src/server/workspace-registry.test.ts | 10 + .../server/src/server/workspace-registry.ts | 2 +- .../workspace-runtime-store/index.test.ts | 18 ++ .../server/workspace-runtime-store/index.ts | 23 ++ 32 files changed, 552 insertions(+), 135 deletions(-) create mode 100644 packages/server/src/server/workspace-runtime-store/index.test.ts create mode 100644 packages/server/src/server/workspace-runtime-store/index.ts diff --git a/docs/architecture.md b/docs/architecture.md index c98f3825af..8e63bacaf6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,6 +74,13 @@ server to capability-aware sessions. It deliberately does not use the broad recu watcher or the per-session Git observer: those are checkout/status mechanisms and intentionally do not retain non-Git directories. +Workspace git and forge runtime data belongs to one daemon-level, directory-keyed store. +Workspace descriptors, checkout diff subscriptions, and script metadata read the last known +snapshot from that store, so reconnecting clients receive it before refresh completes. +Refcounted git watches feed the store through snapshot-updated events; their lifetime controls +refresh only. Session cleanup and workspace archive retain the data. Removing the final active +workspace for a directory clears it. The store lives in memory and resets on daemon restart. + **Key modules:** | Module | Responsibility | diff --git a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts index f9a776d168..1396d1f784 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts @@ -47,8 +47,8 @@ test("bridge preserves tool failure messages and classifies invalid requests as logger, ), projectRegistry: new FileBackedProjectRegistry(path.join(paseoHome, "projects.json"), logger), - workspaceGitService: { - peekSnapshot: () => { + workspaceRuntimeStore: { + get: () => { throw new Error("Unexpected git lookup"); }, }, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 22bf93b625..201702444c 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type { PluginRegistries } from "@getpaseo/protocol/plugin-registry"; import { describeHookWorkspace } from "./plugins/lifecycle/index.js"; import express from "express"; @@ -905,7 +906,17 @@ export async function createPaseoDaemon( forgeOverrides: { github }, }, }); - workspaceRegistry.subscribeToMutations((mutation) => { + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + workspaceGitService.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); + workspaceRegistry.subscribeToMutations(async (mutation) => { + if (mutation.kind === "remove" && mutation.workspace) { + const cwd = path.resolve(mutation.workspace.cwd); + const workspaces = await workspaceRegistry.list(); + const hasActiveWorkspace = workspaces.some( + (workspace) => !workspace.archivedAt && path.resolve(workspace.cwd) === cwd, + ); + if (!hasActiveWorkspace) workspaceRuntimeStore.remove(cwd); + } if (mutation.kind === "archive" && mutation.workspace) { pluginRuntime.emit("workspace.archived", { workspace: describeHookWorkspace(mutation.workspace), @@ -1016,6 +1027,7 @@ export async function createPaseoDaemon( logger, paseoHome: config.paseoHome, workspaceGitService, + workspaceRuntimeStore, }); const archiveWorkspaceRecordExternal = async ( workspaceId: string, @@ -1434,7 +1446,7 @@ export async function createPaseoDaemon( terminalManager, workspaceRegistry, projectRegistry, - workspaceGitService, + workspaceRuntimeStore, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), serviceProxyPublicBaseUrl, @@ -1710,6 +1722,7 @@ export async function createPaseoDaemon( startPaused: true, }, workspaceAutoName, + workspaceRuntimeStore, daemonAuth, speechService, terminalManager, diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 1d3f2e598d..d55171831b 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const { toCheckoutErrorMock } = vi.hoisted(() => ({ @@ -67,7 +68,7 @@ function createPendingManager() { const workspaceGitService = { getCheckoutDiff: async () => ({ diff: "", structured: [] }), getSnapshot: async () => createWorkspaceSnapshot(), - peekSnapshot: () => null, + registerWorkspace: () => ({ unsubscribe: () => {} }), requestWorkingTreeWatch: (cwd: string, onChange: () => void) => { const pending = createDeferred<{ repoRoot: string | null; unsubscribe: () => void }>(); @@ -90,6 +91,7 @@ function createPendingManager() { }; const logger = { child: () => logger, warn: () => {} }; const manager = new CheckoutDiffManager({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), logger: logger as unknown as pino.Logger, paseoHome: "/tmp/paseo-test", workspaceGitService, @@ -125,7 +127,7 @@ describe("CheckoutDiffManager", () => { const workspaceGitService = { subscribe: vi.fn(), - peekSnapshot: vi.fn(), + registerWorkspace: vi.fn( (_params: { cwd: string }, listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) => { onWorkspaceSnapshot = listener; @@ -147,6 +149,7 @@ describe("CheckoutDiffManager", () => { }; const manager = new CheckoutDiffManager({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), logger: logger as unknown as pino.Logger, paseoHome: "/tmp/paseo-test", workspaceGitService: workspaceGitService as unknown as WorkspaceGitService, diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 6dc437297d..917813d587 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; @@ -8,11 +9,7 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; type CheckoutDiffWorkspace = Pick< WorkspaceGitService, - | "getCheckoutDiff" - | "getSnapshot" - | "peekSnapshot" - | "registerWorkspace" - | "requestWorkingTreeWatch" + "getCheckoutDiff" | "getSnapshot" | "registerWorkspace" | "requestWorkingTreeWatch" >; export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; @@ -63,14 +60,17 @@ export interface CheckoutDiffSubscription { export class CheckoutDiffManager { private readonly workspaceGitService: CheckoutDiffWorkspace; + private readonly workspaceRuntimeStore: WorkspaceRuntimeStore; private readonly targets = new Map(); constructor(options: { logger: pino.Logger; paseoHome: string; workspaceGitService: CheckoutDiffWorkspace; + workspaceRuntimeStore: WorkspaceRuntimeStore; }) { this.workspaceGitService = options.workspaceGitService; + this.workspaceRuntimeStore = options.workspaceRuntimeStore; } async read( @@ -366,7 +366,7 @@ export class CheckoutDiffManager { private async openTarget(target: CheckoutDiffWatchTarget): Promise { if (target.compare.mode !== "uncommitted") { const snapshot = - this.workspaceGitService.peekSnapshot(target.cwd) ?? + this.workspaceRuntimeStore.get(target.cwd) ?? (await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false })); target.diffCwd = snapshot.git.repoRoot ?? target.cwd; if (this.targets.get(target.key) !== target || target.listeners.size === 0) { diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 0e0ccf34f7..66740d2ae2 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1221,7 +1221,7 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService { registerWorkspace: () => ({ unsubscribe: () => {}, }), - peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd), + getCheckout: async (cwd) => { try { const snapshot = createWorkspaceGitSnapshot(cwd); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 7d881286fb..2fc05d0131 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,3 +1,7 @@ +import { + createWorkspaceRuntimeStore, + type WorkspaceRuntimeStore, +} from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -289,6 +293,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => { }); interface SessionForTestOptions { + workspaceRuntimeStore?: WorkspaceRuntimeStore; sendToSource?: SessionOptions["onMessageToSource"]; clientId?: string; permissions?: readonly DaemonPermission[]; @@ -302,7 +307,6 @@ interface SessionForTestOptions { getSnapshot?: ReturnType; suggestBranchesForCwd?: ReturnType; listStashes?: ReturnType; - peekSnapshot?: ReturnType; validateBranchRef?: ReturnType; hasLocalBranch?: ReturnType; resolveRepoRemoteUrl?: ReturnType; @@ -353,7 +357,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { getSnapshot: vi.fn(), suggestBranchesForCwd: vi.fn(), listStashes: vi.fn(), - peekSnapshot: vi.fn(), + validateBranchRef: vi.fn(), hasLocalBranch: vi.fn(), resolveRepoRemoteUrl: vi.fn(), @@ -369,6 +373,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const messages = options.messages ?? []; const sessionOptions: SessionOptions = { + workspaceRuntimeStore: options.workspaceRuntimeStore ?? createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -3898,11 +3903,11 @@ describe("session checkout refresh handling", () => { describe("session checkout status handling", () => { test("returns checkout status from the workspace git service snapshot", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/service-worktree")), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "checkout_status_request", @@ -3938,6 +3943,7 @@ describe("session checkout status handling", () => { test("returns fresh service data on the first checkout status read for a cwd", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => null) }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( createWorkspaceGitSnapshot("/tmp/cold-worktree", { @@ -3950,9 +3956,8 @@ describe("session checkout status handling", () => { }, }), ), - peekSnapshot: vi.fn(() => null), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "checkout_status_request", @@ -3960,7 +3965,7 @@ describe("session checkout status handling", () => { requestId: "request-cold-status", }); - expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); + expect(workspaceRuntimeStore.get).not.toHaveBeenCalled(); expect(workspaceGitService.getSnapshot).toHaveBeenCalledTimes(1); expect(messages).toContainEqual({ type: "checkout_status_response", @@ -4000,15 +4005,9 @@ describe("session workspace descriptors", () => { archivedAt: null, }; const session = createSessionForTest({ - messages, - workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, - projectRegistry: { - list: vi.fn().mockResolvedValue([project]), - get: vi.fn().mockResolvedValue(project), - }, - workspaceGitService: { - getSnapshot: vi.fn(), - peekSnapshot: vi.fn(() => + workspaceRuntimeStore: { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => createWorkspaceGitSnapshot("/repo/app", { git: { remoteUrl: "https://github.com/acme/app.git", @@ -4018,6 +4017,16 @@ describe("session workspace descriptors", () => { }, }), ), + }, + messages, + workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, + projectRegistry: { + list: vi.fn().mockResolvedValue([project]), + get: vi.fn().mockResolvedValue(project), + }, + workspaceGitService: { + getSnapshot: vi.fn(), + registerWorkspace: vi.fn(() => () => {}), }, }); @@ -4076,12 +4085,9 @@ describe("session workspace descriptors", () => { archivedAt: null, }; const session = createSessionForTest({ - messages, - workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, - projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() }, - workspaceGitService: { - getSnapshot: vi.fn(), - peekSnapshot: vi.fn(() => + workspaceRuntimeStore: { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => createWorkspaceGitSnapshot("/repo/local", { git: { remoteUrl: null, @@ -4091,6 +4097,13 @@ describe("session workspace descriptors", () => { }, }), ), + }, + messages, + workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, + projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() }, + workspaceGitService: { + getSnapshot: vi.fn(), + registerWorkspace: vi.fn(() => () => {}), }, }); @@ -4127,16 +4140,19 @@ describe("session workspace descriptors", () => { }); }); - test("reads descriptor diff stat from the workspace git service snapshot", async () => { - const workspaceGitService = { - getSnapshot: vi.fn(), - peekSnapshot: vi.fn(() => + test("reads descriptor diff stat from the workspace runtime store", async () => { + const workspaceRuntimeStore = { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => createWorkspaceGitSnapshot("/tmp/workspace", { git: { diffStat: { additions: 7, deletions: 2 } }, }), ), }; - const session = createSessionForTest({ workspaceGitService }); + const workspaceGitService = { + getSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService }); checkoutGitMocks.getCachedCheckoutShortstat.mockReturnValue({ additions: 99, deletions: 88, @@ -4158,7 +4174,7 @@ describe("session workspace descriptors", () => { }, ); - expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace"); + expect(workspaceRuntimeStore.get).toHaveBeenCalledWith("/tmp/workspace"); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(checkoutGitMocks.getCachedCheckoutShortstat).not.toHaveBeenCalled(); expect(checkoutGitMocks.warmCheckoutShortstatInBackground).not.toHaveBeenCalled(); @@ -4166,11 +4182,11 @@ describe("session workspace descriptors", () => { }); test("does not cold-load git data while describing a workspace", async () => { + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => null) }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue(createWorkspaceGitSnapshot("/tmp/workspace")), - peekSnapshot: vi.fn(() => null), }; - const session = createSessionForTest({ workspaceGitService }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService }); const descriptor = await asSessionInternals(session).describeWorkspaceRecordWithGitData( { @@ -4188,7 +4204,7 @@ describe("session workspace descriptors", () => { }, ); - expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/workspace"); + expect(workspaceRuntimeStore.get).toHaveBeenCalledWith("/tmp/workspace"); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(descriptor.diffStat).toBeNull(); expect(descriptor.gitRuntime).toBeUndefined(); @@ -4198,14 +4214,15 @@ describe("session workspace descriptors", () => { describe("session branch validation", () => { test("validates branches through the workspace git service", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), + validateBranchRef: vi .fn() .mockResolvedValue({ kind: "remote-only", name: "feature", remoteRef: "origin/feature" }), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "validate_branch_request", @@ -4243,12 +4260,17 @@ describe("session branch validation", () => { execSync("git tag v1", { cwd: repoDir }); const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), + validateBranchRef: vi.fn().mockResolvedValue({ kind: "not-found" }), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ + workspaceRuntimeStore, + workspaceGitService, + messages, + }); await session.handleMessage({ type: "validate_branch_request", @@ -4323,11 +4345,11 @@ describe("session checkout switch branch handling", () => { describe("session checkout rename branch handling", () => { test("rejects invalid branch slugs without renaming", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "checkout.rename_branch.request", @@ -4356,11 +4378,11 @@ describe("session checkout rename branch handling", () => { test("reports null current branch when branch rename fails", async () => { const messages: unknown[] = []; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists")); await session.handleMessage({ @@ -4374,7 +4396,7 @@ describe("session checkout rename branch handling", () => { "/tmp/repo", "feature/new-name", ); - expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); + expect(workspaceRuntimeStore.get).not.toHaveBeenCalled(); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(messages).toContainEqual({ type: "checkout.rename_branch.response", @@ -4394,6 +4416,14 @@ describe("session checkout rename branch handling", () => { test("forces workspace git refresh after renaming the current branch", async () => { const messages: unknown[] = []; const github = { invalidate: vi.fn() }; + const workspaceRuntimeStore = { + ...createWorkspaceRuntimeStore(), + get: vi.fn(() => + createWorkspaceGitSnapshot("/tmp/repo", { + git: { currentBranch: "feature/old-name" }, + }), + ), + }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue( createWorkspaceGitSnapshot("/tmp/repo", { @@ -4403,13 +4433,13 @@ describe("session checkout rename branch handling", () => { }, }), ), - peekSnapshot: vi.fn(() => - createWorkspaceGitSnapshot("/tmp/repo", { - git: { currentBranch: "feature/old-name" }, - }), - ), }; - const session = createSessionForTest({ github, workspaceGitService, messages }); + const session = createSessionForTest({ + workspaceRuntimeStore, + github, + workspaceGitService, + messages, + }); checkoutGitMocks.renameCurrentBranch.mockResolvedValue({ previousBranch: "feature/old-name", currentBranch: "feature/new-name", @@ -4528,12 +4558,12 @@ describe("session branch suggestions handling", () => { const branchDetails = [ { name: "feature/service", committerDate: 10, hasLocal: true, hasRemote: false }, ]; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), suggestBranchesForCwd: vi.fn().mockResolvedValue(branchDetails), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "branch_suggestions_request", @@ -4572,12 +4602,12 @@ describe("session stash list handling", () => { isPaseo: true, }, ]; + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn() }; const workspaceGitService = { getSnapshot: vi.fn(), listStashes: vi.fn().mockResolvedValue(entries), - peekSnapshot: vi.fn(), }; - const session = createSessionForTest({ workspaceGitService, messages }); + const session = createSessionForTest({ workspaceRuntimeStore, workspaceGitService, messages }); await session.handleMessage({ type: "stash_list_request", @@ -4723,8 +4753,8 @@ describe("session workspace script handling", () => { remoteUrl: "https://github.com/getpaseo/paseo.git", }, }); + const workspaceRuntimeStore = { ...createWorkspaceRuntimeStore(), get: vi.fn(() => snapshot) }; const workspaceGitService = { - peekSnapshot: vi.fn(() => snapshot), getProjectSlug: vi.fn().mockResolvedValue("paseo"), }; const workspaceRegistry = { @@ -4738,6 +4768,7 @@ describe("session workspace script handling", () => { terminalId: "terminal-1", }); const session = createSessionForTest({ + workspaceRuntimeStore, workspaceGitService, workspaceRegistry, terminalManager: { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 221d3148c8..14495fedec 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type { AgentMessageDelivery } from "./agent/agent-messages/index.js"; import { AgentDirectory } from "./agent/directory.js"; import { ConnectedHostSession } from "./connected-hosts/index.js"; @@ -477,6 +478,7 @@ export interface SessionOptions { // defaults to the real checkout-git implementation. renameCurrentBranch?: typeof renameCurrentBranchDefault; workspaceGitService: WorkspaceGitService; + workspaceRuntimeStore: WorkspaceRuntimeStore; workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; pluginRuntime?: { @@ -747,6 +749,7 @@ export class Session { private readonly github: ForgeService; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; private readonly workspaceGitService: WorkspaceGitService; + private readonly workspaceRuntimeStore: WorkspaceRuntimeStore; private readonly workspaceAutoName: WorkspaceAutoName; private readonly gitMutation: GitMutationService; private readonly workspaceProvisioning: WorkspaceProvisioningService; @@ -850,6 +853,7 @@ export class Session { github, renameCurrentBranch, workspaceGitService, + workspaceRuntimeStore, workspaceAutoName, daemonConfigStore, pluginRuntime, @@ -930,6 +934,7 @@ export class Session { this.github = github ?? createGitHubService(); this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.workspaceGitService = workspaceGitService; + this.workspaceRuntimeStore = workspaceRuntimeStore; this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, logger: this.sessionLogger, @@ -1202,7 +1207,7 @@ export class Session { terminalManager: this.terminalManager, workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, - workspaceGitService: this.workspaceGitService, + workspaceRuntimeStore: this.workspaceRuntimeStore, getDaemonTcpPort: this.getDaemonTcpPort, getDaemonTcpHost: this.getDaemonTcpHost, serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl, @@ -2141,7 +2146,7 @@ export class Session { if (!project) { throw new Error(`Project not found for workspace ${workspace.workspaceId}`); } - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceRuntimeStore.get(workspace.cwd); const checkout = checkoutFromPersistedWorkspacePlacement({ workspace, // COMPAT(workspacePlacementBackfill): added in v0.1.107, remove after 2027-01-15. @@ -5611,7 +5616,7 @@ export class Session { projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); let diffStat: { additions: number; deletions: number } | null = null; - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceRuntimeStore.get(workspace.cwd); if (snapshot?.git.diffStat) { diffStat = snapshot.git.diffStat; } @@ -5687,7 +5692,7 @@ export class Session { projectRecord?: PersistedProjectRecord | null, ): Promise { const base = await this.describeWorkspaceRecord(workspace, projectRecord); - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = this.workspaceRuntimeStore.get(workspace.cwd); if (!snapshot) { return base; } diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index c836e6db5d..f13b90d2c8 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -135,7 +136,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { workspaces: Map>; workspaceGitService: WorkspaceGitService & { registerWorkspace: ReturnType; - peekSnapshot: ReturnType; + getSnapshot: ReturnType; refresh: ReturnType; requestWorkingTreeWatch: ReturnType; @@ -177,7 +178,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { unsubscribe, }; }), - peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), + getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), refresh: vi.fn(async () => {}), requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({ @@ -189,6 +190,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { }; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 079dadc26f..2a5402a0c3 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -79,7 +80,6 @@ function createHarness(input: { mainRepoRoot: null, }; }, - peekSnapshot: () => null, }); const emitted: SessionOutboundMessage[] = []; @@ -93,6 +93,7 @@ function createHarness(input: { }; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 71759d33bf..2dfddef55e 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,3 +1,7 @@ +import { + createWorkspaceRuntimeStore, + type WorkspaceRuntimeStore, +} from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -48,7 +52,12 @@ import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata, writePaseoWorktreeMetadata, } from "../utils/worktree-metadata.js"; -import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; +import { + WorkspaceGitServiceImpl, + type WorkspaceGitRuntimeSnapshot, +} from "./workspace-git-service.js"; +import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; +import type pino from "pino"; import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; import { WorkspaceAutoName } from "./workspace-auto-name.js"; import type { ForgeService } from "../services/forge-service.js"; @@ -184,10 +193,10 @@ interface SessionTestAccess { killTerminal(id: string): unknown; clearTerminalAttention?(id: string): Promise; } | null; + workspaceRuntimeStore: WorkspaceRuntimeStore; workspaceGitService: { getCheckout: (cwd: string) => Promise; getSnapshot: (cwd: string, options?: unknown) => Promise; - peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null; registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void }; }; filesystem: { @@ -559,6 +568,7 @@ function createSessionForWorkspaceTests( appVersion?: string | null; onMessage?: (message: SessionOutboundMessage) => void; onWorkspaceRecovered?: SessionOptions["onWorkspaceRecovered"]; + workspaceRuntimeStore?: WorkspaceRuntimeStore; workspaceGitService?: ReturnType; terminalManager?: TerminalManager | null; agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; @@ -647,6 +657,7 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ + workspaceRuntimeStore: options.workspaceRuntimeStore ?? createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1010,6 +1021,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const emitted: SessionOutboundMessage[] = []; const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1166,6 +1178,7 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1306,6 +1319,7 @@ test("create_agent_request does not title an existing workspace from the agent p let generateCalls = 0; const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -1449,7 +1463,6 @@ test("agent_update placement does not refresh git snapshots", async () => { const workspaceGitService = { ...createNoopWorkspaceGitService(), getSnapshot, - peekSnapshot: vi.fn(() => null), }; const session = asTestSession( createSessionForWorkspaceTests({ @@ -1516,7 +1529,6 @@ test("agent_update emits remove when the agent has no workspaceId", async () => workspaceGitService: { ...createNoopWorkspaceGitService(), getSnapshot, - peekSnapshot: vi.fn(() => null), }, }), ); @@ -1578,6 +1590,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2063,6 +2076,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const })); const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2234,6 +2248,7 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2396,6 +2411,7 @@ test("close_items_request continues after an archive failure", async () => { const killTerminalBestEffort = vi.fn(); const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -2606,7 +2622,7 @@ test("workspace placements preserve checkout facts independently from the projec (workspace) => workspace.workspaceId === workspaceId, ) ?? null; session.projectRegistry.get = async () => project; - session.workspaceGitService.peekSnapshot = (cwd: string) => + session.workspaceRuntimeStore.get = (cwd: string) => cwd === paseoSubdirectory.cwd ? createWorkspaceRuntimeSnapshot(cwd, { git: { repoRoot: "/tmp/paseo-worktree" }, @@ -3654,6 +3670,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -4624,7 +4641,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the isPaseoOwnedWorktree: false, mainRepoRoot: null, }); - session.workspaceGitService.peekSnapshot = () => peeked.value; + session.workspaceRuntimeStore.get = () => peeked.value; session.workspaceGitService.registerWorkspace = ( _params, incomingListener: (snapshot: WorkspaceGitRuntimeSnapshot) => void, @@ -6428,7 +6445,7 @@ test("lists Git runtime for a checkout explicitly owned by a non-Git project", a session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; - session.workspaceGitService.peekSnapshot = () => createWorkspaceRuntimeSnapshot(REPO_CWD); + session.workspaceRuntimeStore.get = () => createWorkspaceRuntimeSnapshot(REPO_CWD); const descriptors = Array.from( (await session.buildWorkspaceDescriptorMap({ includeGitData: true })).values(), @@ -6982,9 +6999,10 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git }, }, }); - const peekSnapshotRuntimeFetch = vi.fn(() => runtimeSnapshot); + const getRuntimeSnapshotForFetch = vi.fn(() => runtimeSnapshot); const workspaceGitService = createNoopWorkspaceGitService(); - workspaceGitService.peekSnapshot = peekSnapshotRuntimeFetch; + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + workspaceRuntimeStore.get = getRuntimeSnapshotForFetch; workspaceGitService.registerWorkspace = vi.fn(() => ({ unsubscribe: () => {}, })); @@ -6992,6 +7010,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + workspaceRuntimeStore, }), ); const project = createPersistedProjectRecord({ @@ -7041,7 +7060,7 @@ test("fetch_workspaces_response reads runtime fields from passive workspace git | { type: "fetch_workspaces_response"; payload: Record } | undefined; - expect(peekSnapshotRuntimeFetch).toHaveBeenCalledWith(REPO_CWD); + expect(getRuntimeSnapshotForFetch).toHaveBeenCalledWith(REPO_CWD); expect(response?.payload.entries).toEqual([ expect.objectContaining({ id: "ws-runtime-fetch", @@ -7146,13 +7165,15 @@ test("workspace_update includes updated runtime fields", async () => { }, }, }); - const peekSnapshotRuntimeUpdate = vi.fn(() => runtimeSnapshot); + const getRuntimeSnapshotForUpdate = vi.fn(() => runtimeSnapshot); const workspaceGitService = createNoopWorkspaceGitService(); - workspaceGitService.peekSnapshot = peekSnapshotRuntimeUpdate; + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + workspaceRuntimeStore.get = getRuntimeSnapshotForUpdate; const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + workspaceRuntimeStore, onMessage: (message) => emitted.push(message), }), ); @@ -7199,7 +7220,7 @@ test("workspace_update includes updated runtime fields", async () => { await session.emitWorkspaceUpdateForCwd(REPO_CWD); - expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith(REPO_CWD); + expect(getRuntimeSnapshotForUpdate).toHaveBeenCalledWith(REPO_CWD); expect(emitted).toContainEqual({ type: "workspace_update", payload: { @@ -9692,3 +9713,212 @@ test.each(["failed", "blocked"] as const)( expect(findByType(emitted, "fetch_workspaces_response")!.payload.entries).toEqual([]); }, ); + +// Diagnostic reproduction: PR status on workspace descriptors must survive a +// session teardown (client reconnect after the 90s grace, or any routed-session +// disconnect) when the daemon already observed it. +test("workspace PR status survives session cleanup and reconnect", async () => { + const cwd = path.resolve("/tmp/repro-pr-repo"); + const project = createPersistedProjectRecord({ + projectId: "proj-repro", + rootPath: cwd, + kind: "git", + displayName: "repro", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-repro", + projectId: project.projectId, + cwd, + kind: "worktree", + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + displayName: "repro", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const projectRegistry: SessionOptions["projectRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [project], + get: async (id) => (id === project.projectId ? project : null), + getOrCreateActiveByRoot: async () => project, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + const workspaceRegistry: SessionOptions["workspaceRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [workspace], + get: async (id) => (id === workspace.workspaceId ? workspace : null), + update: async (id, updater) => (id === workspace.workspaceId ? updater(workspace) : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + + const checkoutStatus: CheckoutStatusGit = { + isGit: true, + repoRoot: cwd, + mainRepoRoot: null, + currentBranch: "feature", + isDirty: false, + baseRef: "main", + aheadBehind: { ahead: 0, behind: 0 }, + aheadOfOrigin: 0, + behindOfOrigin: 0, + hasRemote: true, + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, + }; + const facts: CheckoutSnapshotFacts = { + isGit: true, + worktreeRoot: cwd, + currentBranch: "feature", + remoteUrl: "https://github.com/acme/repo.git", + absoluteGitDir: path.join(cwd, ".git"), + gitCommonDir: path.join(cwd, ".git"), + paseoWorktree: { isPaseoOwnedWorktree: false }, + storedBaseRef: null, + resolvedBaseRef: "main", + mainRepoRoot: null, + comparisonBaseRef: null, + branchRemoteName: "origin", + branchMergeRef: "refs/heads/feature", + pullRequestLookupTarget: { headRef: "feature" }, + }; + const githubStub = { + listPullRequests: async () => [], + listIssues: async () => [], + searchIssuesAndPrs: async () => ({ + items: [], + featuresEnabled: true, + githubFeaturesEnabled: true, + }), + getPullRequest: async () => { + throw new Error("unused"); + }, + getPullRequestHeadRef: async () => "feature", + getPullRequestCheckoutTarget: async () => { + throw new Error("unused"); + }, + getCurrentPullRequestStatus: async () => null, + createPullRequest: async () => ({ url: "https://github.com/acme/repo/pull/1", number: 1 }), + mergePullRequest: async () => ({ success: true }), + isAuthenticated: async () => true, + authProbeCanThrow: true, + invalidate: () => {}, + } as unknown as ForgeService; + const gitService = new WorkspaceGitServiceImpl({ + logger: createTestLogger() as unknown as pino.Logger, + paseoHome: "/tmp/paseo-test", + deps: { + subscribe: async () => ({ updateIgnore: async () => {}, unsubscribe: async () => {} }), + getCheckoutSnapshotFacts: async () => facts, + getCheckoutRefDerivedState: async (_cwd, f, current) => ({ + ...current, + upstreamStatus: f.upstreamStatus, + }), + getCheckoutStatus: async () => checkoutStatus, + getCheckoutShortstat: async () => ({ additions: 1, deletions: 0 }), + getCheckoutWorktreeState: async () => ({ + isDirty: false, + diffStat: { additions: 1, deletions: 0 }, + }), + getPullRequestStatus: async () => ({ + status: { + url: "https://github.com/acme/repo/pull/123", + title: "Merged feature", + state: "merged", + baseRefName: "main", + headRefName: "feature", + isMerged: true, + checksStatus: "success", + }, + authState: "authenticated", + featuresEnabled: true, + githubFeaturesEnabled: true, + }), + forgeOverrides: { github: githubStub }, + resolveAbsoluteGitDir: async () => path.join(cwd, ".git"), + hasOriginRemote: async () => false, + runGitFetch: async () => ({ changes: [], error: null }), + runGitCommand: async () => ({ + stdout: `${cwd}\n`, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }), + getWorkspaceGitSelfHealPhaseMs: () => 30_000, + createWatcherLivenessCanary: () => ({ + path: "", + filterEvents: (events) => events, + verify: async () => {}, + }), + now: () => new Date(), + } as never, + }); + + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + gitService.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); + + const emitted1: SessionOutboundMessage[] = []; + const session1 = createSessionForWorkspaceTests({ + onMessage: (m) => emitted1.push(m), + workspaceGitService: gitService, + workspaceRuntimeStore, + projectRegistry, + workspaceRegistry, + }); + await session1.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-1", + subscribe: { subscriptionId: "sub-1" }, + }); + expect(filterByType(emitted1, "rpc_error")).toEqual([]); + const prUrlFrom = (m: SessionOutboundMessage) => + m.type === "workspace_update" && m.payload.kind === "upsert" + ? (m.payload.workspace.githubRuntime?.pullRequest?.url ?? null) + : null; + await vi.waitFor( + () => { + expect(emitted1.map(prUrlFrom)).toContain("https://github.com/acme/repo/pull/123"); + }, + { timeout: 5_000 }, + ); + expect(workspaceRuntimeStore.get(cwd)?.forge.pullRequest?.url).toBe( + "https://github.com/acme/repo/pull/123", + ); + + // Client disconnect past the reconnect grace (or any routed-session drop). + await session1.cleanup(); + + const emitted2: SessionOutboundMessage[] = []; + const session2 = createSessionForWorkspaceTests({ + onMessage: (m) => emitted2.push(m), + workspaceGitService: gitService, + workspaceRuntimeStore, + projectRegistry, + workspaceRegistry, + }); + await session2.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-2", + subscribe: { subscriptionId: "sub-2" }, + }); + const response = filterByType(emitted2, "fetch_workspaces_response")[0]; + const entry = response?.payload.entries.find((e) => e.id === workspace.workspaceId); + const reconnectEntryPrUrl = entry?.githubRuntime?.pullRequest?.url; + try { + // Expected: the daemon already knew this PR; the reconnecting client should get it back + // in the directory listing instead of a descriptor with no githubRuntime at all. + expect(entry?.githubRuntime).toBeDefined(); + expect(reconnectEntryPrUrl).toBe("https://github.com/acme/repo/pull/123"); + } finally { + await session2.cleanup(); + await gitService.dispose(); + } +}); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 2692250b49..0a3b8cb257 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -53,7 +53,6 @@ let provisioning: WorkspaceProvisioningService; function gitService() { return createNoopWorkspaceGitService({ - peekSnapshot: () => null, getSnapshot: async (cwd: string) => { const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); if (!restoredMergedChangeRequestUrl) return snapshot; @@ -242,7 +241,6 @@ test("persists manual worktree ownership separately from its workspace kind", as projectRegistry, isDirectory, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async () => ({ cwd, isGit: true, @@ -330,7 +328,6 @@ test("reopening archived exact-root records restores the fresh Git project", asy projectRegistry, isDirectory, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async () => ({ cwd, isGit: true, @@ -886,7 +883,6 @@ test("a failed import keeps an archived worktree's placement when its directory logger, isDirectory, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async (cwd: string) => { const status = await getCheckoutStatus(cwd, { logger }); return checkoutLiteFromGitSnapshot( @@ -960,7 +956,6 @@ test("a directory that goes away while the git read is in flight keeps its place isDirectory, logger, workspaceGitService: createNoopWorkspaceGitService({ - peekSnapshot: () => null, getCheckout: async (cwd: string) => { rmSync(repo, { recursive: true, force: true }); return { diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 6dbcabdda6..3ac91a834a 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -103,7 +103,7 @@ export function createWorkspaceProvisioningService(deps: { serverId?: string; workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; - workspaceGitService: Pick; + workspaceGitService: Pick; isDirectory: (path: string) => Promise; logger: Logger; lifecycle?: PluginLifecycle; diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index aa366a3495..18ae1f1366 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -22,11 +22,11 @@ import type { SpawnWorkspaceScriptOptions, WorktreeScriptResult, } from "../../worktree-bootstrap.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { WorkspaceRuntimeStore } from "../../workspace-runtime-store/index.js"; import { createWorkspaceScriptsService } from "./workspace-scripts-service.js"; import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js"; -// The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug}, +// The production module reads only WorkspaceRuntimeStore.get, // WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected // spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and // runtime store are the real in-memory implementations, and spawning is injected so no process runs. @@ -51,7 +51,7 @@ function fakeProjectRegistry(record: PersistedProjectRecord | null): Pick; + runtimeStore?: Pick; automationError?: Error; } @@ -106,7 +106,7 @@ function buildService(options: BuildOptions = {}) { options.terminalManager === undefined ? availableTerminalManager : options.terminalManager, workspaceRegistry: fakeWorkspaceRegistry(workspace), projectRegistry: fakeProjectRegistry(options.project ?? null), - workspaceGitService: options.gitService ?? fakeGitService(), + workspaceRuntimeStore: options.runtimeStore ?? fakeRuntimeStore(), getDaemonTcpPort: () => 6767, getDaemonTcpHost: () => "127.0.0.1", serviceProxyPublicBaseUrl: null, @@ -204,7 +204,7 @@ describe("buildSnapshot", () => { workspace, project, serviceProxy, - gitService: { peekSnapshot: () => undefined }, + runtimeStore: { get: () => null }, }); expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe( diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index d449a48855..103c3ed16b 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "../../workspace-runtime-store/index.js"; import type pino from "pino"; import type { ScriptStatusUpdateMessage, @@ -10,7 +11,6 @@ import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { ServiceProxySubsystem } from "../../service-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js"; import type { ScriptHealthState } from "../../script-health-monitor.js"; -import type { WorkspaceGitService } from "../../workspace-git-service.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord, @@ -51,15 +51,13 @@ export interface WorkspaceScriptsService { start(request: StartWorkspaceScriptRequest): Promise; } -type WorkspaceScriptsGitSource = Pick; - export function createWorkspaceScriptsService(deps: { serviceProxy: ServiceProxySubsystem | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; terminalManager: TerminalManager | null; workspaceRegistry: Pick; projectRegistry: Pick; - workspaceGitService: WorkspaceScriptsGitSource; + workspaceRuntimeStore: Pick; getDaemonTcpPort: (() => number | null) | null; getDaemonTcpHost: (() => string | null) | null; serviceProxyPublicBaseUrl: string | null; @@ -78,7 +76,7 @@ export function createWorkspaceScriptsService(deps: { terminalManager, workspaceRegistry, projectRegistry, - workspaceGitService, + workspaceRuntimeStore, getDaemonTcpPort, getDaemonTcpHost, serviceProxyPublicBaseUrl, @@ -95,7 +93,7 @@ export function createWorkspaceScriptsService(deps: { workspace: PersistedWorkspaceRecord, project: { projectId: string; rootPath: string } | null, ) { - const snapshot = workspaceGitService.peekSnapshot(workspace.cwd); + const snapshot = workspaceRuntimeStore.get(workspace.cwd); const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null; if (project) { return { diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 4af5e45c5b..2917d0fec9 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -104,6 +105,7 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index 53b8a44c1e..0027173ab7 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -38,7 +38,7 @@ export function createNoopWorkspaceGitService( onSnapshotUpdated: () => ({ unsubscribe: () => {}, }), - peekSnapshot: () => null, + getCheckout: async (cwd: string) => ({ cwd, isGit: false, diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index df0b02ad43..313d82ff7b 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createServer, type Server as HTTPServer } from "node:http"; import type { AddressInfo } from "node:net"; @@ -306,6 +307,7 @@ function createVoiceAssistantWebSocketServer(params: { null, { allowedOrigins: new Set(["*"]) }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), undefined, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 8ff692d580..c16a079628 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; @@ -117,6 +118,7 @@ function createServer(agentManagerOverrides?: Record) { null, { allowedOrigins: new Set() }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), undefined, undefined, undefined, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index a3c821abd1..859b5007fb 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { Server as HTTPServer } from "http"; @@ -285,6 +286,7 @@ function createServer(options?: { null, { allowedOrigins: new Set(), startPaused: options?.startPaused }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), options?.auth, speechReadiness ? { diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 3f77a0482e..e22e5bf9a7 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; @@ -148,6 +149,7 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work null, { allowedOrigins: new Set() }, createWorkspaceAutoNameStub(), + createWorkspaceRuntimeStore(), undefined, undefined, terminalManager, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 214fc8c9ce..8a28f25d5a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,4 @@ +import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDelivery, type AgentMessageDelivery, @@ -241,7 +242,6 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { onSnapshotUpdated: () => ({ unsubscribe: () => {}, }), - peekSnapshot: () => null, getCheckout: async (cwd: string) => ({ cwd, isGit: false, @@ -649,6 +649,7 @@ export class VoiceAssistantWebSocketServer { mcpBaseUrl: string | null, wsConfig: WebSocketServerConfig, workspaceAutoName: WorkspaceAutoName, + private readonly workspaceRuntimeStore: WorkspaceRuntimeStore, auth?: DaemonAuthConfig, speech?: SpeechService | null, terminalManager?: TerminalManager | null, @@ -1606,6 +1607,7 @@ export class VoiceAssistantWebSocketServer { checkoutDiffManager: this.checkoutDiffManager, github: this.github, workspaceGitService: this.workspaceGitService, + workspaceRuntimeStore: this.workspaceRuntimeStore, workspaceAutoName: this.workspaceAutoName, daemonConfigStore: this.daemonConfigStore, pluginRuntime: this.pluginRuntime, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index c6896c3fb0..00faf1f441 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDeliveryStub } from "./test-utils/session-stubs.js"; import { createMessageReceiptsStub, @@ -214,6 +215,7 @@ function createSessionForWireCompatTest(options?: { ]; const session = new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), messageReceipts: createMessageReceiptsStub(), agentMessages: createAgentMessageDeliveryStub(), creationService: createTestCreationService(), @@ -262,9 +264,7 @@ function createSessionForWireCompatTest(options?: { async listStashes() { return []; }, - peekSnapshot() { - return null; - }, + async validateBranchRef() { return { ok: false, error: "not found" }; }, diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 8ac1ef0018..a8a1f11dfb 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; @@ -204,7 +205,10 @@ test("recursive observation updates tracked state and prunes ignored storms", as runGitCommand, } as never, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: path.join(tempDir, "paseo-home"), workspaceGitService: service, @@ -229,7 +233,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as await vi.waitFor( () => { expect(activeWatcherCount).toBe(2); - expect(service.peekSnapshot(repoDir)).not.toBeNull(); + expect(workspaceRuntimeStore.get(repoDir)).not.toBeNull(); expect(service.getMetrics()).toMatchObject({ workspaceObservationSetupInFlightCount: 0, workspaceRefreshInFlightCount: 0, diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 8285304570..f91a96e464 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import path from "node:path"; import { pathToFileURL } from "node:url"; import type pino from "pino"; @@ -379,11 +380,13 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutStatus, runGitCommand, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); @@ -442,7 +445,10 @@ describe("WorkspaceGitService checkout observation", () => { getPullRequestStatus, getCheckoutDiff, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -509,10 +515,12 @@ describe("WorkspaceGitService checkout observation", () => { }, ); const service = createService(watcher, { getCheckoutDiff }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); const initialUncommitted = await service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); @@ -963,12 +971,14 @@ describe("WorkspaceGitService checkout observation", () => { return { changes: [], nonRemoteRefsChanged: false, error: null }; }), }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await fetchSnapshotRead.promise; // Observation setup also reads facts. Wait for the initial snapshot to be // published before injecting an event that must produce a second refresh. await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)?.git.currentBranch).toBe("feature"); + expect(workspaceRuntimeStore.get(REPO_CWD)?.git.currentBranch).toBe("feature"); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); @@ -1233,7 +1243,10 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutShortstat, getCheckoutDiff, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -1288,7 +1301,10 @@ describe("WorkspaceGitService checkout observation", () => { diffStat: { additions: 4, deletions: 2 }, })); const service = createService(watcher, { getCheckoutDiff, getCheckoutWorktreeState }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -1518,6 +1534,8 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutStatus, getPullRequestStatus, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { @@ -1529,7 +1547,7 @@ describe("WorkspaceGitService checkout observation", () => { expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); }); await vi.advanceTimersByTimeAsync(7_000); @@ -1677,10 +1695,12 @@ describe("WorkspaceGitService checkout observation", () => { getWorkspaceGitSelfHealPhaseMs: () => 60_000, runGitCommand, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const summarySubscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)?.git.isGit).toBe(false); + expect(workspaceRuntimeStore.get(REPO_CWD)?.git.isGit).toBe(false); expect(service.getMetrics()).toMatchObject({ workspaceObservationSetupInFlightCount: 0, workspaceRefreshInFlightCount: 0, @@ -1691,7 +1711,7 @@ describe("WorkspaceGitService checkout observation", () => { isGit = true; await vi.advanceTimersByTimeAsync(5_000); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)?.git.isGit).toBe(true); + expect(workspaceRuntimeStore.get(REPO_CWD)?.git.isGit).toBe(true); expect(service.getMetrics()).toMatchObject({ repositoryTargetCount: 1, repositoryWorkspaceLinkCount: 1, @@ -1728,10 +1748,12 @@ describe("WorkspaceGitService checkout observation", () => { getWorkspaceGitSelfHealPhaseMs: () => 1_000_000, runGitCommand, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD); @@ -2743,7 +2765,10 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutShortstat, getCheckoutDiff, }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const diffManager = new CheckoutDiffManager({ + workspaceRuntimeStore: workspaceRuntimeStore, logger: createLogger(), paseoHome: "/tmp/paseo-home", workspaceGitService: service, @@ -2757,7 +2782,7 @@ describe("WorkspaceGitService checkout observation", () => { ); await vi.waitFor(() => { - expect(service.peekSnapshot(REPO_CWD)).not.toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).not.toBeNull(); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); isDirty = true; diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index ce052cdd7c..9a3599740c 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -566,19 +567,19 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { expect(subscription).toEqual({ unsubscribe: expect.any(Function) }); expect(getCheckoutStatus).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled(); - expect(service.peekSnapshot(REPO_CWD)).toBeNull(); + expect(listener.mock.lastCall?.[0] ?? null).toBeNull(); await vi.waitFor(() => { expect(getCheckoutStatus).toHaveBeenCalledTimes(1); }); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); - expect(service.peekSnapshot(REPO_CWD)).toBeNull(); + expect(listener.mock.lastCall?.[0] ?? null).toBeNull(); checkoutStatusDeferred.resolve(createCheckoutStatus(REPO_CWD)); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); - expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); + expect(listener.mock.lastCall?.[0] ?? null).toEqual(createSnapshot(REPO_CWD)); subscription.unsubscribe(); service.dispose(); @@ -703,7 +704,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" })); await refresh; - expect(service.peekSnapshot(REPO_CWD)).toEqual( + expect(await service.getSnapshot(REPO_CWD)).toEqual( createSnapshot(REPO_CWD, { git: { currentBranch: "feature", @@ -1385,23 +1386,25 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult("Visible PR")), }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); try { - await service.getSnapshot(REPO_CWD); + workspaceRuntimeStore.set(await service.getSnapshot(REPO_CWD)); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR"); + expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR"); headSha = "2222222222222222222222222222222222222222"; nowMs = 3_000; await service.refresh(REPO_CWD); - expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest).toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest).toBeNull(); expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(0); await flushPromises(); expect(forge.getCurrentPullRequestStatus).toHaveBeenCalledTimes(1); - expect(service.peekSnapshot(REPO_CWD)?.forge.pullRequest?.title).toBe("MR self-healed"); + expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest?.title).toBe("MR self-healed"); subscription.unsubscribe(); } finally { service.dispose(); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index a1d6b392e7..7d547b75e6 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1,3 +1,4 @@ +import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import os from "node:os"; import path, { join } from "node:path"; @@ -334,6 +335,8 @@ describe("WorkspaceGitServiceImpl", () => { test("registerWorkspace returns a subscription without an initial snapshot contract", async () => { const service = createService(); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const listener = vi.fn(); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); @@ -341,12 +344,46 @@ describe("WorkspaceGitServiceImpl", () => { expect(subscription).toEqual({ unsubscribe: expect.any(Function) }); expect("initial" in subscription).toBe(false); expect(listener).not.toHaveBeenCalled(); - expect(service.peekSnapshot(REPO_CWD)).toBeNull(); + expect(workspaceRuntimeStore.get(REPO_CWD)).toBeNull(); subscription.unsubscribe(); service.dispose(); }); + test("shared watches publish to the runtime store before listeners and retain data after teardown", async () => { + const subscribe = vi.fn(async () => createAsyncSubscription()); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const service = createService({ subscribe, getPullRequestStatus }); + const store = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => store.set(snapshot)); + const observed: WorkspaceGitRuntimeSnapshot[] = []; + const listener = (snapshot: WorkspaceGitRuntimeSnapshot) => { + expect(store.get(REPO_CWD)).toBe(snapshot); + observed.push(snapshot); + }; + const first = service.registerWorkspace({ cwd: REPO_CWD }, listener); + const second = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, (snapshot) => + listener(snapshot), + ); + await service.getSnapshot(REPO_CWD, { force: true, reason: "runtime-store" }); + await flushPromises(); + expect(observed.length).toBeGreaterThan(0); + expect(service.getMetrics().workspaceTargetCount).toBe(1); + const subscriptionCount = subscribe.mock.calls.length; + const snapshot = store.get(REPO_CWD); + expect(snapshot?.forge.pullRequest).not.toBeNull(); + first.unsubscribe(); + expect(service.getMetrics().workspaceListenerCount).toBe(1); + second.unsubscribe(); + expect(service.getMetrics().workspaceTargetCount).toBe(0); + const pollCount = getPullRequestStatus.mock.calls.length; + await vi.advanceTimersByTimeAsync(360_000); + expect(getPullRequestStatus).toHaveBeenCalledTimes(pollCount); + expect(subscribe).toHaveBeenCalledTimes(subscriptionCount); + expect(store.get(REPO_CWD)).toBe(snapshot); + await service.dispose(); + }); + test("onSnapshotUpdated emits only for observed workspace snapshots and can unsubscribe", async () => { const service = createService(); const snapshotListener = vi.fn(); @@ -516,7 +553,6 @@ describe("WorkspaceGitServiceImpl", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); - expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); await expect(service.getSnapshot(REPO_CWD)).resolves.toEqual(createSnapshot(REPO_CWD)); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); @@ -834,13 +870,15 @@ describe("WorkspaceGitServiceImpl", () => { resolveAbsoluteGitDir, now: () => new Date(nowMs), }); + const workspaceRuntimeStore = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => workspaceRuntimeStore.set(snapshot)); const subscription = service.registerWorkspace({ cwd: join(REPO_CWD, ".") }, vi.fn()); await expect(service.getSnapshot(join(REPO_CWD, "."))).resolves.toEqual( createSnapshot(REPO_CWD), ); - expect(service.peekSnapshot(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); + expect(workspaceRuntimeStore.get(REPO_CWD)).toEqual(createSnapshot(REPO_CWD)); nowMs += 3_000; await service.refresh(REPO_CWD); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index be62376130..74c6f8f18d 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -209,7 +209,6 @@ export interface WorkspaceGitService { ): WorkspaceGitSubscription; onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription; - peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; getCheckout(cwd: string): Promise; getSnapshot( cwd: string, @@ -765,7 +764,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { worktreesRoot: this.worktreesRoot, logger: this.logger, }); - const cached = this.peekSnapshot(normalizedCwd); + const cached = this.getCachedSnapshot(normalizedCwd); if (cached && cached.git.isGit !== status.isGit) { // A fresh checkout read discovered a repository transition. Keep the // shared snapshot current before exposing the new project capability. @@ -795,7 +794,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }); } - peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { + private getCachedSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { cwd = resolve(cwd); return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } @@ -3518,9 +3517,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!options?.notify || target.listeners.size === 0) { return; } - for (const listener of target.listeners) { - listener(snapshot); - } for (const listener of this.snapshotUpdatedListeners) { try { listener(snapshot); @@ -3531,6 +3527,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ); } } + for (const listener of target.listeners) { + listener(snapshot); + } } private async runRepoFetch(target: RepoGitTarget): Promise { diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 04ffc1d7ac..6c27027edc 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -13,6 +13,7 @@ import { FileBackedWorkspaceRegistry, resolveWorkspaceDisplayName, resolveWorkspaceName, + type WorkspaceMutation, } from "./workspace-registry.js"; describe("resolveWorkspaceName", () => { @@ -430,6 +431,10 @@ describe("workspace registries", () => { test("creates, updates, archives, deletes, and lists workspace records", async () => { await workspaceRegistry.initialize(); + const mutations: WorkspaceMutation[] = []; + workspaceRegistry.subscribeToMutations((mutation) => { + mutations.push(mutation); + }); await workspaceRegistry.upsert( createPersistedWorkspaceRecord({ workspaceId: "/tmp/repo", @@ -460,6 +465,11 @@ describe("workspace registries", () => { expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z"); await workspaceRegistry.remove("/tmp/repo"); + expect(mutations.at(-1)).toEqual({ + kind: "remove", + workspaceId: "/tmp/repo", + workspace: archived, + }); expect(await workspaceRegistry.get("/tmp/repo")).toBeNull(); expect(await workspaceRegistry.list()).toEqual([]); }); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 417af353fe..0e0fb4e62c 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -585,7 +585,7 @@ export class FileBackedWorkspaceRegistry override async remove(workspaceId: string): Promise { const workspace = await this.removeIfPresent(workspaceId); if (!workspace) return; - await this.notifyMutation({ kind: "remove", workspaceId, workspace: null }); + await this.notifyMutation({ kind: "remove", workspaceId, workspace }); } async commitWorkspaceLabelMutation(input: { diff --git a/packages/server/src/server/workspace-runtime-store/index.test.ts b/packages/server/src/server/workspace-runtime-store/index.test.ts new file mode 100644 index 0000000000..ade1820506 --- /dev/null +++ b/packages/server/src/server/workspace-runtime-store/index.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; +import { resolve } from "node:path"; +import { createNoGitWorkspaceRuntimeSnapshot } from "../test-utils/workspace-git-service-stub.js"; +import { createWorkspaceRuntimeStore } from "./index.js"; + +test("retains, replaces, and removes snapshots by resolved workspace directory", () => { + const store = createWorkspaceRuntimeStore(); + const cwd = "./workspace"; + const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); + expect(store.get(cwd)).toBeNull(); + store.set(snapshot); + expect(store.get(resolve(cwd))).toBe(snapshot); + const replacement = createNoGitWorkspaceRuntimeSnapshot(cwd); + store.set(replacement); + expect(store.get(cwd)).toBe(replacement); + store.remove(resolve(cwd)); + expect(store.get(cwd)).toBeNull(); +}); diff --git a/packages/server/src/server/workspace-runtime-store/index.ts b/packages/server/src/server/workspace-runtime-store/index.ts new file mode 100644 index 0000000000..778939bf93 --- /dev/null +++ b/packages/server/src/server/workspace-runtime-store/index.ts @@ -0,0 +1,23 @@ +import { resolve } from "node:path"; +import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; + +export interface WorkspaceRuntimeStore { + get(cwd: string): WorkspaceGitRuntimeSnapshot | null; + set(snapshot: WorkspaceGitRuntimeSnapshot): void; + remove(cwd: string): void; +} + +export function createWorkspaceRuntimeStore(): WorkspaceRuntimeStore { + const snapshots = new Map(); + return { + get(cwd) { + return snapshots.get(resolve(cwd)) ?? null; + }, + set(snapshot) { + snapshots.set(resolve(snapshot.cwd), snapshot); + }, + remove(cwd) { + snapshots.delete(resolve(cwd)); + }, + }; +} From 793b16c0df93ef378d95edeaab281ea19ab4c975 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 19:50:06 +0700 Subject: [PATCH 33/62] fix(relay): protect sessions against replayed commands (#7610) Protect relay sessions against replayed commands with authenticated Noise NK negotiation, ordered records, and identity-bound security pins. Preserve legacy admission during migration and add daemon enforcement policy. All 21 CI checks passed. General engineering review is complete; specialist cryptographic review of the handwritten Noise implementation and static-key reuse remains required before release or deployment. Closes #359 --- CLAUDE.md | 1 + SECURITY.md | 10 +- docs/architecture.md | 4 +- docs/relay-endpoint-security.md | 88 ++ package-lock.json | 42 + packages/app/src/runtime/host-runtime.test.ts | 180 +++- packages/app/src/runtime/host-runtime.ts | 119 ++- .../app/src/types/host-connection.test.ts | 151 ++++ packages/app/src/types/host-connection.ts | 80 +- .../app/src/utils/test-daemon-connection.ts | 6 +- packages/cli/src/commands/daemon/pair.ts | 1 + .../src/daemon-client-relay-e2ee-transport.ts | 133 ++- .../src/daemon-client-transport.test.ts | 13 +- .../client/src/daemon-client-transport.ts | 3 +- packages/client/src/daemon-client.test.ts | 318 +++++++ packages/client/src/daemon-client.ts | 41 +- packages/client/src/node/connector.test.ts | 56 ++ packages/client/src/node/index.ts | 9 + packages/protocol/src/connection-offer.ts | 3 + .../protocol/src/daemon-endpoints.test.ts | 37 + packages/protocol/src/daemon-endpoints.ts | 8 + packages/protocol/src/messages.ts | 6 + packages/relay/package.json | 3 + packages/relay/src/e2ee.ts | 17 + packages/relay/src/index.ts | 1 + packages/relay/src/noise/cipher-state.ts | 76 ++ .../fixtures/nk-25519-chachapoly-sha256.json | 85 ++ packages/relay/src/noise/index.ts | 241 +++++ packages/relay/src/noise/noise.test.ts | 167 ++++ packages/relay/src/noise/symmetric-state.ts | 83 ++ packages/relay/src/protected-records.test.ts | 175 ++++ packages/relay/src/protected-records.ts | 229 +++++ packages/relay/src/secure-channel.test.ts | 627 +++++++++++++ packages/relay/src/secure-channel.ts | 835 ++++++++++++++++++ packages/server/src/server/bootstrap.ts | 44 +- packages/server/src/server/config.ts | 27 +- .../server/src/server/connection-offer.ts | 2 + .../src/server/daemon-config-store.test.ts | 29 + .../server/src/server/daemon-config-store.ts | 21 +- .../daemon-e2e/relay-transport.e2e.test.ts | 724 ++++++++++----- packages/server/src/server/pairing-offer.ts | 5 + .../server/src/server/persisted-config.ts | 6 + packages/server/src/server/relay-runtime.ts | 3 - .../server/src/server/relay-transport.test.ts | 107 --- packages/server/src/server/relay-transport.ts | 170 +--- .../server/session/daemon/daemon-session.ts | 2 + .../src/server/test-utils/paseo-daemon.ts | 2 + .../server/src/server/websocket-server.ts | 8 + .../websocket/encrypted-relay-socket.test.ts | 4 - .../websocket/encrypted-relay-socket.ts | 3 - .../websocket/secure-relay-ingress.test.ts | 272 ++++++ .../server/websocket/secure-relay-ingress.ts | 210 +++++ .../server/src/utils/checkout-git.test.ts | 11 +- packages/server/src/utils/checkout-git.ts | 4 +- .../public/schemas/paseo.config.v1.json | 4 + 55 files changed, 4887 insertions(+), 619 deletions(-) create mode 100644 docs/relay-endpoint-security.md create mode 100644 packages/relay/src/noise/cipher-state.ts create mode 100644 packages/relay/src/noise/fixtures/nk-25519-chachapoly-sha256.json create mode 100644 packages/relay/src/noise/index.ts create mode 100644 packages/relay/src/noise/noise.test.ts create mode 100644 packages/relay/src/noise/symmetric-state.ts create mode 100644 packages/relay/src/protected-records.test.ts create mode 100644 packages/relay/src/protected-records.ts create mode 100644 packages/relay/src/secure-channel.test.ts create mode 100644 packages/relay/src/secure-channel.ts create mode 100644 packages/server/src/server/websocket/secure-relay-ingress.test.ts create mode 100644 packages/server/src/server/websocket/secure-relay-ingress.ts diff --git a/CLAUDE.md b/CLAUDE.md index 79bdef92f8..3f1731597c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources | | [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style | | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | +| [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) | Relay E2EE modes: Noise NK mapping, record framing, negotiation, the `requireProtectedSecurity` flag, close codes | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging | diff --git a/SECURITY.md b/SECURITY.md index ea23984fa0..8fbd768c46 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,8 +21,8 @@ The relay is designed to be untrusted. All traffic between your phone and daemon 1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600` 2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key. -3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes. -4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. +3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an `e2ee_hello` message. The daemon will not process any application messages until this handshake completes. +4. Updated peers run a Noise `NK` handshake (`Noise_NK_25519_ChaChaPoly_SHA256`) keyed to the daemon's static key: the `e2ee_hello` carries the first Noise message and the daemon's `e2ee_ready` carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl `box`) and a random `[24-byte nonce][ciphertext]` bundle. Both modes negotiate `binaryCiphertext`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) has the exact mapping. The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations. @@ -34,9 +34,9 @@ The daemon requires a valid cryptographic handshake before processing any comman - **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone - **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon. -- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake -- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected -- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters. +- **Read your traffic** — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode +- **Forge messages** — Both constructions are authenticated encryption; a tampered message is rejected in either mode +- **Replay old messages** — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set `daemon.relay.requireProtectedSecurity: true` to refuse legacy clients once every client you use has updated. ### Trust model diff --git a/docs/architecture.md b/docs/architecture.md index 8e63bacaf6..29f9c7f817 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -182,9 +182,9 @@ Communicates with the daemon via the same WebSocket protocol as the app. Enables remote access when the daemon is behind a firewall. -- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305 +- Updated peers negotiate a Noise `NK` session with per-record counters; peers on either side that predate it fall back to the legacy NaCl `box` construction. [docs/relay-endpoint-security.md](relay-endpoint-security.md) owns the mapping, negotiation, and the `daemon.relay.requireProtectedSecurity` flag - The relay is zero-knowledge — it routes encrypted bytes and cannot read content -- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) +- Client and daemon channels with identical API (`openClientSecureChannel`, `openDaemonSecureChannel`) - Pairing via QR code transfers the daemon's public key to the client - New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot. - Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only diff --git a/docs/relay-endpoint-security.md b/docs/relay-endpoint-security.md new file mode 100644 index 0000000000..0fd6a1899f --- /dev/null +++ b/docs/relay-endpoint-security.md @@ -0,0 +1,88 @@ +# Relay endpoint security + +The relay carries daemon sessions between a client and a daemon that cannot reach each other directly. The relay is untrusted, so the two endpoints secure the session themselves. This doc owns the protected construction, its mapping onto Paseo's handshake and record flow, the negotiation rules, and the enforcement flag. The threat model lives in [SECURITY.md](../SECURITY.md); the code is `packages/relay/src/secure-channel.ts`. + +Fixes [#359](https://github.com/getpaseo/paseo/issues/359): the legacy construction has no replay protection. A recorded legacy hello plus records replays onto a fresh daemon channel, and duplicated records are delivered twice. + +## Two modes + +| Mode | Construction | Replay protection | +| ----------- | ------------------------------------------------ | -------------------------------- | +| `legacy` | X25519 + NaCl `box`, random nonces | none | +| `protected` | `Noise_NK_25519_ChaChaPoly_SHA256` (revision 34) | counters, fresh daemon ephemeral | + +Two updated peers always negotiate `protected`. An old peer on either side falls to `legacy` while the daemon permits it. Everything in `packages/relay/src/encrypted-channel.ts` is the legacy construction and is unchanged. + +The shared entry points are `openClientSecureChannel` and `openDaemonSecureChannel`. Callers get a `SecureChannel` and ask it `securityMode()`; nothing outside the module branches on the construction. + +## Noise mapping + +- **Pattern:** NK. The daemon's static key is the existing `$PASEO_HOME/daemon-keypair.json` X25519 key that pairing already distributes. There is no second daemon identity. +- **Prologue:** the ASCII bytes `paseo-relay-e2ee/1`. +- **Primitives:** `@noble/curves` x25519, `@noble/ciphers` ChaCha20-Poly1305, `@noble/hashes` SHA-256 and HMAC. The state machine in `packages/relay/src/noise/` is our own implementation of the spec and is checked against vendored third-party vectors in `noise/fixtures/`. +- **Message 1** (`-> e, es`) travels inside the legacy hello: `{ type: "e2ee_hello", key, capabilities, protected: { v: 1, message: } }`. `key` is a separate legacy ephemeral so an old daemon still completes a legacy handshake. The Noise payload is the offered parameters. +- **Message 2** (`<- e, ee`) travels inside the ready: `{ type: "e2ee_ready", protected: { v: 1, message: } }`. Its payload is the selected parameters. The outer `capabilities` field is ignored in protected mode because it is not authenticated. +- **Parameters payload:** two bytes, `[0x01 version][flags]`. Flag bit 0 is `binaryCiphertext`. Unknown versions, reserved flag bits, and a selection the client did not offer fail the handshake. +- **Split:** the initiator sends with the first cipher state and receives with the second. The daemon is the mirror. Nonces are 64-bit little-endian in a 12-byte nonce, reserved synchronously before any await, never wrapped. A receiver advances its counter only after the tag verifies. + +Nothing else is bound into the handshake. The outer JSON, the relay URL, and session credentials are not authenticated by Noise. Credentials travel in the application `hello` after message 2 verified the daemon. + +## Records + +Each application frame becomes one record of one or more Noise transport messages: + +``` +chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data +wire record = (u16 BE ciphertextLength || ciphertext)+ +``` + +`kind` is `0x00` text or `0x01` binary. `flags` bit 0 marks the first chunk and bit 1 the last. `data` is at most 65513 bytes per chunk, so a chunk never exceeds the Noise message limit. The chunk count is `max(1, ceil(totalLength / 65513))`. + +The receiver rejects a record whose metadata disagrees between chunks, whose non-final chunks are not full, whose byte total differs from `totalLength`, or that has zero chunks, trailing bytes, reserved flags, or an invalid kind. Partial plaintext never reaches the application. Transport AD is empty, as the Noise spec writes it; framing authenticity comes from the metadata being inside the ciphertext. `packages/relay/src/protected-records.ts` owns this. + +Frame kind is preserved on the wire the same way as legacy: text records are base64 WebSocket text, binary records are WebSocket binary when `binaryCiphertext` was negotiated. + +## Retries and failures + +- The client resends its hello every second until a ready arrives. The daemon caches the exact hello text and ready text for the channel. An identical hello gets the identical ready and counters keep running. A different hello closes the channel (`1008`, transcript mismatch). A ready after the channel is open is ignored. +- A fresh daemon channel always generates a fresh `e`, so a recorded hello and its records fail authentication when replayed later, including after a daemon restart. +- A present but invalid `protected` field fails the handshake. There is no fallback to legacy once protected was attempted. +- Any record failure (bad tag, duplicate, reordered, reflected, malformed) closes the channel and nothing from that record is delivered. The daemon closes with `1011`; the client closes with `4003`, because a browser `WebSocket.close` accepts only `1000` and `3000`-`4999`. +- A record header is checked against the bytes actually received before any buffer is allocated for it, so a header claiming gigabytes costs nothing. +- Keys are cleared on close as a best effort. + +## Negotiation and admission + +The daemon advertises `server_info.features.relayProtectedSecurity: true` on every session, whatever mode that session negotiated. A relay client that ended up in `legacy` with such a daemon closes with `4002` before the client marks itself connected, restores subscriptions, or flushes queued commands. The check lives in the relay E2EE transport, so it also covers the Node connector; direct connections never see it. + +The app pins `minimumSecurityMode: "protected"` on every relay connection to a daemon public key after the first protected handshake with that key, or when the pairing offer carries `minimumSecurityMode`. A pinned client passes `minimumMode: "protected"` to the handshake and refuses a legacy ready before sending credentials. The pin belongs to the daemon identity, not to a relay address: re-importing the same key through any relay keeps it, a different key is a different connection, and connection failures never clear it. + +The handshake result is read in one place, `HostRuntimeController` when the host's active client reports connected. Every client reaches that point, whether the controller created it or adopted it from a pairing or startup probe, so no client constructor carries a persistence callback. A probe that negotiated protected but was never adopted pins nothing; the pin lands when the host first goes online. + +Within one client process the floor also rises on its own. `DaemonClient` and the Node host connector remember a protected result and require it on every later attempt, so a reconnect after a relay drop cannot be answered in legacy mode even before the pin is persisted. + +The daemon refuses a legacy client with close `1008` and reason `protected endpoint security required`, and logs `relay_e2ee_handshake_rejected`. That close code stops at the relay: both relays replace a daemon-side close with their own (the wrangler relay sends `1012 Server disconnected`), so an old client sees an ordinary disconnect and keeps retrying. The only client-detectable failure is the client's own `4002`, which stops reconnecting and tells the user to update. + +The client learns its own `4002` and `4003` from the relay E2EE transport, which reports the close the moment the channel decides it. A socket close event arrives later and the channel's error callback earlier; neither is what `DaemonClient` acts on, because acting on the error would start a reconnect before the code is known. + +## Enforcement flag + +`daemon.relay.requireProtectedSecurity` in `config.json` (also `relay.requireProtectedSecurity` through the config RPC) makes the daemon refuse legacy relay clients. Default `false`. It is a local daemon setting with no relay-server counterpart. + +- It is read when each hello arrives, so toggling it affects the next admission without a restart. Sessions already admitted in legacy mode stay up until they disconnect; turning the flag on does not close them. To finish enforcement, restart the daemon or disconnect its relay sessions after enabling the flag: every reconnect is then a new admission and legacy clients are refused. +- While it is on, pairing offers carry `minimumSecurityMode: "protected"` and `relay://` URIs carry `security=protected`, so new clients pin before their first handshake. +- Direct and local connections are outside the flag. + +Flipping the default is a separate decision, tentatively reviewed about 30 days after the compatibility release. Nothing in the code expires on a date. + +## Known limitation + +While a daemon accepts legacy clients, a transcript recorded from any legacy session (hello, credentials, commands) can still be replayed to it. Pinning on the client does not change that, because the attacker replays the old client's transcript, not the pinned client's. The only remedy is enabling the flag on that daemon. + +## Testing + +- `packages/relay/src/noise/noise.test.ts`: vendored vectors, replay and out-of-order rejection at the cipher-state level. +- `packages/relay/src/protected-records.test.ts`: framing round trips and every malformed-record rule. +- `packages/relay/src/secure-channel.test.ts`: both modes, mixed versions, policy rejection, retries, recorded-transcript replay, downgrade attempts. +- `packages/server/src/server/websocket/secure-relay-ingress.test.ts`: daemon admission and the flag. +- `packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts`: real daemon through the wrangler relay. Set `PASEO_RELAY_E2E_PORT` to run the same cases against an already-running relay such as the Elixir relay in Docker. diff --git a/package-lock.json b/package-lock.json index 9e0a02eb17..2f60b8cc1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8805,6 +8805,45 @@ "license": "MIT", "optional": true }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -42184,6 +42223,9 @@ "name": "@getpaseo/relay", "version": "0.13.0-beta.1", "dependencies": { + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", "ws": "^8.14.2" diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 0142548674..33e4f1a316 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -56,6 +56,7 @@ class FakeDaemonClient { supportsBackgroundWorkspaces(): boolean { return false; } + relaySecurityFloor: "legacy" | "protected" | undefined = undefined; private state: ConnectionState = { status: "idle" }; private listeners = new Set<(status: ConnectionState) => void>(); private error: string | null = null; @@ -345,6 +346,11 @@ class Deferred { } } +function relayPinOf(host: HostProfile | undefined): "protected" | undefined { + const connection = host?.connections[0]; + return connection?.type === "relay" ? connection.minimumSecurityMode : undefined; +} + async function waitForHostOnline(store: HostRuntimeStore, serverId: string): Promise { if (store.getSnapshot(serverId)?.connectionStatus === "online") return; await new Promise((resolve) => { @@ -564,6 +570,7 @@ function createPairingStore( connectAttempts?: string[]; serverIdForHost?: (host: HostProfile) => string; storage?: HostRuntimeStorage; + probeSecurityFloor?: "legacy" | "protected"; } = {}, ): HostRuntimeStore { return new HostRuntimeStore({ @@ -575,8 +582,10 @@ function createPairingStore( if (input.password && host.password !== input.password) { throw new DaemonAuthenticationError("password_required"); } + const client = makeConnectedProbeClient(5); + client.relaySecurityFloor = input.probeSecurityFloor; return { - client: makeConnectedProbeClient(5) as unknown as DaemonClient, + client: client as unknown as DaemonClient, serverId: input.serverIdForHost?.(host) ?? host.serverId, hostname: input.hostname ?? "paired", }; @@ -787,6 +796,85 @@ describe("HostRuntimeController", () => { expect(controller.getSnapshot().client).toBe(createdClients[1]?.client); }); + it("keeps the active relay client when its security pin is learned", async () => { + const relay: HostConnection = { + id: "relay:wss:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + const createdClients: FakeDaemonClient[] = []; + const controller = new HostRuntimeController({ + host: makeHost({ connections: [relay], preferredConnectionId: relay.id }), + deps: { + createClient: () => { + const client = new FakeDaemonClient(); + createdClients.push(client); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host, connection }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: connection.id, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.activateConnection({ connectionId: relay.id }); + // The client negotiated protected itself; the pin it reported is already enforced. + createdClients[0]!.relaySecurityFloor = "protected"; + await controller.updateHost( + makeHost({ + connections: [{ ...relay, minimumSecurityMode: "protected" }], + preferredConnectionId: relay.id, + }), + ); + + expect(createdClients).toHaveLength(1); + expect(createdClients[0]?.isDisposed()).toBe(false); + expect(controller.getSnapshot().client).toBe(createdClients[0]); + }); + + it("replaces the active legacy relay client when a strict offer pins its connection", async () => { + const relay: HostConnection = { + id: "relay:wss:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + const createdClients: Array<{ client: FakeDaemonClient; connection: HostConnection }> = []; + const controller = new HostRuntimeController({ + host: makeHost({ connections: [relay], preferredConnectionId: relay.id }), + deps: { + createClient: ({ connection }) => { + const client = new FakeDaemonClient(); + createdClients.push({ client, connection }); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host, connection }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: connection.id, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.activateConnection({ connectionId: relay.id }); + expect(createdClients[0]?.client.relaySecurityFloor).toBeUndefined(); + const pinned: HostConnection = { ...relay, minimumSecurityMode: "protected" }; + await controller.updateHost( + makeHost({ connections: [pinned], preferredConnectionId: relay.id }), + ); + + expect(createdClients.map((entry) => entry.connection)).toEqual([relay, pinned]); + expect(createdClients[0]?.client.isDisposed()).toBe(true); + expect(controller.getSnapshot().client).toBe(createdClients[1]?.client); + }); + it("keeps known hosts in connecting when a created client reports idle during connect", async () => { const host = makeHost({ connections: [ @@ -3857,6 +3945,96 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + it("keeps a strict offer's security pin through both pairing paths and a reload", async () => { + const storage = createMemoryHostRuntimeStorage(); + const store = createPairingStore({ storage }); + answerHostConfirmations(store, true); + const strictOffer = (serverId: string): ConnectionOffer => ({ + ...makeOffer({ serverId, daemonPublicKeyB64: `pk_${serverId}` }), + minimumSecurityMode: "protected", + }); + + await store.importConnectionLink(encodeOfferUrl(strictOffer("srv_import")), "openProject"); + await store.beginLinkPairing().submit(encodeOfferUrl(strictOffer("srv_pair"))); + + const pinOf = (hosts: HostProfile[], serverId: string) => + relayPinOf(hosts.find((host) => host.serverId === serverId)); + expect(pinOf(store.getHosts(), "srv_import")).toBe("protected"); + expect(pinOf(store.getHosts(), "srv_pair")).toBe("protected"); + + await waitForHostOnline(store, "srv_pair"); + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(pinOf(persisted, "srv_import")).toBe("protected"); + expect(pinOf(persisted, "srv_pair")).toBe("protected"); + store.syncHosts([]); + + const reloaded = createPairingStore({ storage }); + const registryLoaded = onceHostListMatches(reloaded, () => reloaded.isHostRegistryLoaded()); + reloaded.boot(); + await registryLoaded; + expect(pinOf(reloaded.getHosts(), "srv_import")).toBe("protected"); + expect(pinOf(reloaded.getHosts(), "srv_pair")).toBe("protected"); + + reloaded.syncHosts([]); + }); + + it("learns the security pin from an adopted probe client and keeps it across a reload", async () => { + const relay: HostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_saved", + }; + const host = makeHost({ + serverId: "srv_saved", + connections: [relay], + preferredConnectionId: relay.id, + }); + const storage = createMemoryHostRuntimeStorage({ + "@paseo:daemon-registry": JSON.stringify([host]), + "@paseo:e2e": "1", + }); + const store = createPairingStore({ storage, probeSecurityFloor: "protected" }); + + store.boot(); + await waitForHostOnline(store, "srv_saved"); + const pinned = onceHostListMatches( + store, + () => relayPinOf(store.getHosts()[0]) === "protected", + ); + await pinned; + + const activeClient = store.getSnapshot("srv_saved")?.client as unknown as FakeDaemonClient; + expect(activeClient.relaySecurityFloor).toBe("protected"); + expect(activeClient.isDisposed()).toBe(false); + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(persisted[0]?.connections[0]?.minimumSecurityMode).toBe("protected"); + store.syncHosts([]); + + const reloaded = createPairingStore({ storage, probeSecurityFloor: "legacy" }); + const registryLoaded = onceHostListMatches(reloaded, () => reloaded.isHostRegistryLoaded()); + reloaded.boot(); + await registryLoaded; + expect(relayPinOf(reloaded.getHosts()[0])).toBe("protected"); + + reloaded.syncHosts([]); + }); + + it("learns the security pin for a host paired from an old offer once it goes online", async () => { + const storage = createMemoryHostRuntimeStorage(); + const store = createPairingStore({ storage, probeSecurityFloor: "protected" }); + answerHostConfirmations(store, true); + + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); + await waitForHostOnline(store, "srv_offer"); + await onceHostListMatches(store, () => relayPinOf(store.getHosts()[0]) === "protected"); + + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(persisted[0]?.connections[0]?.minimumSecurityMode).toBe("protected"); + + store.syncHosts([]); + }); + it("probes a pairing link immediately and saves only after admission", async () => { const store = createPairingStore({ password: "correct-password" }); answerHostConfirmations(store, true); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index fd8c1499d0..bb953220cb 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -16,6 +16,7 @@ import { connectionFromListen, createRemoteSshHostConnection, normalizeStoredHostProfile, + pinRelayProtectedSecurityInProfiles, upsertHostConnectionInProfiles, registryHasConnection, relayConnectionFromOffer, @@ -191,6 +192,12 @@ function hashForLog(value: string): string { return `h_${Math.abs(hash).toString(16)}`; } +/** A daemon identity that negotiated protected relay security. */ +export interface RelaySecurityPin { + serverId: string; + daemonPublicKeyB64: string; +} + export interface HostRuntimeControllerDeps { createClient: (input: { host: HostProfile; @@ -503,6 +510,29 @@ function buildConnectionCandidates(host: HostProfile): ConnectionCandidate[] { })); } +/** + * A pin learned from the active client is already enforced by that client, so + * it keeps running. A pin that arrived from elsewhere (a strict offer) while + * the active client is still legacy needs a client that enforces it. + */ +function connectionNeedsNewClient( + previous: HostConnection, + next: HostConnection, + activeClient: DaemonClient | null, +): boolean { + if (previous.type === "relay" && next.type === "relay") { + const { minimumSecurityMode: previousPin, ...previousRest } = previous; + const { minimumSecurityMode: nextPin, ...nextRest } = next; + if (!equal(previousRest, nextRest)) return true; + return ( + nextPin === "protected" && + previousPin !== "protected" && + activeClient?.relaySecurityFloor !== "protected" + ); + } + return !equal(previous, next); +} + function findConnectionById(host: HostProfile, connectionId: string | null): HostConnection | null { if (!connectionId) { return null; @@ -616,6 +646,9 @@ function createDefaultDeps( e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64, + ...(connection.minimumSecurityMode + ? { minimumMode: connection.minimumSecurityMode } + : {}), }, }); }, @@ -678,6 +711,7 @@ export class HostRuntimeController { private host: HostProfile; private deps: HostRuntimeControllerDeps; private onReconcileServerId: ((oldId: string, newId: string) => void) | null; + private onRelayProtectedSecurityNegotiated: ((input: RelaySecurityPin) => void) | null; private connectionMachineState: HostRuntimeConnectionMachineState; private connectionEpoch = 0; private snapshot: HostRuntimeSnapshot; @@ -702,10 +736,12 @@ export class HostRuntimeController { host: HostProfile; deps?: HostRuntimeControllerDeps; onReconcileServerId?: (oldId: string, newId: string) => void; + onRelayProtectedSecurityNegotiated?: (input: RelaySecurityPin) => void; }) { this.host = input.host; this.deps = input.deps ?? createDefaultDeps(); this.onReconcileServerId = input.onReconcileServerId ?? null; + this.onRelayProtectedSecurityNegotiated = input.onRelayProtectedSecurityNegotiated ?? null; this.connectionMachineState = { tag: "booting", }; @@ -807,7 +843,12 @@ export class HostRuntimeController { activeConnectionId && previousActiveConnection && nextActiveConnection && - (!equal(previousActiveConnection, nextActiveConnection) || passwordChanged) + (connectionNeedsNewClient( + previousActiveConnection, + nextActiveConnection, + this.activeClient, + ) || + passwordChanged) ) { this.connectionLastProbedAt.delete(activeConnectionId); await this.switchToConnection({ connectionId: activeConnectionId }); @@ -1433,6 +1474,7 @@ export class HostRuntimeController { void failConnection(new DaemonAuthenticationError(client.authFailureReason)); return; } + if (state.status === "connected") this.learnRelaySecurity(client, connection.id); this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError }); this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), @@ -1446,6 +1488,25 @@ export class HostRuntimeController { } } + // The active client is the one place every connection path meets: clients + // created here and probe clients adopted from `connectToDaemon` both land in + // `switchToConnection`. A protected handshake observed here becomes the + // persisted pin for that daemon identity. + private learnRelaySecurity(client: DaemonClient, connectionId: string): void { + const connection = findConnectionById(this.host, connectionId); + if ( + connection?.type !== "relay" || + connection.minimumSecurityMode === "protected" || + client.relaySecurityFloor !== "protected" + ) { + return; + } + this.onRelayProtectedSecurityNegotiated?.({ + serverId: this.host.serverId, + daemonPublicKeyB64: connection.daemonPublicKeyB64, + }); + } + adoptReconciledServerId(newServerId: string): void { this.host = { ...this.host, serverId: newServerId }; this.snapshot = { ...this.snapshot, serverId: newServerId }; @@ -1994,39 +2055,6 @@ export class HostRuntimeStore { }); } - private async upsertRelayConnection(input: { - serverId: string; - relayEndpoint: string; - useTls?: boolean; - daemonPublicKeyB64: string; - label?: string; - password?: string; - }): Promise { - const relayEndpoint = normalizeHostPort(input.relayEndpoint); - const useTls = input.useTls ?? false; - const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim(); - if (!daemonPublicKeyB64) { - throw new Error("daemonPublicKeyB64 is required"); - } - const explicitUseTls = input.useTls !== undefined; - const profile = await this.upsertHostConnection({ - serverId: input.serverId, - label: input.label, - connection: { - id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, - type: "relay", - relayEndpoint, - ...(explicitUseTls ? { useTls } : {}), - daemonPublicKeyB64, - }, - }); - if (input.password) { - await this.setHostPassword(profile.serverId, input.password); - return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile; - } - return profile; - } - // 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. @@ -2094,14 +2122,19 @@ export class HostRuntimeStore { password?: string, ): Promise { const connection = relayConnectionFromOffer(offer); - return this.upsertRelayConnection({ + if (!connection.daemonPublicKeyB64) { + throw new Error("daemonPublicKeyB64 is required"); + } + const profile = await this.upsertHostConnection({ serverId: offer.serverId, - relayEndpoint: connection.relayEndpoint, - useTls: connection.useTls, - daemonPublicKeyB64: connection.daemonPublicKeyB64, label, - password, + connection, }); + if (password) { + await this.setHostPassword(profile.serverId, password); + return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile; + } + return profile; } private async probeAndUpsertOffer( @@ -2147,6 +2180,13 @@ export class HostRuntimeStore { }); } + private async pinRelayProtectedSecurity(input: RelaySecurityPin): Promise { + const next = pinRelayProtectedSecurityInProfiles({ profiles: this.hosts, ...input }); + if (next === this.hosts) return; + this.setHostsAndSync(next); + await this.persistHosts(); + } + private async updateHost( serverId: string, apply: (host: HostProfile) => HostProfile, @@ -2356,6 +2396,7 @@ export class HostRuntimeStore { host, deps: this.deps, onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), + onRelayProtectedSecurityNegotiated: (pin) => void this.pinRelayProtectedSecurity(pin), }); this.controllers.set(host.serverId, controller); useSessionStore.getState().initializeSession(host.serverId, null); diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index 0f0590bb9e..8d7fb87b35 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -4,6 +4,8 @@ import { createRemoteSshHostConnection, normalizeStoredHostProfile, orderHostsLocalFirst, + pinRelayProtectedSecurityInProfiles, + relayConnectionFromOffer, resolveActiveHostServerId, upsertHostConnectionInProfiles, type HostConnection, @@ -314,6 +316,155 @@ describe("upsertHostConnectionInProfiles", () => { }); }); +describe("relay endpoint security pin", () => { + const pinned: HostConnection = { + id: "relay:wss:relay.example.com:443", + type: "relay", + relayEndpoint: "relay.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + minimumSecurityMode: "protected", + }; + const unpinned: HostConnection = { + id: pinned.id, + type: "relay", + relayEndpoint: "relay.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + + it("survives storage", () => { + const profile = normalizeStoredHostProfile({ serverId: "srv", connections: [pinned] }); + expect(profile?.connections[0]).toEqual(pinned); + }); + + it("comes from a pairing offer that requires protected security", () => { + const offer: Parameters[0] = { + v: 2, + serverId: "srv", + daemonPublicKeyB64: "pk_a", + relay: { endpoint: "relay.example.com:443", useTls: true }, + minimumSecurityMode: "protected", + }; + expect(relayConnectionFromOffer(offer)).toEqual(pinned); + expect(relayConnectionFromOffer({ ...offer, minimumSecurityMode: undefined })).toEqual( + unpinned, + ); + }); + + it("is kept when the same daemon identity is paired again without it", () => { + const profiles = upsertHostConnectionInProfiles({ + profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }], + serverId: "srv", + connection: unpinned, + }); + expect(profiles[0].connections).toEqual([pinned]); + }); + + it("is persisted when a strict offer re-imports an unpinned route", () => { + const profiles = [ + { ...makeHost("srv"), connections: [unpinned], preferredConnectionId: unpinned.id }, + ]; + const next = upsertHostConnectionInProfiles({ + profiles, + serverId: "srv", + connection: pinned, + }); + expect(next).not.toBe(profiles); + expect(next[0].connections).toEqual([pinned]); + }); + + it("tightens every route to the same daemon identity when a strict offer arrives through another relay", () => { + const strictElsewhere: HostConnection = { + id: "relay:wss:other.example.com:443", + type: "relay", + relayEndpoint: "other.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + minimumSecurityMode: "protected", + }; + const profiles = upsertHostConnectionInProfiles({ + profiles: [ + { ...makeHost("srv"), connections: [unpinned], preferredConnectionId: unpinned.id }, + ], + serverId: "srv", + connection: strictElsewhere, + }); + expect(profiles[0].connections).toEqual([pinned, strictElsewhere]); + }); + + it("follows the daemon identity to a relay at another address", () => { + const elsewhere: HostConnection = { + id: "relay:wss:other.example.com:443", + type: "relay", + relayEndpoint: "other.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + const profiles = upsertHostConnectionInProfiles({ + profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }], + serverId: "srv", + connection: elsewhere, + }); + expect(profiles[0].connections).toEqual([ + pinned, + { ...elsewhere, minimumSecurityMode: "protected" }, + ]); + }); + + it("does not carry over to a different daemon identity", () => { + const rotated: HostConnection = { ...unpinned, daemonPublicKeyB64: "pk_b" }; + const profiles = upsertHostConnectionInProfiles({ + profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }], + serverId: "srv", + connection: rotated, + }); + expect(profiles[0].connections).toEqual([rotated]); + }); + + it("is learned for every relay route to the identity that negotiated protected security", () => { + const elsewhere: HostConnection = { + ...unpinned, + id: "relay:wss:other.example.com:443", + relayEndpoint: "other.example.com:443", + }; + const rotated: HostConnection = { + ...unpinned, + id: "relay:wss:rotated.example.com:443", + relayEndpoint: "rotated.example.com:443", + daemonPublicKeyB64: "pk_b", + }; + const profiles = [{ ...makeHost("srv"), connections: [unpinned, elsewhere, rotated] }]; + const next = pinRelayProtectedSecurityInProfiles({ + profiles, + serverId: "srv", + daemonPublicKeyB64: "pk_a", + now: "2026-02-01T00:00:00.000Z", + }); + expect(next[0].connections).toEqual([ + pinned, + { ...elsewhere, minimumSecurityMode: "protected" }, + rotated, + ]); + expect(next[0].updatedAt).toBe("2026-02-01T00:00:00.000Z"); + + expect( + pinRelayProtectedSecurityInProfiles({ + profiles: next, + serverId: "srv", + daemonPublicKeyB64: "pk_a", + }), + ).toBe(next); + expect( + pinRelayProtectedSecurityInProfiles({ + profiles, + serverId: "srv", + daemonPublicKeyB64: "pk_other", + }), + ).toBe(profiles); + }); +}); + describe("resolveActiveHostServerId", () => { it("uses the selected host when one is set", () => { expect( diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index fb3150a168..e7449b9a67 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -49,6 +49,12 @@ export interface RelayHostConnection { relayEndpoint: string; useTls?: boolean; daemonPublicKeyB64: string; + /** + * Pinned once this daemon identity has negotiated protected endpoint + * security, or when its pairing offer required it. Never cleared by a + * connection failure; a new daemon identity is a different connection. + */ + minimumSecurityMode?: "protected"; } export interface RoutedHostConnection { @@ -192,7 +198,69 @@ function upsertHostConnectionById( replaced = true; } if (!replaced) next.push(connection); - return next; + // A pin is never lost: the replaced record counts as much as the incoming one. + return shareRelaySecurityPin(next, [...connections, connection]); +} + +/** + * The security pin belongs to the daemon identity, not to one relay address. + * Once any relay route to a key is pinned, by a strict offer or a learned + * handshake, every route to that key is pinned. + */ +function shareRelaySecurityPin( + connections: HostConnection[], + pinSources: HostConnection[], +): HostConnection[] { + const pinnedKeys = new Set(); + for (const connection of pinSources) { + if (connection.type === "relay" && connection.minimumSecurityMode === "protected") { + pinnedKeys.add(connection.daemonPublicKeyB64); + } + } + return connections.map((connection) => + connection.type === "relay" && + connection.minimumSecurityMode !== "protected" && + pinnedKeys.has(connection.daemonPublicKeyB64) + ? { ...connection, minimumSecurityMode: "protected" as const } + : connection, + ); +} + +function relaySecurityPinEquals(left: HostConnection, right: HostConnection): boolean { + const pinOf = (connection: HostConnection) => + connection.type === "relay" ? connection.minimumSecurityMode : undefined; + return pinOf(left) === pinOf(right); +} + +/** + * Records that the relay daemon identity `daemonPublicKeyB64` negotiated + * protected endpoint security. Every relay route to that identity is pinned. + * Returns the same array when nothing changes. + */ +export function pinRelayProtectedSecurityInProfiles(input: { + profiles: HostProfile[]; + serverId: string; + daemonPublicKeyB64: string; + now?: string; +}): HostProfile[] { + const now = input.now ?? new Date().toISOString(); + let changed = false; + const next = input.profiles.map((host) => { + if (host.serverId !== input.serverId) return host; + const connections = host.connections.map((connection) => { + if ( + connection.type !== "relay" || + connection.daemonPublicKeyB64 !== input.daemonPublicKeyB64 || + connection.minimumSecurityMode === "protected" + ) { + return connection; + } + changed = true; + return { ...connection, minimumSecurityMode: "protected" as const }; + }); + return changed ? { ...host, connections, updatedAt: now } : host; + }); + return changed ? next : input.profiles; } function hasProfileChanged(input: { @@ -218,7 +286,10 @@ function hasProfileChanged(input: { connections.length !== previous.connections.length || connections.some((candidate, index) => { const old = previous.connections[index]; - return !old || !hostConnectionEquals(candidate, old); + // Identity equality decides profile matching; the pin is policy on top of it. + return ( + !old || !hostConnectionEquals(candidate, old) || !relaySecurityPinEquals(candidate, old) + ); }) ); } @@ -377,6 +448,7 @@ export function relayConnectionFromOffer(offer: ConnectionOffer): RelayHostConne relayEndpoint, useTls, daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(), + ...(offer.minimumSecurityMode ? { minimumSecurityMode: offer.minimumSecurityMode } : {}), }; } @@ -441,6 +513,7 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [ relayEndpoint: z.string(), useTls: z.boolean().optional(), daemonPublicKeyB64: z.string(), + minimumSecurityMode: z.literal("protected").optional(), }), ]); const StoredHostProfileSchema = z.strictObject({ @@ -504,6 +577,9 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec relayEndpoint, ...(useTls !== undefined ? { useTls } : {}), daemonPublicKeyB64, + ...(connection.minimumSecurityMode + ? { minimumSecurityMode: connection.minimumSecurityMode } + : {}), }; } catch { return null; diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 5942b45621..18ffa62dc4 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -202,7 +202,11 @@ export async function buildClientConfig( useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), serverId, }), - e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, + e2ee: { + enabled: true, + daemonPublicKeyB64: connection.daemonPublicKeyB64, + ...(connection.minimumSecurityMode ? { minimumMode: connection.minimumSecurityMode } : {}), + }, }; } diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index ab208102aa..4636b1841f 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -92,6 +92,7 @@ export async function resolveLocalPairingOffer(options: { relayPublicEndpoint: config.relayPublicEndpoint, relayUseTls: config.relayUseTls, relayPublicUseTls: config.relayPublicUseTls, + relayRequireProtectedSecurity: config.relayRequireProtectedSecurity, appBaseUrl: config.appBaseUrl, includeQr: true, }); diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 237699e600..258c0aa9b5 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -1,7 +1,9 @@ import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { - createClientChannel, - type EncryptedChannel, + openClientSecureChannel, + PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, + type SecureChannel, + type SecurityMode, type Transport as RelayTransport, } from "@getpaseo/relay/e2ee"; import type { @@ -20,39 +22,86 @@ type CloseHandler = (event?: unknown) => void; type ErrorHandler = (event?: unknown) => void; type MessageHandler = (data: unknown, isBinary: boolean) => void; -export function createRelayE2eeTransportFactory(args: { - baseFactory: DaemonTransportFactory; +export interface RelayE2eeTransportOptions { daemonPublicKeyB64: string; logger: TransportLogger; assertOutboundCapacity?: (wireBytes: number) => void; -}): DaemonTransportFactory { - return ({ url }) => { - const base = args.baseFactory({ url }); - return createEncryptedTransport( - base, - args.daemonPublicKeyB64, - args.logger, - args.assertOutboundCapacity, - ); - }; + /** Refuse to open unless the daemon negotiates at least this endpoint security mode. */ + minimumMode?: SecurityMode; + /** Reports the negotiated mode once the daemon has authenticated the handshake. */ + onSecurityMode?: (mode: SecurityMode) => void; } -export function createRelayTransportFactory( +export function createRelayE2eeTransportFactory( + args: RelayE2eeTransportOptions & { baseFactory: DaemonTransportFactory }, +): DaemonTransportFactory { + const { baseFactory, ...options } = args; + return ({ url }) => createEncryptedTransport(baseFactory({ url }), options); +} + +const DAEMON_REQUIRES_PROTECTED_REASON = "daemon requires protected endpoint security"; + +/** + * Returns a user-facing message when this client closed the session itself + * because endpoint security could not be established, so reconnecting cannot help. + * + * The daemon's own policy close never reaches the client: both relays replace a + * daemon-side close with their own code (1012 on the wrangler relay). + */ +export function describeEndpointSecurityClose(event: unknown): string | null { + if (!event || typeof event !== "object") return null; + const code = "code" in event ? event.code : undefined; + const reason = "reason" in event && typeof event.reason === "string" ? event.reason : ""; + if (code === PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE) { + return `Endpoint security handshake failed: ${reason || "protected mode unavailable"}`; + } + return null; +} + +/** + * The first session message from the daemon is `server_info`. A daemon that can + * negotiate protected security advertises it there, so a legacy session with such + * a daemon was downgraded in transit and must not proceed to admission. + */ +function readServerInfoProtectedSupport(text: string): boolean | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + if (!isRecord(parsed) || parsed.type !== "session" || !isRecord(parsed.message)) return null; + const message = parsed.message; + if (message.type !== "status" || !isRecord(message.payload)) return null; + if (message.payload.status !== "server_info") return null; + const features = message.payload.features; + return isRecord(features) && features.relayProtectedSecurity === true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Relay upgrades carry routing only. Daemon credentials and subprotocols must + * never reach the relay; they travel inside the encrypted hello. + */ +export function createRelayUpgradeTransportFactory( baseFactory: DaemonTransportFactory, ): DaemonTransportFactory { - // The relay upgrade precedes E2EE. Only the encrypted hello may carry daemon credentials. return ({ url }) => baseFactory({ url }); } export function createEncryptedTransport( base: DaemonTransport, - daemonPublicKeyB64: string, - logger: TransportLogger, - assertOutboundCapacity: (wireBytes: number) => void = () => {}, + options: RelayE2eeTransportOptions, ): DaemonTransport { - let channel: EncryptedChannel | null = null; + const { daemonPublicKeyB64, logger, minimumMode, onSecurityMode } = options; + const assertOutboundCapacity = options.assertOutboundCapacity ?? (() => {}); + let channel: SecureChannel | null = null; let opened = false; let closed = false; + let awaitingServerInfo = true; const openHandlers = new Set(); const closeHandlers = new Set(); @@ -86,6 +135,20 @@ export function createEncryptedTransport( if (closed) { return; } + if (awaitingServerInfo && typeof data === "string") { + const daemonSupportsProtected = readServerInfoProtectedSupport(data); + if (daemonSupportsProtected !== null) { + awaitingServerInfo = false; + if (daemonSupportsProtected && channel?.securityMode() === "legacy") { + logger.warn( + { reason: DAEMON_REQUIRES_PROTECTED_REASON }, + "relay_e2ee_downgrade_rejected", + ); + channel.close(PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, DAEMON_REQUIRES_PROTECTED_REASON); + return; + } + } + } emitHandlers(messageHandlers, data, data instanceof ArrayBuffer); }; @@ -105,7 +168,13 @@ export function createEncryptedTransport( } base.send(String(data)); }, - close: (code?: number, reason?: string) => base.close(code, reason), + // The channel decides the close code. Report it now: a socket close event + // arrives asynchronously, and the consumer must not act on an earlier + // error before it has seen the code. + close: (code?: number, reason?: string) => { + base.close(code, reason); + emitClose({ code, reason }); + }, onmessage: null, onclose: null, onerror: null, @@ -113,11 +182,23 @@ export function createEncryptedTransport( const startHandshake = async () => { try { - channel = await createClientChannel(relayTransport, daemonPublicKeyB64, { - onopen: emitOpen, - onmessage: (data) => emitMessage(data), - onclose: (code, reason) => emitClose({ code, reason }), - onerror: (error) => emitError(error), + channel = await openClientSecureChannel(relayTransport, { + daemonPublicKeyB64, + ...(minimumMode ? { minimumMode } : {}), + events: { + onopen: () => { + onSecurityMode?.(channel?.securityMode() ?? "legacy"); + emitOpen(); + }, + onmessage: (data) => emitMessage(data), + onclose: (code, reason) => emitClose({ code, reason }), + // Every fatal channel error is followed by a close that carries the + // code and reason; forwarding the error first would make the consumer + // reconnect before it sees them. + onerror: (error) => { + logger.warn({ err: normalizeTransportError(error) }, "relay_e2ee_channel_error"); + }, + }, }); } catch (error) { logger.warn({ err: normalizeTransportError(error) }, "relay_e2ee_handshake_failed"); diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index 6f80717dd1..963a8b541c 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -10,17 +10,9 @@ import { extractRelayMessage, } from "./daemon-client-transport.js"; -const createClientChannelMock = vi.hoisted(() => vi.fn()); - -vi.mock("@getpaseo/relay/e2ee", () => ({ - createClientChannel: createClientChannelMock, -})); - describe("daemon-client transport helpers", () => { test("createEncryptedTransport closes handshake failures with browser-safe code", async () => { - createClientChannelMock.mockReset(); - createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed")); - + // An unusable daemon key fails the handshake before any frame is sent. const connection: { open: (() => void) | null } = { open: null }; const close = vi.fn(); @@ -40,8 +32,7 @@ describe("daemon-client transport helpers", () => { onError: () => () => {}, onMessage: () => () => {}, }, - "daemon-public-key", - { warn: vi.fn() }, + { daemonPublicKeyB64: "not-a-key", logger: { warn: vi.fn() } }, ); expect(connection.open).not.toBeNull(); diff --git a/packages/client/src/daemon-client-transport.ts b/packages/client/src/daemon-client-transport.ts index 01933fd1ec..90c4613f84 100644 --- a/packages/client/src/daemon-client-transport.ts +++ b/packages/client/src/daemon-client-transport.ts @@ -17,7 +17,8 @@ export { export { createEncryptedTransport, createRelayE2eeTransportFactory, - createRelayTransportFactory, + createRelayUpgradeTransportFactory, + describeEndpointSecurityClose, } from "./daemon-client-relay-e2ee-transport.js"; export { bindWsHandler, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 6fef9fea40..f663aeb8be 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -919,6 +919,29 @@ test("keeps relay upgrade credentials out of the socket request", async () => { await expect(connecting).rejects.toThrow("Daemon client closed"); }); +test("keeps relay upgrade credentials out of the socket request without E2EE", async () => { + const mock = createMockTransport(); + const requests: Array<{ url: string; headers?: Record; protocols?: string[] }> = + []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_relay_header_test", + authHeader: "Bearer synthetic-review-token", + reconnect: { enabled: false }, + transportFactory: (request) => { + requests.push(request); + return mock.transport; + }, + }); + clients.push(client); + const connecting = client.connect(); + void connecting.catch(() => {}); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" }); + await client.close(); + await expect(connecting).rejects.toThrow("Daemon client closed"); +}); + test("refuses relay password auth without an encrypted hello", async () => { const requests: unknown[] = []; const client = new DaemonClient({ @@ -4287,6 +4310,40 @@ test("reconnects after relay close with replaced-by-new-connection reason", asyn } }); +test("stops reconnecting after the transport closes for an endpoint security failure", async () => { + const close = { code: 4002, reason: "legacy security below required minimum" }; + const error = "Endpoint security handshake failed: legacy security below required minimum"; + useHeartbeatClock(); + try { + let attempts = 0; + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_close_test", + e2ee: { enabled: true, daemonPublicKeyB64: "daemon-public-key" }, + reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 }, + transportFactory: () => { + attempts += 1; + return mock.transport; + }, + }); + clients.push(client); + + const connecting = client.connect(); + void connecting.catch(() => {}); + await vi.waitFor(() => expect(attempts).toBe(1)); + mock.triggerClose(close); + + await expect(connecting).rejects.toThrow(error); + await vi.advanceTimersByTimeAsync(50); + expect(attempts).toBe(1); + expect(client.getConnectionState().status).toBe("disconnected"); + expect(client.lastError).toContain(error); + } finally { + vi.useRealTimers(); + } +}); + test("requires non-empty clientId", () => { expect(() => { const _client = new DaemonClient({ @@ -7898,3 +7955,264 @@ test("initial acquisition preserves structured transport failures and ordinary o cause: failure, }); }); + +// --------------------------------------------------------------------------- +// Relay endpoint security through the real secure channel +// --------------------------------------------------------------------------- + +import { + createDaemonChannel, + exportPublicKey, + generateKeyPair, + openDaemonSecureChannel, + type SecureChannel, + type Transport as RelayWire, +} from "@getpaseo/relay/e2ee"; + +/** + * An in-memory socket pair: the client side is a DaemonTransport, the daemon + * side is the relay Transport the daemon's secure channel runs over. Frames + * are delivered asynchronously like a real socket, and a close from either + * side reaches the other with its code, as the browser WebSocket does. + */ +function createRelayLink(options?: { toDaemon?: (text: string) => string }) { + const clientOpen = new Set<() => void>(); + const clientClose = new Set<(event?: unknown) => void>(); + const clientError = new Set<(event?: unknown) => void>(); + const clientMessage = new Set<(data: unknown, isBinary: boolean) => void>(); + let closed = false; + const wire: RelayWire = { + send: (data) => { + if (closed) throw new Error("socket closed"); + const isBinary = typeof data !== "string"; + setTimeout(() => { + for (const handler of clientMessage) handler(data, isBinary); + }, 0); + }, + close: (code = 1005, reason = "") => shutdown(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + const shutdown = (code: number, reason: string) => { + if (closed) return; + closed = true; + setTimeout(() => { + wire.onclose?.(code, reason); + for (const handler of clientClose) handler({ code, reason }); + }, 0); + }; + const transport: DaemonTransport = { + send: (data) => { + if (closed) throw new Error("socket closed"); + if (typeof data === "string") { + const text = options?.toDaemon ? options.toDaemon(data) : data; + setTimeout(() => wire.onmessage?.({ data: text, isBinary: false }), 0); + return; + } + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + const copy = bytes.slice().buffer as ArrayBuffer; + setTimeout(() => wire.onmessage?.({ data: copy, isBinary: true }), 0); + }, + close: (code = 1005, reason = "") => shutdown(code, reason), + onOpen: (handler) => { + clientOpen.add(handler); + return () => clientOpen.delete(handler); + }, + onClose: (handler) => { + clientClose.add(handler); + return () => clientClose.delete(handler); + }, + onError: (handler) => { + clientError.add(handler); + return () => clientError.delete(handler); + }, + onMessage: (handler) => { + clientMessage.add(handler); + return () => clientMessage.delete(handler); + }, + }; + return { + transport, + wire, + open: () => { + for (const handler of clientOpen) handler(); + }, + isClosed: () => closed, + }; +} + +function serverInfoFrame(features: Record): string { + return wrapSessionMessage({ + type: "status", + payload: { + status: "server_info", + serverId: "srv_test", + hostname: null, + version: null, + features, + }, + }); +} + +/** Opens the daemon side of a link and answers the application hello with the daemon's feature flags. */ +function serveProtectedDaemon( + link: ReturnType, + keys: ReturnType, + features: Record, + received: unknown[] = [], +): void { + let channel: SecureChannel | null = null; + const pendingAnswers: string[] = []; + const opened = openDaemonSecureChannel(link.wire, { + daemonKeyPair: keys, + events: { + onmessage: (data) => { + received.push(data); + if (typeof data !== "string") return; + const frame = JSON.parse(data) as { type?: string }; + if (frame.type !== "hello") return; + const answer = serverInfoFrame(features); + if (channel) void channel.send(answer); + else pendingAnswers.push(answer); + }, + }, + }); + void (async () => { + try { + channel = await opened; + for (const answer of pendingAnswers.splice(0)) void channel.send(answer); + } catch { + // the test asserts on the client side + } + })(); +} + +test("connects in legacy mode to a daemon that only speaks the legacy construction", async () => { + const keys = generateKeyPair(); + const modes: string[] = []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_legacy_daemon", + e2ee: { + enabled: true, + daemonPublicKeyB64: exportPublicKey(keys.publicKey), + onSecurityMode: (mode) => modes.push(mode), + }, + reconnect: { enabled: false }, + transportFactory: () => { + const link = createRelayLink(); + const opened = createDaemonChannel(link.wire, keys, { + onmessage: (data) => { + if (typeof data !== "string") return; + if ((JSON.parse(data) as { type?: string }).type !== "hello") return; + void opened.then((ch) => ch.send(serverInfoFrame({ ownedSubscriptions: true }))); + }, + }); + void opened.catch(() => {}); + setTimeout(() => link.open(), 0); + return link.transport; + }, + }); + clients.push(client); + + await client.connect(); + expect(client.getConnectionState().status).toBe("connected"); + expect(modes).toEqual(["legacy"]); +}); + +test("refuses legacy endpoint security on reconnect once the daemon negotiated protected", async () => { + const keys = generateKeyPair(); + const modes: string[] = []; + const links: ReturnType[] = []; + const legacyReceived: unknown[] = []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_latch", + e2ee: { + enabled: true, + daemonPublicKeyB64: exportPublicKey(keys.publicKey), + onSecurityMode: (mode) => modes.push(mode), + }, + reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 }, + transportFactory: () => { + const link = createRelayLink(); + links.push(link); + if (links.length === 1) { + serveProtectedDaemon(link, keys, { relayProtectedSecurity: true }); + } else { + // A daemon that only speaks the legacy construction (older release). + void createDaemonChannel(link.wire, keys, { + onmessage: (data) => legacyReceived.push(data), + }).catch(() => {}); + } + setTimeout(() => link.open(), 0); + return link.transport; + }, + }); + clients.push(client); + + await client.connect(); + expect(client.getConnectionState().status).toBe("connected"); + expect(modes).toEqual(["protected"]); + + // The relay drops the session; the client reconnects to a legacy-only daemon. + links[0].wire.close(1012, "Server disconnected"); + await vi.waitFor(() => expect(links).toHaveLength(2)); + await vi.waitFor(() => expect(links[1].isClosed()).toBe(true)); + await vi.waitFor(() => expect(client.getConnectionState().status).toBe("disconnected")); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(links).toHaveLength(2); + expect(modes).toEqual(["protected"]); + expect(legacyReceived).toEqual([]); + expect(client.lastError).toContain( + "Endpoint security handshake failed: daemon offered only legacy endpoint security", + ); +}); + +test("closes before admission when a protected-capable daemon is reached in legacy mode", async () => { + const keys = generateKeyPair(); + const daemonReceived: unknown[] = []; + let attempts = 0; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_downgrade", + e2ee: { enabled: true, daemonPublicKeyB64: exportPublicKey(keys.publicKey) }, + reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 }, + transportFactory: () => { + attempts += 1; + // A relay in the middle strips the protected offer from the client hello. + const link = createRelayLink({ + toDaemon: (text) => { + if (!text.startsWith("{")) return text; + const parsed = JSON.parse(text) as Record; + if (parsed.type !== "e2ee_hello") return text; + const { protected: _dropped, ...legacyHello } = parsed; + return JSON.stringify(legacyHello); + }, + }); + serveProtectedDaemon(link, keys, { relayProtectedSecurity: true }, daemonReceived); + setTimeout(() => link.open(), 0); + return link.transport; + }, + }); + clients.push(client); + + const connecting = client.connect(); + void connecting.catch(() => {}); + const queued = client.ping({ timeoutMs: 10_000 }); + void queued.catch(() => {}); + + await expect(connecting).rejects.toThrow( + "Endpoint security handshake failed: daemon requires protected endpoint security", + ); + await expect(queued).rejects.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(attempts).toBe(1); + expect(client.getConnectionState().status).toBe("disconnected"); + // Only the application hello reached the daemon; the queued command never did. + expect(daemonReceived).toHaveLength(1); + expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" }); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index de127c5b6d..524df56704 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -162,10 +162,11 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import { createRelayE2eeTransportFactory, - createRelayTransportFactory, + createRelayUpgradeTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, + describeEndpointSecurityClose, describeTransportClose, describeTransportError, type DaemonTransport, @@ -394,6 +395,9 @@ export type DaemonEventHandler = (event: DaemonEvent) => void; export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest; export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse; +/** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */ +export type RelaySecurityMode = "legacy" | "protected"; + export interface DaemonClientConfig { /** Deliver compact bodies/hash references to a caller-owned snapshot cache. * The default keeps public SDK snapshot entries expanded. */ @@ -415,6 +419,10 @@ export interface DaemonClientConfig { e2ee?: { enabled?: boolean; daemonPublicKeyB64?: string; + /** Refuse connections whose negotiated endpoint security is below this mode. */ + minimumMode?: RelaySecurityMode; + /** Called with the negotiated mode after each authenticated relay handshake. */ + onSecurityMode?: (mode: RelaySecurityMode) => void; }; reconnect?: { enabled?: boolean; @@ -1334,6 +1342,12 @@ export class DaemonClient { private completedBinaryFileReads = new Map(); private logger: Logger; private pendingSendQueue: PendingSend[] = []; + /** + * Lowest relay endpoint security this client accepts on its next attempt. + * Starts at the configured minimum and rises to "protected" once a daemon + * negotiates it, so a later reconnect cannot be downgraded to legacy. + */ + private relaySecurityFloorValue: RelaySecurityMode | undefined; private readonly logConnectionPath: "direct" | "relay"; private readonly logServerId: string | null; private readonly logClientIdHash: string; @@ -1349,6 +1363,7 @@ export class DaemonClient { constructor(private config: DaemonClientConfig) { this.logger = config.logger ?? consoleLogger; + this.relaySecurityFloorValue = config.e2ee?.minimumMode; this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? "relay" : "direct"; let parsedUrlForLog: URL | null = null; try { @@ -1450,14 +1465,15 @@ export class DaemonClient { this.config.transportFactory ?? createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory); const isRelayTransport = isRelayClientWebSocketUrl(this.config.url); - const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayTransport; this.assertEncryptedRelayAuth(selected.helloAuth, isRelayTransport); + // Relay upgrades are routing only: credentials never reach the relay socket. let transportFactory = isRelayTransport - ? createRelayTransportFactory(baseTransportFactory) + ? createRelayUpgradeTransportFactory(baseTransportFactory) : baseTransportFactory; - if (shouldUseRelayE2ee) { - const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64; + // Endpoint security is selected by configuration, never inferred from the URL. + if (this.config.e2ee?.enabled === true) { + const daemonPublicKeyB64 = this.config.e2ee.daemonPublicKeyB64; if (!daemonPublicKeyB64) { throw new Error("daemonPublicKeyB64 is required for relay E2EE"); } @@ -1465,6 +1481,11 @@ export class DaemonClient { baseFactory: transportFactory, daemonPublicKeyB64, logger: this.logger, + ...(this.relaySecurityFloorValue ? { minimumMode: this.relaySecurityFloorValue } : {}), + onSecurityMode: (mode) => { + if (mode === "protected") this.relaySecurityFloorValue = "protected"; + this.config.e2ee?.onSecurityMode?.(mode); + }, }); } const transportUrl = this.resolveTransportUrlForAttempt(); @@ -1522,9 +1543,12 @@ export class DaemonClient { this.pendingGenericTransportErrorTimeout = null; } this.authFailureReasonValue ??= authFailureFromLegacyClose(event); + // An endpoint security policy close cannot be fixed by retrying. + const securityFailure = describeEndpointSecurityClose(event); + if (securityFailure) this.setReconnectEnabled(false); const reason = this.authFailureReasonValue ? new DaemonAuthenticationError(this.authFailureReasonValue).message - : describeTransportClose(event); + : (securityFailure ?? describeTransportClose(event)); if (reason) { this.lastErrorValue = reason; } @@ -1826,6 +1850,11 @@ export class DaemonClient { return this.connectionState.status === "connecting"; } + /** The relay endpoint security this client requires on its next attempt. */ + get relaySecurityFloor(): RelaySecurityMode | undefined { + return this.relaySecurityFloorValue; + } + get lastError(): string | null { return this.lastErrorValue; } diff --git a/packages/client/src/node/connector.test.ts b/packages/client/src/node/connector.test.ts index 6b31107f24..6e916d4bec 100644 --- a/packages/client/src/node/connector.test.ts +++ b/packages/client/src/node/connector.test.ts @@ -151,3 +151,59 @@ test("encrypted connector rejects overload synchronously and preserves the physi ).toBe(true); expect(server.wss.clients.size).toBe(1); }); + +import { openDaemonSecureChannel } from "@getpaseo/relay/e2ee"; +test("encrypted connector refuses legacy endpoint security on a later attempt once protected was negotiated", async () => { + const server = await endpoint(); + const keys = generateKeyPair(); + const legacyReceived: unknown[] = []; + let connections = 0; + server.wss.on("connection", (socket) => { + connections += 1; + 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) { + void openDaemonSecureChannel(wire, { daemonKeyPair: keys }).catch(() => {}); + } else { + // The daemon behind the relay now only speaks the legacy construction. + void createDaemonChannel(wire, keys, { + onmessage: (data) => legacyReceived.push(data), + }).catch(() => {}); + } + }); + const port = new URL(server.url).port; + const factory = createHostTransportFactory( + `relay://127.0.0.1:${port}/srv_latch?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`, + ); + + const first = factory({ url: server.url }); + cleanup.push(async () => first.close()); + await new Promise((resolve) => first.onOpen(resolve)); + first.close(); + + const second = factory({ url: server.url }); + cleanup.push(async () => second.close()); + let opened = false; + second.onOpen(() => { + opened = true; + }); + const closed = await new Promise((resolve) => second.onClose(resolve)); + expect(closed).toMatchObject({ code: 4002, reason: expect.stringContaining("legacy") }); + expect(opened).toBe(false); + expect(legacyReceived).toEqual([]); +}); diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts index 857c2ba432..7132d09f10 100644 --- a/packages/client/src/node/index.ts +++ b/packages/client/src/node/index.ts @@ -7,6 +7,7 @@ import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js"; import { createWebSocketTransportFactory } from "../daemon-client-websocket-transport.js"; import { createRelayE2eeTransportFactory } from "../daemon-client-relay-e2ee-transport.js"; +import type { SecurityMode } from "@getpaseo/relay/e2ee"; import type { DaemonTransport, DaemonTransportFactory, @@ -24,6 +25,10 @@ export function createHostTransportFactory( budget = new HostTransportBudget(), ): DaemonTransportFactory { const target = parseHostTarget(connection); + // Rises to "protected" once the daemon negotiates it, so a later attempt + // cannot be downgraded to legacy endpoint security. + let securityFloor: SecurityMode | undefined = + target.kind === "socket" ? target.offer?.minimumSecurityMode : undefined; return (attemptOptions) => { const opens = new Set<() => void>(); const closes = new Set<(event?: unknown) => void>(); @@ -120,6 +125,10 @@ export function createHostTransportFactory( factory = createRelayE2eeTransportFactory({ baseFactory: factory, daemonPublicKeyB64: socketTarget.offer.daemonPublicKeyB64, + ...(securityFloor ? { minimumMode: securityFloor } : {}), + onSecurityMode: (mode) => { + if (mode === "protected") securityFloor = "protected"; + }, logger: { warn() {} }, assertOutboundCapacity: (bytes) => assertOutboundCapacity(bytes), }); diff --git a/packages/protocol/src/connection-offer.ts b/packages/protocol/src/connection-offer.ts index 073cf0be0c..3b5d455ad6 100644 --- a/packages/protocol/src/connection-offer.ts +++ b/packages/protocol/src/connection-offer.ts @@ -14,6 +14,9 @@ export const ConnectionOfferV2Schema = z.object({ endpoint: z.string().min(1), useTls: z.boolean().optional(), }), + // COMPAT(relayProtectedSecurity): added in v0.13.0. Present when the daemon requires + // protected endpoint security; clients pin it before their first handshake. + minimumSecurityMode: z.literal("protected").optional(), }); export type ConnectionOfferV2 = z.infer; diff --git a/packages/protocol/src/daemon-endpoints.test.ts b/packages/protocol/src/daemon-endpoints.test.ts index 3ac1c111ac..58c08f95ed 100644 --- a/packages/protocol/src/daemon-endpoints.test.ts +++ b/packages/protocol/src/daemon-endpoints.test.ts @@ -232,3 +232,40 @@ describe("relay connection URI", () => { ).toEqual(parts); }); }); + +describe("relay connection URI security mode", () => { + test("round-trips a protected minimum security mode", () => { + const uri = serializeRelayConnectionUri({ + offer: { + v: 2, + serverId: "srv_test", + daemonPublicKeyB64: "key", + relay: { endpoint: "relay.example.com:443", useTls: true }, + minimumSecurityMode: "protected", + }, + }); + + expect(new URL(uri).searchParams.get("security")).toBe("protected"); + expect(parseRelayConnectionUri(uri).offer.minimumSecurityMode).toBe("protected"); + }); + + test("omits the security parameter when the offer has no minimum", () => { + const uri = serializeRelayConnectionUri({ + offer: { + v: 2, + serverId: "srv_test", + daemonPublicKeyB64: "key", + relay: { endpoint: "relay.example.com:443", useTls: true }, + }, + }); + + expect(new URL(uri).searchParams.has("security")).toBe(false); + expect(parseRelayConnectionUri(uri).offer).not.toHaveProperty("minimumSecurityMode"); + }); + + test("rejects an unknown security mode instead of silently downgrading", () => { + expect(() => + parseRelayConnectionUri("relay://relay.example.com:443/srv_test?key=k&security=legacy"), + ).toThrow("unsupported security mode"); + }); +}); diff --git a/packages/protocol/src/daemon-endpoints.ts b/packages/protocol/src/daemon-endpoints.ts index a4b72c9416..5c4270bcb9 100644 --- a/packages/protocol/src/daemon-endpoints.ts +++ b/packages/protocol/src/daemon-endpoints.ts @@ -42,12 +42,17 @@ export function parseRelayConnectionUri(input: string): ParsedRelayConnectionUri } const endpoint = normalizeHostPort(url.host); const password = url.searchParams.get("password") || undefined; + const security = url.searchParams.get("security"); + if (security !== null && security !== "protected") { + throw new Error("Relay connection URI has an unsupported security mode"); + } return { offer: { v: 2, serverId, daemonPublicKeyB64: key, relay: { endpoint, useTls: url.searchParams.get("ssl") === "true" }, + ...(security === "protected" ? { minimumSecurityMode: "protected" as const } : {}), }, ...(password ? { password } : {}), }; @@ -59,6 +64,9 @@ export function serializeRelayConnectionUri(parts: ParsedRelayConnectionUri): st ); url.searchParams.set("key", parts.offer.daemonPublicKeyB64); if (parts.offer.relay.useTls) url.searchParams.set("ssl", "true"); + if (parts.offer.minimumSecurityMode) { + url.searchParams.set("security", parts.offer.minimumSecurityMode); + } if (parts.password) url.searchParams.set("password", parts.password); return url.toString(); } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 3e5deeb88e..1fcd96ed85 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -187,6 +187,9 @@ const MutableBrowserToolsConfigSchema = z const MutableRelayConfigSchema = z .object({ enabled: z.boolean(), + // COMPAT(relayProtectedSecurity): added in v0.13.0, keep optional while old daemons are supported. + // When true the daemon admits only relay sessions that negotiated protected endpoint security. + requireProtectedSecurity: z.boolean().optional(), }) .passthrough(); @@ -3745,6 +3748,9 @@ export const ServerInfoStatusPayloadSchema = z workspaceMarkUnread: z.boolean().optional(), // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. hubRelationship: z.boolean().optional(), + // COMPAT(relayProtectedSecurity): added in v0.13.0. A capable daemon always advertises + // it; relay clients that negotiated legacy security reject the connection on seeing it. + relayProtectedSecurity: z.boolean().optional(), // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. projectGithubClone: z.boolean().optional(), // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. diff --git a/packages/relay/package.json b/packages/relay/package.json index 6b28a6e5d3..ea3d9d1111 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -35,6 +35,9 @@ "test:watch": "vitest" }, "dependencies": { + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", "ws": "^8.14.2" diff --git a/packages/relay/src/e2ee.ts b/packages/relay/src/e2ee.ts index c3a3b852fb..231e517058 100644 --- a/packages/relay/src/e2ee.ts +++ b/packages/relay/src/e2ee.ts @@ -1,3 +1,20 @@ +export { + openClientSecureChannel, + openDaemonSecureChannel, + PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, + PROTECTED_MODE_REQUIRED_CLOSE_CODE, + PROTECTED_MODE_REQUIRED_CLOSE_REASON, + PROTECTED_RECORD_FAILED_CLOSE_CODE, + SecureChannelRejectedError, +} from "./secure-channel.js"; +export type { + ClientSecureChannelOptions, + DaemonSecureChannelOptions, + SecureChannel, + SecureChannelEvents, + SecurityMode, +} from "./secure-channel.js"; + export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js"; export type { Transport, TransportMessage, EncryptedChannelEvents } from "./encrypted-channel.js"; diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index b9f455e0f1..9f8dbc43d8 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -16,4 +16,5 @@ export { EncryptedChannel, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js"; +export { maxSecuredPlaintextByteLength } from "./secure-channel.js"; export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js"; diff --git a/packages/relay/src/noise/cipher-state.ts b/packages/relay/src/noise/cipher-state.ts new file mode 100644 index 0000000000..e54ab6bc07 --- /dev/null +++ b/packages/relay/src/noise/cipher-state.ts @@ -0,0 +1,76 @@ +import { chacha20poly1305 } from "@noble/ciphers/chacha.js"; + +/** Noise spec §5.1 CipherState for ChaChaPoly. Nonce is 4 zero bytes then 64-bit little-endian n. */ + +export const NOISE_TAG_BYTES = 16; +export const NOISE_MAX_MESSAGE_BYTES = 65535; +const MAX_NONCE = (1n << 64n) - 1n; + +export class NoiseProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "NoiseProtocolError"; + } +} + +export class CipherState { + private k: Uint8Array | null = null; + private n = 0n; + + initializeKey(key: Uint8Array | null, nonce = 0n): void { + this.k = key; + this.n = nonce; + } + + hasKey(): boolean { + return this.k !== null; + } + + nonce(): bigint { + return this.n; + } + + encryptWithAd(ad: Uint8Array, plaintext: Uint8Array): Uint8Array { + if (this.k === null) return plaintext; + const nonce = this.takeNonce(); + return chacha20poly1305(this.k, nonce, ad).encrypt(plaintext); + } + + decryptWithAd(ad: Uint8Array, ciphertext: Uint8Array): Uint8Array { + if (this.k === null) return ciphertext; + if (this.n === MAX_NONCE) throw new NoiseProtocolError("nonce exhausted"); + let plaintext: Uint8Array; + try { + plaintext = chacha20poly1305(this.k, encodeNonce(this.n), ad).decrypt(ciphertext); + } catch { + throw new NoiseProtocolError("authentication failed"); + } + // Only a successful authentication advances the receive counter. + this.n += 1n; + return plaintext; + } + + /** Reserves the next nonce synchronously so concurrent senders never share one. */ + private takeNonce(): Uint8Array { + if (this.n === MAX_NONCE) throw new NoiseProtocolError("nonce exhausted"); + const nonce = encodeNonce(this.n); + this.n += 1n; + return nonce; + } + + clear(): void { + this.k?.fill(0); + this.k = null; + this.n = MAX_NONCE; + } +} + +function encodeNonce(n: bigint): Uint8Array { + const nonce = new Uint8Array(12); + let value = n; + for (let i = 4; i < 12; i += 1) { + nonce[i] = Number(value & 0xffn); + value >>= 8n; + } + return nonce; +} diff --git a/packages/relay/src/noise/fixtures/nk-25519-chachapoly-sha256.json b/packages/relay/src/noise/fixtures/nk-25519-chachapoly-sha256.json new file mode 100644 index 0000000000..07b258c384 --- /dev/null +++ b/packages/relay/src/noise/fixtures/nk-25519-chachapoly-sha256.json @@ -0,0 +1,85 @@ +{ + "vectors": [ + { + "name": "Noise_NK_25519_ChaChaPoly_SHA256", + "pattern": "NK", + "dh": "25519", + "cipher": "ChaChaPoly", + "hash": "SHA256", + "init_prologue": "4a6f686e2047616c74", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62", + "resp_prologue": "4a6f686e2047616c74", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "messages": [ + { + "payload": "4c756477696720766f6e204d69736573", + "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79448134d00711fdb390a0d178fa008f6d47d2891e5ea18ae136c3b4c23ac384efb0" + }, + { + "payload": "4d757272617920526f746862617264", + "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088438ea16e3701bc0d77744f117bee22451c9afa7f4cdbbcff00c04a8ee0913c88" + }, + { + "payload": "462e20412e20486179656b", + "ciphertext": "a62de29ce27cb80245d440d986ed816c156e9d757d7008df2198b0" + }, + { + "payload": "4361726c204d656e676572", + "ciphertext": "174a35f11c689f4530d7208618e0564ae12f2f50ba8eb4df5382ff" + }, + { + "payload": "4a65616e2d426170746973746520536179", + "ciphertext": "337e475ebb8eae60f91974c4e455a5af38d1d8628d1803b160d60442874b0a1777" + }, + { + "payload": "457567656e2042f6686d20766f6e2042617765726b", + "ciphertext": "047e80e060b7bb08b53c5a23dfe9920cae135b9d1dc6302fc475003062723700366346ac9d" + } + ], + "source": "https://raw.githubusercontent.com/rweather/noise-c/master/tests/vector/cacophony.txt" + }, + { + "name": "Noise_NK_25519_ChaChaPoly_SHA256", + "pattern": "NK", + "dh": "25519", + "cipher": "ChaChaPoly", + "hash": "SHA256", + "init_prologue": "50726f6c6f677565313233", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62", + "resp_prologue": "50726f6c6f677565313233", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "messages": [ + { + "payload": "4c756477696720766f6e204d69736573", + "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79448134d00711fdb390a0d178fa008f6d47f49a76e297aa164052a3d842aa8ff7d8" + }, + { + "payload": "4d757272617920526f746862617264", + "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088438ea16e3701bc0d77744f117bee22451628b075da65d4114bd343e2d93006c4" + }, + { + "payload": "462e20412e20486179656b", + "ciphertext": "a62de29ce27cb80245d440d986ed816c156e9d757d7008df2198b0" + }, + { + "payload": "4361726c204d656e676572", + "ciphertext": "174a35f11c689f4530d7208618e0564ae12f2f50ba8eb4df5382ff" + }, + { + "payload": "4a65616e2d426170746973746520536179", + "ciphertext": "337e475ebb8eae60f91974c4e455a5af38d1d8628d1803b160d60442874b0a1777" + }, + { + "payload": "457567656e2042f6686d20766f6e2042617765726b", + "ciphertext": "047e80e060b7bb08b53c5a23dfe9920cae135b9d1dc6302fc475003062723700366346ac9d" + } + ], + "handshake_hash": "d5c4ce9ffe8bcd940aa50f842a5d4d90d3f7163f4b3916deb87a5d747712d718", + "source": "https://raw.githubusercontent.com/rweather/noise-c/master/tests/vector/noise-c-basic.txt" + } + ] +} diff --git a/packages/relay/src/noise/index.ts b/packages/relay/src/noise/index.ts new file mode 100644 index 0000000000..758d6a540b --- /dev/null +++ b/packages/relay/src/noise/index.ts @@ -0,0 +1,241 @@ +/** + * Conforming Noise_NK_25519_ChaChaPoly_SHA256 (Noise Protocol Framework rev 34). + * + * The pattern is executed token by token from its definition, so the vector + * tests exercise the same code path as production. Only NK is provided; the + * daemon's static X25519 key is the responder's `s`, known to the initiator + * from pairing. + * + * NK: + * <- s + * ... + * -> e, es + * <- e, ee + */ +import { x25519 } from "@noble/curves/ed25519.js"; +import { CipherState, NoiseProtocolError, NOISE_MAX_MESSAGE_BYTES } from "./cipher-state.js"; +import { SymmetricState, concat } from "./symmetric-state.js"; + +export { + CipherState, + NoiseProtocolError, + NOISE_MAX_MESSAGE_BYTES, + NOISE_TAG_BYTES, +} from "./cipher-state.js"; + +export const NOISE_NK_PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_SHA256"; +export const DH_BYTES = 32; + +export interface NoiseKeyPair { + publicKey: Uint8Array; + secretKey: Uint8Array; +} + +export interface NoiseInitiatorOptions { + /** Responder's static public key, trusted out of band. */ + remoteStatic: Uint8Array; + prologue: Uint8Array; + /** Test injection only; production generates a fresh ephemeral. */ + ephemeral?: NoiseKeyPair; +} + +export interface NoiseResponderOptions { + localStatic: NoiseKeyPair; + prologue: Uint8Array; + /** Test injection only; production generates a fresh ephemeral. */ + ephemeral?: NoiseKeyPair; +} + +export interface TransportKeys { + /** Initiator → responder. */ + send: CipherState; + /** Responder → initiator. */ + receive: CipherState; +} + +export interface NoiseHandshake { + /** Builds the next handshake message this side is due to write. */ + writeMessage(payload: Uint8Array): Uint8Array; + /** Consumes the next handshake message this side is due to read; returns its payload. */ + readMessage(message: Uint8Array): Uint8Array; + /** True once both handshake messages have been processed. */ + isComplete(): boolean; + handshakeHash(): Uint8Array; + /** Derives transport keys, oriented for this side. Ends the handshake state. */ + split(): { send: CipherState; receive: CipherState }; +} + +type Token = "e" | "es" | "ee"; +const NK_MESSAGES: ReadonlyArray> = [ + ["e", "es"], + ["e", "ee"], +]; + +export function generateNoiseKeyPair(): NoiseKeyPair { + const secretKey = x25519.utils.randomSecretKey(); + return { secretKey, publicKey: x25519.getPublicKey(secretKey) }; +} + +export function createNoiseInitiator(options: NoiseInitiatorOptions): NoiseHandshake { + requireKeyLength(options.remoteStatic, "remote static key"); + return new HandshakeState({ + initiator: true, + prologue: options.prologue, + s: null, + rs: options.remoteStatic, + ephemeral: options.ephemeral ?? null, + }); +} + +export function createNoiseResponder(options: NoiseResponderOptions): NoiseHandshake { + requireKeyLength(options.localStatic.publicKey, "local static public key"); + requireKeyLength(options.localStatic.secretKey, "local static secret key"); + return new HandshakeState({ + initiator: false, + prologue: options.prologue, + s: options.localStatic, + rs: null, + ephemeral: options.ephemeral ?? null, + }); +} + +class HandshakeState implements NoiseHandshake { + private readonly symmetric = new SymmetricState(NOISE_NK_PROTOCOL_NAME); + private readonly initiator: boolean; + private readonly s: NoiseKeyPair | null; + private e: NoiseKeyPair | null; + private rs: Uint8Array | null; + private re: Uint8Array | null = null; + private messageIndex = 0; + private finished = false; + + constructor(params: { + initiator: boolean; + prologue: Uint8Array; + s: NoiseKeyPair | null; + rs: Uint8Array | null; + ephemeral: NoiseKeyPair | null; + }) { + this.initiator = params.initiator; + this.s = params.s; + this.rs = params.rs; + this.e = params.ephemeral; + this.symmetric.mixHash(params.prologue); + // Pre-message "<- s": the responder's static public key. + const preMessageStatic = this.initiator ? this.rs : this.s?.publicKey; + if (!preMessageStatic) throw new NoiseProtocolError("missing responder static key"); + this.symmetric.mixHash(preMessageStatic); + } + + writeMessage(payload: Uint8Array): Uint8Array { + const tokens = this.nextTokens(this.initiator); + const parts: Uint8Array[] = []; + for (const token of tokens) { + switch (token) { + case "e": { + if (!this.e) this.e = generateNoiseKeyPair(); + parts.push(this.e.publicKey); + this.symmetric.mixHash(this.e.publicKey); + break; + } + case "es": + this.mixDh(this.initiator ? this.e : this.s, this.initiator ? this.rs : this.re); + break; + case "ee": + this.mixDh(this.e, this.re); + break; + } + } + parts.push(this.symmetric.encryptAndHash(payload)); + const message = concat(...parts); + if (message.byteLength > NOISE_MAX_MESSAGE_BYTES) { + throw new NoiseProtocolError("handshake message too large"); + } + this.advance(); + return message; + } + + readMessage(message: Uint8Array): Uint8Array { + if (message.byteLength > NOISE_MAX_MESSAGE_BYTES) { + throw new NoiseProtocolError("handshake message too large"); + } + const tokens = this.nextTokens(!this.initiator); + let offset = 0; + for (const token of tokens) { + switch (token) { + case "e": { + if (message.byteLength < offset + DH_BYTES) { + throw new NoiseProtocolError("handshake message truncated"); + } + this.re = message.slice(offset, offset + DH_BYTES); + offset += DH_BYTES; + this.symmetric.mixHash(this.re); + break; + } + case "es": + this.mixDh(this.initiator ? this.e : this.s, this.initiator ? this.rs : this.re); + break; + case "ee": + this.mixDh(this.e, this.re); + break; + } + } + const payload = this.symmetric.decryptAndHash(message.slice(offset)); + this.advance(); + return payload; + } + + isComplete(): boolean { + return this.messageIndex >= NK_MESSAGES.length; + } + + handshakeHash(): Uint8Array { + return this.symmetric.handshakeHash(); + } + + split(): { send: CipherState; receive: CipherState } { + if (!this.isComplete()) throw new NoiseProtocolError("handshake incomplete"); + if (this.finished) throw new NoiseProtocolError("handshake already split"); + this.finished = true; + const [c1, c2] = this.symmetric.split(); + this.e?.secretKey.fill(0); + this.e = null; + return this.initiator ? { send: c1, receive: c2 } : { send: c2, receive: c1 }; + } + + private nextTokens(expectedWriterIsInitiator: boolean): ReadonlyArray { + if (this.isComplete() || this.finished) throw new NoiseProtocolError("handshake complete"); + const writerIsInitiator = this.messageIndex % 2 === 0; + if (writerIsInitiator !== expectedWriterIsInitiator) { + throw new NoiseProtocolError("handshake message out of turn"); + } + return NK_MESSAGES[this.messageIndex]; + } + + private advance(): void { + this.messageIndex += 1; + } + + private mixDh(local: NoiseKeyPair | null, remote: Uint8Array | null): void { + if (!local || !remote) throw new NoiseProtocolError("missing DH key"); + let shared: Uint8Array; + try { + shared = x25519.getSharedSecret(local.secretKey, remote); + } catch { + throw new NoiseProtocolError("invalid DH public key"); + } + if (isAllZero(shared)) throw new NoiseProtocolError("invalid DH public key"); + this.symmetric.mixKey(shared); + shared.fill(0); + } +} + +function requireKeyLength(key: Uint8Array, label: string): void { + if (key.byteLength !== DH_BYTES) throw new NoiseProtocolError(`${label} must be 32 bytes`); +} + +function isAllZero(bytes: Uint8Array): boolean { + let acc = 0; + for (const byte of bytes) acc |= byte; + return acc === 0; +} diff --git a/packages/relay/src/noise/noise.test.ts b/packages/relay/src/noise/noise.test.ts new file mode 100644 index 0000000000..b8d1228d1a --- /dev/null +++ b/packages/relay/src/noise/noise.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "vitest"; +import { + CipherState, + createNoiseInitiator, + createNoiseResponder, + generateNoiseKeyPair, + NoiseProtocolError, + NOISE_NK_PROTOCOL_NAME, +} from "./index.js"; +import vectors from "./fixtures/nk-25519-chachapoly-sha256.json" with { type: "json" }; +import { x25519 } from "@noble/curves/ed25519.js"; + +function hex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} +function fromHex(hexText: string): Uint8Array { + return new Uint8Array(Buffer.from(hexText, "hex")); +} +function keyPairFromSecret(secretHex: string) { + const secretKey = fromHex(secretHex); + return { secretKey, publicKey: x25519.getPublicKey(secretKey) }; +} + +describe(NOISE_NK_PROTOCOL_NAME, () => { + for (const vector of vectors.vectors) { + test(`matches vector from ${vector.source}`, () => { + expect(vector.name).toBe(NOISE_NK_PROTOCOL_NAME); + const responderStatic = keyPairFromSecret(vector.resp_static); + expect(hex(responderStatic.publicKey)).toBe(vector.init_remote_static); + const initiator = createNoiseInitiator({ + remoteStatic: fromHex(vector.init_remote_static), + prologue: fromHex(vector.init_prologue), + ephemeral: keyPairFromSecret(vector.init_ephemeral), + }); + const responder = createNoiseResponder({ + localStatic: responderStatic, + prologue: fromHex(vector.resp_prologue), + ephemeral: keyPairFromSecret(vector.resp_ephemeral), + }); + + const [m1, m2, ...transport] = vector.messages; + const msg1 = initiator.writeMessage(fromHex(m1.payload)); + expect(hex(msg1)).toBe(m1.ciphertext); + expect(hex(responder.readMessage(msg1))).toBe(m1.payload); + const msg2 = responder.writeMessage(fromHex(m2.payload)); + expect(hex(msg2)).toBe(m2.ciphertext); + expect(hex(initiator.readMessage(msg2))).toBe(m2.payload); + expect(initiator.isComplete()).toBe(true); + expect(responder.isComplete()).toBe(true); + if ("handshake_hash" in vector) { + expect(hex(initiator.handshakeHash())).toBe(vector.handshake_hash); + expect(hex(responder.handshakeHash())).toBe(vector.handshake_hash); + } + + const initiatorKeys = initiator.split(); + const responderKeys = responder.split(); + const empty = new Uint8Array(0); + transport.forEach((message, index) => { + const fromInitiator = index % 2 === 0; + const sender = fromInitiator ? initiatorKeys.send : responderKeys.send; + const receiver = fromInitiator ? responderKeys.receive : initiatorKeys.receive; + const ciphertext = sender.encryptWithAd(empty, fromHex(message.payload)); + expect(hex(ciphertext)).toBe(message.ciphertext); + expect(hex(receiver.decryptWithAd(empty, ciphertext))).toBe(message.payload); + }); + }); + } + + test("rejects a replayed, reflected, or out-of-order transport message", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new TextEncoder().encode("test"); + const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue }); + const responder = createNoiseResponder({ localStatic: daemon, prologue }); + const empty = new Uint8Array(0); + responder.readMessage(initiator.writeMessage(empty)); + initiator.readMessage(responder.writeMessage(empty)); + const client = initiator.split(); + const server = responder.split(); + + const first = client.send.encryptWithAd(empty, new Uint8Array([1])); + const second = client.send.encryptWithAd(empty, new Uint8Array([2])); + expect(server.receive.decryptWithAd(empty, first)).toEqual(new Uint8Array([1])); + // duplicate + expect(() => server.receive.decryptWithAd(empty, first)).toThrow(NoiseProtocolError); + // a failed attempt did not advance the counter: the genuine next record still decrypts + expect(server.receive.decryptWithAd(empty, second)).toEqual(new Uint8Array([2])); + // reflection: a record the client sent, handed back to the client + expect(() => client.receive.decryptWithAd(empty, second)).toThrow(NoiseProtocolError); + // out of order + const third = client.send.encryptWithAd(empty, new Uint8Array([3])); + const fourth = client.send.encryptWithAd(empty, new Uint8Array([4])); + expect(() => server.receive.decryptWithAd(empty, fourth)).toThrow(NoiseProtocolError); + expect(server.receive.decryptWithAd(empty, third)).toEqual(new Uint8Array([3])); + expect(server.receive.decryptWithAd(empty, fourth)).toEqual(new Uint8Array([4])); + }); + + test("a recorded first message cannot derive the keys of a fresh responder", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new TextEncoder().encode("test"); + const empty = new Uint8Array(0); + const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue }); + const msg1 = initiator.writeMessage(empty); + const original = createNoiseResponder({ localStatic: daemon, prologue }); + original.readMessage(msg1); + initiator.readMessage(original.writeMessage(empty)); + const clientKeys = initiator.split(); + const record = clientKeys.send.encryptWithAd(empty, new Uint8Array([7])); + + const replayTarget = createNoiseResponder({ localStatic: daemon, prologue }); + replayTarget.readMessage(msg1); + replayTarget.writeMessage(empty); + const replayKeys = replayTarget.split(); + expect(() => replayKeys.receive.decryptWithAd(empty, record)).toThrow(NoiseProtocolError); + }); + + test("rejects a low-order remote ephemeral and a wrong responder key", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new TextEncoder().encode("test"); + const empty = new Uint8Array(0); + const responder = createNoiseResponder({ localStatic: daemon, prologue }); + const zeroEphemeral = new Uint8Array(32 + 16); + expect(() => responder.readMessage(zeroEphemeral)).toThrow(NoiseProtocolError); + + const other = generateNoiseKeyPair(); + const initiator = createNoiseInitiator({ remoteStatic: other.publicKey, prologue }); + const victim = createNoiseResponder({ localStatic: daemon, prologue }); + expect(() => victim.readMessage(initiator.writeMessage(empty))).toThrow(NoiseProtocolError); + }); + + test("stops at the last nonce in both directions and keeps state on a failed tag", () => { + const MAX = (1n << 64n) - 1n; + const key = new Uint8Array(32).map((_, i) => i + 1); + const ad = new Uint8Array(0); + const sender = new CipherState(); + const receiver = new CipherState(); + sender.initializeKey(key.slice(), MAX - 1n); + receiver.initializeKey(key.slice(), MAX - 1n); + const last = sender.encryptWithAd(ad, new Uint8Array([1, 2, 3])); + expect(sender.nonce()).toBe(MAX); + expect(() => sender.encryptWithAd(ad, new Uint8Array([4]))).toThrow("nonce exhausted"); + expect(sender.nonce()).toBe(MAX); + + const tampered = last.slice(); + tampered[0] ^= 1; + expect(() => receiver.decryptWithAd(ad, tampered)).toThrow("authentication failed"); + expect(receiver.nonce()).toBe(MAX - 1n); + expect(Array.from(receiver.decryptWithAd(ad, last))).toEqual([1, 2, 3]); + expect(receiver.nonce()).toBe(MAX); + expect(() => receiver.decryptWithAd(ad, last)).toThrow("nonce exhausted"); + }); + + test("refuses to proceed out of turn or after completion", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new Uint8Array(0); + const empty = new Uint8Array(0); + const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue }); + const responder = createNoiseResponder({ localStatic: daemon, prologue }); + expect(() => responder.writeMessage(empty)).toThrow(NoiseProtocolError); + expect(() => initiator.split()).toThrow(NoiseProtocolError); + responder.readMessage(initiator.writeMessage(empty)); + expect(() => initiator.writeMessage(empty)).toThrow(NoiseProtocolError); + initiator.readMessage(responder.writeMessage(empty)); + expect(() => initiator.readMessage(empty)).toThrow(NoiseProtocolError); + initiator.split(); + expect(() => initiator.split()).toThrow(NoiseProtocolError); + }); +}); diff --git a/packages/relay/src/noise/symmetric-state.ts b/packages/relay/src/noise/symmetric-state.ts new file mode 100644 index 0000000000..a4afcc3c40 --- /dev/null +++ b/packages/relay/src/noise/symmetric-state.ts @@ -0,0 +1,83 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { hmac } from "@noble/hashes/hmac.js"; +import { CipherState } from "./cipher-state.js"; + +/** Noise spec §5.2 SymmetricState for SHA256 (HASHLEN 32). */ + +const HASHLEN = 32; +const EMPTY = new Uint8Array(0); + +export class SymmetricState { + readonly cipher = new CipherState(); + private ck: Uint8Array; + private h: Uint8Array; + + constructor(protocolName: string) { + const name = new TextEncoder().encode(protocolName); + if (name.byteLength <= HASHLEN) { + this.h = new Uint8Array(HASHLEN); + this.h.set(name); + } else { + this.h = sha256(name); + } + this.ck = this.h.slice(); + this.cipher.initializeKey(null); + } + + mixKey(inputKeyMaterial: Uint8Array): void { + const [ck, tempK] = hkdf2(this.ck, inputKeyMaterial); + this.ck = ck; + this.cipher.initializeKey(tempK); + } + + mixHash(data: Uint8Array): void { + this.h = sha256(concat(this.h, data)); + } + + handshakeHash(): Uint8Array { + return this.h.slice(); + } + + encryptAndHash(plaintext: Uint8Array): Uint8Array { + const ciphertext = this.cipher.encryptWithAd(this.h, plaintext); + this.mixHash(ciphertext); + return ciphertext; + } + + decryptAndHash(ciphertext: Uint8Array): Uint8Array { + const plaintext = this.cipher.decryptWithAd(this.h, ciphertext); + this.mixHash(ciphertext); + return plaintext; + } + + split(): [CipherState, CipherState] { + const [k1, k2] = hkdf2(this.ck, EMPTY); + const c1 = new CipherState(); + const c2 = new CipherState(); + c1.initializeKey(k1); + c2.initializeKey(k2); + this.ck.fill(0); + this.cipher.clear(); + return [c1, c2]; + } +} + +/** Noise spec §4.3 HKDF with two outputs: HMAC chain with 0x01 and 0x02 info bytes. */ +function hkdf2(chainingKey: Uint8Array, inputKeyMaterial: Uint8Array): [Uint8Array, Uint8Array] { + const tempKey = hmac(sha256, chainingKey, inputKeyMaterial); + const output1 = hmac(sha256, tempKey, new Uint8Array([0x01])); + const output2 = hmac(sha256, tempKey, concat(output1, new Uint8Array([0x02]))); + tempKey.fill(0); + return [output1, output2]; +} + +export function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + return out; +} diff --git a/packages/relay/src/protected-records.test.ts b/packages/relay/src/protected-records.test.ts new file mode 100644 index 0000000000..fd3247b344 --- /dev/null +++ b/packages/relay/src/protected-records.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "vitest"; +import { CipherState, NoiseProtocolError } from "./noise/index.js"; +import { + decodeProtectedRecord, + encodeProtectedRecord, + MAX_CHUNK_DATA_BYTES, + protectedRecordByteLength, +} from "./protected-records.js"; + +function pair(): { sender: CipherState; receiver: CipherState } { + const key = new Uint8Array(32).map((_, i) => i + 1); + const sender = new CipherState(); + const receiver = new CipherState(); + sender.initializeKey(key.slice()); + receiver.initializeKey(key.slice()); + return { sender, receiver }; +} + +function frameOf(length: number): Uint8Array { + return new Uint8Array(length).map((_, i) => (i * 7 + 3) & 0xff); +} + +const EMPTY = new Uint8Array(0); + +/** Re-encrypts one chunk's plaintext under the next sender nonce. */ +function reencrypt(sender: CipherState, plaintext: Uint8Array): Uint8Array { + const ct = sender.encryptWithAd(EMPTY, plaintext); + const out = new Uint8Array(2 + ct.byteLength); + out[0] = ct.byteLength >>> 8; + out[1] = ct.byteLength & 0xff; + out.set(ct, 2); + return out; +} + +function chunkPlaintext(kind: number, flags: number, total: number, data: Uint8Array): Uint8Array { + const pt = new Uint8Array(6 + data.byteLength); + pt[0] = kind; + pt[1] = flags; + pt[2] = (total >>> 24) & 0xff; + pt[3] = (total >>> 16) & 0xff; + pt[4] = (total >>> 8) & 0xff; + pt[5] = total & 0xff; + pt.set(data, 6); + return pt; +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0)); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.byteLength; + } + return out; +} + +describe("protected records", () => { + test.each([ + ["empty text", "text", 0], + ["empty binary", "binary", 0], + ["small binary", "binary", 100], + ["exactly one chunk", "text", MAX_CHUNK_DATA_BYTES], + ["one byte over a chunk", "binary", MAX_CHUNK_DATA_BYTES + 1], + ["exact multiple of chunks", "text", MAX_CHUNK_DATA_BYTES * 3], + ["three and a bit chunks", "binary", MAX_CHUNK_DATA_BYTES * 3 + 17], + ] as const)("round-trips %s", (_label, kind, length) => { + const { sender, receiver } = pair(); + const frame = frameOf(length); + const record = encodeProtectedRecord(kind, frame, sender); + expect(record.byteLength).toBe(protectedRecordByteLength(length)); + const decoded = decodeProtectedRecord(record, receiver); + expect(decoded.kind).toBe(kind); + expect(Buffer.from(decoded.data).equals(Buffer.from(frame))).toBe(true); + expect(receiver.nonce()).toBe(sender.nonce()); + }); + + test("rejects two valid records concatenated into one message", () => { + const { sender, receiver } = pair(); + const a = encodeProtectedRecord("text", frameOf(10), sender); + const b = encodeProtectedRecord("text", frameOf(10), sender); + expect(() => decodeProtectedRecord(concat(a, b), receiver)).toThrow(NoiseProtocolError); + }); + + test("rejects a multi-chunk record split across messages", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES + 5), sender); + const firstChunkLength = 2 + ((record[0] << 8) | record[1]); + const firstOnly = record.subarray(0, firstChunkLength); + expect(() => decodeProtectedRecord(firstOnly, receiver)).toThrow(NoiseProtocolError); + }); + + test("rejects a record whose complete final chunk is removed", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES * 2), sender); + const firstChunkLength = 2 + ((record[0] << 8) | record[1]); + expect(() => decodeProtectedRecord(record.subarray(0, firstChunkLength), receiver)).toThrow( + NoiseProtocolError, + ); + }); + + test("rejects zero chunks and trailing bytes", () => { + const { sender, receiver } = pair(); + expect(() => decodeProtectedRecord(new Uint8Array(0), receiver)).toThrow(NoiseProtocolError); + const record = encodeProtectedRecord("text", frameOf(3), sender); + expect(() => decodeProtectedRecord(concat(record, new Uint8Array([0])), receiver)).toThrow( + NoiseProtocolError, + ); + }); + + test("rejects an extra zero-byte FINAL chunk after an exact-multiple frame", () => { + const { sender, receiver } = pair(); + const total = MAX_CHUNK_DATA_BYTES; + const data = frameOf(total); + const forged = concat( + reencrypt(sender, chunkPlaintext(0x00, 0x01, total, data)), + reencrypt(sender, chunkPlaintext(0x00, 0x02, total, EMPTY)), + ); + expect(() => decodeProtectedRecord(forged, receiver)).toThrow(NoiseProtocolError); + }); + + test("rejects reserved flags, invalid kind, changed kind, and changed total length", () => { + const cases: Array<[Uint8Array[]]> = [ + [[chunkPlaintext(0x00, 0x01 | 0x02 | 0x04, 1, new Uint8Array([1]))]], + [[chunkPlaintext(0x02, 0x03, 1, new Uint8Array([1]))]], + [ + [ + chunkPlaintext(0x00, 0x01, MAX_CHUNK_DATA_BYTES + 1, frameOf(MAX_CHUNK_DATA_BYTES)), + chunkPlaintext(0x01, 0x02, MAX_CHUNK_DATA_BYTES + 1, new Uint8Array([1])), + ], + ], + [ + [ + chunkPlaintext(0x00, 0x01, MAX_CHUNK_DATA_BYTES + 1, frameOf(MAX_CHUNK_DATA_BYTES)), + chunkPlaintext(0x00, 0x02, MAX_CHUNK_DATA_BYTES + 2, new Uint8Array([1, 2])), + ], + ], + ]; + for (const [plaintexts] of cases) { + const { sender, receiver } = pair(); + const forged = concat(...plaintexts.map((pt) => reencrypt(sender, pt))); + expect(() => decodeProtectedRecord(forged, receiver)).toThrow(NoiseProtocolError); + } + }); + + test("rejects a tampered chunk without advancing past it, and later chunks never escape", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES + 5), sender); + const tampered = record.slice(); + tampered[2 + 10] ^= 0x01; + expect(() => decodeProtectedRecord(tampered, receiver)).toThrow(NoiseProtocolError); + expect(receiver.nonce()).toBe(0n); + }); + + test("rejects a record whose header claims more bytes than it carries before allocating", () => { + const { sender, receiver } = pair(); + const claimed = 64 * 1024 * 1024; + const forged = reencrypt(sender, chunkPlaintext(0x01, 0x01, claimed, new Uint8Array([1]))); + const before = process.memoryUsage().arrayBuffers; + expect(() => decodeProtectedRecord(forged, receiver)).toThrow("record length mismatch"); + expect(process.memoryUsage().arrayBuffers - before).toBeLessThan(1024 * 1024); + // A relay that truncates a genuine large record gets the same answer. + const { sender: s2, receiver: r2 } = pair(); + const genuine = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES * 2 + 1), s2); + const firstChunkOnly = genuine.subarray(0, 2 + 6 + MAX_CHUNK_DATA_BYTES + 16); + expect(() => decodeProtectedRecord(firstChunkOnly, r2)).toThrow("record length mismatch"); + expect(r2.nonce()).toBe(1n); + }); + + test("a record delivered twice fails the second time", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("text", frameOf(20), sender); + decodeProtectedRecord(record, receiver); + expect(() => decodeProtectedRecord(record, receiver)).toThrow(NoiseProtocolError); + }); +}); diff --git a/packages/relay/src/protected-records.ts b/packages/relay/src/protected-records.ts new file mode 100644 index 0000000000..a8eb661b80 --- /dev/null +++ b/packages/relay/src/protected-records.ts @@ -0,0 +1,229 @@ +/** + * Authenticated application-frame framing over Noise transport messages. + * + * One application frame becomes one wire record. Every Noise transport message + * (chunk) carries, inside its authenticated plaintext, the frame kind, FIRST + * and FINAL flags, and the total frame length, so a relay cannot merge two + * records, split one across WebSocket messages, truncate a final chunk, or + * re-chunk a frame without an authentication failure or a framing violation. + * Transport AD is always empty, as in Noise §5. + * + * chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data + * wire record = for each chunk: ciphertextLength(u16 BE) || ciphertext + */ +import { + CipherState, + NoiseProtocolError, + NOISE_MAX_MESSAGE_BYTES, + NOISE_TAG_BYTES, +} from "./noise/index.js"; + +export type FrameKind = "text" | "binary"; + +const KIND_TEXT = 0x00; +const KIND_BINARY = 0x01; +const FLAG_FIRST = 0x01; +const FLAG_FINAL = 0x02; +const HEADER_BYTES = 6; +const LENGTH_PREFIX_BYTES = 2; +const EMPTY_AD = new Uint8Array(0); + +/** Largest data payload a single chunk carries. */ +export const MAX_CHUNK_DATA_BYTES = NOISE_MAX_MESSAGE_BYTES - NOISE_TAG_BYTES - HEADER_BYTES; +/** Largest application frame a record may carry; bounds allocation before decryption. */ +export const MAX_FRAME_BYTES = 0xffff_ffff; +const CHUNK_OVERHEAD_BYTES = LENGTH_PREFIX_BYTES + HEADER_BYTES + NOISE_TAG_BYTES; + +export function chunkCount(frameBytes: number): number { + return Math.max(1, Math.ceil(frameBytes / MAX_CHUNK_DATA_BYTES)); +} + +/** Wire bytes a frame of `frameBytes` occupies once protected. */ +export function protectedRecordByteLength(frameBytes: number): number { + return frameBytes + chunkCount(frameBytes) * CHUNK_OVERHEAD_BYTES; +} + +export function encodeProtectedRecord( + kind: FrameKind, + frame: Uint8Array, + sender: CipherState, +): Uint8Array { + if (frame.byteLength > MAX_FRAME_BYTES) throw new NoiseProtocolError("frame too large"); + const chunks = chunkCount(frame.byteLength); + const out = new Uint8Array(protectedRecordByteLength(frame.byteLength)); + let offset = 0; + for (let index = 0; index < chunks; index += 1) { + const start = index * MAX_CHUNK_DATA_BYTES; + const data = frame.subarray(start, Math.min(frame.byteLength, start + MAX_CHUNK_DATA_BYTES)); + const plaintext = new Uint8Array(HEADER_BYTES + data.byteLength); + plaintext[0] = kind === "text" ? KIND_TEXT : KIND_BINARY; + plaintext[1] = (index === 0 ? FLAG_FIRST : 0) | (index === chunks - 1 ? FLAG_FINAL : 0); + writeU32(plaintext, 2, frame.byteLength); + plaintext.set(data, HEADER_BYTES); + const ciphertext = sender.encryptWithAd(EMPTY_AD, plaintext); + writeU16(out, offset, ciphertext.byteLength); + out.set(ciphertext, offset + LENGTH_PREFIX_BYTES); + offset += LENGTH_PREFIX_BYTES + ciphertext.byteLength; + } + return out; +} + +export interface DecodedFrame { + kind: FrameKind; + data: Uint8Array; +} + +/** + * Decodes one wire record. Chunks are authenticated in order and the receive + * counter advances per authenticated chunk; any framing violation throws and + * nothing partial is returned. The caller must close the channel on error. + */ +export function decodeProtectedRecord(record: Uint8Array, receiver: CipherState): DecodedFrame { + let offset = 0; + let assembly: RecordAssembly | null = null; + while (offset < record.byteLength) { + if (assembly !== null && assembly.index >= assembly.expectedChunks) { + throw new NoiseProtocolError("unexpected chunk after final"); + } + const { chunk, next } = readChunk(record, offset, receiver); + offset = next; + assembly = + assembly === null + ? startAssembly(chunk, record.byteLength) + : continueAssembly(assembly, chunk); + } + if (assembly === null) throw new NoiseProtocolError("empty record"); + if (assembly.index !== assembly.expectedChunks || assembly.written !== assembly.totalLength) { + throw new NoiseProtocolError("record incomplete"); + } + return { kind: assembly.kind, data: assembly.frame }; +} + +interface Chunk { + kind: FrameKind; + isFirst: boolean; + isFinal: boolean; + totalLength: number; + data: Uint8Array; +} + +interface RecordAssembly { + kind: FrameKind; + totalLength: number; + expectedChunks: number; + frame: Uint8Array; + written: number; + index: number; +} + +function readChunk( + record: Uint8Array, + offset: number, + receiver: CipherState, +): { chunk: Chunk; next: number } { + if (record.byteLength - offset < LENGTH_PREFIX_BYTES) { + throw new NoiseProtocolError("record truncated"); + } + const ciphertextLength = readU16(record, offset); + const start = offset + LENGTH_PREFIX_BYTES; + if ( + ciphertextLength < NOISE_TAG_BYTES + HEADER_BYTES || + ciphertextLength > NOISE_MAX_MESSAGE_BYTES + ) { + throw new NoiseProtocolError("invalid chunk length"); + } + if (record.byteLength - start < ciphertextLength) { + throw new NoiseProtocolError("record truncated"); + } + const plaintext = receiver.decryptWithAd( + EMPTY_AD, + record.subarray(start, start + ciphertextLength), + ); + const flags = plaintext[1]; + if ((flags & ~(FLAG_FIRST | FLAG_FINAL)) !== 0) { + throw new NoiseProtocolError("reserved flag set"); + } + return { + chunk: { + kind: decodeKind(plaintext[0]), + isFirst: (flags & FLAG_FIRST) !== 0, + isFinal: (flags & FLAG_FINAL) !== 0, + totalLength: readU32(plaintext, 2), + data: plaintext.subarray(HEADER_BYTES), + }, + next: start + ciphertextLength, + }; +} + +function startAssembly(chunk: Chunk, recordByteLength: number): RecordAssembly { + if (!chunk.isFirst) throw new NoiseProtocolError("record does not start with FIRST"); + // The claimed frame length fixes the record's exact wire length. Check it + // before allocating so a tiny authenticated header cannot demand gigabytes. + if (protectedRecordByteLength(chunk.totalLength) !== recordByteLength) { + throw new NoiseProtocolError("record length mismatch"); + } + const assembly: RecordAssembly = { + kind: chunk.kind, + totalLength: chunk.totalLength, + expectedChunks: chunkCount(chunk.totalLength), + frame: new Uint8Array(chunk.totalLength), + written: 0, + index: 0, + }; + return appendChunk(assembly, chunk); +} + +function continueAssembly(assembly: RecordAssembly, chunk: Chunk): RecordAssembly { + if (chunk.isFirst) throw new NoiseProtocolError("FIRST repeated inside record"); + if (chunk.kind !== assembly.kind) { + throw new NoiseProtocolError("frame kind changed inside record"); + } + if (chunk.totalLength !== assembly.totalLength) { + throw new NoiseProtocolError("frame length changed inside record"); + } + return appendChunk(assembly, chunk); +} + +function appendChunk(assembly: RecordAssembly, chunk: Chunk): RecordAssembly { + const isLastExpected = assembly.index === assembly.expectedChunks - 1; + if (chunk.isFinal !== isLastExpected) throw new NoiseProtocolError("FINAL flag mismatch"); + const expectedData = isLastExpected + ? assembly.totalLength - assembly.index * MAX_CHUNK_DATA_BYTES + : MAX_CHUNK_DATA_BYTES; + if (chunk.data.byteLength !== expectedData) throw new NoiseProtocolError("chunk size mismatch"); + assembly.frame.set(chunk.data, assembly.written); + assembly.written += chunk.data.byteLength; + assembly.index += 1; + return assembly; +} + +function decodeKind(byte: number): FrameKind { + if (byte === KIND_TEXT) return "text"; + if (byte === KIND_BINARY) return "binary"; + throw new NoiseProtocolError("invalid frame kind"); +} + +function writeU16(target: Uint8Array, offset: number, value: number): void { + target[offset] = (value >>> 8) & 0xff; + target[offset + 1] = value & 0xff; +} + +function readU16(source: Uint8Array, offset: number): number { + return (source[offset] << 8) | source[offset + 1]; +} + +function writeU32(target: Uint8Array, offset: number, value: number): void { + target[offset] = (value >>> 24) & 0xff; + target[offset + 1] = (value >>> 16) & 0xff; + target[offset + 2] = (value >>> 8) & 0xff; + target[offset + 3] = value & 0xff; +} + +function readU32(source: Uint8Array, offset: number): number { + return ( + ((source[offset] << 24) >>> 0) + + (source[offset + 1] << 16) + + (source[offset + 2] << 8) + + source[offset + 3] + ); +} diff --git a/packages/relay/src/secure-channel.test.ts b/packages/relay/src/secure-channel.test.ts new file mode 100644 index 0000000000..8f5034368d --- /dev/null +++ b/packages/relay/src/secure-channel.test.ts @@ -0,0 +1,627 @@ +import { describe, expect, it, vi } from "vitest"; +import { + openClientSecureChannel, + openDaemonSecureChannel, + SecureChannelRejectedError, + type SecureChannel, +} from "./secure-channel.js"; +import { + createClientChannel, + createDaemonChannel, + type Transport, + type TransportMessage, +} from "./encrypted-channel.js"; +import { exportPublicKey, generateKeyPair } from "./crypto.js"; +import { MAX_CHUNK_DATA_BYTES } from "./protected-records.js"; + +interface Frame { + data: string | ArrayBuffer; + isBinary: boolean; +} +type Interceptor = (frame: Frame) => Frame | null; + +/** + * Two transports wired back to back through an optional interceptor per + * direction, standing in for a relay that may record, drop, reorder, or + * rewrite frames. + */ +/** Delivers a frame to a transport a few milliseconds later, like a relay retry. */ +function deliverLater(transport: Transport, frame: TransportMessage, ms = 5): void { + setTimeout(() => transport.onmessage?.(frame), ms); +} + +function createLink() { + const toDaemon: Frame[] = []; + const toClient: Frame[] = []; + let interceptToDaemon: Interceptor = (frame) => frame; + let interceptToClient: Interceptor = (frame) => frame; + + const daemon: Transport = { + send: vi.fn((data: string | ArrayBuffer) => { + const recorded = toFrame(data); + toClient.push(recorded); + const frame = interceptToClient(recorded); + if (frame) setTimeout(() => client.onmessage?.(frame), 0); + }), + close: vi.fn(), + onmessage: null, + onclose: null, + onerror: null, + }; + const client: Transport = { + send: vi.fn((data: string | ArrayBuffer) => { + const recorded = toFrame(data); + toDaemon.push(recorded); + const frame = interceptToDaemon(recorded); + if (frame) setTimeout(() => daemon.onmessage?.(frame), 0); + }), + // The client may run in a browser: WebSocket.close throws on any code other + // than 1000 or 3000-4999 and leaves the socket open, so mirror that here. + close: vi.fn((code?: number, reason?: string) => { + if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) { + throw new DOMException( + `The code must be either 1000, or between 3000 and 4999. ${code} is neither.`, + "InvalidAccessError", + ); + } + setTimeout(() => client.onclose?.(code ?? 1005, reason ?? ""), 0); + }), + onmessage: null, + onclose: null, + onerror: null, + }; + return { + daemon, + client, + recordedToDaemon: toDaemon, + recordedToClient: toClient, + setToDaemon(fn: Interceptor) { + interceptToDaemon = fn; + }, + setToClient(fn: Interceptor) { + interceptToClient = fn; + }, + }; +} + +function toFrame(data: string | ArrayBuffer): Frame { + return { data, isBinary: data instanceof ArrayBuffer }; +} + +async function settle(ms = 30): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +interface Pair { + link: ReturnType; + daemonKeyPair: ReturnType; + client: SecureChannel; + daemon: SecureChannel; + daemonReceived: Array; + clientReceived: Array; +} + +async function openProtectedPair( + options: { + link?: ReturnType; + daemonKeyPair?: ReturnType; + clientMinimum?: "legacy" | "protected"; + daemonMinimum?: "legacy" | "protected"; + } = {}, +): Promise { + const link = options.link ?? createLink(); + const daemonKeyPair = options.daemonKeyPair ?? generateKeyPair(); + const daemonReceived: Array = []; + const clientReceived: Array = []; + const opened = deferred(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + minimumMode: options.daemonMinimum, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + minimumMode: options.clientMinimum, + events: { onopen: () => opened.resolve(), onmessage: (data) => clientReceived.push(data) }, + }); + const daemon = await daemonPromise; + await opened.promise; + return { link, daemonKeyPair, client, daemon, daemonReceived, clientReceived }; +} + +function bytes(length: number): ArrayBuffer { + return new Uint8Array(length).map((_, i) => (i * 13 + 1) & 0xff).buffer; +} + +describe("secure channel: protected mode", () => { + it("negotiates protected mode between two updated peers and carries both frame kinds", async () => { + const pair = await openProtectedPair(); + expect(pair.client.securityMode()).toBe("protected"); + expect(pair.daemon.securityMode()).toBe("protected"); + + await pair.client.send("hello daemon"); + await pair.client.send(bytes(5)); + await pair.daemon.send("hello client"); + await pair.daemon.send(bytes(7)); + await settle(); + + expect(pair.daemonReceived[0]).toBe("hello daemon"); + expect(new Uint8Array(pair.daemonReceived[1] as ArrayBuffer)).toEqual(new Uint8Array(bytes(5))); + expect(pair.clientReceived[0]).toBe("hello client"); + expect(new Uint8Array(pair.clientReceived[1] as ArrayBuffer)).toEqual(new Uint8Array(bytes(7))); + }); + + it("keeps text as base64 text frames and binary as binary frames when negotiated", async () => { + const pair = await openProtectedPair(); + pair.link.recordedToDaemon.length = 0; + await pair.client.send("text"); + await pair.client.send(bytes(3)); + await settle(); + expect(pair.link.recordedToDaemon.map((frame) => frame.isBinary)).toEqual([false, true]); + expect(pair.link.recordedToDaemon[0].data).not.toContain("text"); + expect(pair.client.outboundWireByteLength(bytes(3))).toBe(3 + 2 + 6 + 16); + expect(pair.client.outboundWireByteLength("text")).toBe(4 * Math.ceil((4 + 24) / 3)); + }); + + it("carries frames larger than one Noise message", async () => { + const pair = await openProtectedPair(); + const big = bytes(MAX_CHUNK_DATA_BYTES * 2 + 100); + await pair.client.send(big); + await settle(100); + expect(new Uint8Array(pair.daemonReceived[0] as ArrayBuffer)).toEqual(new Uint8Array(big)); + }); + + it("flushes sends queued before the handshake completes, in order", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonReceived: Array = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await client.send("one"); + await client.send("two"); + expect(client.isOpen()).toBe(false); + await daemonPromise; + await settle(); + expect(daemonReceived).toEqual(["one", "two"]); + }); + + it("rejects an exact duplicate record and delivers nothing twice", async () => { + const pair = await openProtectedPair(); + let captured: Frame | null = null; + pair.link.setToDaemon((frame) => { + captured ??= frame; + return frame; + }); + await pair.client.send("once"); + await settle(); + expect(pair.daemonReceived).toEqual(["once"]); + + pair.link.daemon.onmessage?.(captured!); + await settle(); + expect(pair.daemonReceived).toEqual(["once"]); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); + + it("rejects a reflected record", async () => { + const pair = await openProtectedPair(); + let captured: Frame | null = null; + pair.link.setToDaemon((frame) => { + captured ??= frame; + return frame; + }); + await pair.client.send("mine"); + await settle(); + pair.link.client.onmessage?.(captured!); + await settle(); + expect(pair.clientReceived).toEqual([]); + expect(pair.link.client.close).toHaveBeenCalledWith(4003, expect.any(String)); + }); + + it("closes the browser socket and reports the close when a record fails at the client", async () => { + const closes: Array<[number, string]> = []; + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + events: { onclose: (code, reason) => closes.push([code, reason]) }, + }); + await daemonPromise; + await settle(); + link.client.onmessage?.({ + data: Buffer.from("not a record").toString("base64"), + isBinary: false, + }); + await settle(); + expect(client.isOpen()).toBe(false); + expect(closes).toEqual([[4003, expect.any(String)]]); + }); + + it("rejects out-of-order records and delivers nothing from them", async () => { + const pair = await openProtectedPair(); + const held: Frame[] = []; + pair.link.setToDaemon((frame) => { + held.push(frame); + return null; + }); + await pair.client.send("first"); + await pair.client.send("second"); + await settle(); + expect(held).toHaveLength(2); + pair.link.daemon.onmessage?.(held[1]); + await settle(); + expect(pair.daemonReceived).toEqual([]); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + pair.link.daemon.onmessage?.(held[0]); + await settle(); + expect(pair.daemonReceived).toEqual([]); + }); + + it("rejects a tampered record without delivering it", async () => { + const pair = await openProtectedPair(); + pair.link.setToDaemon((frame) => { + const tampered = new Uint8Array((frame.data as ArrayBuffer).slice(0)); + tampered[tampered.byteLength - 1] ^= 0x01; + return { data: tampered.buffer, isBinary: true }; + }); + await pair.client.send(bytes(10)); + await settle(); + expect(pair.daemonReceived).toEqual([]); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); + + it("closes on plaintext application traffic", async () => { + const pair = await openProtectedPair(); + pair.link.daemon.onmessage?.({ data: JSON.stringify({ type: "hello" }), isBinary: false }); + await settle(); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + expect(pair.daemonReceived).toEqual([]); + }); + + it("answers an identical hello retry with the identical ready and keeps counters", async () => { + const pair = await openProtectedPair(); + const hello = pair.link.recordedToDaemon.find( + (frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_hello"'), + ); + const readyFrames = () => + (pair.link.daemon.send as ReturnType).mock.calls + .map(([data]) => data) + .filter((data) => typeof data === "string" && data.includes('"e2ee_ready"')); + expect(hello).toBeDefined(); + expect(readyFrames()).toHaveLength(1); + + pair.link.daemon.onmessage?.(hello!); + await settle(); + expect(readyFrames()).toHaveLength(2); + expect(readyFrames()[0]).toBe(readyFrames()[1]); + expect(pair.link.daemon.close).not.toHaveBeenCalled(); + + await pair.client.send("still protected"); + await pair.daemon.send("both ways"); + await settle(); + expect(pair.daemonReceived).toEqual(["still protected"]); + expect(pair.clientReceived).toEqual(["both ways"]); + }); + + it("closes when a different hello arrives on an open channel", async () => { + const pair = await openProtectedPair(); + const hello = pair.link.recordedToDaemon.find( + (frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_hello"'), + )!; + const changed = (hello.data as string).replace('"v":1', '"v":1 '); + expect(changed).not.toBe(hello.data); + pair.link.daemon.onmessage?.({ data: changed, isBinary: false }); + await settle(); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1008, expect.stringContaining("mismatch")); + }); + + it("answers an identical hello that arrives while the ready send is in flight", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const sendFromDaemon = link.daemon.send; + link.daemon.send = vi.fn(async (data: string | ArrayBuffer) => { + await settle(20); + sendFromDaemon(data); + }); + link.setToDaemon((frame) => { + // The relay delivers the client's 1s retry while the daemon is still sending ready. + if (typeof frame.data === "string" && frame.data.includes('"e2ee_hello"')) { + deliverLater(link.daemon, frame); + } + return frame; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + const daemon = await daemonPromise; + await settle(80); + const readyFrames = link.recordedToClient.filter( + (frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_ready"'), + ); + expect(readyFrames).toHaveLength(2); + expect(readyFrames[0].data).toBe(readyFrames[1].data); + expect(link.daemon.close).not.toHaveBeenCalled(); + expect(client.isOpen()).toBe(true); + expect(daemon.isOpen()).toBe(true); + }); + + it("closes when a different hello arrives while the ready send is in flight", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const sendFromDaemon = link.daemon.send; + link.daemon.send = vi.fn(async (data: string | ArrayBuffer) => { + await settle(20); + sendFromDaemon(data); + }); + link.setToDaemon((frame) => { + if (typeof frame.data === "string" && frame.data.includes('"e2ee_hello"')) { + const changed = frame.data.replace('"v":1', '"v":1 '); + deliverLater(link.daemon, { data: changed, isBinary: false }); + } + return frame; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + const daemon = await daemonPromise; + await settle(80); + expect(link.daemon.close).toHaveBeenCalledWith(1008, expect.stringContaining("mismatch")); + expect(daemon.isOpen()).toBe(false); + }); + + it("does not let a recorded hello and records replay into a fresh daemon channel", async () => { + // Session 1: a legitimate client talks to the daemon; the relay records everything. + const first = await openProtectedPair(); + await first.client.send("secret command"); + await first.client.send(bytes(4)); + await settle(); + expect(first.daemonReceived).toHaveLength(2); + const transcript = first.link.recordedToDaemon.slice(); + expect(transcript.length).toBeGreaterThanOrEqual(3); + + // Session 2: the same daemon key pair (restart or reconnect), attacker replays the transcript. + const link = createLink(); + const replayed: Array = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair: first.daemonKeyPair, + events: { onmessage: (data) => replayed.push(data) }, + }); + for (const frame of transcript) link.daemon.onmessage?.(frame); + const daemon = await daemonPromise; + await settle(); + expect(daemon.securityMode()).toBe("protected"); + expect(replayed).toEqual([]); + expect(link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); +}); + +describe("secure channel: negotiation and policy", () => { + it("rejects a present but invalid protected hello instead of falling back", async () => { + for (const mutate of [ + (hello: Record) => ({ ...hello, protected: { v: 2, message: "AAAA" } }), + (hello: Record) => ({ ...hello, protected: { v: 1, message: "AAAA" } }), + (hello: Record) => ({ ...hello, protected: "nope" }), + ]) { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToDaemon((frame) => { + if (typeof frame.data !== "string") return frame; + return { data: JSON.stringify(mutate(JSON.parse(frame.data))), isBinary: false }; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await expect(daemonPromise).rejects.toThrow(); + expect((link.daemon.send as ReturnType).mock.calls).toHaveLength(0); + } + }); + + it("rejects a malformed protected ready instead of waiting for a legacy one", async () => { + for (const protectedValue of [ + null, + {}, + { v: 1, message: "" }, + { v: "1", message: "AAAA" }, + { v: 1, message: 7 }, + [], + ]) { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + // A tampered ready first, then the daemon's genuine legacy-shaped ready. + deliverLater(link.client, { + data: JSON.stringify({ type: "e2ee_ready" }), + isBinary: false, + }); + return { data: JSON.stringify({ ...ready, protected: protectedValue }), isBinary: false }; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await daemonPromise; + await settle(40); + expect(link.client.close).toHaveBeenCalledWith(4002, expect.stringContaining("malformed")); + expect(client.isOpen()).toBe(false); + await expect(client.send("never")).rejects.toThrow("Channel not open"); + } + }); + + it("rejects a tampered protected ready and never retries legacy", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + const message = Buffer.from(ready.protected.message, "base64"); + message[message.byteLength - 1] ^= 0x01; + ready.protected.message = message.toString("base64"); + return { data: JSON.stringify(ready), isBinary: false }; + }); + const errors: Error[] = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + events: { onerror: (error) => errors.push(error) }, + }); + await daemonPromise; + await settle(); + expect(client.isOpen()).toBe(false); + expect(link.client.close).toHaveBeenCalledWith(4002, expect.any(String)); + expect(errors).toHaveLength(1); + await expect(client.send("x")).rejects.toThrow("Channel not open"); + }); + + it("rejects a ready that selects an unoffered parameter", async () => { + // The daemon cannot do this by itself; a forged msg2 would need the keys, so the + // check is exercised by driving the decoder with a crafted selection. + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + ready.capabilities = { binaryCiphertext: true }; + return { data: JSON.stringify(ready), isBinary: false }; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await daemonPromise; + await settle(); + // Unauthenticated outer capabilities are ignored in protected mode. + expect(client.isOpen()).toBe(true); + expect(client.securityMode()).toBe("protected"); + }); + + it("stripping protected from the ready does not downgrade a protected daemon", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + delete ready.protected; + return { data: JSON.stringify(ready), isBinary: false }; + }); + const daemonReceived: unknown[] = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await daemonPromise; + await settle(); + expect(client.securityMode()).toBe("legacy"); + await client.send("legacy record"); + await settle(); + expect(daemonReceived).toEqual([]); + expect(link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); + + it("client with a protected minimum refuses a legacy-only daemon", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const errors: Error[] = []; + const daemonPromise = createDaemonChannel(link.daemon, daemonKeyPair); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + minimumMode: "protected", + events: { onerror: (error) => errors.push(error) }, + }); + await daemonPromise; + await settle(); + expect(client.isOpen()).toBe(false); + expect(link.client.close).toHaveBeenCalledWith(4002, expect.stringContaining("legacy")); + }); + + it("daemon with a protected minimum refuses a legacy-only client", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + minimumMode: () => "protected", + }); + await createClientChannel(link.client, exportPublicKey(daemonKeyPair.publicKey)); + await expect(daemonPromise).rejects.toBeInstanceOf(SecureChannelRejectedError); + await expect(daemonPromise).rejects.toMatchObject({ + closeCode: 1008, + closeReason: "protected endpoint security required", + }); + expect((link.daemon.send as ReturnType).mock.calls).toHaveLength(0); + }); +}); + +describe("secure channel: legacy compatibility", () => { + it("new client against an old daemon negotiates legacy and exchanges records", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonReceived: unknown[] = []; + const clientReceived: unknown[] = []; + const opened = deferred(); + const daemonPromise = createDaemonChannel(link.daemon, daemonKeyPair, { + onmessage: (data) => daemonReceived.push(data), + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + events: { onopen: () => opened.resolve(), onmessage: (data) => clientReceived.push(data) }, + }); + const daemon = await daemonPromise; + await opened.promise; + expect(client.securityMode()).toBe("legacy"); + await client.send("from new client"); + await daemon.send("from old daemon"); + await settle(); + expect(daemonReceived).toEqual(["from new client"]); + expect(clientReceived).toEqual(["from old daemon"]); + }); + + it("old client against a new daemon negotiates legacy and keeps legacy retry semantics", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonReceived: unknown[] = []; + const opened = deferred(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await createClientChannel( + link.client, + exportPublicKey(daemonKeyPair.publicKey), + { + onopen: () => opened.resolve(), + }, + ); + const daemon = await daemonPromise; + await opened.promise; + expect(daemon.securityMode()).toBe("legacy"); + const hello = (link.client.send as ReturnType).mock.calls.find( + ([data]) => typeof data === "string" && data.includes('"e2ee_hello"'), + )?.[0] as string; + link.daemon.onmessage?.({ data: hello, isBinary: false }); + await settle(); + expect(link.daemon.close).not.toHaveBeenCalled(); + await client.send("legacy still works"); + await settle(); + expect(daemonReceived).toEqual(["legacy still works"]); + }); +}); diff --git a/packages/relay/src/secure-channel.ts b/packages/relay/src/secure-channel.ts new file mode 100644 index 0000000000..760dcc49d0 --- /dev/null +++ b/packages/relay/src/secure-channel.ts @@ -0,0 +1,835 @@ +/// +/** + * Endpoint security for relay-carried daemon sessions. + * + * This is the one entrypoint both endpoints use to secure a transport. It owns + * the handshake, record protection, counters, retries, termination, and the + * negotiated security mode. Relay adapters own routing and physical I/O; + * daemon admission owns credentials and authorization. + * + * Two modes exist during migration: + * + * - "protected": conforming Noise_NK_25519_ChaChaPoly_SHA256 bound to the + * daemon's static key, with authenticated record framing. Handshake + * messages travel byte-exact (base64) inside the existing `e2ee_hello` / + * `e2ee_ready` JSON envelopes under `protected`. + * - "legacy": the pre-existing ephemeral-static box construction in + * `encrypted-channel.ts`. It has no replay protection. Chosen only when the + * peer omits `protected` (an older peer) and policy allows it. + * + * A present but invalid `protected` field never falls back to legacy. + * + * See docs/relay-endpoint-security.md for the byte-level mapping. + */ + +import { + EncryptedChannel, + base64EncryptedWireByteLength, + maxBase64EncryptedPlaintextByteLength, + type EncryptedChannelEvents, + type Transport, + type TransportMessage, +} from "./encrypted-channel.js"; +import { + deriveSharedKey, + exportPublicKey, + generateKeyPair, + importPublicKey, + type KeyPair, +} from "./crypto.js"; +import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; +import { + createNoiseInitiator, + createNoiseResponder, + NoiseProtocolError, + type CipherState, + type NoiseHandshake, +} from "./noise/index.js"; +import { + decodeProtectedRecord, + encodeProtectedRecord, + protectedRecordByteLength, +} from "./protected-records.js"; + +export type SecurityMode = "legacy" | "protected"; + +export type SecureChannelEvents = EncryptedChannelEvents; + +export interface SecureChannel { + send(data: string | ArrayBuffer): Promise; + close(code?: number, reason?: string): void; + isOpen(): boolean; + securityMode(): SecurityMode; + /** Bytes the transport will carry for `data`, including protection overhead. */ + outboundWireByteLength(data: string | ArrayBuffer): number; + onTransitionToOpen(cb: () => void): void; + onClose(cb: () => void): void; +} + +export interface ClientSecureChannelOptions { + daemonPublicKeyB64: string; + /** Lowest mode this client accepts. "protected" refuses a daemon that only offers legacy. */ + minimumMode?: SecurityMode; + events?: SecureChannelEvents; +} + +export interface DaemonSecureChannelOptions { + daemonKeyPair: KeyPair; + /** + * Lowest mode this daemon admits. "protected" refuses a client that only + * offers legacy. A function is evaluated when the hello arrives so a policy + * change applies to every admission from that moment on. + */ + minimumMode?: SecurityMode | (() => SecurityMode); + events?: SecureChannelEvents; +} + +/** Thrown by {@link openDaemonSecureChannel} when the handshake is refused; carries the close to send. */ +export class SecureChannelRejectedError extends Error { + constructor( + message: string, + readonly closeCode: number, + readonly closeReason: string, + ) { + super(message); + this.name = "SecureChannelRejectedError"; + } +} + +export const PROTECTED_MODE_REQUIRED_CLOSE_CODE = 1008; +export const PROTECTED_MODE_REQUIRED_CLOSE_REASON = "protected endpoint security required"; +/** Client-side close when a protected handshake fails. Browser-safe (4000-4999). */ +export const PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE = 4002; +/** + * Client-side close when an inbound record fails authentication or framing. + * Browser WebSocket.close accepts only 1000 or 3000-4999, so a client must + * never try to send a 1011. + */ +export const PROTECTED_RECORD_FAILED_CLOSE_CODE = 4003; +/** Daemon-side close for the same failures; the daemon runs on Node's ws. */ +const DAEMON_RECORD_FAILURE_CLOSE_CODE = 1011; +const REHANDSHAKE_REJECTION_CODE = 1008; +const REHANDSHAKE_MISMATCH_CLOSE_REASON = "E2EE re-handshake transcript mismatch"; + +const NOISE_PROLOGUE = new TextEncoder().encode("paseo-relay-e2ee/1"); +const PROTECTED_ENVELOPE_VERSION = 1; +const PARAMS_VERSION = 0x01; +const PARAM_FLAG_BINARY_CIPHERTEXT = 0x01; +const PARAM_FLAGS_KNOWN = PARAM_FLAG_BINARY_CIPHERTEXT; +const HANDSHAKE_RETRY_MS = 1000; +const MAX_PENDING_SENDS = 200; + +interface ProtectedEnvelope { + v: number; + message: string; +} + +interface HelloEnvelope { + type: "e2ee_hello"; + key: string; + capabilities?: { binaryCiphertext?: boolean }; + protected?: ProtectedEnvelope; +} + +interface ReadyEnvelope { + type: "e2ee_ready"; + capabilities?: { binaryCiphertext?: boolean }; + protected?: ProtectedEnvelope; +} + +interface NegotiatedParams { + binaryCiphertext: boolean; +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/** + * Secures a transport as the initiator. Resolves once the hello has been sent; + * `events.onopen` fires when the channel is usable. Sends queued before open + * are flushed in order after the handshake, capped at {@link MAX_PENDING_SENDS}. + */ +export async function openClientSecureChannel( + transport: Transport, + options: ClientSecureChannelOptions, +): Promise { + const events = options.events ?? {}; + const minimumMode = options.minimumMode ?? "legacy"; + const daemonPublicKey = importPublicKey(options.daemonPublicKeyB64); + + // Separate ephemeral keys per protocol: the legacy box key is routing and + // compatibility input only; the Noise ephemeral lives inside the handshake. + const legacyKeyPair = generateKeyPair(); + const legacySharedKey = deriveSharedKey(legacyKeyPair.secretKey, daemonPublicKey); + const noise = createNoiseInitiator({ remoteStatic: daemonPublicKey, prologue: NOISE_PROLOGUE }); + const offered: NegotiatedParams = { binaryCiphertext: true }; + const msg1 = noise.writeMessage(encodeParams(offered)); + const hello: HelloEnvelope = { + type: "e2ee_hello", + key: exportPublicKey(legacyKeyPair.publicKey), + capabilities: { binaryCiphertext: true }, + protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg1)) }, + }; + const helloText = JSON.stringify(hello); + + const channel = new ClientSecureChannel(transport, events, { + minimumMode, + noise, + offered, + legacySharedKey, + }); + + let retry: ReturnType | null = null; + const emitSendError = (error: unknown) => { + events.onerror?.(error instanceof Error ? error : new Error(String(error))); + }; + const sendHello = () => { + try { + const result = transport.send(helloText); + if (result) void result.catch(emitSendError); + } catch (error) { + // Daemon restarts move the socket through CLOSING/CLOSED; report, never throw from timers. + emitSendError(error); + } + }; + const clearRetry = () => { + if (retry) { + clearInterval(retry); + retry = null; + } + }; + channel.onTransitionToOpen(clearRetry); + channel.onClose(clearRetry); + + sendHello(); + retry = setInterval(() => { + if (channel.isOpen()) { + clearRetry(); + return; + } + sendHello(); + }, HANDSHAKE_RETRY_MS); + unref(retry); + + return channel; +} + +interface ClientHandshakeState { + minimumMode: SecurityMode; + noise: NoiseHandshake; + offered: NegotiatedParams; + legacySharedKey: Uint8Array; +} + +/** + * Client channel: waits for the daemon's ready, selects the mode, then + * delegates records to the mode's implementation. + */ +class ClientSecureChannel implements SecureChannel { + private state: "handshaking" | "open" | "closed" = "handshaking"; + private closeNotified = false; + private inner: InnerChannel | null = null; + private pendingSends: Array = []; + private readonly onOpenCallbacks: Array<() => void> = []; + private readonly onCloseCallbacks: Array<() => void> = []; + private handshake: ClientHandshakeState | null; + + constructor( + private readonly transport: Transport, + private readonly events: SecureChannelEvents, + handshake: ClientHandshakeState, + ) { + this.handshake = handshake; + Object.assign(transport, { + onmessage: (message: TransportMessage) => this.handleHandshakeMessage(message), + onclose: (code: number, reason: string) => this.handleTransportClose(code, reason), + onerror: (error: Error) => this.events.onerror?.(error), + }); + } + + private handleHandshakeMessage(message: TransportMessage): void { + if (this.state !== "handshaking" || !this.handshake) return; + let parsed: unknown; + try { + if (message.isBinary) return; + parsed = JSON.parse(decodeTransportText(message.data)); + } catch { + return; // not handshake traffic; ignore while handshaking + } + if (!isRecord(parsed) || parsed.type !== "e2ee_ready") return; + + try { + // A ready the daemon recognizably sent but that fails validation is a + // broken or tampered handshake, never unrelated traffic to wait past. + if (!isReadyEnvelope(parsed)) throw new NoiseProtocolError("malformed ready envelope"); + const ready: ReadyEnvelope = parsed; + if (ready.protected !== undefined) { + this.openProtected(ready.protected); + } else { + this.openLegacy(ready); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.events.onerror?.(err); + this.fail(PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, err.message); + } + } + + private openProtected(envelope: ProtectedEnvelope): void { + const handshake = this.handshake!; + if (envelope.v !== PROTECTED_ENVELOPE_VERSION) { + throw new NoiseProtocolError("unsupported protected envelope version"); + } + const msg2 = new Uint8Array(base64ToArrayBuffer(envelope.message)); + const selected = decodeParams(handshake.noise.readMessage(msg2)); + if (selected.binaryCiphertext && !handshake.offered.binaryCiphertext) { + throw new NoiseProtocolError("daemon selected an unoffered parameter"); + } + const keys = handshake.noise.split(); + this.becomeOpen( + new ProtectedChannel(this.transport, this.events, keys.send, keys.receive, selected, { + retry: null, + failureCloseCode: PROTECTED_RECORD_FAILED_CLOSE_CODE, + }), + ); + } + + private openLegacy(ready: ReadyEnvelope): void { + const handshake = this.handshake!; + if (handshake.minimumMode === "protected") { + throw new Error("daemon offered only legacy endpoint security"); + } + const legacy = new EncryptedChannel(this.transport, handshake.legacySharedKey, this.events, { + binaryCiphertext: ready.capabilities?.binaryCiphertext === true, + }); + legacy.setState("open"); + this.becomeOpen(new LegacyInner(legacy)); + } + + private becomeOpen(inner: InnerChannel): void { + this.handshake = null; + this.inner = inner; + this.state = "open"; + // The mode implementation now owns the transport callbacks; mirror its close. + inner.onClose(() => this.notifyClosed()); + this.events.onopen?.(); + for (const cb of this.onOpenCallbacks) cb(); + const pending = this.pendingSends; + this.pendingSends = []; + void (async () => { + try { + for (const item of pending) await inner.send(item); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.events.onerror?.(err); + this.fail(PROTECTED_RECORD_FAILED_CLOSE_CODE, err.message); + } + })(); + } + + /** Transport closed while this wrapper still owned its callbacks (handshaking). */ + private handleTransportClose(code: number, reason: string): void { + this.events.onclose?.(code, reason); + this.notifyClosed(); + } + + private notifyClosed(): void { + this.state = "closed"; + this.handshake = null; + if (this.closeNotified) return; + this.closeNotified = true; + for (const cb of this.onCloseCallbacks) cb(); + } + + private fail(code: number, reason: string): void { + if (this.state === "closed") return; + this.state = "closed"; + this.handshake = null; + try { + this.transport.close(code, reason); + } catch { + // ignore + } + } + + async send(data: string | ArrayBuffer): Promise { + if (this.state === "handshaking") { + if (this.pendingSends.length >= MAX_PENDING_SENDS) this.pendingSends.shift(); + this.pendingSends.push(data); + return; + } + if (this.state !== "open" || !this.inner) throw new Error("Channel not open"); + await this.inner.send(data); + } + + close(code = 1000, reason = "Normal closure"): void { + if (this.inner) { + this.inner.close(code, reason); + } else { + this.transport.close(code, reason); + } + this.state = "closed"; + this.handshake = null; + } + + isOpen(): boolean { + return this.state === "open" && this.inner?.isOpen() === true; + } + + securityMode(): SecurityMode { + if (!this.inner) throw new Error("security mode is unknown until the channel opens"); + return this.inner.mode; + } + + outboundWireByteLength(data: string | ArrayBuffer): number { + if (this.inner) return this.inner.outboundWireByteLength(data); + // Mode unknown while handshaking: report the larger of the two so callers stay within limits. + const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength; + return Math.max( + base64EncryptedWireByteLength(plaintextBytes), + 4 * Math.ceil(protectedRecordByteLength(plaintextBytes) / 3), + ); + } + + onTransitionToOpen(cb: () => void): void { + this.onOpenCallbacks.push(cb); + } + + onClose(cb: () => void): void { + this.onCloseCallbacks.push(cb); + } +} + +// --------------------------------------------------------------------------- +// Daemon +// --------------------------------------------------------------------------- + +/** + * Secures a transport as the responder. Resolves once the peer's hello has + * been accepted and the ready sent. Rejects with + * {@link SecureChannelRejectedError} when policy refuses the peer, and with a + * plain error for malformed or failed handshakes; the caller closes the socket. + */ +export async function openDaemonSecureChannel( + transport: Transport, + options: DaemonSecureChannelOptions, +): Promise { + const events = options.events ?? {}; + const minimumMode = (): SecurityMode => + typeof options.minimumMode === "function" + ? options.minimumMode() + : (options.minimumMode ?? "legacy"); + return new Promise((resolve, reject) => { + const buffered: TransportMessage[] = []; + + const handleHello = async (message: TransportMessage): Promise => { + try { + if (message.isBinary) throw new Error("Invalid hello message (binary frame)"); + const helloText = decodeTransportText(message.data); + let parsed: unknown; + try { + parsed = JSON.parse(helloText); + } catch { + throw new Error(`Invalid hello message: ${preview(helloText)}`); + } + if (!isHelloEnvelope(parsed)) + throw new Error(`Invalid hello message: ${preview(helloText)}`); + + // Buffer traffic that arrives while the ready is in flight so an early + // record is not mistaken for a second hello. + Object.assign(transport, { onmessage: (next: TransportMessage) => buffered.push(next) }); + + const channel = + parsed.protected !== undefined + ? await acceptProtectedHello( + transport, + events, + options.daemonKeyPair, + parsed, + helloText, + ) + : await acceptLegacyHello( + transport, + events, + options.daemonKeyPair, + parsed, + minimumMode(), + ); + + events.onopen?.(); + // The channel now owns the transport: buffered records are decoded and a + // buffered hello retry gets the same exact-transcript answer as a live one. + for (const next of buffered) transport.onmessage?.(next); + resolve(channel); + } catch (error) { + reject(error); + } + }; + + Object.assign(transport, { + onmessage: handleHello, + onerror: (error: Error) => reject(error), + onclose: (code: number, reason: string) => + reject(new Error(`Connection closed during handshake: ${code} ${reason}`)), + }); + }); +} + +async function acceptProtectedHello( + transport: Transport, + events: SecureChannelEvents, + daemonKeyPair: KeyPair, + hello: HelloEnvelope, + helloText: string, +): Promise { + const envelope = hello.protected!; + if (envelope.v !== PROTECTED_ENVELOPE_VERSION) { + throw new NoiseProtocolError("unsupported protected envelope version"); + } + const noise = createNoiseResponder({ localStatic: daemonKeyPair, prologue: NOISE_PROLOGUE }); + const msg1 = new Uint8Array(base64ToArrayBuffer(envelope.message)); + const offered = decodeParams(noise.readMessage(msg1)); + const selected: NegotiatedParams = { binaryCiphertext: offered.binaryCiphertext }; + const msg2 = noise.writeMessage(encodeParams(selected)); + const ready: ReadyEnvelope = { + type: "e2ee_ready", + protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg2)) }, + }; + const readyText = JSON.stringify(ready); + await transport.send(readyText); + const keys = noise.split(); + return new ProtectedChannel(transport, events, keys.send, keys.receive, selected, { + retry: { helloText, readyText }, + failureCloseCode: DAEMON_RECORD_FAILURE_CLOSE_CODE, + }); +} + +async function acceptLegacyHello( + transport: Transport, + events: SecureChannelEvents, + daemonKeyPair: KeyPair, + hello: HelloEnvelope, + minimumMode: SecurityMode, +): Promise { + if (minimumMode === "protected") { + throw new SecureChannelRejectedError( + "client offered only legacy endpoint security", + PROTECTED_MODE_REQUIRED_CLOSE_CODE, + PROTECTED_MODE_REQUIRED_CLOSE_REASON, + ); + } + const clientPublicKey = importPublicKey(hello.key); + const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey); + const binaryCiphertext = hello.capabilities?.binaryCiphertext === true; + await transport.send( + JSON.stringify({ + type: "e2ee_ready", + ...(binaryCiphertext ? { capabilities: { binaryCiphertext: true } } : {}), + } satisfies ReadyEnvelope), + ); + const legacy = new EncryptedChannel(transport, sharedKey, events, { + daemonKeyPair, + binaryCiphertext, + }); + legacy.setState("open"); + return new LegacyInner(legacy); +} + +// --------------------------------------------------------------------------- +// Mode implementations +// --------------------------------------------------------------------------- + +interface InnerChannel extends SecureChannel { + readonly mode: SecurityMode; +} + +class LegacyInner implements InnerChannel { + readonly mode = "legacy" as const; + constructor(readonly legacy: EncryptedChannel) {} + send(data: string | ArrayBuffer): Promise { + return this.legacy.send(data); + } + close(code?: number, reason?: string): void { + this.legacy.close(code, reason); + } + isOpen(): boolean { + return this.legacy.isOpen(); + } + securityMode(): SecurityMode { + return this.mode; + } + outboundWireByteLength(data: string | ArrayBuffer): number { + return this.legacy.outboundWireByteLength(data); + } + onTransitionToOpen(cb: () => void): void { + this.legacy.onTransitionToOpen(cb); + } + onClose(cb: () => void): void { + this.legacy.onClose(cb); + } +} + +interface DaemonRetryTranscript { + helloText: string; + readyText: string; +} + +/** + * Protected records over an open transport. Every inbound record is + * authenticated and framing-checked before delivery; any failure closes the + * channel and clears the keys. + */ +class ProtectedChannel implements InnerChannel { + readonly mode = "protected" as const; + private state: "open" | "closed" = "open"; + private readonly onCloseCallbacks: Array<() => void> = []; + + constructor( + private readonly transport: Transport, + private readonly events: SecureChannelEvents, + private readonly sender: CipherState, + private readonly receiver: CipherState, + private readonly params: NegotiatedParams, + private readonly role: { + /** Daemon side only: exact transcript used to answer identical retries. */ + retry: DaemonRetryTranscript | null; + failureCloseCode: number; + }, + ) { + Object.assign(transport, { + onmessage: (message: TransportMessage) => this.handleMessage(message), + onclose: (code: number, reason: string) => { + this.markClosed(); + this.events.onclose?.(code, reason); + }, + onerror: (error: Error) => this.events.onerror?.(error), + }); + } + + private async handleMessage(message: TransportMessage): Promise { + if (this.state !== "open") return; + try { + const handshake = !message.isBinary ? parseHandshakeEnvelope(message.data) : null; + if (handshake) { + await this.handleHandshakeTraffic(handshake); + return; + } + const record = message.isBinary + ? new Uint8Array(requireArrayBuffer(message.data)) + : new Uint8Array(base64ToArrayBuffer(decodeTransportText(message.data))); + const frame = decodeProtectedRecord(record, this.receiver); + const plaintext = + frame.kind === "text" + ? new TextDecoder("utf-8", { fatal: true }).decode(frame.data) + : toArrayBuffer(frame.data); + this.events.onmessage?.(plaintext); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.fail(this.role.failureCloseCode, err.message); + } + } + + private async handleHandshakeTraffic( + handshake: { kind: "hello"; text: string } | { kind: "ready" }, + ): Promise { + if (handshake.kind === "ready") return; // client ignores duplicate ready after open + if (!this.role.retry) return; // a client never answers hellos + if (handshake.text === this.role.retry.helloText) { + await this.transport.send(this.role.retry.readyText); + return; + } + this.fail(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_MISMATCH_CLOSE_REASON); + } + + async send(data: string | ArrayBuffer): Promise { + if (this.state !== "open") throw new Error("Channel not open"); + const kind = typeof data === "string" ? "text" : "binary"; + const frame = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); + let wire: Uint8Array; + try { + // Synchronous up to here: the nonce is reserved before any await. + wire = encodeProtectedRecord(kind, frame, this.sender); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.fail(this.role.failureCloseCode, err.message); + throw err; + } + if (this.params.binaryCiphertext && kind === "binary") { + await this.transport.send(toArrayBuffer(wire)); + return; + } + await this.transport.send(arrayBufferToBase64(toArrayBuffer(wire))); + } + + outboundWireByteLength(data: string | ArrayBuffer): number { + const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength; + const wireBytes = protectedRecordByteLength(plaintextBytes); + if (this.params.binaryCiphertext && typeof data !== "string") return wireBytes; + return 4 * Math.ceil(wireBytes / 3); + } + + close(code = 1000, reason = "Normal closure"): void { + this.markClosed(); + this.transport.close(code, reason); + } + + private fail(code: number, reason: string): void { + if (this.state !== "open") return; + this.markClosed(); + try { + this.transport.close(code, reason); + } catch { + // ignore + } + } + + private markClosed(): void { + if (this.state === "closed") return; + this.state = "closed"; + this.sender.clear(); + this.receiver.clear(); + for (const cb of this.onCloseCallbacks) cb(); + } + + isOpen(): boolean { + return this.state === "open"; + } + + securityMode(): SecurityMode { + return this.mode; + } + + onTransitionToOpen(cb: () => void): void { + cb(); + } + + onClose(cb: () => void): void { + this.onCloseCallbacks.push(cb); + } +} + +/** + * Largest plaintext that fits in `wireBytes` as a base64 text frame in either + * mode. Callers size payloads before the mode is known, so take the tighter bound. + */ +export function maxSecuredPlaintextByteLength(wireBytes: number): number { + const legacy = maxBase64EncryptedPlaintextByteLength(wireBytes); + const base64Capacity = Math.floor(wireBytes / 4) * 3; + let protectedBound = base64Capacity; + while (protectedBound > 0 && protectedRecordByteLength(protectedBound) > base64Capacity) { + protectedBound = base64Capacity - (protectedRecordByteLength(protectedBound) - protectedBound); + } + return Math.max(0, Math.min(legacy, protectedBound)); +} + +// --------------------------------------------------------------------------- +// Envelope parsing and parameter encoding +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isCapabilities(value: unknown): value is { binaryCiphertext?: boolean } { + return ( + value === undefined || + (isRecord(value) && + (value.binaryCiphertext === undefined || typeof value.binaryCiphertext === "boolean")) + ); +} + +function isProtectedEnvelope(value: unknown): value is ProtectedEnvelope { + return ( + isRecord(value) && + typeof value.v === "number" && + typeof value.message === "string" && + value.message.length > 0 + ); +} + +function isHelloEnvelope(value: unknown): value is HelloEnvelope { + return ( + isRecord(value) && + value.type === "e2ee_hello" && + typeof value.key === "string" && + value.key.trim().length > 0 && + isCapabilities(value.capabilities) && + (value.protected === undefined || isProtectedEnvelope(value.protected)) + ); +} + +function isReadyEnvelope(value: unknown): value is ReadyEnvelope { + return ( + isRecord(value) && + value.type === "e2ee_ready" && + isCapabilities(value.capabilities) && + (value.protected === undefined || isProtectedEnvelope(value.protected)) + ); +} + +/** Classifies plaintext handshake traffic seen on an open channel; null for anything else. */ +function parseHandshakeEnvelope( + data: string | ArrayBuffer, +): { kind: "hello"; text: string } | { kind: "ready" } | null { + let text: string; + try { + text = decodeTransportText(data); + } catch { + return null; + } + if (!text.trim().startsWith("{")) return null; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("Received plaintext frame on encrypted channel"); + } + if (isRecord(parsed) && parsed.type === "e2ee_hello") return { kind: "hello", text }; + if (isRecord(parsed) && parsed.type === "e2ee_ready") return { kind: "ready" }; + // Any other JSON is plaintext app traffic: the peer is not encrypting. + throw new Error("Received plaintext frame on encrypted channel"); +} + +function encodeParams(params: NegotiatedParams): Uint8Array { + return new Uint8Array([ + PARAMS_VERSION, + params.binaryCiphertext ? PARAM_FLAG_BINARY_CIPHERTEXT : 0, + ]); +} + +function decodeParams(payload: Uint8Array): NegotiatedParams { + if (payload.byteLength !== 2) throw new NoiseProtocolError("invalid parameter payload"); + if (payload[0] !== PARAMS_VERSION) throw new NoiseProtocolError("unsupported parameter version"); + if ((payload[1] & ~PARAM_FLAGS_KNOWN) !== 0) + throw new NoiseProtocolError("reserved parameter flag set"); + return { binaryCiphertext: (payload[1] & PARAM_FLAG_BINARY_CIPHERTEXT) !== 0 }; +} + +function preview(text: string): string { + const compact = text.replace(/\s+/g, " ").trim(); + return JSON.stringify(compact.length > 160 ? `${compact.slice(0, 157)}...` : compact); +} + +function decodeTransportText(data: string | ArrayBuffer): string { + return typeof data === "string" ? data : new TextDecoder().decode(data); +} + +function requireArrayBuffer(data: string | ArrayBuffer): ArrayBuffer { + if (data instanceof ArrayBuffer) return data; + throw new Error("Binary WebSocket frame did not contain bytes"); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +function utf8ByteLength(text: string): number { + return new TextEncoder().encode(text).byteLength; +} + +function unref(timer: unknown): void { + if (typeof timer === "object" && timer !== null && "unref" in timer) { + (timer as { unref: () => void }).unref(); + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 201702444c..d8d611be70 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -173,6 +173,7 @@ import { createConfiguredTerminalManager } from "../terminal/terminal-manager-fa import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js"; +import { admitSecuredRelaySocket } from "./websocket/secure-relay-ingress.js"; import type { PushNotificationSender } from "./push/index.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; @@ -424,6 +425,7 @@ export interface PaseoDaemonConfig { agentStoragePath: string; relayEnabled?: boolean; relayEnabledMutable?: boolean; + relayRequireProtectedSecurity?: boolean; relayEndpoint?: string; relayPublicEndpoint?: string; relayUseTls?: boolean; @@ -539,11 +541,18 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri return config.trustedProxies ?? ["loopback"]; } +function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] { + return { + enabled: config.relayEnabled ?? true, + requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false, + }; +} + function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig { const providers = config.providerOverrides ?? {}; const initialConfig: MutableDaemonConfig = { - relay: { enabled: config.relayEnabled ?? true }, + relay: createInitialRelayConfig(config), mcp: { enabled: config.mcpEnabled ?? true, injectIntoAgents: config.mcpInjectIntoAgents ?? true, @@ -1758,14 +1767,17 @@ export async function createPaseoDaemon( return appBaseUrl; }, desktopManaged: config.desktopManaged === true, - getRelayConfig: () => - relayRuntime?.getConfig() ?? { + getRelayConfig: () => ({ + ...(relayRuntime?.getConfig() ?? { enabled: daemonConfigStore.get().relay?.enabled ?? relayEnabled, endpoint: relayEndpoint, publicEndpoint: relayPublicEndpoint, useTls: relayUseTls, publicUseTls: relayPublicUseTls, - }, + }), + requireProtectedSecurity: + daemonConfigStore.get().relay?.requireProtectedSecurity === true, + }), }, serviceProxyPublicBaseUrl, browserToolsBroker, @@ -1779,6 +1791,7 @@ export async function createPaseoDaemon( await pluginRuntime.start(); providerSnapshotManager.settlePluginProviders(); wsServer.beginAcceptingConnections(); + const relaySecurityLogger = logger.child({ module: "relay-security" }); relayRuntime = createRelayRuntime({ config: { enabled: relayEnabled, @@ -1788,12 +1801,25 @@ export async function createPaseoDaemon( publicUseTls: relayPublicUseTls, }, logger, - attachSocket: async (ws, metadata) => { - if (!wsServer) throw new Error("WebSocket server is not ready"); - await wsServer.attachExternalSocket(ws, metadata); - }, + attachSocket: (ws, metadata) => + admitSecuredRelaySocket( + ws, + { + daemonKeyPair: daemonKeyPair.keyPair, + logger: relaySecurityLogger, + requireProtectedMode: () => + daemonConfigStore.get().relay?.requireProtectedSecurity === true, + }, + async (secured) => { + if (!wsServer) throw new Error("WebSocket server is not ready"); + await wsServer.attachExternalSocket(secured.socket, { + ...metadata, + transport: "relay", + securityMode: secured.securityMode, + }); + }, + ), serverId, - daemonKeyPair: daemonKeyPair.keyPair, }); daemonConfigStore.onFieldChange("relay.enabled", (value) => { relayRuntime?.setEnabled(value === true); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 7b2d06560e..cc4eee4fec 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -275,6 +275,7 @@ interface ResolveRelayInput { interface ResolvedRelay { enabled: boolean; enabledMutable: boolean; + requireProtectedSecurity: boolean; endpoint: string; publicEndpoint: string; useTls: boolean; @@ -297,6 +298,21 @@ function resolveTlsFromEnv( return persistedValue ?? fallback; } +function resolveRelayEndpoints(input: ResolveRelayInput): { + endpoint: string; + publicEndpoint: string; +} { + const endpoint = + input.env.PASEO_RELAY_ENDPOINT ?? + input.persisted.daemon?.relay?.endpoint ?? + DEFAULT_RELAY_ENDPOINT; + const publicEndpoint = + input.env.PASEO_RELAY_PUBLIC_ENDPOINT ?? + input.persisted.daemon?.relay?.publicEndpoint ?? + endpoint; + return { endpoint, publicEndpoint }; +} + function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED); // COMPAT(relayOptInDefault): daemons whose startup config omitted this field @@ -306,14 +322,7 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { environmentEnabled ?? input.persisted.daemon?.relay?.enabled ?? input.enabledFallback; - const endpoint = - input.env.PASEO_RELAY_ENDPOINT ?? - input.persisted.daemon?.relay?.endpoint ?? - DEFAULT_RELAY_ENDPOINT; - const publicEndpoint = - input.env.PASEO_RELAY_PUBLIC_ENDPOINT ?? - input.persisted.daemon?.relay?.publicEndpoint ?? - endpoint; + const { endpoint, publicEndpoint } = resolveRelayEndpoints(input); const useTls = input.cliRelayUseTls ?? resolveTlsFromEnv( @@ -329,6 +338,7 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { return { enabled, enabledMutable: input.cliRelayEnabled === undefined && environmentEnabled === undefined, + requireProtectedSecurity: input.persisted.daemon?.relay?.requireProtectedSecurity ?? false, endpoint, publicEndpoint, useTls, @@ -635,6 +645,7 @@ export function resolveConfigFromPersisted( agentClients: {}, relayEnabled: relay.enabled, relayEnabledMutable: relay.enabledMutable, + relayRequireProtectedSecurity: relay.requireProtectedSecurity, relayEndpoint: relay.endpoint, relayPublicEndpoint: relay.publicEndpoint, relayUseTls: relay.useTls, diff --git a/packages/server/src/server/connection-offer.ts b/packages/server/src/server/connection-offer.ts index d435049f06..df2e952a06 100644 --- a/packages/server/src/server/connection-offer.ts +++ b/packages/server/src/server/connection-offer.ts @@ -31,12 +31,14 @@ export async function createConnectionOfferV2(args: { serverId: string; daemonPublicKeyB64: string; relay: { endpoint: string; useTls?: boolean }; + minimumSecurityMode?: "protected"; }): Promise { return ConnectionOfferV2Schema.parse({ v: 2, serverId: args.serverId, daemonPublicKeyB64: args.daemonPublicKeyB64, relay: args.relay, + ...(args.minimumSecurityMode ? { minimumSecurityMode: args.minimumSecurityMode } : {}), }); } diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 3629a0fd18..c68cda02d2 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -120,6 +120,35 @@ describe("DaemonConfigStore", () => { expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true); }); + test("patch persists the relay protected security requirement independently of relay enablement", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore( + paseoHome, + { + relay: { enabled: true }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }, + undefined, + // A launch override owns relay.enabled; the security requirement is still editable. + { relayEnabledMutable: false }, + ); + const changes: unknown[] = []; + store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value)); + + store.patch({ relay: { requireProtectedSecurity: true } }); + + expect(changes).toEqual([true]); + expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true }); + expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true); + }); + test("patch round-trips agent profiles through the strictly-parsed persisted config", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 3e285bded0..489f68ed5d 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -17,7 +17,7 @@ type MutableDaemonConfigPatch = import("@getpaseo/protocol/messages").MutableDae type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOverride; interface SupportedMutableConfigPatch { - relay?: { enabled?: boolean }; + relay?: { enabled?: boolean; requireProtectedSecurity?: boolean }; mcp?: { injectIntoAgents?: boolean }; browserTools?: { enabled?: boolean }; providers?: MutableDaemonConfig["providers"]; @@ -170,6 +170,7 @@ function isEqualValue(a: unknown, b: unknown): boolean { const RELOADABLE_PATHS = [ "daemon.relay.enabled", + "daemon.relay.requireProtectedSecurity", "daemon.mcp.enabled", "daemon.mcp.injectIntoAgents", "daemon.browserTools.enabled", @@ -193,6 +194,7 @@ const RELOADABLE_PATHS = [ const PERSISTED_TO_MUTABLE_PATH = new Map([ ["daemon.relay.enabled", "relay.enabled"], + ["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"], ["daemon.mcp.enabled", "mcp.enabled"], ["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"], ["daemon.browserTools.enabled", "browserTools.enabled"], @@ -249,9 +251,21 @@ function compactOwnedPaths(paths: readonly string[], owners: readonly string[]): return Array.from(compacted).sort(); } +function pickRelayPatch( + relay: MutableDaemonConfigPatch["relay"], +): Pick { + const picked: NonNullable = { + ...(relay?.enabled !== undefined ? { enabled: relay.enabled } : {}), + ...(relay?.requireProtectedSecurity !== undefined + ? { requireProtectedSecurity: relay.requireProtectedSecurity } + : {}), + }; + return Object.keys(picked).length > 0 ? { relay: picked } : {}; +} + function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch { return { - ...(patch.relay?.enabled !== undefined ? { relay: { enabled: patch.relay.enabled } } : {}), + ...pickRelayPatch(patch.relay), ...(patch.mcp?.injectIntoAgents !== undefined ? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } } : {}), @@ -646,6 +660,9 @@ function mergeMutableDaemonPatch( if (persistRelayEnabled && patch.relay?.enabled !== undefined) { next.relay = { ...next.relay, enabled: patch.relay.enabled }; } + if (patch.relay?.requireProtectedSecurity !== undefined) { + next.relay = { ...next.relay, requireProtectedSecurity: patch.relay.requireProtectedSecurity }; + } if (patch.mcp?.injectIntoAgents !== undefined) { next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents }; } diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index facf6dffd7..dd0ac2fbdb 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -26,7 +26,12 @@ import os from "node:os"; import { generateLocalPairingOffer } from "../pairing-offer.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js"; -import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; +import { + createClientChannel, + openClientSecureChannel, + type SecureChannel, + type Transport, +} from "@getpaseo/relay/e2ee"; import { deriveSharedKey, decrypt, @@ -59,6 +64,7 @@ async function getPairingOfferUrl(args: { relayEnabled?: boolean; relayEndpoint?: string; relayPublicEndpoint?: string; + relayRequireProtectedSecurity?: boolean; appBaseUrl?: string; }): Promise { const pairing = await generateLocalPairingOffer({ @@ -66,6 +72,7 @@ async function getPairingOfferUrl(args: { relayEnabled: args.relayEnabled, relayEndpoint: args.relayEndpoint, relayPublicEndpoint: args.relayPublicEndpoint, + relayRequireProtectedSecurity: args.relayRequireProtectedSecurity, appBaseUrl: args.appBaseUrl, includeQr: false, }); @@ -90,6 +97,113 @@ function decodeOfferFromFragmentUrl(url: string): { return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 }; } +/** + * An updated relay client: the shared secure channel over a raw relay socket. + * Resolves once the daemon answers the app hello with server_info. + */ +async function openUpdatedRelayClient(args: { + relayPort: number; + serverId: string; + daemonPublicKeyB64: string; + clientId: string; + minimumMode?: "protected"; +}): Promise<{ + channel: SecureChannel; + serverInfo: { features?: Record }; + messages: unknown[]; + waitFor: (predicate: (message: unknown) => boolean) => Promise; + close: () => void; +}> { + const ws = new WebSocket( + buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${args.relayPort}`, + useTls: false, + serverId: args.serverId, + role: "client", + }), + ); + const transport: Transport = { + send: (data) => ws.send(data), + close: (code?: number, reason?: string) => ws.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + ws.on("message", (data, isBinary) => { + const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer); + transport.onmessage?.({ + data: isBinary + ? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + : buffer.toString("utf8"), + isBinary, + }); + }); + ws.on("close", (code, reason) => transport.onclose?.(code, reason.toString())); + ws.on("error", (error) => transport.onerror?.(error)); + await once(ws, "open"); + + const messages: unknown[] = []; + const waiters: Array<{ + predicate: (message: unknown) => boolean; + resolve: (m: unknown) => void; + }> = []; + const deliver = (message: unknown) => { + messages.push(message); + for (const waiter of waiters.slice()) { + if (!waiter.predicate(message)) continue; + waiters.splice(waiters.indexOf(waiter), 1); + waiter.resolve(message); + } + }; + const waitFor = (predicate: (message: unknown) => boolean) => + withTimeout( + new Promise((resolve) => { + const existing = messages.find(predicate); + if (existing !== undefined) resolve(existing); + else waiters.push({ predicate, resolve }); + }), + 20_000, + "relay client message", + ); + const isServerInfo = (message: unknown) => { + const parsed = WSOutboundMessageSchema.safeParse(message); + return ( + parsed.success && + parsed.data.type === "session" && + parsed.data.message.type === "status" && + parsed.data.message.payload?.status === "server_info" + ); + }; + + const channel = await openClientSecureChannel(transport, { + daemonPublicKeyB64: args.daemonPublicKeyB64, + ...(args.minimumMode ? { minimumMode: args.minimumMode } : {}), + events: { + onmessage: (data) => { + if (typeof data === "string") deliver(JSON.parse(data)); + }, + }, + }); + await channel.send( + JSON.stringify({ + type: "hello", + clientId: args.clientId, + clientType: "cli", + protocolVersion: 1, + }), + ); + const serverInfoMessage = (await waitFor(isServerInfo)) as { + message: { payload: { features?: Record } }; + }; + return { + channel, + serverInfo: serverInfoMessage.message.payload, + messages, + waitFor, + close: () => ws.close(), + }; +} + function sha256(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } @@ -204,9 +318,18 @@ async function waitForCapturedLog( let relayPort: number; let relayProcess: ChildProcess | null = null; let relayStdoutLines: string[] = []; + // PASEO_RELAY_E2E_PORT points at an already-running relay (for example the + // Elixir relay) so the same daemon/client exchanges run against both. + const externalRelayPort = Number(process.env.PASEO_RELAY_E2E_PORT ?? ""); + const usesExternalRelay = Number.isInteger(externalRelayPort) && externalRelayPort > 0; const startRelay = async (options: { useLocalRelay?: boolean; port?: number } = {}) => { relayStdoutLines = []; + if (usesExternalRelay) { + relayPort = externalRelayPort; + await waitForRelayWebSocketReady(relayPort, 60000); + return; + } relayPort = options.port ?? (await getAvailablePort()); const relayDir = path.resolve(process.cwd(), "../relay"); const relayArgs = [ @@ -273,144 +396,149 @@ async function waitForCapturedLog( await withTimeout(exited, 10000, "Local relay did not stop"); }; - test("connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", async () => { - let a: Awaited> | undefined; - let b: Awaited> | undefined; - let origin: ReturnType | undefined; - let api: PaseoApi | undefined; - let remote: PaseoApi | undefined; - let sibling: PaseoApi | undefined; - let terminalRoute: Awaited> | undefined; - let wire: Awaited> | undefined; - try { - await startRelay({ useLocalRelay: true }); - const port = relayPort; - b = await createTestPaseoDaemon({ - relayEnabled: true, - relayEndpoint: `127.0.0.1:${port}`, - relayUseTls: false, - relayPublicUseTls: false, - auth: { password: hashSync("relay-password", 4) }, - }); - a = await createTestPaseoDaemon(); - origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() }); - await origin.connect(); - api = createPaseoApi(origin); - const offerUrl = await getPairingOfferUrl({ - paseoHome: b.paseoHome, - relayEnabled: true, - relayEndpoint: b.config.relayEndpoint, - }); - const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); - wire = await relayWire(port); - const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`); - connection.searchParams.set("key", daemonPublicKeyB64); - connection.searchParams.set("password", "wrong-password"); - const saved = await api.hosts.add({ connection: connection.toString() }); - await expect - .poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 }) - .toBe("authentication_failed"); - expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/); - connection.searchParams.set("password", "relay-password"); - await api.hosts.update(saved.registrationId, { connection: connection.toString() }); - await expect - .poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 }) - .toBe("online"); - const admittedConnections = wire.connections(); - expect(wire.active()).toBe(1); - remote = await api.hosts.client(serverId); - sibling = await api.hosts.client(serverId); - expect(wire.connections()).toBe(admittedConnections); - expect(wire.active()).toBe(1); - expect((await remote.workspaces.list()).entries).toEqual([]); - const observed = await remote.workspaces.list({ subscribe: {} }); - let snapshots = 0; - observed.subscription.subscribe({ - snapshot: () => { - snapshots++; - }, - update() {}, - }); - const workspace = await remote.workspaces.create({ - source: { kind: "directory", path: b.staticDir }, - title: "Relayed workspace", - }); - const terminal = await workspace.terminals.create({ - command: process.execPath, - args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], - }); - terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); - expect(wire.connections()).toBe(admittedConnections); - terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n"); - await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE"); - terminalRoute.close(); - terminalRoute = undefined; - const before = snapshots; - await stopRelay(); - await expect - .poll( - async () => { - try { - await remote!.workspaces.list(); - return "online"; - } catch (error) { - return (error as { code: string }).code; - } - }, - { timeout: 15000 }, - ) - .toBe("DAEMON_CONNECTION_LOST"); - await startRelay({ useLocalRelay: true, port }); - await expect - .poll( - async () => { - try { - return (await remote!.workspaces.list()).entries.length; - } catch { - return -1; - } - }, - { timeout: 30000 }, - ) - .toBe(1); - await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before); - expect(wire.active()).toBe(1); - terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); - terminalRoute.write("ENCRYPTED_BINARY_AFTER\n"); - await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER"); - terminalRoute.close(); - terminalRoute = undefined; - await remote.dispose(); - remote = undefined; - expect((await sibling.workspaces.list()).entries).toHaveLength(1); - await sibling.terminals.ref(terminal.id).kill(); - await api.dispose(); - await expect(sibling.workspaces.list()).rejects.toThrow(); - const direct = createHostClient(`127.0.0.1:${b.port}`, { - clientId: crypto.randomUUID(), - password: "relay-password", - reconnect: { enabled: false }, - }); + // Needs to stop and restart the relay, which only the spawned wrangler relay allows. + test.skipIf(usesExternalRelay)( + "connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", + async () => { + let a: Awaited> | undefined; + let b: Awaited> | undefined; + let origin: ReturnType | undefined; + let api: PaseoApi | undefined; + let remote: PaseoApi | undefined; + let sibling: PaseoApi | undefined; + let terminalRoute: Awaited> | undefined; + let wire: Awaited> | undefined; try { - await direct.connect(); - const directApi = createPaseoApi(direct); - expect((await directApi.workspaces.list()).entries).toHaveLength(1); - await directApi.dispose(); + await startRelay({ useLocalRelay: true }); + const port = relayPort; + b = await createTestPaseoDaemon({ + relayEnabled: true, + relayEndpoint: `127.0.0.1:${port}`, + relayUseTls: false, + relayPublicUseTls: false, + auth: { password: hashSync("relay-password", 4) }, + }); + a = await createTestPaseoDaemon(); + origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() }); + await origin.connect(); + api = createPaseoApi(origin); + const offerUrl = await getPairingOfferUrl({ + paseoHome: b.paseoHome, + relayEnabled: true, + relayEndpoint: b.config.relayEndpoint, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + wire = await relayWire(port); + const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`); + connection.searchParams.set("key", daemonPublicKeyB64); + connection.searchParams.set("password", "wrong-password"); + const saved = await api.hosts.add({ connection: connection.toString() }); + await expect + .poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 }) + .toBe("authentication_failed"); + expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/); + connection.searchParams.set("password", "relay-password"); + await api.hosts.update(saved.registrationId, { connection: connection.toString() }); + await expect + .poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 }) + .toBe("online"); + const admittedConnections = wire.connections(); + expect(wire.active()).toBe(1); + remote = await api.hosts.client(serverId); + sibling = await api.hosts.client(serverId); + expect(wire.connections()).toBe(admittedConnections); + expect(wire.active()).toBe(1); + expect((await remote.workspaces.list()).entries).toEqual([]); + const observed = await remote.workspaces.list({ subscribe: {} }); + let snapshots = 0; + observed.subscription.subscribe({ + snapshot: () => { + snapshots++; + }, + update() {}, + }); + const workspace = await remote.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + title: "Relayed workspace", + }); + const terminal = await workspace.terminals.create({ + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], + }); + terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); + expect(wire.connections()).toBe(admittedConnections); + terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n"); + await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE"); + terminalRoute.close(); + terminalRoute = undefined; + const before = snapshots; + await stopRelay(); + await expect + .poll( + async () => { + try { + await remote!.workspaces.list(); + return "online"; + } catch (error) { + return (error as { code: string }).code; + } + }, + { timeout: 15000 }, + ) + .toBe("DAEMON_CONNECTION_LOST"); + await startRelay({ useLocalRelay: true, port }); + await expect + .poll( + async () => { + try { + return (await remote!.workspaces.list()).entries.length; + } catch { + return -1; + } + }, + { timeout: 30000 }, + ) + .toBe(1); + await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before); + expect(wire.active()).toBe(1); + terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); + terminalRoute.write("ENCRYPTED_BINARY_AFTER\n"); + await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER"); + terminalRoute.close(); + terminalRoute = undefined; + await remote.dispose(); + remote = undefined; + expect((await sibling.workspaces.list()).entries).toHaveLength(1); + await sibling.terminals.ref(terminal.id).kill(); + await api.dispose(); + await expect(sibling.workspaces.list()).rejects.toThrow(); + const direct = createHostClient(`127.0.0.1:${b.port}`, { + clientId: crypto.randomUUID(), + password: "relay-password", + reconnect: { enabled: false }, + }); + try { + await direct.connect(); + const directApi = createPaseoApi(direct); + expect((await directApi.workspaces.list()).entries).toHaveLength(1); + await directApi.dispose(); + } finally { + await direct.close(); + } } finally { - await direct.close(); + terminalRoute?.close(); + await remote?.dispose(); + await sibling?.dispose(); + await api?.dispose(); + await origin?.close(); + await a?.close(); + await b?.close(); + await wire?.close(); + await stopRelay(); } - } finally { - terminalRoute?.close(); - await remote?.dispose(); - await sibling?.dispose(); - await api?.dispose(); - await origin?.close(); - await a?.close(); - await b?.close(); - await wire?.close(); - await stopRelay(); - } - }, 120000); + }, + 120000, + ); test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => { await startRelay({ useLocalRelay: true }); @@ -620,11 +748,11 @@ async function waitForCapturedLog( } }, 90000); - test("daemon closes a relay client that sends an unsupported handshake key", async () => { + test("updated client negotiates protected endpoint security through the relay", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; const { logger, lines } = createCapturingLogger(); - await startRelay({ useLocalRelay: true }); + await startRelay(); const daemon = await createTestPaseoDaemon({ listen: "127.0.0.1", @@ -641,8 +769,76 @@ async function waitForCapturedLog( relayPublicEndpoint: daemon.config.relayPublicEndpoint, appBaseUrl: daemon.config.appBaseUrl, }); - const { serverId } = decodeOfferFromFragmentUrl(offerUrl); - const ws = new WebSocket( + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + const client = await openUpdatedRelayClient({ + relayPort, + serverId, + daemonPublicKeyB64, + clientId: "cid_relay_protected_client", + }); + try { + expect(client.channel.securityMode()).toBe("protected"); + expect(client.serverInfo.features?.relayProtectedSecurity).toBe(true); + + await client.channel.send(JSON.stringify({ type: "ping" })); + const pong = await client.waitFor( + (message) => (message as { type?: string }).type === "pong", + ); + expect(pong).toEqual({ type: "pong" }); + await waitForCapturedLog( + lines, + (line) => line.includes('"securityMode":"protected"'), + 5_000, + ); + } finally { + client.close(); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", lines.slice(-50).join("")); + throw err; + } finally { + await daemon.close(); + await stopRelay(); + } + }, 90000); + + test("daemon requiring protected security rejects a legacy relay client and admits an updated one", async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + await startRelay(); + + const daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + relayRequireProtectedSecurity: true, + }); + + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + relayRequireProtectedSecurity: daemon.config.relayRequireProtectedSecurity, + appBaseUrl: daemon.config.appBaseUrl, + }); + const offer = ConnectionOfferSchema.parse( + JSON.parse( + Buffer.from( + offerUrl.slice(offerUrl.indexOf("#offer=") + "#offer=".length), + "base64url", + ).toString("utf8"), + ), + ); + expect(offer.minimumSecurityMode).toBe("protected"); + const { serverId, daemonPublicKeyB64 } = offer; + + // A client that only speaks the legacy construction is refused before admission. + const legacyWs = new WebSocket( buildRelayWebSocketUrl({ endpoint: `127.0.0.1:${relayPort}`, useTls: false, @@ -650,92 +846,188 @@ async function waitForCapturedLog( role: "client", }), ); - - let receivedServerInfo = false; - const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected"); - const outcome = await new Promise< - { type: "closed"; code: number; reason: string } | { type: "errored"; error: Error } - >((resolve, reject) => { - const timeout = setTimeout(() => { - ws.close(); - reject(new Error("timed out waiting for daemon to reject unsupported handshake key")); - }, 20_000); - - ws.on("open", async () => { - try { - await waitForCapturedLog(lines, isRelayDataConnectedLog); - ws.send( - JSON.stringify({ - type: "e2ee_hello", - key: exportPublicKey(new Uint8Array(32)), - }), - ); - ws.send( - encodeCiphertext( - encrypt( - UNSUPPORTED_PEER_TRANSPORT_KEY, - JSON.stringify({ - type: "hello", - clientId: "cid_relay_unsupported_key", - clientType: "cli", - protocolVersion: 1, - }), - ), - ), - ); - } catch (error) { - clearTimeout(timeout); - reject(error); - } - }); - - ws.on("message", (data) => { - try { - const parsed = WSOutboundMessageSchema.parse( - parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()), - ); - if ( - parsed.type === "session" && - parsed.message.type === "status" && - parsed.message.payload?.status === "server_info" - ) { - receivedServerInfo = true; - clearTimeout(timeout); - reject( - new Error("daemon sent server_info after accepting an unsupported handshake key"), - ); - } - } catch { - // Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion. - } - }); - ws.on("close", (code, reason) => { - clearTimeout(timeout); - resolve({ type: "closed", code, reason: reason.toString() }); - }); - ws.on("error", (error) => { - clearTimeout(timeout); - resolve({ type: "errored", error }); + const legacyTransport: Transport = { + send: (data) => legacyWs.send(data), + close: (code?: number, reason?: string) => legacyWs.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + legacyWs.on("message", (data, isBinary) => { + const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer); + legacyTransport.onmessage?.({ + data: isBinary + ? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + : buffer.toString("utf8"), + isBinary, }); }); + const legacyClosed = new Promise<{ code: number; reason: string }>((resolve) => { + legacyWs.on("close", (code, reason) => resolve({ code, reason: reason.toString() })); + }); + await once(legacyWs, "open"); + const legacyMessages: unknown[] = []; + void createClientChannel(legacyTransport, daemonPublicKeyB64, { + onmessage: (data) => legacyMessages.push(data), + }).catch(() => undefined); + const closed = await withTimeout(legacyClosed, 20_000, "legacy client close"); + // The daemon closes its side with 1008; the relay reports its own close code to + // the client (the wrangler relay sends 1012 "Server disconnected"), so only the + // daemon log carries the policy reason. + expect(closed.code).not.toBe(1000); + expect(legacyMessages).toEqual([]); + await waitForCapturedLog( + lines, + (line) => + line.includes("relay_e2ee_handshake_rejected") && + line.includes("legacy endpoint security"), + ); - expect(receivedServerInfo).toBe(false); - if (outcome.type === "closed") { - expect(outcome.code).toBeGreaterThan(0); - } else { - expect(outcome.error).toBeInstanceOf(Error); + // An updated client pinned by the offer is admitted in protected mode. + const client = await openUpdatedRelayClient({ + relayPort, + serverId, + daemonPublicKeyB64, + clientId: "cid_relay_protected_required", + minimumMode: offer.minimumSecurityMode, + }); + try { + expect(client.channel.securityMode()).toBe("protected"); + expect(client.serverInfo.features?.relayProtectedSecurity).toBe(true); + } finally { + client.close(); } - - const isRejectedHandshakeLog = (line: string) => - line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key"); - await waitForCapturedLog(lines, isRejectedHandshakeLog); - expect(lines.some(isRejectedHandshakeLog)).toBe(true); + } catch (err) { + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", lines.slice(-50).join("")); + throw err; } finally { await daemon.close(); await stopRelay(); } }, 90000); + // The Elixir relay rejects the invalid key itself, so the daemon never sees this hello. + test.skipIf(usesExternalRelay)( + "daemon closes a relay client that sends an unsupported handshake key", + async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + await startRelay({ useLocalRelay: true }); + + const daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + }); + + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); + const { serverId } = decodeOfferFromFragmentUrl(offerUrl); + const ws = new WebSocket( + buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + ); + + let receivedServerInfo = false; + const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected"); + const outcome = await new Promise< + { type: "closed"; code: number; reason: string } | { type: "errored"; error: Error } + >((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("timed out waiting for daemon to reject unsupported handshake key")); + }, 20_000); + + ws.on("open", async () => { + try { + await waitForCapturedLog(lines, isRelayDataConnectedLog); + ws.send( + JSON.stringify({ + type: "e2ee_hello", + key: exportPublicKey(new Uint8Array(32)), + }), + ); + ws.send( + encodeCiphertext( + encrypt( + UNSUPPORTED_PEER_TRANSPORT_KEY, + JSON.stringify({ + type: "hello", + clientId: "cid_relay_unsupported_key", + clientType: "cli", + protocolVersion: 1, + }), + ), + ), + ); + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); + + ws.on("message", (data) => { + try { + const parsed = WSOutboundMessageSchema.parse( + parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()), + ); + if ( + parsed.type === "session" && + parsed.message.type === "status" && + parsed.message.payload?.status === "server_info" + ) { + receivedServerInfo = true; + clearTimeout(timeout); + reject( + new Error("daemon sent server_info after accepting an unsupported handshake key"), + ); + } + } catch { + // Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion. + } + }); + ws.on("close", (code, reason) => { + clearTimeout(timeout); + resolve({ type: "closed", code, reason: reason.toString() }); + }); + ws.on("error", (error) => { + clearTimeout(timeout); + resolve({ type: "errored", error }); + }); + }); + + expect(receivedServerInfo).toBe(false); + if (outcome.type === "closed") { + expect(outcome.code).toBeGreaterThan(0); + } else { + expect(outcome.error).toBeInstanceOf(Error); + } + + const isRejectedHandshakeLog = (line: string) => + line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key"); + await waitForCapturedLog(lines, isRejectedHandshakeLog); + expect(lines.some(isRejectedHandshakeLog)).toBe(true); + } finally { + await daemon.close(); + await stopRelay(); + } + }, + 90000, + ); + test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; diff --git a/packages/server/src/server/pairing-offer.ts b/packages/server/src/server/pairing-offer.ts index c8f038e886..59b8c623c7 100644 --- a/packages/server/src/server/pairing-offer.ts +++ b/packages/server/src/server/pairing-offer.ts @@ -18,6 +18,8 @@ export async function generateLocalPairingOffer(args: { relayPublicEndpoint?: string; relayUseTls?: boolean; relayPublicUseTls?: boolean; + /** Stamp the offer so new clients require protected endpoint security from their first handshake. */ + relayRequireProtectedSecurity?: boolean; appBaseUrl?: string; includeQr?: boolean; logger?: Logger; @@ -42,6 +44,9 @@ export async function generateLocalPairingOffer(args: { serverId, daemonPublicKeyB64: daemonKeyPair.publicKeyB64, relay: { endpoint: relayPublicEndpoint, useTls: relayPublicUseTls }, + ...(args.relayRequireProtectedSecurity === true + ? { minimumSecurityMode: "protected" as const } + : {}), }); const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 11d94495b1..cccc0228d8 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -278,6 +278,12 @@ export const PersistedConfigSchema = z relay: z .object({ enabled: z.boolean().optional(), + requireProtectedSecurity: z + .boolean() + .optional() + .describe( + "Admit only relay clients that negotiate protected endpoint security (replay protection). Defaults to false; older clients still connect while it is off.", + ), endpoint: z.string().optional(), publicEndpoint: z.string().optional(), useTls: z.boolean().optional(), diff --git a/packages/server/src/server/relay-runtime.ts b/packages/server/src/server/relay-runtime.ts index e9f2ccf158..885aaa9b6e 100644 --- a/packages/server/src/server/relay-runtime.ts +++ b/packages/server/src/server/relay-runtime.ts @@ -1,5 +1,4 @@ import type pino from "pino"; -import type { KeyPair } from "@getpaseo/relay/e2ee"; import type { ExternalSocketMetadata } from "./websocket-server.js"; import { startRelayTransport, @@ -20,7 +19,6 @@ interface RelayRuntimeOptions { logger: pino.Logger; attachSocket(ws: RelaySocketLike, metadata?: ExternalSocketMetadata): Promise; serverId: string; - daemonKeyPair: KeyPair; startTransport?: typeof startRelayTransport; } @@ -43,7 +41,6 @@ export function createRelayRuntime(options: RelayRuntimeOptions): RelayRuntime { relayEndpoint: config.endpoint, relayUseTls: config.useTls, serverId: options.serverId, - daemonKeyPair: options.daemonKeyPair, }); } diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index 2dcf29b948..26868e7112 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import pino from "pino"; -import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; -import { exportPublicKey, generateKeyPair } from "@getpaseo/relay"; import { startRelayTransport } from "./relay-transport"; function createMockLogger() { @@ -146,37 +144,6 @@ describe("relay-transport control lifecycle", () => { vi.useRealTimers(); }); - test("releases a stalled encrypted data connection without restarting control", async () => { - vi.useFakeTimers(); - let attachments = 0; - controllers.push( - startRelayTransport({ - logger: pino({ level: "silent" }), - attachSocket: async () => { - attachments += 1; - }, - relayEndpoint: "relay.paseo.sh:443", - relayUseTls: true, - serverId: "srv_test", - daemonKeyPair: generateKeyPair(), - createWebSocket: relay.createWebSocket, - }), - ); - const control = relay.sockets[0]; - control.open(); - const connected = JSON.stringify({ type: "connected", connectionId: "stalled" }); - control.message(connected); - const stalled = relay.sockets[1]; - stalled.open(); - await vi.advanceTimersByTimeAsync(15_000); - expect(stalled.terminateCalls).toBe(1); - expect(attachments).toBe(0); - expect(control.terminateCalls).toBe(0); - control.message(connected); - expect(relay.sockets).toHaveLength(3); - expect(relay.sockets[2].url).toBe(stalled.url); - }); - test("logs relay_control_connected only after first valid control message", () => { const logger = createMockLogger(); const controller = startRelayTransport({ @@ -286,80 +253,6 @@ describe("relay-transport control lifecycle", () => { ]); }); - test("encrypted sends wait for the physical data socket callback", async () => { - const logger = createMockLogger(); - const daemonKeyPair = generateKeyPair(); - let resolveAttached: ((socket: unknown) => void) | undefined; - const attached = new Promise((resolve) => { - resolveAttached = resolve; - }); - const controller = startRelayTransport({ - logger: logger as unknown as pino.Logger, - attachSocket: async (socket) => resolveAttached?.(socket), - relayEndpoint: "relay.paseo.sh:443", - relayUseTls: true, - serverId: "srv_test", - daemonKeyPair, - createWebSocket: relay.createWebSocket, - }); - controllers.push(controller); - - const control = relay.sockets[0]; - control.open(); - control.message(JSON.stringify({ type: "sync", connectionIds: [] }), false); - control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }), false); - - const dataSocket = relay.sockets[1]; - dataSocket.deferSendCompletion = true; - dataSocket.open(); - let clientTransport: Transport; - clientTransport = { - send: (data) => dataSocket.message(data, data instanceof ArrayBuffer), - close: () => undefined, - onmessage: null, - onclose: null, - onerror: null, - }; - dataSocket.onSend = (data) => { - clientTransport.onmessage?.({ - data: data instanceof Uint8Array ? data.slice().buffer : data, - isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array, - }); - }; - let resolveClientOpen: (() => void) | undefined; - const clientOpen = new Promise((resolve) => { - resolveClientOpen = resolve; - }); - await createClientChannel(clientTransport, exportPublicKey(daemonKeyPair.publicKey), { - onopen: () => resolveClientOpen?.(), - }); - - let attachedCompleted = false; - void attached.then(() => { - attachedCompleted = true; - return undefined; - }); - await clientOpen; - await Promise.resolve(); - expect(attachedCompleted).toBe(false); - dataSocket.completeNextSend(); - const encryptedSocket = (await attached) as { - send: (data: Uint8Array) => void | Promise; - }; - let completed = false; - - const sending = Promise.resolve(encryptedSocket.send(new Uint8Array([1, 2, 3]))).then(() => { - completed = true; - return undefined; - }); - await Promise.resolve(); - expect(completed).toBe(false); - - dataSocket.completeNextSend(); - await sending; - expect(completed).toBe(true); - }); - test("uses relayUseTls for control and data socket URLs", () => { const logger = createMockLogger(); const controller = startRelayTransport({ diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 113cce605c..08fcd826d5 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -1,15 +1,8 @@ /// -import { EventEmitter } from "node:events"; import { WebSocket } from "ws"; import type pino from "pino"; -import { - createDaemonChannel, - type Transport as RelayTransport, - type KeyPair, -} from "@getpaseo/relay/e2ee"; import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import type { ExternalSocketMetadata } from "./websocket-server.js"; -import { createEncryptedRelaySocket } from "./websocket/encrypted-relay-socket.js"; export interface RelayTransportOptions { logger: pino.Logger; @@ -17,7 +10,6 @@ export interface RelayTransportOptions { relayEndpoint: string; // "host:port" relayUseTls: boolean; serverId: string; - daemonKeyPair?: KeyPair; createWebSocket?: RelayWebSocketFactory; } @@ -30,13 +22,12 @@ export interface RelaySocketLike { bufferedAmount?: number; send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void; close: (code?: number, reason?: string) => void; - terminate?: () => void; + terminate: () => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void; } interface RelayWebSocketLike extends RelaySocketLike { - terminate: () => void; ping: () => void; on: ( event: "open" | "message" | "close" | "error" | "pong", @@ -118,7 +109,6 @@ export function startRelayTransport({ relayEndpoint, relayUseTls, serverId, - daemonKeyPair, createWebSocket = createDefaultRelayWebSocket, }: RelayTransportOptions): RelayTransportController { const relayLogger = logger.child({ module: "relay-transport" }); @@ -385,17 +375,7 @@ export function startRelayTransport({ externalSessionKey: `session:${connectionId}`, relayConnectionId: connectionId, }; - if (daemonKeyPair) { - void attachEncryptedSocket( - socket, - daemonKeyPair, - relayLogger.child({ connectionId }), - attachSocket, - externalMetadata, - ); - } else { - void attachSocket(socket, externalMetadata); - } + void attachSocket(socket, externalMetadata); }); socket.on("close", (code, reason) => { @@ -418,149 +398,3 @@ export function startRelayTransport({ return { stop }; } - -async function attachEncryptedSocket( - socket: RelayWebSocketLike, - daemonKeyPair: KeyPair, - logger: pino.Logger, - attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise, - metadata?: ExternalSocketMetadata, -): Promise { - const handshakeTimeout = setTimeout(() => { - logger.warn("relay_e2ee_handshake_timeout_terminating"); - socket.terminate(); - }, 15_000); - try { - const relayTransport = createRelayTransportAdapter(socket, logger); - const emitter = new EventEmitter(); - const pendingMessages: Array = []; - let attached = false; - const emitMessage = (data: string | ArrayBuffer) => { - if (attached) { - emitter.emit("message", data); - return; - } - pendingMessages.push(data); - }; - const channel = await createDaemonChannel(relayTransport, daemonKeyPair, { - onmessage: emitMessage, - onclose: (code, reason) => emitter.emit("close", code, reason), - onerror: (error) => { - logger.warn({ err: error }, "relay_e2ee_error"); - emitter.emit("error", error); - }, - }); - clearTimeout(handshakeTimeout); - const encryptedSocket = createEncryptedRelaySocket({ - channel, - emitter, - getTransportBufferedAmount: () => socket.bufferedAmount, - terminateTransport: () => socket.terminate(), - }); - await attachSocket(encryptedSocket, metadata); - attached = true; - for (const message of pendingMessages) { - emitter.emit("message", message); - } - pendingMessages.length = 0; - } catch (error) { - logger.warn({ err: error }, "relay_e2ee_handshake_failed"); - try { - socket.close(1011, "E2EE handshake failed"); - } catch { - // ignore - } - } finally { - clearTimeout(handshakeTimeout); - } -} - -function createRelayTransportAdapter( - socket: RelayWebSocketLike, - logger: pino.Logger, -): RelayTransport { - const relayTransport: RelayTransport = { - send: (data) => - new Promise((resolve, reject) => { - try { - socket.send(data, (error) => { - if (!error) { - resolve(); - return; - } - logger.warn({ err: error }, "relay_socket_send_failed"); - reject(error); - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - logger.warn({ err }, "relay_socket_send_failed"); - reject(err); - } - }), - close: (code?: number, reason?: string) => socket.close(code, reason), - onmessage: null, - onclose: null, - onerror: null, - }; - - socket.on("message", (data, isBinary) => { - const binary = isBinary === true; - relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary }); - }); - socket.on("close", (code, reason) => { - const closeCode = typeof code === "number" ? code : 1006; - relayTransport.onclose?.(closeCode, String(reason ?? "")); - }); - socket.on("error", (err) => { - relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err))); - }); - - return relayTransport; -} - -function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer { - if (!isBinary) { - if (typeof data === "string") return data; - const buffer = bufferFromWsData(data); - if (buffer) return buffer.toString("utf8"); - return String(data); - } - - if (data instanceof ArrayBuffer) return data; - - const buffer = bufferFromWsData(data); - if (buffer) { - const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - const out = new Uint8Array(view.byteLength); - out.set(view); - return out.buffer; - } - - return String(data); -} - -function bufferFromWsData(data: unknown): Buffer | null { - if (Buffer.isBuffer(data)) return data; - if (Array.isArray(data)) { - const buffers: Buffer[] = []; - for (const part of data) { - if (Buffer.isBuffer(part)) { - buffers.push(part); - } else if (part instanceof ArrayBuffer) { - buffers.push(Buffer.from(part)); - } else if (ArrayBuffer.isView(part)) { - buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength)); - } else if (typeof part === "string") { - buffers.push(Buffer.from(part, "utf8")); - } else { - return null; - } - } - return Buffer.concat(buffers); - } - if (data instanceof ArrayBuffer) return Buffer.from(data); - if (ArrayBuffer.isView(data)) { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength); - } - return null; -} diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index 95caafc58b..fbe1a06415 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -24,6 +24,7 @@ export interface DaemonRuntimeConfig { publicEndpoint: string; useTls: boolean; publicUseTls: boolean; + requireProtectedSecurity?: boolean; } | null; } @@ -224,6 +225,7 @@ export class DaemonSession { relayPublicEndpoint: relay?.publicEndpoint, relayUseTls: relay?.useTls, relayPublicUseTls: relay?.publicUseTls, + relayRequireProtectedSecurity: relay?.requireProtectedSecurity ?? false, appBaseUrl: this.daemonRuntimeConfig?.appBaseUrl, includeQr: true, logger: this.logger, diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 91b125328c..289d556bec 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -30,6 +30,7 @@ interface TestPaseoDaemonOptions { relayEndpoint?: string; relayUseTls?: boolean; relayPublicUseTls?: boolean; + relayRequireProtectedSecurity?: boolean; daemonStatusRpcCapability?: boolean; relayConfigCapability?: boolean; agentClients?: Partial>; @@ -200,6 +201,7 @@ async function prepareTestDaemonConfig( relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", relayUseTls: options.relayUseTls, relayPublicUseTls: options.relayPublicUseTls, + relayRequireProtectedSecurity: options.relayRequireProtectedSecurity, appBaseUrl: "https://app.paseo.sh", auth: options.auth, pushNotificationSender: options.pushNotificationSender, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8a28f25d5a..d896bcf37d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -112,6 +112,7 @@ import { } from "./lifecycle-reasons.js"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import type { SecurityMode } from "@getpaseo/relay/e2ee"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import { DirectorySyncService } from "./directory-sync/index.js"; @@ -133,6 +134,8 @@ export interface ExternalSocketMetadata { transport: "relay" | "hub"; externalSessionKey?: string; relayConnectionId?: string; + /** Negotiated endpoint security for relay-carried sockets. */ + securityMode?: SecurityMode; hubDaemonId?: string; } @@ -160,6 +163,7 @@ interface WebSocketConnectionIdentity { userAgent?: string; remoteAddress?: string; relayConnectionId?: string; + securityMode?: SecurityMode; hubDaemonId?: string; clientId?: string; sessionId?: string; @@ -1943,6 +1947,8 @@ export class VoiceAssistantWebSocketServer { features: { connectedHosts: true, hostMultiplexing: true, + // COMPAT(relayProtectedSecurity): added in v0.13.0, remove gate after 2027-04-21. + relayProtectedSecurity: true, usageSources: true, ownedSubscriptions: true, agentRequestReceipts: true, @@ -2984,6 +2990,7 @@ function createWebSocketConnectionIdentity( ...(requestMetadata.userAgent ? { userAgent: requestMetadata.userAgent } : {}), ...(requestMetadata.remoteAddress ? { remoteAddress: requestMetadata.remoteAddress } : {}), ...(metadata?.relayConnectionId ? { relayConnectionId: metadata.relayConnectionId } : {}), + ...(metadata?.securityMode ? { securityMode: metadata.securityMode } : {}), ...(metadata?.hubDaemonId ? { hubDaemonId: metadata.hubDaemonId } : {}), }; } @@ -3002,6 +3009,7 @@ function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record = []; private resolveSend: (() => void) | null = null; - setState(state: "open"): void { - expect(state).toBe("open"); - } - send(data: string | ArrayBuffer): Promise { this.sent.push(data); return new Promise((resolve) => { diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.ts b/packages/server/src/server/websocket/encrypted-relay-socket.ts index 3b76a49711..f11162761c 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.ts @@ -2,7 +2,6 @@ import { EventEmitter } from "node:events"; import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES } from "./physical-socket.js"; export interface EncryptedRelayChannel { - setState: (state: "open") => void; send: (data: string | ArrayBuffer) => Promise; outboundWireByteLength: (data: string | ArrayBuffer) => number; close: (code?: number, reason?: string) => void; @@ -27,8 +26,6 @@ export function createEncryptedRelaySocket(params: { const { channel, emitter, getTransportBufferedAmount, terminateTransport } = params; let readyState = 1; - channel.setState("open"); - const terminate = () => { if (readyState === 3) return; readyState = 3; diff --git a/packages/server/src/server/websocket/secure-relay-ingress.test.ts b/packages/server/src/server/websocket/secure-relay-ingress.test.ts new file mode 100644 index 0000000000..d6bae93889 --- /dev/null +++ b/packages/server/src/server/websocket/secure-relay-ingress.test.ts @@ -0,0 +1,272 @@ +import { afterEach, expect, test, vi } from "vitest"; +import pino from "pino"; +import { + createClientChannel, + exportPublicKey, + generateKeyPair, + openClientSecureChannel, +} from "@getpaseo/relay/e2ee"; +import type { Transport } from "@getpaseo/relay/e2ee"; +import { admitSecuredRelaySocket, type SecuredRelaySocket } from "./secure-relay-ingress.js"; + +type Listener = (...args: unknown[]) => void; + +class FakeIngressSocket { + readyState = 1; + bufferedAmount = 0; + terminateCalls = 0; + closes: Array<{ code?: number; reason?: string }> = []; + deferSendCompletion = false; + onSend: ((data: string | Uint8Array | ArrayBuffer) => void) | null = null; + private pendingSendCallbacks: Array<(error?: Error) => void> = []; + private listeners = new Map(); + + on(event: "message" | "close" | "error", listener: Listener) { + this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]); + } + + send(data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) { + this.onSend?.(data); + if (!callback) return; + if (this.deferSendCompletion) { + this.pendingSendCallbacks.push(callback); + return; + } + callback(); + } + + completeNextSend() { + this.pendingSendCallbacks.shift()?.(); + } + + close(code?: number, reason?: string) { + this.closes.push({ code, reason }); + this.readyState = 3; + } + + terminate() { + this.terminateCalls += 1; + this.readyState = 3; + this.emit("close", 1006, ""); + } + + message(data: string | ArrayBuffer) { + this.emit("message", data, data instanceof ArrayBuffer); + } + + private emit(event: string, ...args: unknown[]) { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + } +} + +const logger = pino({ level: "silent" }); +const allowLegacy = { requireProtectedMode: () => false }; + +/** Wires a client secure channel to the fake socket so both ends talk directly. */ +async function openClientAgainst( + socket: FakeIngressSocket, + daemonPublicKeyB64: string, + events: { onopen?: () => void; onmessage?: (data: string | ArrayBuffer) => void } = {}, +) { + const clientTransport: Transport = { + send: (data) => socket.message(data), + close: () => undefined, + onmessage: null, + onclose: null, + onerror: null, + }; + socket.onSend = (data) => { + clientTransport.onmessage?.({ + data: data instanceof Uint8Array ? data.slice().buffer : data, + isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array, + }); + }; + return openClientSecureChannel(clientTransport, { daemonPublicKeyB64, events }); +} + +/** An older client that only speaks the legacy construction. */ +async function openLegacyClientAgainst(socket: FakeIngressSocket, daemonPublicKeyB64: string) { + const clientTransport: Transport = { + send: (data) => socket.message(data), + close: () => undefined, + onmessage: null, + onclose: null, + onerror: null, + }; + socket.onSend = (data) => { + clientTransport.onmessage?.({ + data: data instanceof Uint8Array ? data.slice().buffer : data, + isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array, + }); + }; + return createClientChannel(clientTransport, daemonPublicKeyB64); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +test("terminates a relay socket whose peer never completes the handshake", async () => { + vi.useFakeTimers(); + const socket = new FakeIngressSocket(); + let attachments = 0; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair: generateKeyPair(), logger, ...allowLegacy }, + async () => { + attachments += 1; + }, + ); + await vi.advanceTimersByTimeAsync(15_000); + await admitted; + expect(socket.terminateCalls).toBe(1); + expect(attachments).toBe(0); +}); + +test("admits the secured socket with its negotiated security mode", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let secured: SecuredRelaySocket | null = null; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, ...allowLegacy }, + async (next) => { + secured = next; + }, + ); + await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(secured).not.toBeNull(); + expect(secured!.securityMode).toBe("protected"); + expect(secured!.socket.readyState).toBe(1); +}); + +test("admits a legacy client while protected mode is not required", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let secured: SecuredRelaySocket | null = null; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, ...allowLegacy }, + async (next) => { + secured = next; + }, + ); + await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(secured!.securityMode).toBe("legacy"); +}); + +test("rejects a legacy client with a policy close when protected mode is required", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let attachments = 0; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, requireProtectedMode: () => true }, + async () => { + attachments += 1; + }, + ); + await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(attachments).toBe(0); + expect(socket.closes).toEqual([{ code: 1008, reason: "protected endpoint security required" }]); +}); + +test("reads the policy when the hello arrives, not when the socket was accepted", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let required = false; + let attachments = 0; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, requireProtectedMode: () => required }, + async () => { + attachments += 1; + }, + ); + required = true; + await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(attachments).toBe(0); + expect(socket.closes[0]?.code).toBe(1008); +}); + +test("delivers records received before admission completes, in order, after attach", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let releaseAttach: (() => void) | undefined; + const attachGate = new Promise((resolve) => { + releaseAttach = resolve; + }); + const received: unknown[] = []; + let secured: SecuredRelaySocket | null = null; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, ...allowLegacy }, + async (next) => { + secured = next; + next.socket.on("message", (data) => received.push(data)); + await attachGate; + }, + ); + let clientOpen: () => void = () => undefined; + const opened = new Promise((resolve) => { + clientOpen = resolve; + }); + const client = await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey), { + onopen: () => clientOpen(), + }); + await opened; + await vi.waitFor(() => expect(secured).not.toBeNull()); + await client.send("first"); + await client.send("second"); + expect(received).toEqual([]); + releaseAttach?.(); + await admitted; + expect(received).toEqual(["first", "second"]); +}); + +test("encrypted sends wait for the physical socket send callback", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + socket.deferSendCompletion = true; + let resolveAttached: ((secured: SecuredRelaySocket) => void) | undefined; + const attached = new Promise((resolve) => { + resolveAttached = resolve; + }); + void admitSecuredRelaySocket(socket, { daemonKeyPair, logger, ...allowLegacy }, async (next) => + resolveAttached?.(next), + ); + let resolveClientOpen: (() => void) | undefined; + const clientOpen = new Promise((resolve) => { + resolveClientOpen = resolve; + }); + await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey), { + onopen: () => resolveClientOpen?.(), + }); + + let attachedCompleted = false; + void attached.then(() => { + attachedCompleted = true; + return undefined; + }); + await clientOpen; + await Promise.resolve(); + expect(attachedCompleted).toBe(false); + socket.completeNextSend(); + const { socket: encryptedSocket } = await attached; + + let completed = false; + const sending = Promise.resolve(encryptedSocket.send(new Uint8Array([1, 2, 3]))).then(() => { + completed = true; + return undefined; + }); + await Promise.resolve(); + expect(completed).toBe(false); + + socket.completeNextSend(); + await sending; + expect(completed).toBe(true); +}); diff --git a/packages/server/src/server/websocket/secure-relay-ingress.ts b/packages/server/src/server/websocket/secure-relay-ingress.ts new file mode 100644 index 0000000000..013e3e8e03 --- /dev/null +++ b/packages/server/src/server/websocket/secure-relay-ingress.ts @@ -0,0 +1,210 @@ +/// +/** + * Endpoint security boundary for relay-carried daemon sessions. + * + * A relay data socket arrives here as plaintext bytes. This module runs the + * shared secure channel over it and hands the secured socket to session + * admission, together with the negotiated security mode. Relay routing lives in + * `relay-transport.ts`; credentials and authorization live in admission. + */ +import { EventEmitter } from "node:events"; +import type pino from "pino"; +import { + openDaemonSecureChannel, + SecureChannelRejectedError, + type KeyPair, + type SecurityMode, + type Transport as RelayTransport, +} from "@getpaseo/relay/e2ee"; +import { createEncryptedRelaySocket, type EncryptedRelaySocket } from "./encrypted-relay-socket.js"; + +const HANDSHAKE_TIMEOUT_MS = 15_000; + +export interface RelayIngressSocket { + readyState: number; + bufferedAmount?: number; + send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void; + close: (code?: number, reason?: string) => void; + terminate: () => void; + on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; +} + +export interface SecuredRelaySocket { + socket: EncryptedRelaySocket; + securityMode: SecurityMode; +} + +export interface SecureRelayIngressOptions { + daemonKeyPair: KeyPair; + logger: pino.Logger; + /** + * Current policy, read when the peer's hello arrives so a flag flipped at + * runtime applies to every admission from that moment on. + */ + requireProtectedMode: () => boolean; +} + +/** + * Secures `socket` and admits it through `attach`. Messages the peer sends + * before `attach` resolves are delivered once it has. + * + * A peer that never completes the handshake is terminated after + * {@link HANDSHAKE_TIMEOUT_MS}; a failed handshake closes the socket. + */ +export async function admitSecuredRelaySocket( + socket: RelayIngressSocket, + options: SecureRelayIngressOptions, + attach: (secured: SecuredRelaySocket) => Promise, +): Promise { + const { logger } = options; + const handshakeTimeout = setTimeout(() => { + logger.warn("relay_e2ee_handshake_timeout_terminating"); + socket.terminate(); + }, HANDSHAKE_TIMEOUT_MS); + try { + const relayTransport = createRelayTransportAdapter(socket, logger); + const emitter = new EventEmitter(); + const pendingMessages: Array = []; + let attached = false; + const emitMessage = (data: string | ArrayBuffer) => { + if (attached) { + emitter.emit("message", data); + return; + } + pendingMessages.push(data); + }; + const channel = await openDaemonSecureChannel(relayTransport, { + daemonKeyPair: options.daemonKeyPair, + minimumMode: () => (options.requireProtectedMode() ? "protected" : "legacy"), + events: { + onmessage: emitMessage, + onclose: (code, reason) => emitter.emit("close", code, reason), + onerror: (error) => { + logger.warn({ err: error }, "relay_e2ee_error"); + emitter.emit("error", error); + }, + }, + }); + clearTimeout(handshakeTimeout); + const encryptedSocket = createEncryptedRelaySocket({ + channel, + emitter, + getTransportBufferedAmount: () => socket.bufferedAmount, + terminateTransport: () => socket.terminate(), + }); + await attach({ socket: encryptedSocket, securityMode: channel.securityMode() }); + attached = true; + for (const message of pendingMessages) { + emitter.emit("message", message); + } + pendingMessages.length = 0; + } catch (error) { + if (error instanceof SecureChannelRejectedError) { + logger.warn({ reason: error.message }, "relay_e2ee_handshake_rejected"); + try { + socket.close(error.closeCode, error.closeReason); + } catch { + // ignore + } + return; + } + logger.warn({ err: error }, "relay_e2ee_handshake_failed"); + try { + socket.close(1011, "E2EE handshake failed"); + } catch { + // ignore + } + } finally { + clearTimeout(handshakeTimeout); + } +} + +function createRelayTransportAdapter( + socket: RelayIngressSocket, + logger: pino.Logger, +): RelayTransport { + const relayTransport: RelayTransport = { + send: (data) => + new Promise((resolve, reject) => { + try { + socket.send(data, (error) => { + if (!error) { + resolve(); + return; + } + logger.warn({ err: error }, "relay_socket_send_failed"); + reject(error); + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn({ err }, "relay_socket_send_failed"); + reject(err); + } + }), + close: (code?: number, reason?: string) => socket.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + + socket.on("message", (data, isBinary) => { + const binary = isBinary === true; + relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary }); + }); + socket.on("close", (code, reason) => { + const closeCode = typeof code === "number" ? code : 1006; + relayTransport.onclose?.(closeCode, String(reason ?? "")); + }); + socket.on("error", (err) => { + relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err))); + }); + + return relayTransport; +} + +function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer { + if (!isBinary) { + if (typeof data === "string") return data; + const buffer = bufferFromWsData(data); + if (buffer) return buffer.toString("utf8"); + return String(data); + } + + if (data instanceof ArrayBuffer) return data; + + const buffer = bufferFromWsData(data); + if (buffer) { + const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const out = new Uint8Array(view.byteLength); + out.set(view); + return out.buffer; + } + + return String(data); +} + +function bufferFromWsData(data: unknown): Buffer | null { + if (Buffer.isBuffer(data)) return data; + if (Array.isArray(data)) { + const buffers: Buffer[] = []; + for (const part of data) { + if (Buffer.isBuffer(part)) { + buffers.push(part); + } else if (part instanceof ArrayBuffer) { + buffers.push(Buffer.from(part)); + } else if (ArrayBuffer.isView(part)) { + buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength)); + } else if (typeof part === "string") { + buffers.push(Buffer.from(part, "utf8")); + } else { + return null; + } + } + return Buffer.concat(buffers); + } + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + return null; +} diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index ff13dc2ad8..3cc5c3dace 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -14,7 +14,7 @@ import { join } from "path"; import { win32 } from "node:path"; import { tmpdir } from "os"; import pino from "pino"; -import { base64EncryptedWireByteLength } from "@getpaseo/relay"; +import { base64EncryptedWireByteLength, maxSecuredPlaintextByteLength } from "@getpaseo/relay"; import { __resetCheckoutShortstatCacheForTests, __resetPullRequestStatusCacheForTests, @@ -1654,9 +1654,14 @@ const x = 1; CHECKOUT_DIFF_MAX_STRUCTURED_BYTES + frameEnvelopeHeadroomBytes, ); - expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe(24_117_208); + // The cap is sized for the tighter of the legacy and protected relay framings. + expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe( + maxSecuredPlaintextByteLength(relayFrameBytes) - frameEnvelopeHeadroomBytes, + ); + expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeLessThanOrEqual(24_117_208); + expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeGreaterThan(24_000_000); expect(diffWireBytes).toBeLessThan(relayFrameBytes); - expect(maximumFrameWireBytes).toBe(relayFrameBytes); + expect(maximumFrameWireBytes).toBeLessThanOrEqual(relayFrameBytes); }); it("marks tracked generated one-line diffs as too_large by content size", async () => { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index f2001e4651..fbbb494b4a 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -7,7 +7,7 @@ import { setImmediate } from "node:timers/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; -import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay"; +import { maxSecuredPlaintextByteLength } from "@getpaseo/relay"; import type { Logger } from "pino"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { @@ -2162,7 +2162,7 @@ const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024; // binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for // the surrounding WebSocket JSON envelope after inverting that exact wire expansion. export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES = - maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) - + maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; interface StructuredDiffAccumulator { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 0ba1cdbc7f..7e34e35e5c 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -194,6 +194,10 @@ "enabled": { "type": "boolean" }, + "requireProtectedSecurity": { + "description": "Admit only relay clients that negotiate protected endpoint security (replay protection). Defaults to false; older clients still connect while it is off.", + "type": "boolean" + }, "endpoint": { "type": "string" }, From 55a7590952b708621ae94f0dd07a34996c382a69 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Oct 2026 19:59:28 +0700 Subject: [PATCH 34/62] Sort sidebar workspaces and keep active overflow visible (#7635) Order ordinary sidebar workspaces by Created or Last prompt, preserve project and pin ordering, and keep later active or unacknowledged work visible beyond the first seven rows. Preserve reveal state through live updates and verify sorting, visibility, compatibility, and rendering behavior. --- docs/glossary.md | 1 + .../sidebar-mobile-menu-sheets.spec.ts | 1 + .../browser/sidebar-project-filter.spec.ts | 1 + .../app/e2e/browser/sidebar-reorder.spec.ts | 268 +++++++++++++++++- .../browser/sidebar-sort-old-daemon.spec.ts | 84 ++++++ .../workspace-relative-navigation.spec.ts | 20 ++ .../native-sidebar-drag-release.ios.ad | 3 +- packages/app/e2e/support/creation-fixtures.ts | 3 +- packages/app/e2e/support/helpers/creation.ts | 2 + packages/app/e2e/support/helpers/sidebar.ts | 10 + .../sidebar-workspace-list.test.tsx | 197 ++++++++++++- .../src/components/sidebar-workspace-list.tsx | 150 ++++------ .../sidebar/display-preferences/menu.tsx | 44 ++- .../sidebar/display-preferences/model.ts | 9 + .../sidebar/sidebar-group-visibility.ts | 21 ++ .../src/components/sidebar/sidebar-model.tsx | 2 +- .../sidebar/sidebar-status-list.tsx | 7 +- .../sidebar/sidebar-workspace-row.tsx | 4 +- .../sidebar/use-limited-sidebar-group.ts | 20 +- .../use-long-press-drag-interaction.ts | 4 +- ...space-shortcut-targets-subscriber.test.tsx | 3 +- .../app/src/composer/diff-stat-pill.test.tsx | 2 + .../session-workspace-upserts.test.ts | 2 + .../sidebar-workspaces-view-model.test.ts | 50 ++-- .../hooks/sidebar-workspaces-view-model.ts | 70 +++-- .../src/hooks/use-sidebar-workspaces-list.ts | 27 +- packages/app/src/i18n/resources/ar.ts | 6 + packages/app/src/i18n/resources/en.ts | 6 + packages/app/src/i18n/resources/es.ts | 6 + packages/app/src/i18n/resources/fr.ts | 6 + packages/app/src/i18n/resources/ja.ts | 6 + packages/app/src/i18n/resources/ko.ts | 6 + packages/app/src/i18n/resources/pt-BR.ts | 6 + packages/app/src/i18n/resources/ru.ts | 6 + packages/app/src/i18n/resources/zh-CN.ts | 6 + .../activity.integration.test.tsx | 2 + .../plugins/client-state/snapshots.test.ts | 2 + .../src/projects/workspace-structure.test.ts | 50 ++++ .../app/src/projects/workspace-structure.ts | 38 ++- .../src/runtime/directory-sync/index.test.ts | 9 +- .../src/runtime/replica-cache/index.test.ts | 31 ++ .../app/src/runtime/replica-cache/index.ts | 13 +- .../workspace/workspace-route-state.test.ts | 2 + .../workspace-source-of-truth.test.ts | 2 + .../src/stores/session-store-hooks/index.ts | 20 +- .../session-store-hooks/selectors.test.ts | 27 +- .../stores/session-store-hooks/selectors.ts | 30 +- packages/app/src/stores/session-store.test.ts | 2 + packages/app/src/stores/session-store.ts | 5 + .../src/stores/sidebar-order-store.test.ts | 46 +-- .../app/src/stores/sidebar-order-store.ts | 78 +---- .../app/src/stores/sidebar-view-store.test.ts | 13 + packages/app/src/stores/sidebar-view-store.ts | 13 +- .../src/utils/agent-directory-sync.test.ts | 2 + packages/app/src/utils/projects.test.ts | 2 + .../workspace-archive-navigation.test.ts | 2 + .../app/src/utils/workspace-identity.test.ts | 2 + .../src/workspace/workspace-archive.test.ts | 2 + packages/protocol/src/messages.ts | 3 + packages/server/src/server/agent/directory.ts | 24 +- packages/server/src/server/session.ts | 4 + .../src/server/session.workspaces.test.ts | 4 +- .../server/src/server/websocket-server.ts | 1 + .../src/server/workspace-directory.test.ts | 90 +++++- .../server/src/server/workspace-directory.ts | 5 + packages/website/playwright.config.ts | 4 +- 66 files changed, 1225 insertions(+), 362 deletions(-) create mode 100644 packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts create mode 100644 packages/app/src/components/sidebar/sidebar-group-visibility.ts diff --git a/docs/glossary.md b/docs/glossary.md index 644c109d44..d2c871f74b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -26,6 +26,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). +- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time. - **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. - **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). - **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log. diff --git a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts index 8b06e14f67..92e4d7a095 100644 --- a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts +++ b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts @@ -25,6 +25,7 @@ test("project and workspace kebabs open action sheets on compact layouts", async await page.getByTestId("sidebar-display-preferences-menu").click(); await expect(page.getByRole("menuitem")).toHaveText([ /^Grouping/, + /^Sort/, /^Title/, "Show", "Project", diff --git a/packages/app/e2e/browser/sidebar-project-filter.spec.ts b/packages/app/e2e/browser/sidebar-project-filter.spec.ts index a2f8d14348..e406ba5a3f 100644 --- a/packages/app/e2e/browser/sidebar-project-filter.spec.ts +++ b/packages/app/e2e/browser/sidebar-project-filter.spec.ts @@ -32,6 +32,7 @@ test.describe("Sidebar project filter", () => { await page.getByTestId("sidebar-display-preferences-menu").click(); await expect(page.getByRole("menuitem")).toHaveText([ /^Grouping/, + /^Sort/, /^Title/, "Show", "Project", diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts index 6129d37a63..f6bf4d3f1b 100644 --- a/packages/app/e2e/browser/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -1,9 +1,10 @@ -import type { Locator } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { getServerId } from "../support/helpers/server-id"; import { seedWorkspace } from "../support/helpers/seed-client"; +import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; async function rowTestIds(rows: Locator) { @@ -57,7 +58,19 @@ async function quickDragFirstRowAfterSecond( await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]); } -test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({ +async function dragWithoutReordering(rows: Locator): Promise { + const before = await rowTestIds(rows); + const source = await visibleBoundingBox(rows.nth(0)); + const target = await visibleBoundingBox(rows.nth(1)); + const page = rows.page(); + await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); + await page.mouse.down(); + await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 }); + await page.mouse.up(); + await expect.poll(() => rowTestIds(rows)).toEqual(before); +} + +test("projects and pinned chats reorder while ordinary workspaces stay newest first", async ({ page, }) => { const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" }); @@ -87,12 +100,13 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra ); const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`; const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`; - await quickDragFirstRowAfterSecond( - page.locator( - `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, - ), - pressWorkspaceRow, + const ordinaryRows = page.locator( + `[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`, ); + await expect + .poll(() => rowTestIds(ordinaryRows)) + .toEqual([secondWorkspaceTestId, firstWorkspaceTestId]); + await dragWithoutReordering(ordinaryRows); await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true); await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true); @@ -108,3 +122,243 @@ test("projects, workspaces, and pinned chats reorder with an immediate mouse dra await secondProject.cleanup(); } }); + +async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Sort/ }).click(); + await page.getByRole("menuitem", { name, exact: true }).click(); +} + +async function reconnectSidebar(page: Page): Promise { + await page.reload(); + await waitForSidebarHydration(page); +} + +async function expectNoWorkspaceSortChoice(page: Page): Promise { + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0); +} + +async function promptWorkspace(seed: Awaited>, agentId: string) { + await seed.client.sendAgentMessage(agentId, "sidebar ordering check"); + expect((await seed.client.waitForFinish(agentId, 30_000)).status).toBe("idle"); +} + +test("Created stays stable, Last prompt follows human submissions and survives tab archive", async ({ + page, +}) => { + test.setTimeout(120_000); + const seed = await seedWorkspace({ repoPrefix: "sidebar-sort-" }); + try { + const newer = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: "Newer workspace", + }); + if (!newer.workspace) throw new Error(newer.error ?? "Workspace creation failed"); + const agent = await seed.client.createAgent({ + provider: "mock", + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + modeId: "load-test", + model: "e2e-fast-stream", + title: "Ordering agent", + }); + const newerAgent = await seed.client.createAgent({ + provider: "mock", + cwd: seed.repoPath, + workspaceId: newer.workspace.id, + modeId: "load-test", + model: "e2e-fast-stream", + title: "Newer agent", + }); + await gotoAppShell(page); + await waitForSidebarHydration(page); + const oldId = `sidebar-workspace-row-${getServerId()}:${seed.workspaceId}`; + const newId = `sidebar-workspace-row-${getServerId()}:${newer.workspace.id}`; + const rows = page.locator(`[data-testid="${oldId}"], [data-testid="${newId}"]`); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await promptWorkspace(seed, agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await selectWorkspaceSort(page, "Last prompt"); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await promptWorkspace(seed, newerAgent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await promptWorkspace(seed, agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await seed.client.archiveAgent(agent.id); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await reconnectSidebar(page); + await expect.poll(() => rowTestIds(rows)).toEqual([oldId, newId]); + await selectWorkspaceSort(page, "Created"); + await expect.poll(() => rowTestIds(rows)).toEqual([newId, oldId]); + await selectSidebarStatusGrouping(page); + await expectNoWorkspaceSortChoice(page); + } finally { + await seed.cleanup(); + } +}); + +async function seedCompactProject() { + const seed = await seedWorkspace({ + repoPrefix: "sidebar-visibility-", + title: "Oldest workspace", + }); + const ids = [seed.workspaceId]; + for (let index = 2; index <= 12; index += 1) { + const created = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: `Workspace ${index}`, + }); + if (!created.workspace) throw new Error(created.error ?? "Workspace creation failed"); + ids.unshift(created.workspace.id); + } + const agent = await seed.client.createAgent({ + provider: "mock", + model: "e2e-fast-stream", + modeId: "load-test", + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + title: "Overflow agent", + }); + return { seed, ids, agent }; +} + +async function expectWorkspaceOrder(rows: Locator, ids: string[]) { + await expect + .poll(() => rowTestIds(rows)) + .toEqual(ids.map((id) => `sidebar-workspace-row-${getServerId()}:${id}`)); +} + +async function revealWorkspaces(list: Locator, action: "Show more" | "Show less") { + await list.getByLabel(action, { exact: true }).click(); +} + +async function selectProjectGrouping(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Group/ }).click(); + await page.getByTestId("sidebar-grouping-project").click(); +} + +type CompactProject = Awaited>; + +function compactProjectRows(page: Page, seed: CompactProject["seed"]) { + const list = page.getByTestId(`sidebar-workspace-list-${seed.projectKey}`); + return { + rows: list.locator('[data-testid^="sidebar-workspace-row-"]'), + group: list.locator(".."), + project: page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seed.projectKey)}`), + }; +} + +async function expectActiveOverflowTransitions(page: Page, { seed, ids, agent }: CompactProject) { + const { rows } = compactProjectRows(page, seed); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + + await seed.client.sendAgentMessage(agent.id, "show active overflow"); + await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]); + await seed.client.waitForFinish(agent.id, 15_000); + await expectWorkspaceOrder(rows, [...ids.slice(0, 7), ids[11]]); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +async function expectRevealSurvivesSortingAndCollapse( + page: Page, + { seed, ids, agent }: CompactProject, +) { + const { rows, group, project } = compactProjectRows(page, seed); + const retainedRows = await rows.elementHandles(); + await revealWorkspaces(group, "Show more"); + await expectWorkspaceOrder(rows, ids); + for (const [index, retained] of retainedRows.entries()) { + expect( + await rows.nth(index).evaluate((current, previous) => current === previous, retained), + ).toBe(true); + } + await project.click(); + await expect(rows).toHaveCount(0); + await project.click(); + await expectWorkspaceOrder(rows, ids); + await seed.client.sendAgentMessage(agent.id, "expanded overflow"); + await seed.client.waitForFinish(agent.id, 15_000); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expectWorkspaceOrder(rows, ids); + await selectWorkspaceSort(page, "Last prompt"); + await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 11)]); + const revealedRow = await rows.nth(8).elementHandle(); + await revealWorkspaces(group, "Show less"); + await expectWorkspaceOrder(rows, [ids[11], ...ids.slice(0, 6)]); + await selectWorkspaceSort(page, "Created"); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); + expect(await revealedRow!.evaluate((row) => row.isConnected)).toBe(false); + await reconnectSidebar(page); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +async function expectStatusBucketsRetainActiveOverflow( + page: Page, + { seed, ids, agent }: CompactProject, +) { + const { rows } = compactProjectRows(page, seed); + await selectSidebarStatusGrouping(page); + const done = page.getByTestId("sidebar-status-group-rows-done"); + const doneRows = done.locator('[data-testid^="sidebar-workspace-row-"]'); + await expect(doneRows).toHaveCount(7); + await revealWorkspaces(done, "Show more"); + await expect(doneRows).toHaveCount(12); + await seed.client.sendAgentMessage(agent.id, "status overflow"); + await seed.client.waitForFinish(agent.id, 15_000); + await expect( + page + .getByTestId("sidebar-status-group-rows-attention") + .getByText("Oldest workspace", { exact: true }), + ).toBeVisible(); + await expect(doneRows).toHaveCount(11); + await seed.client.clearWorkspaceAttention(seed.workspaceId); + await expect(doneRows).toHaveCount(12); + await revealWorkspaces(done, "Show less"); + await expect(doneRows).toHaveCount(7); + const activeAgents: Array<{ id: string }> = []; + for (const workspaceId of ids.slice(0, 8)) { + activeAgents.push( + await seed.client.createAgent({ + provider: "mock", + model: "e2e-fast-stream", + modeId: "load-test", + cwd: seed.repoPath, + workspaceId, + title: "Active overflow agent", + }), + ); + } + await Promise.all( + activeAgents.map((item) => seed.client.sendAgentMessage(item.id, "active bucket")), + ); + await Promise.all(activeAgents.map((item) => seed.client.waitForFinish(item.id, 15_000))); + const attention = page.getByTestId("sidebar-status-group-rows-attention"); + await expect(attention.locator('[data-testid^="sidebar-workspace-row-"]')).toHaveCount(8); + await expect(attention.getByLabel("Show more", { exact: true })).toHaveCount(0); + for (const workspaceId of ids.slice(0, 8)) await seed.client.clearWorkspaceAttention(workspaceId); + await expect(doneRows).toHaveCount(7); + await selectProjectGrouping(page); + await expectWorkspaceOrder(rows, ids.slice(0, 7)); +} + +test("compact groups retain active overflow and reveal the current ordered rows", async ({ + page, +}) => { + test.setTimeout(120_000); + const fixture = await seedCompactProject(); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await test.step("Active overflow appears without intervening Done rows", () => + expectActiveOverflowTransitions(page, fixture)); + await test.step("Reveal follows current sort and survives collapse", () => + expectRevealSurvivesSortingAndCollapse(page, fixture)); + await test.step("Status reveals Done rows and keeps every active row", () => + expectStatusBucketsRetainActiveOverflow(page, fixture)); + } finally { + await fixture.seed.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts new file mode 100644 index 0000000000..ba210131db --- /dev/null +++ b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts @@ -0,0 +1,84 @@ +import type { Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; +import { randomUUID } from "node:crypto"; +import { metroTest as test, expect } from "../support/fixtures"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { buildSeededHost } from "../support/helpers/daemon-registry"; +import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; + +test("published old daemon keeps workspace navigation and gates sorting", async ({ page }) => { + test.setTimeout(180_000); + const daemon = await startIsolatedHostDaemon(`srv_sort_old_${randomUUID()}`, { + publishedVersion: "0.8.0", + }); + try { + const project = await seedWorkspace({ repoPrefix: "sidebar-old-host-", port: daemon.port }); + try { + const second = await project.client.createWorkspace({ + source: { kind: "directory", path: project.repoPath, projectId: project.projectId }, + title: "Second old workspace", + }); + expect(second.workspace).toBeTruthy(); + await openPublishedHost(page, daemon, project.workspaceId); + await openWorkspace(page, daemon.serverId, second.workspace!.id); + await expectSortingRequiresHostUpdate(page); + await selectSidebarStatusGrouping(page); + await expectStatusHasNoSorting(page); + } finally { + await project.cleanup(); + } + } finally { + await daemon.close(); + } +}); + +async function openPublishedHost( + page: Page, + daemon: { serverId: string; port: number }, + workspaceId: string, +) { + const host = buildSeededHost({ + serverId: daemon.serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.addInitScript((seededHost) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem( + "sidebar-view", + JSON.stringify({ version: 8, state: { groupMode: "project", workspaceSort: "lastPrompt" } }), + ); + }, host); + await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId)); +} + +async function openWorkspace(page: Page, serverId: string, workspaceId: string) { + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await expect(row).toBeVisible(); + await row.click(); + await expect(page).toHaveURL(new RegExp(workspaceId)); +} + +async function expectSortingRequiresHostUpdate(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await page.getByRole("menuitem", { name: /^Sort/ }).click(); + await expect( + page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Update hosts to sort workspaces" }), + ).toBeDisabled(); + await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0); + await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +async function expectStatusHasNoSorting(page: Page) { + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Update hosts to sort workspaces" })).toHaveCount( + 0, + ); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toHaveCount(0); +} diff --git a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts index 4853e9daa4..909672c489 100644 --- a/packages/app/e2e/browser/workspace-relative-navigation.spec.ts +++ b/packages/app/e2e/browser/workspace-relative-navigation.spec.ts @@ -3,6 +3,7 @@ import { test, expect } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; +import { revealSidebarProjectWorkspaces } from "../support/helpers/sidebar"; import { expectSidebarWorkspaceSelected, switchWorkspaceViaSidebar, @@ -46,6 +47,7 @@ test("previous and next traverse every visible workspace while digit shortcuts s } await page.setViewportSize({ width: 1440, height: 1100 }); await gotoAppShell(page); + await revealSidebarProjectWorkspaces(page, project.projectKey); const order = await readVisibleWorkspaceOrder(page); await test.step("next crosses the ninth workspace and previous returns to it", async () => { @@ -68,7 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s workspaceId: order[8], }); }); + await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => { + await expectHiddenWorkspaceShortcuts(page, order); + }); } finally { await project.cleanup(); } }); + +async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) { + await page.getByLabel("Show less", { exact: true }).click(); + const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ }); + await expect(rows).toHaveCount(7); + await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] }); + await page.keyboard.press("Alt+9"); + await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`)); + await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount( + 0, + ); + await page.keyboard.press("Alt+]"); + await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`)); + await expect(rows).toHaveCount(7); +} diff --git a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad index c94e24914d..359e89c5c9 100644 --- a/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-sidebar-drag-release.ios.ad @@ -2,7 +2,8 @@ context platform=ios timeout=60000 retries=0 env APP_ID=sh.paseo.debug # Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible, -# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms). +# unselected PINNED workspace row. Ordinary workspace rows are not draggable. +# Hold past drag arming (180ms), then release. open "${APP_ID}" press "label=\"Open menu\"" wait "id=\"${WORKSPACE_ROW}\"" 10000 diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts index 9608a95a0c..c6d3579206 100644 --- a/packages/app/e2e/support/creation-fixtures.ts +++ b/packages/app/e2e/support/creation-fixtures.ts @@ -35,7 +35,8 @@ export const test = base.extend<{ import { readFile, rm } from "node:fs/promises"; export default function contribute(server) { return server.before("agent.session_open", async ({ request }) => { - if (request.reason !== "create") return request; + // Metadata helpers have no workspace and must not consume the one-shot failure. + if (request.reason !== "create" || !request.workspaceId) return request; const gate = ${JSON.stringify(gate)}; let command = await readFile(gate, "utf8").catch(() => "release"); while (command === "hold") { diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts index 9098068698..1c3d5dda89 100644 --- a/packages/app/e2e/support/helpers/creation.ts +++ b/packages/app/e2e/support/helpers/creation.ts @@ -17,6 +17,7 @@ import { workspaceDeckEntryLocator, } from "./workspace-ui"; import { getServerId } from "./server-id"; +import { revealSidebarProjectWorkspaces } from "./sidebar"; import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention"; import type { installDaemonWebSocketGate } from "./daemon-websocket-gate"; @@ -282,6 +283,7 @@ export async function createCreationScenario(page: Page) { }); } await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0); + await revealSidebarProjectWorkspaces(page, project.projectKey); await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId }); await page .getByTestId(draftTestId!) diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index 5b7b819bf0..66a913e7b3 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -1,5 +1,6 @@ import { expect, type Page } from "@playwright/test"; import { getServerId } from "./server-id"; +import { projectEquivalenceViewKey } from "./project-view-key"; interface ContextMenuAnchor { x: number; @@ -295,3 +296,12 @@ export async function expectMobileExplorerHidden(page: Page): Promise { timeout: 5_000, }); } + +export async function revealSidebarProjectWorkspaces( + page: Page, + projectKey: string, +): Promise { + await page + .getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(projectKey)}`) + .click(); +} diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 73c008dd22..0e1d7d28ab 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -1,7 +1,8 @@ /** * @vitest-environment jsdom */ -import { act } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; +import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -83,6 +84,8 @@ function workspace(input: { scripts?: WorkspaceDescriptor["scripts"]; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId, projectDisplayName: input.projectDisplayName, @@ -171,10 +174,6 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], - workspaceOrderByProject: { - ["project-a"]: [`${SERVER_ID}:a-main`, `${SERVER_ID}:a-one`, `${SERVER_ID}:a-two`], - ["project-b"]: [`${SERVER_ID}:b-main`, `${SERVER_ID}:b-one`, `${SERVER_ID}:b-two`], - }, }); }); } @@ -348,7 +347,6 @@ describe("sidebar workspace render isolation", () => { useSessionStore.getState().clearSession(SERVER_ID); useSidebarOrderStore.setState({ projectOrder: [], - workspaceOrderByProject: {}, }); }); }); @@ -439,3 +437,190 @@ describe("sidebar workspace render isolation", () => { }); }); }); + +describe("sidebar group visibility", () => { + const entries = (statuses: WorkspaceDescriptor["status"][]) => + statuses.map((status, index) => + createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: workspace({ + id: `row-${index + 1}`, + projectId: "project-a", + projectDisplayName: "Project A", + name: `Row ${index + 1}`, + status, + }), + }), + ); + const policy = { + kind: "workspaces" as const, + getStatusBucket: (entry: ReturnType) => entry.statusBucket, + }; + const names = (items: readonly ReturnType[]) => + items.map((item) => item.workspaceId); + + it.each([0, 7, 8])("limits %i Done rows to seven", (count) => { + const items = entries(Array(count).fill("done")); + const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(result.current.visibleItems).toEqual(items.slice(0, 7)); + expect(result.current.canToggle).toBe(count > 7); + }); + + it.each(["needs_input", "failed", "attention", "running"] as const)( + "keeps row twelve visible for %s without filling gaps", + (status) => { + const items = entries([...Array(11).fill("done"), status]); + const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(names(result.current.visibleItems)).toEqual([ + "row-1", + "row-2", + "row-3", + "row-4", + "row-5", + "row-6", + "row-7", + "row-12", + ]); + expect(result.current.visibleItems[7]).toBe(items[11]); + expect(result.current.canToggle).toBe(true); + }, + ); + + it("shows eight active rows once with no redundant toggle", () => { + const items = entries([ + "running", + "needs_input", + "attention", + "failed", + "running", + "attention", + "needs_input", + "failed", + ]); + const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(result.current.visibleItems).toEqual(items); + expect(result.current.canToggle).toBe(false); + }); + + it("recomputes current ordered membership and retains reveal through status, order and size changes", () => { + const items = entries([ + ...Array(7).fill("done"), + "running", + "done", + "failed", + "done", + "attention", + ]); + const { result, rerender, unmount } = renderHook( + ({ rows }) => useLimitedSidebarGroup(rows, policy), + { initialProps: { rows: items } }, + ); + expect(names(result.current.visibleItems)).toEqual([ + "row-1", + "row-2", + "row-3", + "row-4", + "row-5", + "row-6", + "row-7", + "row-8", + "row-10", + "row-12", + ]); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toEqual(items); + rerender({ rows: items.map((item) => ({ ...item, statusBucket: "running" })) }); + expect(result.current.canToggle).toBe(false); + expect(result.current.expanded).toBe(true); + rerender({ rows: items.slice(0, 3) }); + expect(result.current.canToggle).toBe(false); + rerender({ rows: items.toReversed() }); + expect(result.current.visibleItems).toEqual(items.toReversed()); + act(() => result.current.toggleExpanded()); + expect(names(result.current.visibleItems)).toEqual([ + "row-12", + "row-11", + "row-10", + "row-9", + "row-8", + "row-7", + "row-6", + ]); + const newest = { ...items[0], workspaceId: "new", workspaceKey: "srv:new" }; + rerender({ rows: [newest, ...items] }); + expect(result.current.visibleItems[0]).toBe(newest); + expect(result.current.visibleItems).toHaveLength(10); + expect(result.current.expanded).toBe(false); + act(() => result.current.toggleExpanded()); + unmount(); + const remounted = renderHook(() => useLimitedSidebarGroup(items, policy)); + expect(remounted.result.current.expanded).toBe(false); + expect(remounted.result.current.visibleItems).toHaveLength(10); + }); + + it("follows effective pending creation, recovery and agent activity without changing Created order", () => { + const items = entries(Array(12).fill("done")); + const descriptor = workspace({ + id: "row-12", + projectId: "project-a", + projectDisplayName: "Project A", + name: "Row 12", + }); + const pending = { + draftId: "draft", + serverId: SERVER_ID, + workspaceId: descriptor.id, + agentId: null, + clientMessageId: "message", + text: "Create", + timestamp: 1234, + lifecycle: "active" as const, + }; + const { result, rerender } = renderHook( + ({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy), + { initialProps: { last: items[11] } }, + ); + expect(result.current.visibleItems).toHaveLength(7); + const creating = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + pendingCreateAttempts: { draft: pending }, + }); + expect(creating.statusBucket).toBe("running"); + rerender({ last: creating }); + expect(names(result.current.visibleItems)).toEqual([...names(items.slice(0, 7)), "row-12"]); + const abandoned = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + pendingCreateAttempts: { draft: { ...pending, lifecycle: "abandoned" } }, + }); + rerender({ last: abandoned }); + expect(result.current.visibleItems).toHaveLength(7); + rerender({ last: creating }); + for (const status of ["running", "attention", "needs_input", "failed", "done"] as const) { + const last = createSidebarWorkspaceEntry({ + serverId: SERVER_ID, + workspace: descriptor, + workspaceAgentActivity: new Map([ + [descriptor.id, { agentId: "agent", status, enteredAt: null }], + ]), + }); + expect(last.statusBucket).toBe(status); + rerender({ last }); + expect(names(result.current.visibleItems)).toEqual( + status === "done" ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-12"], + ); + } + }); + + it("preserves the twenty-row pinned policy regardless of status", () => { + const items = entries(Array(21).fill("running")); + const { result } = renderHook(() => useLimitedSidebarGroup(items)); + expect(result.current.visibleItems).toEqual(items.slice(0, 20)); + expect(result.current.canToggle).toBe(true); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toEqual(items); + act(() => result.current.toggleExpanded()); + expect(result.current.visibleItems).toHaveLength(20); + }); +}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 74d57ca667..1c7b3f28d0 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -272,7 +272,7 @@ interface WorkspaceRowInnerProps { shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; isArchiving: boolean; isCreating?: boolean; @@ -381,8 +381,6 @@ function getProjectWorkspaceRowStyle({ ]; } -function noop() {} - const prBadgeStyles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", @@ -1236,7 +1234,7 @@ function WorkspaceRowWithMenu({ shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; @@ -1436,7 +1434,7 @@ function WorkspaceRowItem({ enabled: selectionEnabled, })} onPress={handlePress} - drag={drag ?? noop} + drag={drag} isDragging={isDragging} dragHandleProps={dragHandleProps} /> @@ -1465,7 +1463,7 @@ function areWorkspaceRowItemPropsEqual( previous.hostBadge === next.hostBadge && previous.leadingProjectName === next.leadingProjectName && previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri && - previous.shortcutNumber === next.shortcutNumber && + (!next.showShortcutBadge || previous.shortcutNumber === next.shortcutNumber) && previous.showShortcutBadge === next.showShortcutBadge && previous.canCopyBranchName === next.canCopyBranchName && previous.canPin === next.canPin && @@ -1507,7 +1505,7 @@ function WorkspaceRow({ shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; - drag: () => void; + drag?: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; @@ -1552,16 +1550,12 @@ function ProjectBlock({ selectionEnabled, showShortcutBadges, shortcutIndexByWorkspaceKey, - parentGestureRef, onToggleCollapsed, onWorkspacePress, - onWorkspaceReorder, onWorktreeCreated, drag, isDragging, dragHandleProps, - useNestable, - dragGestureHostActive, creatingWorkspaceIds, activeWorkspaceSelection, hostBadgeByServerId, @@ -1577,16 +1571,12 @@ function ProjectBlock({ selectionEnabled: boolean; showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; - parentGestureRef?: MutableRefObject; onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; - onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; - useNestable: boolean; - dragGestureHostActive?: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; hostBadgeByServerId: ReadonlyMap; @@ -1594,12 +1584,20 @@ function ProjectBlock({ supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { + const visibilityPolicy = useMemo( + () => ({ + kind: "workspaces" as const, + getStatusBucket: (workspace: SidebarWorkspacePlacement) => + workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket, + }), + [workspaceEntriesByKey], + ); const { visibleItems: visibleWorkspaces, expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(project.workspaces); + } = useLimitedSidebarGroup(project.workspaces, visibilityPolicy); const activeServerId = activeWorkspaceSelection?.serverId ?? null; const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? ""); const preferredServerId = activeHostStatus === "online" ? activeServerId : null; @@ -1628,16 +1626,10 @@ function ProjectBlock({ }); const renderWorkspaceRow = useCallback( - ( - item: SidebarWorkspacePlacement, - input?: { - drag?: () => void; - isDragging?: boolean; - dragHandleProps?: DraggableListDragHandleProps; - }, - ) => { + (item: SidebarWorkspacePlacement) => { return ( ); }, @@ -1671,29 +1660,6 @@ function ProjectBlock({ ], ); - const renderWorkspace = useCallback( - ({ - item, - drag: workspaceDrag, - isActive, - dragHandleProps: workspaceDragHandleProps, - }: DraggableRenderItemInfo) => { - return renderWorkspaceRow(item, { - drag: workspaceDrag, - isDragging: isActive, - dragHandleProps: workspaceDragHandleProps, - }); - }, - [renderWorkspaceRow], - ); - - const handleWorkspaceDragEnd = useCallback( - (workspaces: SidebarWorkspacePlacement[]) => { - onWorkspaceReorder(project.viewKey, workspaces); - }, - [onWorkspaceReorder, project.viewKey], - ); - const toast = useToast(); const { t } = useTranslation(); const [isRemovingProject, setIsRemovingProject] = useState(false); @@ -1759,20 +1725,12 @@ function ProjectBlock({ if (project.workspaces.length > 0) { projectChildren = ( <> - + style={styles.workspaceListContainer} + > + {visibleWorkspaces.map(renderWorkspaceRow)} + {canToggleWorkspaces ? ( [0]; function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { return ( previous.project === next.project && - previous.workspaceEntriesByKey === next.workspaceEntriesByKey && + projectWorkspaceEntriesEqual(previous, next) && previous.collapsed === next.collapsed && previous.displayName === next.displayName && previous.iconDataUri === next.iconDataUri && previous.selectionEnabled === next.selectionEnabled && previous.showShortcutBadges === next.showShortcutBadges && - previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey && + projectShortcutBadgesEqual(previous, next) && previous.hostBadgeByServerId === next.hostBadgeByServerId && previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId && previous.supportsPinningByServerId === next.supportsPinningByServerId && previous.onToggleWorkspacePin === next.onToggleWorkspacePin && - previous.parentGestureRef === next.parentGestureRef && previous.onToggleCollapsed === next.onToggleCollapsed && previous.onWorkspacePress === next.onWorkspacePress && - previous.onWorkspaceReorder === next.onWorkspaceReorder && previous.onWorktreeCreated === next.onWorktreeCreated && previous.drag === next.drag && previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && - previous.useNestable === next.useNestable && - previous.dragGestureHostActive === next.dragGestureHostActive && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId && areProjectBlockSelectionsEqual(previous, next) ); } +function projectShortcutBadgesEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { + if ( + !next.showShortcutBadges || + previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey + ) + return true; + return next.project.workspaces.every( + ({ workspaceKey }) => + previous.shortcutIndexByWorkspaceKey.get(workspaceKey) === + next.shortcutIndexByWorkspaceKey.get(workspaceKey), + ); +} + +function projectWorkspaceEntriesEqual( + previous: ProjectBlockProps, + next: ProjectBlockProps, +): boolean { + if (previous.workspaceEntriesByKey === next.workspaceEntriesByKey) return true; + return next.project.workspaces.every( + ({ workspaceKey }) => + previous.workspaceEntriesByKey.get(workspaceKey) === + next.workspaceEntriesByKey.get(workspaceKey), + ); +} + function areProjectBlockSelectionsEqual( previous: ProjectBlockProps, next: ProjectBlockProps, @@ -2137,8 +2116,6 @@ function ProjectModeList({ const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); - const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); - const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); const isWorkspaceRoute = useMemo( () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), @@ -2236,30 +2213,6 @@ function ProjectModeList({ [getProjectOrder, setProjectOrder], ); - const handleWorkspaceReorder = useCallback( - (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { - const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); - const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey); - if ( - !hasVisibleOrderChanged({ - currentOrder: currentWorkspaceOrder, - reorderedVisibleKeys: reorderedWorkspaceKeys, - }) - ) { - return; - } - - setWorkspaceOrder( - projectViewKey, - mergeWithRemainder({ - currentOrder: currentWorkspaceOrder, - reorderedVisibleKeys: reorderedWorkspaceKeys, - }), - ); - }, - [getWorkspaceOrder, setWorkspaceOrder], - ); - const handleWorktreeCreated = useCallback((workspaceId: string) => { setCreatingWorkspaceIds((current) => { const next = new Set(current); @@ -2306,16 +2259,12 @@ function ProjectModeList({ selectionEnabled={selectionEnabled} showShortcutBadges={showShortcutBadges} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} - parentGestureRef={parentGestureRef} onToggleCollapsed={onToggleProjectCollapsed} onWorkspacePress={onWorkspacePress} - onWorkspaceReorder={handleWorkspaceReorder} onWorktreeCreated={handleWorktreeCreated} drag={dragState.drag} isDragging={dragState.isDragging} dragHandleProps={dragState.dragHandleProps} - useNestable={platformIsNative} - dragGestureHostActive={dragGestureHostActive} creatingWorkspaceIds={creatingWorkspaceIds} activeWorkspaceSelection={activeWorkspaceSelection} hostBadgeByServerId={hostBadgeByServerId} @@ -2329,15 +2278,12 @@ function ProjectModeList({ collapsedProjectKeys, activeWorkspaceSelection, handleWorktreeCreated, - handleWorkspaceReorder, hostBadgeByServerId, supportsMultiplicityByServerId, supportsPinningByServerId, onToggleWorkspacePin, onWorkspacePress, onToggleProjectCollapsed, - parentGestureRef, - dragGestureHostActive, projectIconByProjectViewKey, selectionEnabled, shortcutIndexByWorkspaceKey, diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index be5a66ff6f..4fb04ebb67 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -167,7 +167,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { const hosts = useHosts(); // `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed // from it would lose the row that undoes the filter as soon as the filter narrowed to one. - const { allProjects, resolvedProjectFilters } = useSidebarModel(); + const { allProjects, resolvedProjectFilters, workspaceSortSupported } = useSidebarModel(); const { labels } = useWorkspaceLabelProjection(); const [managerOpen, setManagerOpen] = useState(false); const openManager = useCallback(() => setManagerOpen(true), []); @@ -240,6 +240,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { }, ]; + if (preferences.grouping === "project") { + definitions.push({ + id: "sort", + title: t("sidebar.display.sort.label"), + content: workspaceSortSupported ? ( + + ) : ( + {t("sidebar.display.sort.updateHost")} + ), + }); + } + if (showHostFilter) { definitions.push({ id: "hostFilter", @@ -273,6 +285,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { }, [ t, preferences, + workspaceSortSupported, hosts, showHostFilter, showProjectFilter, @@ -308,6 +321,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { > {t("sidebar.display.grouping.label")} + {preferences.grouping === "project" ? ( + + {t("sidebar.display.sort.label")} + + ) : null} setWorkspaceSort("created"), [setWorkspaceSort]); + const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]); + return ( + <> + + {t("sidebar.display.sort.created")} + + + {t("sidebar.display.sort.lastPrompt")} + + + ); +} + /** * Every label you could filter by, wherever it lives, one row each. * diff --git a/packages/app/src/components/sidebar/display-preferences/model.ts b/packages/app/src/components/sidebar/display-preferences/model.ts index 68d347a258..a2ef3212e0 100644 --- a/packages/app/src/components/sidebar/display-preferences/model.ts +++ b/packages/app/src/components/sidebar/display-preferences/model.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSort } from "@/projects/workspace-structure"; import { useCallback, useMemo } from "react"; import { useAppSettings, @@ -19,6 +20,8 @@ export interface SidebarDisplayPreferences { showBackground: boolean; toggleBackground: () => void; grouping: SidebarGroupMode; + workspaceSort: WorkspaceSort; + setWorkspaceSort: (sort: WorkspaceSort) => void; setGrouping: (mode: SidebarGroupMode) => void; titleSource: WorkspaceTitleSource; setTitleSource: (source: WorkspaceTitleSource) => void; @@ -52,6 +55,8 @@ export interface SidebarDisplayPreferences { export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { const showBackground = useSidebarViewStore((state) => state.showBackground); const toggleBackground = useSidebarViewStore((state) => state.toggleBackground); + const workspaceSort = useSidebarViewStore((state) => state.workspaceSort); + const setWorkspaceSort = useSidebarViewStore((state) => state.setWorkspaceSort); const grouping = useSidebarViewStore((state) => state.groupMode); const setGrouping = useSidebarViewStore((state) => state.setGroupMode); const hostFilters = useSidebarViewStore((state) => state.hostFilters); @@ -111,6 +116,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { showBackground, toggleBackground, grouping, + workspaceSort, + setWorkspaceSort, setGrouping, titleSource: workspaceTitleSource, setTitleSource, @@ -134,6 +141,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { showBackground, toggleBackground, grouping, + workspaceSort, + setWorkspaceSort, setGrouping, workspaceTitleSource, setTitleSource, diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.ts new file mode 100644 index 0000000000..ce9f47671c --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.ts @@ -0,0 +1,21 @@ +import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model"; + +export type SidebarGroupVisibilityPolicy = + | { kind: "pinned" } + | { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket }; + +export function selectSidebarGroupVisibility( + items: readonly T[], + expanded: boolean, + policy: SidebarGroupVisibilityPolicy, +) { + const limit = policy.kind === "pinned" ? 20 : 7; + const baseline = items.filter( + (item, index) => + index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"), + ); + return { + visibleItems: expanded ? items.slice() : baseline, + canToggle: baseline.length < items.length, + }; +} diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index ec834aee19..cfa69428f8 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -134,7 +134,7 @@ export function SidebarModelProvider({ visibleWorkspaceKeys.has(workspace.workspaceKey), ); return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0) - ? [{ ...project, workspaces }] + ? [workspaces.length === project.workspaces.length ? project : { ...project, workspaces }] : []; }); } diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index baffc3a010..28575df64e 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -319,6 +319,11 @@ function StatusGroupList({ ); } +const STATUS_GROUP_VISIBILITY = { + kind: "workspaces" as const, + getStatusBucket: (workspace: SidebarWorkspaceEntry) => workspace.statusBucket, +}; + function StatusGroupRows({ group, collapsed, @@ -345,7 +350,7 @@ function StatusGroupRows({ expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(group.rows); + } = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY); return ( diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index c5f8a278bd..2488e7956e 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -41,8 +41,6 @@ import { type SidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; -function noop() {} - interface SidebarWorkspaceRowProps { workspace: SidebarWorkspaceEntry; selected: boolean; @@ -239,7 +237,7 @@ function WorkspaceRowBody({ const trailing = useSidebarWorkspaceTrailing(); const draggable = Boolean(drag); const interaction = useLongPressDragInteraction({ - drag: drag ?? noop, + drag, menuController: null, }); const { diff --git a/packages/app/src/components/sidebar/use-limited-sidebar-group.ts b/packages/app/src/components/sidebar/use-limited-sidebar-group.ts index 5bf10ecec6..99e4476dfb 100644 --- a/packages/app/src/components/sidebar/use-limited-sidebar-group.ts +++ b/packages/app/src/components/sidebar/use-limited-sidebar-group.ts @@ -1,15 +1,21 @@ import { useCallback, useMemo, useState } from "react"; +import { + selectSidebarGroupVisibility, + type SidebarGroupVisibilityPolicy, +} from "./sidebar-group-visibility"; -const INITIAL_VISIBLE_ITEMS = 20; +const PINNED_POLICY = { kind: "pinned" } as const; -export function useLimitedSidebarGroup(items: readonly T[]) { +export function useLimitedSidebarGroup( + items: readonly T[], + policy: SidebarGroupVisibilityPolicy = PINNED_POLICY, +) { const [expanded, setExpanded] = useState(false); - const visibleItems = useMemo( - () => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)), - [expanded, items], + const visibility = useMemo( + () => selectSidebarGroupVisibility(items, expanded, policy), + [expanded, items, policy], ); - const canToggle = items.length > INITIAL_VISIBLE_ITEMS; const toggleExpanded = useCallback(() => setExpanded((current) => !current), []); - return { visibleItems, expanded, canToggle, toggleExpanded }; + return { ...visibility, expanded, toggleExpanded }; } diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts index ae1d67dce3..f3de17b230 100644 --- a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts +++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts @@ -6,7 +6,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import type { useContextMenu } from "@/components/ui/context-menu"; export function useLongPressDragInteraction(input: { - drag: () => void; + drag?: () => void; menuController: ReturnType | null; }) { const { drag, menuController } = input; @@ -59,7 +59,7 @@ export function useLongPressDragInteraction(input: { const armTimers = useCallback(() => { clearTimers(); - if (platformIsWeb) { + if (platformIsWeb || !drag) { return; } diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 7124fd2f0a..84e9141b6a 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -31,6 +31,8 @@ function workspaceDescriptor(input: { statusEnteredAt?: Date | null; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", @@ -86,7 +88,6 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { }); useSidebarOrderStore.setState({ projectOrder: [], - workspaceOrderByProject: {}, }); useSidebarViewStore.setState({ groupMode: "project", diff --git a/packages/app/src/composer/diff-stat-pill.test.tsx b/packages/app/src/composer/diff-stat-pill.test.tsx index 96e1e1139a..5115666994 100644 --- a/packages/app/src/composer/diff-stat-pill.test.tsx +++ b/packages/app/src/composer/diff-stat-pill.test.tsx @@ -11,6 +11,8 @@ const SERVER_ID = "diff-stat-pill"; const WORKSPACE_ID = "workspace"; const workspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: WORKSPACE_ID, projectId: "project", projectDisplayName: "Project", diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts index c7ef8e281b..c0c3bea91f 100644 --- a/packages/app/src/contexts/session-workspace-upserts.test.ts +++ b/packages/app/src/contexts/session-workspace-upserts.test.ts @@ -8,6 +8,8 @@ import { } from "@/contexts/session-workspace-upserts"; const baseWorkspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: "/repo/worktree", projectId: "/repo", projectDisplayName: "Repo", diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 1bc4cf2135..59648e237b 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -9,6 +9,7 @@ import { buildSidebarWorkspacePlacementModel, buildSidebarProjectsFromStructure, computeSidebarOrderUpdates, + createSidebarWorkspacePlacementSelector, createSidebarWorkspaceEntry, deriveProjectStatusBucket, deriveSidebarLoadingState, @@ -20,6 +21,8 @@ import { function workspaceWithForge(forge: string | undefined, prUrl: string): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "ws-1", projectId: "proj", projectDisplayName: "repo", @@ -150,6 +153,8 @@ function workspace(input: { statusEnteredAt?: Date | null; }): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId, projectDisplayName: input.projectDisplayName, @@ -610,13 +615,12 @@ describe("computeSidebarOrderUpdates", () => { const updates = computeSidebarOrderUpdates({ projects: [], persistedProjectOrder: ["stale-project"], - getWorkspaceOrder: () => [], }); - expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] }); + expect(updates).toEqual({ projectOrder: null }); }); - it("appends unseen projects while putting unseen workspaces before the saved order", () => { + it("appends unseen projects without changing saved project order", () => { const projects = [ sidebarProject({ projectKey: "project-a", workspaceKeys: ["ws-1", "ws-2"] }), sidebarProject({ projectKey: "project-b", workspaceKeys: ["ws-3"] }), @@ -625,36 +629,9 @@ describe("computeSidebarOrderUpdates", () => { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["project-a"], - getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : []), }); expect(updates.projectOrder).toEqual(["project-a", "project-b"]); - expect(updates.workspaceOrders).toEqual([ - { projectViewKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] }, - { projectViewKey: "project-b", order: ["srv:ws-3"] }, - ]); - }); - - it("preserves the saved workspace order behind multiple newly discovered workspaces", () => { - const projects = [ - sidebarProject({ - projectKey: "project-a", - workspaceKeys: ["newest", "newer", "old-a", "old-b"], - }), - ]; - - const updates = computeSidebarOrderUpdates({ - projects, - persistedProjectOrder: ["project-a"], - getWorkspaceOrder: () => ["srv:old-b", "srv:old-a"], - }); - - expect(updates.workspaceOrders).toEqual([ - { - projectViewKey: "project-a", - order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"], - }, - ]); }); it("returns no project-order update when persisted order already covers visible keys", () => { @@ -666,11 +643,9 @@ describe("computeSidebarOrderUpdates", () => { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["project-b", "project-a"], - getWorkspaceOrder: (projectKey) => (projectKey === "project-a" ? ["srv:ws-1"] : ["srv:ws-2"]), }); expect(updates.projectOrder).toBeNull(); - expect(updates.workspaceOrders).toEqual([]); }); }); @@ -993,3 +968,14 @@ describe("deriveProjectStatusBucket", () => { ).toBe("done"); }); }); + +it("retains unrelated projects and keyed row placements when one project reorders", () => { + const select = createSidebarWorkspacePlacementSelector(); + const a = project({ projectKey: "a", workspaceKeys: ["srv:a1", "srv:a2"] }); + const b = project({ projectKey: "b", workspaceKeys: ["srv:b1"] }); + const before = select([a, b]); + const after = select([{ ...a, workspaceKeys: ["srv:a2", "srv:a1"] }, b]); + expect(after.projects[1]).toBe(before.projects[1]); + expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]); + expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]); +}); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index e004c3fb89..cec59e071b 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -1,3 +1,4 @@ +import equal from "fast-deep-equal"; import type { PrHint } from "@/git/pr-hint"; import { selectPrHintFromStatus } from "@/git/pr-hint"; import { type HostProjectListItem } from "@/projects/host-project-model"; @@ -287,6 +288,34 @@ export function deriveProjectStatusBucket(input: { return aggregateSidebarStateBuckets(buckets); } +/** One bounded projection per sidebar; unchanged placements survive project reordering. */ +export function createSidebarWorkspacePlacementSelector(): ( + projects: readonly HostProjectListItem[], +) => SidebarWorkspacePlacementModel { + let previous: SidebarWorkspacePlacementModel | null = null; + return (projects) => { + const next = buildSidebarWorkspacePlacementModel({ projects }); + const previousProjects = new Map( + previous?.projects.map((project) => [project.viewKey, project]), + ); + const previousWorkspaces = new Map( + previous?.workspaces.map((workspace) => [workspace.workspaceKey, workspace]), + ); + next.projects = next.projects.map((project) => { + const prior = previousProjects.get(project.viewKey); + if (prior && equal(prior, project)) return prior; + project.workspaces = project.workspaces.map((workspace) => { + const priorWorkspace = previousWorkspaces.get(workspace.workspaceKey); + return priorWorkspace && equal(priorWorkspace, workspace) ? priorWorkspace : workspace; + }); + return project; + }); + next.workspaces = next.projects.flatMap((project) => project.workspaces); + previous = next; + return next; + }; +} + export function buildSidebarWorkspacePlacementModel(input: { projects: readonly HostProjectListItem[]; }): SidebarWorkspacePlacementModel { @@ -555,56 +584,21 @@ export function appendMissingOrderKeys(input: { return [...input.currentOrder, ...missingKeys]; } -export function prependMissingOrderKeys(input: { - currentOrder: string[]; - visibleKeys: string[]; -}): string[] { - if (input.visibleKeys.length === 0) { - return input.currentOrder; - } - - const existingKeys = new Set(input.currentOrder); - const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); - if (missingKeys.length === 0) { - return input.currentOrder; - } - - return [...missingKeys, ...input.currentOrder]; -} - export interface SidebarOrderUpdates { projectOrder: string[] | null; - workspaceOrders: Array<{ projectViewKey: string; order: string[] }>; } export function computeSidebarOrderUpdates(input: { projects: SidebarProjectEntry[]; persistedProjectOrder: string[]; - getWorkspaceOrder: (projectViewKey: string) => string[]; }): SidebarOrderUpdates { - if (input.projects.length === 0) { - return { projectOrder: null, workspaceOrders: [] }; - } - const nextProjectOrder = appendMissingOrderKeys({ currentOrder: input.persistedProjectOrder, visibleKeys: input.projects.map((project) => project.viewKey), }); - const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; - - const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = []; - for (const project of input.projects) { - const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey); - const nextWorkspaceOrder = prependMissingOrderKeys({ - currentOrder: persistedWorkspaceOrder, - visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), - }); - if (nextWorkspaceOrder !== persistedWorkspaceOrder) { - workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder }); - } - } - - return { projectOrder, workspaceOrders }; + return { + projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder, + }; } export interface SidebarLoadingState { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index d8cc3383e5..5665b07e11 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,15 +1,16 @@ +import { useHostFeatureMap } from "@/runtime/host-features"; import { useCallback, useEffect, useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks"; import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors"; -import { useHostProjects } from "@/projects/host-projects"; +import { useWorkspaceStructure } from "@/stores/session-store-hooks"; import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { - buildSidebarWorkspacePlacementModel, + createSidebarWorkspacePlacementSelector, computeSidebarOrderUpdates, createSidebarWorkspaceEntry, deriveProjectStatusBucket, @@ -85,6 +86,7 @@ const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = []; const EMPTY_PROJECT_NAMES = new Map(); export interface SidebarWorkspacesListResult { + workspaceSortSupported: boolean; workspacePlacements: SidebarWorkspacePlacement[]; projects: SidebarProjectEntry[]; projectNamesByViewKey: Map; @@ -138,14 +140,17 @@ export function useSidebarWorkspacesList(options?: { const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds); - const hostProjects = useHostProjects(directoryServerIds); + const workspaceSort = useSidebarViewStore((state) => state.workspaceSort); + const groupMode = useSidebarViewStore((state) => state.groupMode); + const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes"); + const workspaceSortSupported = [...sortSupport.values()].every(Boolean); + const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined; + const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort); + const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []); const sidebarModel = useMemo( - () => - buildSidebarWorkspacePlacementModel({ - projects: hostProjects, - }), - [hostProjects], + () => selectPlacements(hostProjects), + [hostProjects, selectPlacements], ); const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS; @@ -161,16 +166,11 @@ export function useSidebarWorkspacesList(options?: { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder, - getWorkspaceOrder: (projectViewKey) => - orderStore.workspaceOrderByProject[projectViewKey] ?? EMPTY_ORDER, }); if (updates.projectOrder) { orderStore.setProjectOrder(updates.projectOrder); } - for (const { projectViewKey, order } of updates.workspaceOrders) { - orderStore.setWorkspaceOrder(projectViewKey, order); - } }, [persistedProjectOrder, projects]); const refreshAll = useCallback(() => { @@ -193,6 +193,7 @@ export function useSidebarWorkspacesList(options?: { }); return { + workspaceSortSupported, workspacePlacements, projects, projectNamesByViewKey, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e28613bbff..16779d95e1 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1166,6 +1166,12 @@ export const ar: TranslationResources = { showBackground: "إظهار مساحات العمل في الخلفية", trigger: "تفضيلات العرض", heading: "العرض", + sort: { + label: "الترتيب", + created: "تاريخ الإنشاء", + lastPrompt: "آخر طلب", + updateHost: "حدّث المضيفين لترتيب مساحات العمل", + }, grouping: { label: "التجميع", project: "المشروع", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e056fdc855..6471b03f60 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1174,6 +1174,12 @@ export const en = { showBackground: "Show background", trigger: "Display preferences", heading: "Display", + sort: { + label: "Sort", + created: "Created", + lastPrompt: "Last prompt", + updateHost: "Update hosts to sort workspaces", + }, grouping: { label: "Grouping", project: "Project", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 6d5f422e3a..82ab9e6977 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1202,6 +1202,12 @@ export const es: TranslationResources = { showBackground: "Mostrar espacios en segundo plano", trigger: "Preferencias de visualización", heading: "Visualización", + sort: { + label: "Orden", + created: "Creación", + lastPrompt: "Último prompt", + updateHost: "Actualiza los hosts para ordenar los espacios de trabajo", + }, grouping: { label: "Agrupación", project: "Proyecto", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 3159dd4b61..44aae87628 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1195,6 +1195,12 @@ export const fr: TranslationResources = { showBackground: "Afficher les espaces en arrière-plan", trigger: "Préférences d’affichage", heading: "Affichage", + sort: { + label: "Tri", + created: "Création", + lastPrompt: "Dernier prompt", + updateHost: "Mettez les hôtes à jour pour trier les espaces de travail", + }, grouping: { label: "Regroupement", project: "Projet", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 1fcbbc7ad6..baa248aa31 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1179,6 +1179,12 @@ export const ja: TranslationResources = { showBackground: "バックグラウンドを表示", trigger: "表示設定", heading: "表示", + sort: { + label: "並び順", + created: "作成日時", + lastPrompt: "最後のプロンプト", + updateHost: "ワークスペースを並べ替えるにはホストを更新してください", + }, grouping: { label: "グループ化", project: "プロジェクト", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index ea823a94ce..a573ef1ffa 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1173,6 +1173,12 @@ export const ko: TranslationResources = { showBackground: "백그라운드 표시", trigger: "표시 설정", heading: "표시", + sort: { + label: "정렬", + created: "생성일", + lastPrompt: "마지막 프롬프트", + updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요", + }, grouping: { label: "그룹화", project: "프로젝트", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index de85b202c8..43c32d2914 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1192,6 +1192,12 @@ export const ptBR: TranslationResources = { showBackground: "Mostrar espaços em segundo plano", trigger: "Preferências de exibição", heading: "Exibição", + sort: { + label: "Ordenação", + created: "Criação", + lastPrompt: "Último prompt", + updateHost: "Atualize os hosts para ordenar os espaços de trabalho", + }, grouping: { label: "Agrupamento", project: "Projeto", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 5e70b0c1f4..586ad8595d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1183,6 +1183,12 @@ export const ru: TranslationResources = { showBackground: "Показать фоновые пространства", trigger: "Настройки отображения", heading: "Отображение", + sort: { + label: "Сортировка", + created: "Дата создания", + lastPrompt: "Последний запрос", + updateHost: "Обновите хосты для сортировки рабочих пространств", + }, grouping: { label: "Группировка", project: "Проект", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 96758eaca7..08bb8491cd 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1158,6 +1158,12 @@ export const zhCN: TranslationResources = { showBackground: "显示后台工作区", trigger: "显示偏好", heading: "显示", + sort: { + label: "排序", + created: "创建时间", + lastPrompt: "最近提示词", + updateHost: "请更新主机以排序工作区", + }, grouping: { label: "分组", project: "项目", diff --git a/packages/app/src/panels/pull-request/activity.integration.test.tsx b/packages/app/src/panels/pull-request/activity.integration.test.tsx index 0cb403f9b7..194a61efa3 100644 --- a/packages/app/src/panels/pull-request/activity.integration.test.tsx +++ b/packages/app/src/panels/pull-request/activity.integration.test.tsx @@ -124,6 +124,8 @@ beforeEach(async () => { workspaceKind: "local_checkout", name: "main", status: "done", + createdAt: null, + lastUserMessageAt: null, statusEnteredAt: null, archivingAt: null, diffStat: null, diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index cbbbaaed4b..0c90185e53 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -3,6 +3,8 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots"; const workspace: WorkspaceDescriptor = { + createdAt: null, + lastUserMessageAt: null, id: "workspace-1", projectId: "project-1", projectDisplayName: "Paseo", diff --git a/packages/app/src/projects/workspace-structure.test.ts b/packages/app/src/projects/workspace-structure.test.ts index 350f8855a7..ba8bc1431f 100644 --- a/packages/app/src/projects/workspace-structure.test.ts +++ b/packages/app/src/projects/workspace-structure.test.ts @@ -20,6 +20,8 @@ function project(input: { function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id, projectId, projectDisplayName: "acme/app", @@ -174,3 +176,51 @@ describe("buildWorkspaceStructureProjects", () => { ); }); }); + +test("sorts by creation or human prompt without changing picker name order", () => { + const old = { + ...workspace("old", "p", "/p"), + name: "A", + createdAt: new Date("2026-01-01"), + lastUserMessageAt: new Date("2026-03-01"), + }; + const fresh = { + ...workspace("fresh", "p", "/p"), + name: "B", + createdAt: new Date("2026-02-01"), + lastUserMessageAt: null, + }; + const sessions = [ + { + serverId: "host", + projects: [project({ id: "p", key: "p", root: "/p" })], + workspaces: [old, fresh], + }, + ]; + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys, + ).toEqual(["host:fresh", "host:old"]); + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "lastPrompt" })[0].workspaceKeys, + ).toEqual(["host:old", "host:fresh"]); + expect(buildWorkspaceStructureProjects({ sessions })[0].workspaceKeys).toEqual([ + "host:old", + "host:fresh", + ]); +}); + +test("time ties use workspace identity so renaming cannot move Created rows", () => { + const a = { ...workspace("a", "p", "/p"), name: "Z", createdAt: new Date("2026-01-01") }; + const b = { ...workspace("b", "p", "/p"), name: "A", createdAt: a.createdAt }; + const sessions = [ + { serverId: "h", projects: [project({ id: "p", key: "p", root: "/p" })], workspaces: [b, a] }, + ]; + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys, + ).toEqual(["h:a", "h:b"]); + a.name = "A"; + b.name = "Z"; + expect( + buildWorkspaceStructureProjects({ sessions, workspaceSort: "created" })[0].workspaceKeys, + ).toEqual(["h:a", "h:b"]); +}); diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 939e1381d2..92e6c68de4 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -30,6 +30,14 @@ interface WorkspaceStructureSession { workspaces: Iterable; } +export type WorkspaceSort = "created" | "lastPrompt"; + +interface WorkspaceSortItem { + workspaceName: string; + workspaceKey: string; + sortTime: number; +} + interface ProjectDraft { viewKey: string; projectKey: string | null; @@ -38,12 +46,13 @@ interface ProjectDraft { projectKind: WorkspaceDescriptor["projectKind"]; iconWorkingDir: string; hosts: Map; - workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>; + workspaces: WorkspaceSortItem[]; } /** The single app boundary that turns host-local projects into grouped display projects. */ export function buildWorkspaceStructureProjects(input: { sessions: WorkspaceStructureSession[]; + workspaceSort?: WorkspaceSort; }): WorkspaceStructureProject[] { const byProject = new Map(); const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = []; @@ -84,7 +93,7 @@ export function buildWorkspaceStructureProjects(input: { const viewKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId); if (!viewKey) continue; byProject.get(viewKey)?.workspaces.push({ - workspaceId: workspace.id, + sortTime: workspaceSortTime(workspace, input.workspaceSort), workspaceName: workspace.name, workspaceKey: `${session.serverId}:${workspace.id}`, }); @@ -100,7 +109,7 @@ export function buildWorkspaceStructureProjects(input: { iconWorkingDir: draft.iconWorkingDir, hosts: Array.from(draft.hosts.values()), workspaceKeys: draft.workspaces - .sort(compareWorkspaceStructureItems) + .sort(input.workspaceSort ? compareWorkspaceTimes : compareWorkspaceStructureItems) .map((workspace) => workspace.workspaceKey), })) .sort( @@ -196,14 +205,27 @@ function getOrCreate(map: Map, key: K, create: () => V): V { return value; } -function compareWorkspaceStructureItems( - left: { workspaceId: string; workspaceName: string }, - right: { workspaceId: string; workspaceName: string }, -): number { +function compareWorkspaceStructureItems(left: WorkspaceSortItem, right: WorkspaceSortItem): number { return ( left.workspaceName.localeCompare(right.workspaceName, undefined, { numeric: true, sensitivity: "base", - }) || left.workspaceId.localeCompare(right.workspaceId, undefined, { sensitivity: "base" }) + }) || left.workspaceKey.localeCompare(right.workspaceKey) ); } + +function workspaceSortTime( + workspace: WorkspaceDescriptor, + sort: WorkspaceSort | undefined, +): number { + if (!sort) return 0; + const time = + sort === "lastPrompt" + ? (workspace.lastUserMessageAt ?? workspace.createdAt) + : workspace.createdAt; + return time?.getTime() ?? 0; +} + +function compareWorkspaceTimes(left: WorkspaceSortItem, right: WorkspaceSortItem): number { + return right.sortTime - left.sortTime || left.workspaceKey.localeCompare(right.workspaceKey); +} diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 98e0ea29b3..6fae0a7e92 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -1550,7 +1550,14 @@ it("fills every cached workspace beneath live updates received during the SQLite type: "workspace_update", payload: { kind: "upsert", - workspace: { ...workspaces[0], name: "Live", activityAt: null, statusEnteredAt: null }, + workspace: { + ...workspaces[0], + createdAt: undefined, + lastUserMessageAt: null, + name: "Live", + activityAt: null, + statusEnteredAt: null, + }, }, }); }); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index c48777a3e2..eaad715a52 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -306,6 +306,37 @@ async function expectLegacyWorkspaceRefresh({ } describe("ReplicaCache", () => { + it("preserves workspace sorting timestamps across cache reopen", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const baseline = directory(); + const workspace = normalizeWorkspaceDescriptor({ + ...workspacePayload(), + createdAt: "2026-07-18T08:00:00.000Z", + lastUserMessageAt: "2026-07-18T09:00:00.000Z", + }); + baseline.workspaces.set(workspace.id, workspace); + commitDirectory(writer, SERVER_ID, baseline); + await writer.flush(); + const reader = createCache(storage); + expect((await reader.readDirectory(SERVER_ID)).workspaces.get(workspace.id)).toEqual(workspace); + expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace).toEqual(workspace); + }); + + it.each(["createdAt", "lastUserMessageAt"])( + "refetches workspaces cached without %s", + async (field) => { + const cached = await cacheWithoutBackgroundVisibility(); + const key = `${SERVER_ID}:workspace:workspace-1`; + const row = cached.storage.rows.get(key)!; + const payload = JSON.parse(row.payload); + payload.background = false; + delete payload[field]; + cached.storage.rows.set(key, { ...row, payload: JSON.stringify(payload) }); + await expectLegacyWorkspaceRefresh(cached); + }, + ); + it.each([true, false, undefined])( "preserves background visibility (%s) when reopening a cached workspace", async (background) => { diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 681d11a2c4..59578e65d7 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -296,6 +296,9 @@ const WorkspaceGitRuntimeSchema = z .optional(); const StoredWorkspaceSchema = z.strictObject({ + // Require both keys: a cursor without these values would preserve the wrong sort on reconnect. + createdAt: IsoDateSchema.nullable(), + lastUserMessageAt: IsoDateSchema.nullable(), id: z.string(), projectId: z.string(), projectDisplayName: z.string(), @@ -688,8 +691,14 @@ function deserializeAgent(serverId: string, stored: StoredAgent): Agent { }; } +function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor { + return normalizeWorkspaceDescriptor({ ...stored, createdAt: stored.createdAt ?? undefined }); +} + function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { return { + createdAt: workspace.createdAt?.toISOString() ?? null, + lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null, id: workspace.id, projectId: workspace.projectId, projectDisplayName: workspace.projectDisplayName, @@ -856,7 +865,7 @@ function applyDirectoryRow( case "workspace": { const stored = parseStoredPayload(StoredWorkspaceSchema, row.payload); if (stored.id !== row.id) throw new Error("Replica workspace row id mismatch"); - result.workspaces.set(row.id, normalizeWorkspaceDescriptor(stored)); + result.workspaces.set(row.id, restoreWorkspace(stored)); return; } case "project": { @@ -946,7 +955,7 @@ export class ReplicaCache { try { const stored = parseStoredPayload(StoredWorkspaceSchema, workspaceRow.payload); if (stored.id !== workspaceRow.id) throw new Error("Replica workspace row id mismatch"); - workspace = normalizeWorkspaceDescriptor(stored); + workspace = restoreWorkspace(stored); } catch { await this.deleteInvalidRow(workspaceRow); return undefined; diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts index 813cef01c0..1bb74ef4ea 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts @@ -5,6 +5,8 @@ import { resolveWorkspaceRouteState } from "./workspace-route-state"; function createWorkspaceDescriptor(): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "workspace-1", projectId: "project-1", projectDisplayName: "Project", diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 7e0b10e929..a24e49a672 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -14,6 +14,8 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; function createWorkspaceDescriptor(input: Partial = {}): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "/repo/main", projectId: "remote:github.com/getpaseo/paseo", projectDisplayName: "getpaseo/paseo", diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 89978811ec..48c5594f21 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSort } from "@/projects/workspace-structure"; import { useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; @@ -15,7 +16,6 @@ import { selectWorkspaceExists, selectWorkspaceFields, selectWorkspaceKeys, - selectWorkspaceOrderByScope, selectWorkspaceStatusesForBadges, workspaceEqualityFns, type WorkspaceStructure, @@ -99,10 +99,13 @@ export function useWorkspaceDirectory( ); } -export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure { +export function useWorkspaceStructure( + serverIds: string[], + workspaceSort?: WorkspaceSort, +): WorkspaceStructure { const selectProjects = useMemo( - () => createWorkspaceStructureProjectsSelector(serverIds), - [serverIds], + () => createWorkspaceStructureProjectsSelector(serverIds, workspaceSort), + [serverIds, workspaceSort], ); const projects = useStoreWithEqualityFn( useSessionStore, @@ -114,20 +117,13 @@ export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure { (state) => selectProjectOrder(state), workspaceEqualityFns.deep, ); - const workspaceOrderByScope = useStoreWithEqualityFn( - useSidebarOrderStore, - (state) => selectWorkspaceOrderByScope(state), - workspaceEqualityFns.deep, - ); - return useMemo( () => composeWorkspaceStructure({ projects, projectOrder, - workspaceOrderByScope, }), - [projectOrder, projects, workspaceOrderByScope], + [projectOrder, projects], ); } diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index a79d30fe0d..a9c9d07772 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -14,7 +14,6 @@ import { selectWorkspaceDirectory, selectWorkspaceFields, selectWorkspaceKeys, - selectWorkspaceOrderByScope, selectWorkspaceStatusesForBadges, selectWorkspaceStructureProjects, workspaceEqualityFns, @@ -36,6 +35,8 @@ function createWorkspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", @@ -118,7 +119,6 @@ function trackSelector( function emptySidebarOrder(): SidebarOrderSnapshot { return { projectOrder: [], - workspaceOrderByProject: {}, }; } @@ -364,7 +364,6 @@ describe("workspace structure composition", () => { return composeWorkspaceStructure({ projects: selectWorkspaceStructureProjects(useSessionStore.getState(), [serverId]), projectOrder: selectProjectOrder(sidebar), - workspaceOrderByScope: selectWorkspaceOrderByScope(sidebar), }); } @@ -619,3 +618,25 @@ describe("selectWorkspaceStatusesForBadges", () => { tracked.stop(); }); }); + +it("a prompt changes only its project's structure and output preserves both project identities", () => { + const a = { ...createWorkspace({ id: "a", projectId: "p" }), createdAt: new Date("2026-01-01") }; + const b = { ...createWorkspace({ id: "b", projectId: "p" }), createdAt: new Date("2026-02-01") }; + const c = { ...createWorkspace({ id: "c", projectId: "q" }), createdAt: new Date("2026-02-01") }; + const projects = new Map([a, c].map((w) => [w.projectId, projectDescriptorFromTestWorkspace(w)])); + const select = createWorkspaceStructureProjectsSelector([SERVER_ID], "lastPrompt"); + const state = (workspaces: WorkspaceDescriptor[]) => ({ + sessions: { [SERVER_ID]: { projects, workspaces: new Map(workspaces.map((w) => [w.id, w])) } }, + }); + const before = select(state([a, b, c])); + const prompted = { ...a, lastUserMessageAt: new Date("2026-03-01") }; + const after = select(state([prompted, b, c])); + expect(after.find((p) => p.viewKey === "p")?.workspaceKeys).toEqual([ + `${SERVER_ID}:a`, + `${SERVER_ID}:b`, + ]); + expect(after.find((p) => p.viewKey === "q")).toBe(before.find((p) => p.viewKey === "q")); + const output = select(state([{ ...prompted, status: "running" }, b, c])); + expect(output[0]).toBe(after[0]); + expect(output[1]).toBe(after[1]); +}); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index 019b9d781b..6c1100edd6 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -1,6 +1,7 @@ import equal from "fast-deep-equal"; import { buildWorkspaceStructureProjects, + type WorkspaceSort, type WorkspaceStructure, type WorkspaceStructureProject, } from "@/projects/workspace-structure"; @@ -25,7 +26,6 @@ export interface SessionsSnapshot { export interface SidebarOrderSnapshot { projectOrder: string[]; - workspaceOrderByProject: Record; } const EMPTY_WORKSPACE_KEYS: string[] = []; @@ -154,6 +154,7 @@ export function selectWorkspaceDirectoryServerIds( export function selectWorkspaceStructureProjects( state: SessionsSnapshot, serverIds: readonly string[], + workspaceSort?: WorkspaceSort, ): WorkspaceStructureProject[] { const sessions: Array<{ serverId: string; @@ -179,11 +180,12 @@ export function selectWorkspaceStructureProjects( return EMPTY_WORKSPACE_STRUCTURE.projects; } - return buildWorkspaceStructureProjects({ sessions }); + return buildWorkspaceStructureProjects({ sessions, workspaceSort }); } export function createWorkspaceStructureProjectsSelector( serverIds: readonly string[], + workspaceSort?: WorkspaceSort, ): (state: SessionsSnapshot) => WorkspaceStructureProject[] { let previousInputs: Array<{ workspaces: Map | undefined; @@ -209,7 +211,12 @@ export function createWorkspaceStructureProjectsSelector( } previousInputs = inputs; - previousProjects = selectWorkspaceStructureProjects(state, serverIds); + const next = selectWorkspaceStructureProjects(state, serverIds, workspaceSort); + const previousByKey = new Map(previousProjects?.map((project) => [project.viewKey, project])); + previousProjects = next.map((project) => { + const previous = previousByKey.get(project.viewKey); + return previous && equal(previous, project) ? previous : project; + }); return previousProjects; }; } @@ -244,31 +251,16 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] { return state.projectOrder ?? EMPTY_WORKSPACE_KEYS; } -export function selectWorkspaceOrderByScope(state: SidebarOrderSnapshot): Record { - return state.workspaceOrderByProject ?? {}; -} - export function composeWorkspaceStructure(input: { projects: WorkspaceStructureProject[]; projectOrder: readonly string[]; - workspaceOrderByScope: Record; }): WorkspaceStructure { if (input.projects.length === 0) { return EMPTY_WORKSPACE_STRUCTURE; } const orderedProjects = applyStoredOrdering({ - items: input.projects.map((project) => { - const workspaceOrder = input.workspaceOrderByScope[project.viewKey] ?? EMPTY_WORKSPACE_KEYS; - return { - ...project, - workspaceKeys: applyStoredOrdering({ - items: project.workspaceKeys, - storedOrder: workspaceOrder, - getKey: (workspaceKey) => workspaceKey, - }), - }; - }), + items: input.projects, storedOrder: input.projectOrder, getKey: (project) => project.viewKey, }); diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index c53eae4c49..86ea81afc5 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -64,6 +64,8 @@ function createWorkspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project 1", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index dfeeadb7b0..aed06eef11 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -103,6 +103,8 @@ export interface Agent { } export interface WorkspaceDescriptor { + createdAt: Date | null; + lastUserMessageAt: Date | null; id: string; projectId: string; projectDisplayName: string; @@ -139,6 +141,9 @@ export function normalizeWorkspaceDescriptor( ? new Date(statusEnteredAtRaw) : null; return { + // COMPAT(workspaceSortTimes): added in v0.13.0, remove after 2027-04-22. + createdAt: payload.createdAt ? new Date(payload.createdAt) : null, + lastUserMessageAt: payload.lastUserMessageAt ? new Date(payload.lastUserMessageAt) : null, id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id, projectId: payload.projectId, projectDisplayName: payload.projectDisplayName, diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts index a68ca206f9..b34fb02240 100644 --- a/packages/app/src/stores/sidebar-order-store.test.ts +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -6,7 +6,7 @@ import { import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store"; describe("migrateSidebarOrderState", () => { - it("prefixes legacy per-server workspace order with the source server id", () => { + it("discards legacy workspace ordering while retaining project ordering", () => { const migrated = migrateSidebarOrderState({ projectOrderByServerId: { "host-a": ["project-a"], @@ -21,15 +21,12 @@ describe("migrateSidebarOrderState", () => { expect(migrated).toEqual({ projectOrder: ["project-a"], pinnedWorkspaceOrder: [], - workspaceOrderByProject: { - "project-a": ["host-a:main", "host-a:feature", "host-b:main"], - }, }); }); - it("normalizes pinned workspace order", () => { + it("deduplicates pinned workspace order without rewriting identities", () => { const migrated = migrateSidebarOrderState({ - pinnedWorkspaceOrder: [" host-a:one ", "host-a:one", "", "host-b:two"], + pinnedWorkspaceOrder: ["host-a:one", "host-a:one", "", "host-b:two"], }); expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]); @@ -46,7 +43,6 @@ describe("sidebar order keys that end in whitespace", () => { useSidebarOrderStore.setState({ projectOrder: [], pinnedWorkspaceOrder: [], - workspaceOrderByProject: {}, }); }); @@ -56,17 +52,6 @@ describe("sidebar order keys that end in whitespace", () => { expect(useSidebarOrderStore.getState().projectOrder).toEqual([PROJECT_KEY]); }); - it("scopes a workspace order under the project key the sidebar indexes by", () => { - useSidebarOrderStore.getState().setWorkspaceOrder(PROJECT_KEY, ["srv-1:main"]); - - // use-sidebar-workspaces-list reads workspaceOrderByProject by view key - // directly, so the scope has to be stored under that same key. - expect(useSidebarOrderStore.getState().workspaceOrderByProject[PROJECT_KEY]).toEqual([ - "srv-1:main", - ]); - expect(useSidebarOrderStore.getState().getWorkspaceOrder(PROJECT_KEY)).toEqual(["srv-1:main"]); - }); - it("settles the sidebar order reconcile instead of rewriting it forever", () => { const projects: SidebarProjectEntry[] = [ { @@ -82,7 +67,6 @@ describe("sidebar order keys that end in whitespace", () => { computeSidebarOrderUpdates({ projects, persistedProjectOrder: useSidebarOrderStore.getState().projectOrder, - getWorkspaceOrder: (key) => useSidebarOrderStore.getState().getWorkspaceOrder(key), }); // First pass adds the newly visible project. @@ -95,11 +79,31 @@ describe("sidebar order keys that end in whitespace", () => { expect(runPass().projectOrder).toBeNull(); }); - it("still trims keys when migrating persisted state", () => { + it("still trims legacy keys when migrating per-host persisted state", () => { const migrated = migrateSidebarOrderState({ - projectOrder: [" host-a:one ", "host-a:one"], + projectOrderByServerId: { host: [" host-a:one ", "host-a:one"] }, }); expect(migrated.projectOrder).toEqual(["host-a:one"]); }); }); + +it("preserves project and pinned order while discarding ordinary workspace order", () => { + expect( + migrateSidebarOrderState({ + projectOrder: ["b", "a"], + pinnedWorkspaceOrder: ["h:y", "h:x"], + workspaceOrderByProject: { a: ["h:old", "h:new"] }, + }), + ).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] }); +}); + +it("preserves whitespace in current project and pin identities during upgrade", () => { + expect( + migrateSidebarOrderState({ + projectOrder: ["project "], + pinnedWorkspaceOrder: ["host:workspace "], + workspaceOrderByProject: {}, + }), + ).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] }); +}); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 10831480f8..54f27e714f 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -7,13 +7,10 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora interface SidebarOrderStoreState { projectOrder: string[]; pinnedWorkspaceOrder: string[]; - workspaceOrderByProject: Record; getProjectOrder: () => string[]; setProjectOrder: (keys: string[]) => void; getPinnedWorkspaceOrder: () => string[]; setPinnedWorkspaceOrder: (keys: string[]) => void; - getWorkspaceOrder: (projectViewKey: string) => string[]; - setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; } interface SidebarOrderPersistedState { @@ -28,16 +25,12 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); const SidebarOrderPersistedStateSchema = z.strictObject({ projectOrder: z.array(z.string()).optional(), pinnedWorkspaceOrder: z.array(z.string()).optional(), + // COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins. workspaceOrderByProject: StringArrayRecordSchema.optional(), projectOrderByServerId: StringArrayRecordSchema.optional(), workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), }); -interface SidebarWorkspaceOrderScope { - serverId: string; - projectViewKey: string; -} - // Trims each key. Only for persisted state read at migration time, where a // stray space is an artifact of an older format rather than part of the key. function normalizeKeys(keys: string[]): string[] { @@ -78,46 +71,17 @@ function dedupeKeys(keys: string[]): string[] { return deduped; } -function normalizeWorkspaceOrderByProject( - workspaceOrderByProject: Record | undefined, -): Record { - const normalized: Record = {}; - for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) { - const scope = projectViewKey.trim(); - if (!scope) continue; - normalized[scope] = normalizeKeys(order); - } - return normalized; -} - -function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null { - const separatorIndex = scopeKey.indexOf("::"); - if (separatorIndex < 0) return null; - const serverId = scopeKey.slice(0, separatorIndex).trim(); - const projectViewKey = scopeKey.slice(separatorIndex + 2).trim(); - if (!serverId || !projectViewKey) return null; - return { serverId, projectViewKey }; -} - -function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null { - const workspaceKey = rawWorkspaceKey.trim(); - if (!workspaceKey) return null; - const serverPrefix = `${serverId}:`; - return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`; -} - export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; pinnedWorkspaceOrder: string[]; - workspaceOrderByProject: Record; } { const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); if (!result.success) { - return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} }; + return { projectOrder: [], pinnedWorkspaceOrder: [] }; } const state: SidebarOrderPersistedState = result.data; - const projectOrder = normalizeKeys(state.projectOrder ?? []); + const projectOrder = dedupeKeys(state.projectOrder ?? []); const seenProjects = new Set(projectOrder); for (const keys of Object.values(state.projectOrderByServerId ?? {})) { for (const key of normalizeKeys(keys)) { @@ -127,26 +91,9 @@ export function migrateSidebarOrderState(persistedState: unknown): { } } - const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject); - for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) { - const scope = extractWorkspaceOrderScope(scopeKey); - if (!scope) continue; - const existing = workspaceOrderByProject[scope.projectViewKey] ?? []; - const merged = [...existing]; - const seen = new Set(merged); - for (const key of order) { - const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key); - if (!workspaceKey || seen.has(workspaceKey)) continue; - seen.add(workspaceKey); - merged.push(workspaceKey); - } - workspaceOrderByProject[scope.projectViewKey] = merged; - } - return { projectOrder, - pinnedWorkspaceOrder: normalizeKeys(state.pinnedWorkspaceOrder ?? []), - workspaceOrderByProject, + pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []), }; } @@ -155,7 +102,6 @@ export const useSidebarOrderStore = create()( (set, get) => ({ projectOrder: [], pinnedWorkspaceOrder: [], - workspaceOrderByProject: {}, getProjectOrder: () => get().projectOrder, setProjectOrder: (keys) => { set({ projectOrder: dedupeKeys(keys) }); @@ -164,19 +110,6 @@ export const useSidebarOrderStore = create()( setPinnedWorkspaceOrder: (keys) => { set({ pinnedWorkspaceOrder: dedupeKeys(keys) }); }, - getWorkspaceOrder: (projectViewKey) => { - if (!projectViewKey.trim()) return []; - return get().workspaceOrderByProject[projectViewKey] ?? []; - }, - setWorkspaceOrder: (projectViewKey, keys) => { - if (!projectViewKey.trim()) return; - set((state) => ({ - workspaceOrderByProject: { - ...state.workspaceOrderByProject, - [projectViewKey]: dedupeKeys(keys), - }, - })); - }, }), { name: "sidebar-project-workspace-order", @@ -184,9 +117,8 @@ export const useSidebarOrderStore = create()( partialize: (state) => ({ projectOrder: state.projectOrder, pinnedWorkspaceOrder: state.pinnedWorkspaceOrder, - workspaceOrderByProject: state.workspaceOrderByProject, }), - version: 1, + version: 2, migrate: migrateSidebarOrderState, }, ), diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts index e21c8f8a15..a9e44e146b 100644 --- a/packages/app/src/stores/sidebar-view-store.test.ts +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -32,6 +32,7 @@ function createMemoryStorage(entries: Record): MemoryStor describe("sidebar view store", () => { beforeEach(() => { useSidebarViewStore.setState({ + workspaceSort: "created", showBackground: false, groupMode: "project", hostFilters: [], @@ -85,6 +86,7 @@ describe("sidebar view store", () => { }, }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "status", hostFilters: [], @@ -100,6 +102,7 @@ describe("sidebar view store", () => { hostFilter: "host-a", }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "status", hostFilters: ["host-a"], @@ -115,6 +118,7 @@ describe("sidebar view store", () => { hostFilters: ["host-a", "host-b"], }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "status", hostFilters: ["host-a", "host-b"], @@ -226,6 +230,7 @@ describe("sidebar view store", () => { projectFilters: ["project-a", "project-b"], }), ).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "project", hostFilters: ["host-a"], @@ -236,6 +241,7 @@ describe("sidebar view store", () => { it("never keeps project filters from state the schema rejects", () => { expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({ + workspaceSort: "created", showBackground: false, groupMode: "project", hostFilters: [], @@ -287,3 +293,10 @@ describe("sidebar view store", () => { expect(storage.reads).toEqual(["sidebar-view"]); }); }); + +it("defaults to Created and persists the Last prompt choice across migration", () => { + expect(migrateSidebarViewState({}).workspaceSort).toBe("created"); + expect( + migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }), + ).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" }); +}); diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 0e12c304a8..229e95f96b 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -1,3 +1,4 @@ +import type { WorkspaceSort } from "@/projects/workspace-structure"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { persist, type StateStorage } from "zustand/middleware"; @@ -9,7 +10,7 @@ export type SidebarGroupMode = "project" | "status"; const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view"; const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode"; -const SIDEBAR_VIEW_STORE_VERSION = 7; +const SIDEBAR_VIEW_STORE_VERSION = 8; /** * The key standing for "this workspace carries no labels at all". @@ -47,6 +48,7 @@ function toggleFilterEntry(list: readonly string[], key: string): string[] { interface SidebarViewStoreState { groupMode: SidebarGroupMode; + workspaceSort: WorkspaceSort; showBackground: boolean; // Empty means "all hosts". A non-empty list pins the sidebar to those hosts. hostFilters: string[]; @@ -64,6 +66,7 @@ interface SidebarViewStoreState { labelFilter: SidebarLabelFilter; toggleBackground: () => void; setGroupMode: (mode: SidebarGroupMode) => void; + setWorkspaceSort: (sort: WorkspaceSort) => void; toggleHostFilter: (serverId: string) => void; clearHostFilters: () => void; toggleProjectFilter: (viewKey: string) => void; @@ -76,6 +79,7 @@ interface SidebarViewStoreState { interface SidebarViewPersistedState { groupMode: SidebarGroupMode; + workspaceSort: WorkspaceSort; showBackground: boolean; hostFilters: string[]; projectFilters: string[]; @@ -87,6 +91,7 @@ const SidebarLabelFilterSchema = z.object({ labels: z.array(z.string()), }); const SidebarViewPersistedStateSchema = z.strictObject({ + workspaceSort: z.enum(["created", "lastPrompt"]).optional(), showBackground: z.boolean().optional(), groupMode: PersistedSidebarGroupModeSchema.optional(), hostFilters: z.array(z.string()).optional(), @@ -128,6 +133,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer return { showBackground: false, groupMode: "project", + workspaceSort: "created", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), @@ -140,6 +146,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer return { showBackground: false, groupMode: legacyGroupMode, + workspaceSort: "created", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), @@ -149,6 +156,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer return { showBackground: state.showBackground ?? false, groupMode: state.groupMode === "status" ? "status" : "project", + workspaceSort: state.workspaceSort ?? "created", hostFilters: readHostFilters(state), projectFilters: state.projectFilters ?? [], labelFilter: state.labelFilter @@ -187,11 +195,13 @@ export const useSidebarViewStore = create()( (set) => ({ showBackground: false, groupMode: "project", + workspaceSort: "created", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), toggleBackground: () => set((state) => ({ showBackground: !state.showBackground })), setGroupMode: (mode) => set({ groupMode: mode }), + setWorkspaceSort: (workspaceSort) => set({ workspaceSort }), toggleHostFilter: (serverId) => set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })), clearHostFilters: () => set({ hostFilters: [] }), @@ -239,6 +249,7 @@ export const useSidebarViewStore = create()( partialize: (state) => ({ showBackground: state.showBackground ?? false, groupMode: state.groupMode, + workspaceSort: state.workspaceSort, hostFilters: state.hostFilters, projectFilters: state.projectFilters, labelFilter: state.labelFilter, diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 0362aa56de..1acd5d8320 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -262,6 +262,8 @@ describe("replaceFetchedAgentDirectory", () => { workspaceKind: "worktree", name: "repo", status: "done", + createdAt: null, + lastUserMessageAt: null, statusEnteredAt: null, archivingAt: null, diffStat: null, diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index c3416d19b1..e217f8d271 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -22,6 +22,8 @@ function descriptor( function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id, projectId, projectDisplayName: "acme/app", diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index 0bed394be0..755a88c91f 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -11,6 +11,8 @@ function workspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project", diff --git a/packages/app/src/utils/workspace-identity.test.ts b/packages/app/src/utils/workspace-identity.test.ts index b3324b939d..1287552ce7 100644 --- a/packages/app/src/utils/workspace-identity.test.ts +++ b/packages/app/src/utils/workspace-identity.test.ts @@ -6,6 +6,8 @@ function createWorkspace( input: Partial & Pick, ): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: input.id, projectId: input.projectId ?? "project-1", projectDisplayName: input.projectDisplayName ?? "Project", diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index e7fe660f3b..3facdee34e 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -32,6 +32,8 @@ function archivePayload(input: { function workspace(input?: Partial): WorkspaceDescriptor { return { + createdAt: null, + lastUserMessageAt: null, id: "workspace-1", projectId: "project-1", projectDisplayName: "Project", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1fcd96ed85..4beaf0a0b2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3781,6 +3781,7 @@ export const ServerInfoStatusPayloadSchema = z explicitEventSubscriptions: z.boolean().optional(), ownedSubscriptions: z.boolean().optional(), workspaceSetupObservation: z.boolean().optional(), + workspaceSortTimes: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), agentMessageProvenance: z.boolean().optional(), @@ -4126,6 +4127,8 @@ export const WorkspaceDescriptorPayloadSchema = z .nullish() .transform((value) => value ?? null), activityAt: z.string().nullable(), + createdAt: z.string().optional(), + lastUserMessageAt: z.string().nullable().optional(), diffStat: z .object({ additions: z.number(), diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts index 9df68dfde6..b15053ff04 100644 --- a/packages/server/src/server/agent/directory.ts +++ b/packages/server/src/server/agent/directory.ts @@ -7,8 +7,8 @@ import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; export class AgentDirectory { constructor( private readonly deps: { - manager: AgentManager; - storage: AgentStorage; + manager: Pick; + storage: Pick; workspaces: Pick; projectLive(agent: ManagedAgent): Promise; projectStored(record: StoredAgentRecord): AgentSnapshotPayload; @@ -17,6 +17,26 @@ export class AgentDirectory { }, ) {} + /** Human prompt recency includes archived/unavailable agents without projecting their UI payloads. */ + async workspaceLastUserMessageTimes(): Promise> { + const live = this.deps.manager.listAgents(); + const liveIds = new Set(live.map((agent) => agent.id)); + const times = new Map(); + const record = (workspaceId: string | undefined, at: string | null | undefined) => { + if (!workspaceId || !at) return; + const previous = times.get(workspaceId); + if (!previous || at > previous) times.set(workspaceId, at); + }; + for (const agent of await this.deps.storage.list()) { + if (!agent.internal && !liveIds.has(agent.id)) + record(agent.workspaceId, agent.lastUserMessageAt); + } + for (const agent of live) { + if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString()); + } + return times; + } + async includes( agent: Pick, includeBackground = false, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 14495fedec..e5fdbca32f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1228,6 +1228,7 @@ export class Session { projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, listAgentPayloads: () => this.listAgentPayloads({ includeBackground: true }), + workspaceLastUserMessageTimes: () => this.agentDirectory.workspaceLastUserMessageTimes(), listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(), listTerminalActivityContributions: () => this.listTerminalActivityContributions(), isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), @@ -5649,6 +5650,7 @@ export class Session { status: "done", statusEnteredAt: null, activityAt: null, + createdAt: workspace.createdAt, diffStat, scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), ...(resolvedProjectRecord @@ -5745,6 +5747,8 @@ export class Session { status: "done", statusEnteredAt: result.workspace.createdAt, activityAt: null, + createdAt: result.workspace.createdAt, + lastUserMessageAt: null, diffStat: { additions: 0, deletions: 0 }, scripts: [], gitRuntime: { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 2dfddef55e..dcc1d65b40 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -6420,7 +6420,9 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc expect(describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project); expect(describeWorkspaceRecordWithGitData).not.toHaveBeenCalled(); - expect(descriptors).toEqual([baselineDescriptor]); + expect(descriptors).toEqual([ + { ...baselineDescriptor, createdAt: workspace.createdAt, lastUserMessageAt: null }, + ]); }); test("lists Git runtime for a checkout explicitly owned by a non-Git project", async () => { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index d896bcf37d..adbc0bd38f 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1963,6 +1963,7 @@ export class VoiceAssistantWebSocketServer { // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. workspaceSetupRun: true, workspaceSetupObservation: true, + workspaceSortTimes: true, // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. providersSnapshot: true, // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index e317b5427d..be59c3a24a 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -1,3 +1,8 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentStorage } from "./agent/agent-storage.js"; +import { AgentDirectory } from "./agent/directory.js"; import { describe, expect, test } from "vitest"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { createTestLogger } from "../test-utils/test-logger.js"; @@ -70,6 +75,16 @@ class WorkspaceStatus { projectRegistry: { list: async () => [this.project] }, workspaceRegistry: { list: async () => this.workspaces }, listAgentPayloads: async () => this.agents, + workspaceLastUserMessageTimes: async () => { + const times = new Map(); + for (const agent of this.agents) { + if (!agent.workspaceId || !agent.lastUserMessageAt) continue; + const previous = times.get(agent.workspaceId); + if (!previous || agent.lastUserMessageAt > previous) + times.set(agent.workspaceId, agent.lastUserMessageAt); + } + return times; + }, listProviderSubagentActivity: async () => this.providerSubagents, listTerminalActivityContributions: async () => this.terminals, isProviderVisibleToClient: () => true, @@ -242,6 +257,8 @@ class WorkspaceStatus { interface AgentState { id: string; status: AgentSnapshotPayload["status"]; + lastUserMessageAt?: string | null; + archivedAt?: string | null; pendingPermissionCount?: number; requiresAttention?: boolean; attentionReason?: AgentSnapshotPayload["attentionReason"]; @@ -261,7 +278,7 @@ function createAgent( effectiveThinkingOptionId: null, createdAt: NOW, updatedAt: NOW, - lastUserMessageAt: null, + lastUserMessageAt: input.lastUserMessageAt ?? null, status: input.status, capabilities: { supportsStreaming: true, @@ -289,7 +306,7 @@ function createAgent( requiresAttention: input.requiresAttention ?? false, attentionReason: input.attentionReason ?? null, attentionTimestamp: null, - archivedAt: null, + archivedAt: input.archivedAt ?? null, } satisfies AgentSnapshotPayload; } @@ -560,6 +577,7 @@ describe("WorkspaceDirectory empty projects", () => { projectRegistry: { list: async () => input.projects }, workspaceRegistry: { list: async () => input.workspaces }, listAgentPayloads: async () => [], + workspaceLastUserMessageTimes: async () => new Map(), listProviderSubagentActivity: async () => [], listTerminalActivityContributions: async () => [], isProviderVisibleToClient: () => true, @@ -723,6 +741,7 @@ test("Git observation targets exclude archived records without hydrating app des ], }, listAgentPayloads: unexpectedHydration, + workspaceLastUserMessageTimes: unexpectedHydration, listProviderSubagentActivity: unexpectedHydration, listTerminalActivityContributions: unexpectedHydration, buildWorkspaceDescriptor: unexpectedHydration, @@ -732,3 +751,70 @@ test("Git observation targets exclude archived records without hydrating app des { id: "observed", workspaceDirectory: "/workspace/observed", workspaceKind: "local_checkout" }, ]); }); + +test("workspace sort times retain the latest human prompt after its agent is archived", async () => { + const scenario = new WorkspaceStatus(); + scenario.hasRootAgent({ + id: "older", + status: "idle", + lastUserMessageAt: "2026-03-03T12:00:00.000Z", + }); + scenario.hasRootAgent({ + id: "archived", + status: "idle", + lastUserMessageAt: "2026-03-04T12:00:00.000Z", + archivedAt: "2026-03-05T12:00:00.000Z", + }); + scenario.hasRootAgent({ id: "output-only", status: "running" }); + expect(await scenario.workspaceDescriptor()).toMatchObject({ + createdAt: NOW, + lastUserMessageAt: "2026-03-04T12:00:00.000Z", + status: "running", + }); +}); + +test("prompt recency reads archived records without projecting agents or exposing internal prompts", async () => { + const home = await mkdtemp(join(tmpdir(), "workspace-prompt-times-")); + const storage = new AgentStorage(home, createTestLogger()); + try { + const base = { + provider: "unavailable", + cwd: home, + workspaceId: "w", + createdAt: NOW, + updatedAt: NOW, + labels: {}, + lastStatus: "closed" as const, + }; + await storage.upsert({ + ...base, + id: "archived", + archivedAt: NOW, + lastUserMessageAt: "2026-03-04T00:00:00.000Z", + }); + await storage.upsert({ + ...base, + id: "internal", + internal: true, + lastUserMessageAt: "2026-03-05T00:00:00.000Z", + }); + await storage.upsert({ ...base, id: "output", lastActivityAt: "2026-03-06T00:00:00.000Z" }); + const unexpectedProjection = (): never => { + throw new Error("Prompt sorting projected an agent payload"); + }; + const directory = new AgentDirectory({ + storage, + manager: { listAgents: () => [] }, + workspaces: { get: async () => null, list: async () => [] }, + projectLive: unexpectedProjection, + projectStored: unexpectedProjection, + isProviderVisible: () => false, + isStoredProviderAvailable: () => false, + }); + expect(await directory.workspaceLastUserMessageTimes()).toEqual( + new Map([["w", "2026-03-04T00:00:00.000Z"]]), + ); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 8ccb4fa341..50bc58a06d 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -77,6 +77,7 @@ export interface WorkspaceDirectoryDeps { list(): Promise; }; listAgentPayloads(): Promise; + workspaceLastUserMessageTimes(): Promise>; listProviderSubagentActivity(): Promise; listTerminalActivityContributions(): Promise< Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }> @@ -218,12 +219,14 @@ export class WorkspaceDirectory { }): Promise> { const [ agents, + lastUserMessageTimes, providerSubagentActivity, persistedWorkspaces, persistedProjects, terminalContributions, ] = await Promise.all([ this.deps.listAgentPayloads(), + this.deps.workspaceLastUserMessageTimes(), this.deps.listProviderSubagentActivity(), this.deps.workspaceRegistry.list(), this.deps.projectRegistry.list(), @@ -258,6 +261,8 @@ export class WorkspaceDirectory { const workspaceId = includedWorkspaces[i].workspaceId; descriptorsByWorkspaceId.set(workspaceId, { ...workspaceDescriptors[i], + createdAt: includedWorkspaces[i].createdAt, + lastUserMessageAt: lastUserMessageTimes.get(workspaceId) ?? null, archivingAt: this.archivingByWorkspaceId.get(workspaceId) ?? null, }); } diff --git a/packages/website/playwright.config.ts b/packages/website/playwright.config.ts index a80b84abd8..ebb29f0237 100644 --- a/packages/website/playwright.config.ts +++ b/packages/website/playwright.config.ts @@ -19,7 +19,9 @@ export default defineConfig({ }, { command: "npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort", - url: baseURL, + // Probe the server without running the homepage loader's live GitHub requests. + url: new URL("/logo.svg", baseURL).href, + stdout: "pipe", env: { CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", PLUGINS_REGISTRY_URL: "http://127.0.0.1:8188", From d972f67c3fea06fbc163608f5ddcc2c443de83ac Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:09:27 +0000 Subject: [PATCH 35/62] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index d06b479e80..1a51631fc2 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-DkB70/NH4SriKZ6hMDhI7XfwkqjGfb9e1zC3dxjBaTg= +sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU= From 3c3c272925cb22ecef4d5e832640fd03d916a37e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 11:18:25 +0700 Subject: [PATCH 36/62] fix(server): publish workspace snapshots without UI subscribers (#7681) Computed snapshots must reach the runtime store and auto-archive even when no UI observes the workspace. Reject retired targets so late refreshes cannot replace newer published state. --- .../auto-archive-on-merge/behavior.test.ts | 175 +++++++++++++++++- .../src/server/workspace-git-service.test.ts | 86 +++++++-- .../src/server/workspace-git-service.ts | 31 +--- .../workspace-runtime-store/index.test.ts | 54 +++++- 4 files changed, 301 insertions(+), 45 deletions(-) diff --git a/packages/server/src/server/auto-archive-on-merge/behavior.test.ts b/packages/server/src/server/auto-archive-on-merge/behavior.test.ts index 218ae4aee3..be952b8efa 100644 --- a/packages/server/src/server/auto-archive-on-merge/behavior.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/behavior.test.ts @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { Logger } from "pino"; +import pino, { type Logger } from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import { @@ -10,8 +10,14 @@ import { type AutoArchiveOnMergeDependencies, type AutoArchiveOnMergeOptions, } from "./index.js"; -import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js"; -import { createWorktree } from "../../utils/worktree.js"; +import { + WorkspaceGitServiceImpl, + type WorkspaceGitRuntimeSnapshot, +} from "../workspace-git-service.js"; +import { createGitHubService } from "../../services/github-service.js"; +import { archiveIfSafe } from "./archive-if-safe.js"; +import { killTerminalsForWorkspace } from "../workspace-archive-service.js"; +import { isPaseoOwnedWorktreeCwd, createWorktree } from "../../utils/worktree.js"; type PullRequestState = "open" | "closed" | "merged"; @@ -22,6 +28,7 @@ interface PullRequestObservation { } const cleanupPaths: string[] = []; +const cleanupServices: WorkspaceGitServiceImpl[] = []; function run(cwd: string, args: string[]): void { execFileSync("git", args, { cwd, stdio: "pipe" }); @@ -32,7 +39,7 @@ async function settleObservation(): Promise { await new Promise((resolve) => setImmediate(resolve)); } -async function createWorkspaceJourney() { +async function createWorkspaceFiles() { const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "auto-archive-behavior-"))); cleanupPaths.push(tempDir); const repoDir = path.join(tempDir, "repo"); @@ -51,6 +58,11 @@ async function createWorkspaceJourney() { paseoHome, }); + return { repoDir, paseoHome, worktree }; +} + +async function createWorkspaceJourney() { + const { repoDir, worktree } = await createWorkspaceFiles(); const workspaceId = "workspace-under-test"; let active = true; let currentPullRequest: PullRequestObservation | null = null; @@ -149,6 +161,95 @@ async function createWorkspaceJourney() { }; } +async function createUnobservedWorkspace(archiveBarrier?: Promise) { + const { repoDir, paseoHome, worktree } = await createWorkspaceFiles(); + const cwd = worktree.worktreePath; + run(repoDir, ["remote", "add", "origin", "https://github.com/acme/repo.git"]); + run(repoDir, ["update-ref", "refs/remotes/origin/workspace", "HEAD"]); + run(cwd, ["branch", "--set-upstream-to=origin/workspace"]); + const logger = pino({ level: "silent" }); + const github = createGitHubService({ + resolveGhPath: async () => "/test/gh", + runner: async () => ({ stdout: "", stderr: "" }), + }); + let observation: PullRequestObservation | null = null; + const reads: Array = []; + const archives: string[] = []; + let active = true; + const service = new WorkspaceGitServiceImpl({ + logger, + paseoHome, + deps: { + forgeOverrides: { github }, + getPullRequestStatus: async (_cwd, _forge, options) => { + reads.push(options?.reason); + return { + status: observation && { + ...observation, + title: observation.headRefName, + baseRefName: "main", + isMerged: observation.state === "merged", + }, + authState: "authenticated", + featuresEnabled: true, + githubFeaturesEnabled: true, + }; + }, + }, + }); + cleanupServices.push(service); + const options = { + logger, + paseoHome, + daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) }, + workspaceGitService: service, + getAutoArchivedChangeRequestUrl: async () => null, + listActiveWorkspaces: async () => + active ? [{ workspaceId: "workspace-under-test", cwd, kind: "worktree" }] : [], + } as unknown as AutoArchiveOnMergeOptions; + setupAutoArchiveOnMerge(options, { + resolvePath: path.resolve, + archiveIfSafe: async (input) => + archiveIfSafe({ + ...input, + deps: { + isPaseoOwnedWorktreeCwd, + killTerminalsForWorkspace, + archiveByScope: async (_options, request) => { + if (request.scope.kind !== "workspace") throw new Error("Expected workspace archive"); + archives.push(request.scope.workspaceId); + await archiveBarrier; + active = false; + return { + archivedAgentIds: [], + archivedWorkspaceIds: [request.scope.workspaceId], + removedDirectory: false, + }; + }, + }, + }), + }); + return { + archives, + reads, + metrics: () => service.getMetrics(), + isActive: () => active, + makeDirty: () => writeFileSync(path.join(cwd, "README.md"), "uncommitted work\n"), + commitUnpushed: () => { + writeFileSync(path.join(cwd, "README.md"), "unpushed work\n"); + run(cwd, ["add", "README.md"]); + run(cwd, ["-c", "commit.gpgsign=false", "commit", "-m", "unpushed"]); + }, + observe: async (state: PullRequestState) => { + observation = pullRequest("workspace", state); + const snapshot = await service.getSnapshot(cwd, { force: true, reason: "workspace-journey" }); + expect(snapshot.forge.pullRequest?.state).toBe(state); + await settleObservation(); + return snapshot; + }, + }; +} + function pullRequest( headRefName: string, state: PullRequestState, @@ -161,13 +262,77 @@ function pullRequest( }; } -afterEach(() => { +afterEach(async () => { + for (const service of cleanupServices.splice(0)) await service.dispose(); for (const target of cleanupPaths.splice(0)) { rmSync(target, { recursive: true, force: true }); } }); describe("workspace auto-archive behavior", () => { + test("archives a clean owned workspace on an unobserved open-to-merged transition only once", async () => { + let releaseArchive!: () => void; + const archiveBarrier = new Promise((resolve) => { + releaseArchive = resolve; + }); + const workspace = await createUnobservedWorkspace(archiveBarrier); + try { + await workspace.observe("open"); + expect(workspace.archives).toEqual([]); + + const merged = await workspace.observe("merged"); + expect(merged.git).toMatchObject({ + isDirty: false, + aheadOfOrigin: 0, + isPaseoOwnedWorktree: true, + }); + await expect.poll(() => workspace.archives).toEqual(["workspace-under-test"]); + expect(workspace.isActive()).toBe(true); + await workspace.observe("merged"); + + expect(workspace.metrics().workspaceListenerCount).toBe(0); + expect(workspace.archives).toEqual(["workspace-under-test"]); + // Confirmation reads the completed snapshot cache; it does not shell out again. + expect(workspace.reads).toEqual([ + "workspace-journey", + "workspace-journey", + "workspace-journey", + ]); + } finally { + releaseArchive(); + } + await expect.poll(() => workspace.isActive()).toBe(false); + }); + + test("keeps an unobserved workspace active when its first snapshot is already merged", async () => { + const workspace = await createUnobservedWorkspace(); + await workspace.observe("merged"); + + expect(workspace.metrics().workspaceListenerCount).toBe(0); + expect(workspace.archives).toEqual([]); + expect(workspace.reads).toEqual(["workspace-journey"]); + }); + + test("keeps dirty work when an unobserved workspace PR merges", async () => { + const workspace = await createUnobservedWorkspace(); + await workspace.observe("open"); + workspace.makeDirty(); + const merged = await workspace.observe("merged"); + + expect(merged.git.isDirty).toBe(true); + expect(workspace.archives).toEqual([]); + }); + + test("keeps unpushed commits when an unobserved workspace PR merges", async () => { + const workspace = await createUnobservedWorkspace(); + await workspace.observe("open"); + workspace.commitUnpushed(); + const merged = await workspace.observe("merged"); + + expect(merged.git).toMatchObject({ isDirty: false, aheadOfOrigin: 1 }); + expect(workspace.archives).toEqual([]); + }); + test("archives when the checked-out PR changes from open to merged", async () => { const workspace = await createWorkspaceJourney(); workspace.checkout("feature"); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 7d547b75e6..4b0b13b7a2 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -384,28 +384,84 @@ describe("WorkspaceGitServiceImpl", () => { await service.dispose(); }); - test("onSnapshotUpdated emits only for observed workspace snapshots and can unsubscribe", async () => { + test("onSnapshotUpdated delivers without workspace subscribers and can unsubscribe", async () => { const service = createService(); - const snapshotListener = vi.fn(); - const snapshotSubscription = service.onSnapshotUpdated(snapshotListener); + const snapshots: WorkspaceGitRuntimeSnapshot[] = []; + const subscription = service.onSnapshotUpdated((snapshot) => snapshots.push(snapshot)); + try { + const initial = await service.getSnapshot(REPO_CWD); + expect(snapshots).toEqual([initial]); + expect(service.getMetrics().workspaceListenerCount).toBe(0); - await service.getSnapshot(REPO_CWD, { force: true, reason: "unobserved" }); + await service.getSnapshot(REPO_CWD); + expect(snapshots).toEqual([initial]); - expect(snapshotListener).not.toHaveBeenCalled(); + const refreshed = await service.getSnapshot(REPO_CWD, { + force: true, + reason: "unobserved-refresh", + }); + expect(snapshots).toEqual([initial, refreshed]); - const workspaceSubscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - await service.getSnapshot(REPO_CWD, { force: true, reason: "observed" }); + subscription.unsubscribe(); + await service.getSnapshot(REPO_CWD, { force: true, reason: "after-unsubscribe" }); + expect(snapshots).toEqual([initial, refreshed]); + } finally { + await service.dispose(); + } + }); - expect(snapshotListener).toHaveBeenCalledTimes(1); - expect(snapshotListener).toHaveBeenCalledWith(createSnapshot(REPO_CWD)); + test("a retired target's late refresh cannot overwrite the runtime store's newer snapshot", async () => { + const retiredRead = createDeferred(); + const retiredReadStarted = createDeferred(); + const merged = createPullRequestStatusResult(); + if (!merged.status) throw new Error("Expected pull request status"); + merged.status = { ...merged.status, state: "merged", isMerged: true }; + let reads = 0; + const service = new WorkspaceGitServiceImpl({ + logger: createLogger() as unknown as pino.Logger, + paseoHome: "/tmp/paseo-test", + deps: { + ...buildDefaultTestServiceDeps(), + getPullRequestStatus: async () => { + reads += 1; + if (reads === 1) { + retiredReadStarted.resolve(); + return retiredRead.promise; + } + return merged; + }, + }, + }); + const store = createWorkspaceRuntimeStore(); + const published: WorkspaceGitRuntimeSnapshot[] = []; + service.onSnapshotUpdated((snapshot) => { + store.set(snapshot); + published.push(snapshot); + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => {}); + const oldRefresh = service.getSnapshot(REPO_CWD, { force: true, reason: "retiring-target" }); + try { + await retiredReadStarted.promise; + subscription.unsubscribe(); + expect(service.getMetrics().workspaceTargetCount).toBe(0); - snapshotSubscription.unsubscribe(); - await service.getSnapshot(REPO_CWD, { force: true, reason: "after-unsubscribe" }); + const latest = await service.getSnapshot(REPO_CWD, { + force: true, + reason: "replacement-target", + }); + expect(latest.forge.pullRequest).toMatchObject({ state: "merged", isMerged: true }); + expect(store.get(REPO_CWD)).toBe(latest); - expect(snapshotListener).toHaveBeenCalledTimes(1); - - workspaceSubscription.unsubscribe(); - service.dispose(); + retiredRead.resolve(createPullRequestStatusResult()); + const retired = await oldRefresh; + expect(retired.forge.pullRequest).toMatchObject({ state: "open", isMerged: false }); + expect(store.get(REPO_CWD)).toBe(latest); + expect(published).toEqual([latest]); + } finally { + retiredRead.resolve(createPullRequestStatusResult()); + await oldRefresh; + await service.dispose(); + } }); test("getSnapshot populates github pull request state in the runtime snapshot", async () => { diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 74c6f8f18d..dcd2ead214 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -208,6 +208,7 @@ export interface WorkspaceGitService { listener: WorkspaceGitListener, ): WorkspaceGitSubscription; + /** Publishes computed snapshot changes independently of workspace UI subscriptions. */ onSnapshotUpdated(listener: WorkspaceGitSnapshotUpdatedListener): WorkspaceGitSubscription; getCheckout(cwd: string): Promise; getSnapshot( @@ -334,7 +335,6 @@ interface WorkspaceGitRefreshRequest { includeForge: boolean; emitUnchanged?: boolean; reason: string; - notify: boolean; queueIfBusy: boolean; movedRemoteRefs: Set; } @@ -747,7 +747,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): Promise { this.assertNotDisposed(); cwd = resolve(cwd); - const request = this.normalizeRefreshRequest(options, "getSnapshot", true); + const request = this.normalizeRefreshRequest(options, "getSnapshot"); const target = this.ensureWorkspaceTarget(cwd); if (!request.force && target.latestSnapshot) { return target.latestSnapshot; @@ -974,7 +974,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { refreshWorktree: true, includeForge: false, reason: "refresh", - notify: true, queueIfBusy: false, movedRemoteRefs: new Set(), }); @@ -1239,7 +1238,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { refreshWorktree: true, includeForge: true, reason: "initial", - notify: true, queueIfBusy: false, movedRemoteRefs: new Set(), }); @@ -1631,7 +1629,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { refreshWorktree: true, includeForge: false, reason: "working-tree-watch-fallback", - notify: true, queueIfBusy: true, movedRemoteRefs: new Set(), }); @@ -2691,7 +2688,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { includeForge: false, emitUnchanged: true, reason: "git-metadata-watch-fallback", - notify: true, queueIfBusy: true, movedRemoteRefs: new Set(), }); @@ -2824,9 +2820,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.rememberForgePrStatusSnapshot( target, buildForgeSnapshotFromStatus(status, resolution.forge), - { - notify: true, - }, ); }, onError: (error) => { @@ -2901,9 +2894,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!closed && this.isActiveObservedWorkspaceTarget(target)) { latestStatus = status; consecutiveErrors = 0; - this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge), { - notify: true, - }); + this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge)); } } catch (error) { consecutiveErrors += 1; @@ -3079,7 +3070,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private normalizeRefreshRequest( options: WorkspaceGitSnapshotOptions | undefined, defaultReason: string, - notify: boolean, ): WorkspaceGitRefreshRequest { if (options?.force && !options.reason) { throw new Error("WorkspaceGitService.getSnapshot force refresh requires a reason"); @@ -3092,7 +3082,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { refreshWorktree: true, includeForge: options?.includeForge ?? true, reason: options?.reason ?? defaultReason, - notify, queueIfBusy: false, movedRemoteRefs: new Set(), }; @@ -3121,7 +3110,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { includeForge: options?.includeForge ?? false, emitUnchanged: options?.emitUnchanged, reason: options?.reason ?? "watch", - notify: true, queueIfBusy: options?.queueIfBusy ?? true, movedRemoteRefs: new Set(options?.movedRemoteRefs), }; @@ -3160,7 +3148,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { upgradesMovedRefs ? request.reason : pending.reason, - notify: pending.notify || request.notify, queueIfBusy: pending.queueIfBusy || request.queueIfBusy, movedRemoteRefs, }; @@ -3181,7 +3168,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.latestSnapshot ) { this.rememberSnapshot(target, target.latestSnapshot, { - notify: request.notify, forceEmit: true, }); } @@ -3198,7 +3184,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } snapshot = admittedSnapshot; this.rememberSnapshot(target, snapshot, { - notify: request.notify, forceEmit: request.force || (request.emitUnchanged === true && request.movedRemoteRefs.size > 0), }); @@ -3485,7 +3470,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private rememberForgePrStatusSnapshot( target: WorkspaceGitTarget, github: WorkspaceGitRuntimeSnapshot["forge"], - options?: { notify?: boolean }, ): void { if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { return; @@ -3494,7 +3478,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.latestForge = github; target.latestForgeLoadedAtMs = this.deps.now().getTime(); this.rememberSnapshot(target, this.combineSnapshot(target), { - notify: options?.notify, forceEmit: false, }); } @@ -3502,8 +3485,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private rememberSnapshot( target: WorkspaceGitTarget, snapshot: WorkspaceGitRuntimeSnapshot, - options?: { forceEmit?: boolean; notify?: boolean }, + options?: { forceEmit?: boolean }, ): void { + if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { + return; + } target.latestSnapshot = snapshot; if (target.listeners.size > 0) { this.updateForgePrStatusPollForTarget(target); @@ -3514,9 +3500,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } target.latestFingerprint = fingerprint; - if (!options?.notify || target.listeners.size === 0) { - return; - } for (const listener of this.snapshotUpdatedListeners) { try { listener(snapshot); diff --git a/packages/server/src/server/workspace-runtime-store/index.test.ts b/packages/server/src/server/workspace-runtime-store/index.test.ts index ade1820506..2e83328107 100644 --- a/packages/server/src/server/workspace-runtime-store/index.test.ts +++ b/packages/server/src/server/workspace-runtime-store/index.test.ts @@ -1,5 +1,10 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path, { resolve } from "node:path"; +import pino from "pino"; +import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; import { expect, test } from "vitest"; -import { resolve } from "node:path"; import { createNoGitWorkspaceRuntimeSnapshot } from "../test-utils/workspace-git-service-stub.js"; import { createWorkspaceRuntimeStore } from "./index.js"; @@ -16,3 +21,50 @@ test("retains, replaces, and removes snapshots by resolved workspace directory", store.remove(resolve(cwd)); expect(store.get(cwd)).toBeNull(); }); + +test("retains refreshed Git changes without a workspace subscription", async () => { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-runtime-store-"))); + const repo = path.join(root, "repo"); + let now = Date.now(); + const service = new WorkspaceGitServiceImpl({ + logger: pino({ enabled: false }), + paseoHome: path.join(root, "home"), + deps: { now: () => new Date(now) }, + }); + const store = createWorkspaceRuntimeStore(); + service.onSnapshotUpdated((snapshot) => store.set(snapshot)); + try { + execFileSync("git", ["init", "-q", "-b", "main", repo]); + const git = (...args: string[]) => execFileSync("git", args, { cwd: repo, stdio: "pipe" }); + writeFileSync(path.join(repo, "README.md"), "# Temp\n"); + git("add", "README.md"); + git( + "-c", + "user.name=T", + "-c", + "user.email=t@localhost", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "init", + ); + execFileSync("git", ["init", "-q", "--bare", path.join(root, "remote.git")]); + git("remote", "add", "origin", path.join(root, "remote.git")); + git("push", "-q", "-u", "origin", "main"); + + writeFileSync(path.join(repo, "README.md"), "# Temp\none\ntwo\n"); + await service.refresh(repo); + expect(service.getMetrics().workspaceListenerCount).toBe(0); + expect((await service.getSnapshot(repo)).git.diffStat).toEqual({ additions: 2, deletions: 0 }); + expect(store.get(repo)?.git.diffStat).toEqual({ additions: 2, deletions: 0 }); + + now += 3_000; + writeFileSync(path.join(repo, "README.md"), "# Temp\none\ntwo\nthree\n"); + await service.refresh(repo); + expect(store.get(repo)?.git.diffStat).toEqual({ additions: 3, deletions: 0 }); + } finally { + await service.dispose(); + rmSync(root, { recursive: true, force: true }); + } +}); From 99a1d9a7a26b766cc5ba5aafb1741b6155aace2d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 11:41:00 +0700 Subject: [PATCH 37/62] fix(app): keep the iPhone skill picker above the composer and keyboard (#7679) Keep iOS command and file suggestions above the composer and keyboard across chat, workspace drafts and New workspace. Share transform-free anchor measurement and keyboard translation through keyboard/shift. Includes failing-first native placement and exact-insertion coverage, plus two CI test reliability fixes. Co-authored-by: sam <76036008+masz77@users.noreply.github.com> --- docs/floating-panels.md | 44 +- docs/mobile-testing.md | 32 +- .../e2e/mobile/composer-keyboard/android.sh | 546 ++++++++++-------- .../assert-composer-keyboard.mjs | 25 + .../autocomplete-placement.mjs | 94 +++ .../app/e2e/mobile/composer-keyboard/ios.mjs | 229 ++++++++ .../use-file-link.test.tsx | 10 +- .../components/ui/autocomplete-popover.tsx | 81 +-- packages/app/src/keyboard/shift/index.ts | 1 + .../app/src/keyboard/shift/internal/anchor.ts | 31 + .../src/keyboard/shift/internal/context.ts | 31 + .../src/keyboard/shift/internal/measure.ts | 60 ++ .../keyboard/shift/internal/policy.test.ts | 16 + .../app/src/keyboard/shift/internal/policy.ts | 10 + .../shift/internal/translate.android.tsx | 19 +- .../keyboard/shift/internal/translate.ios.tsx | 23 +- .../src/server/agent/mcp-server.test.ts | 22 +- .../providers/mock-load-test-agent.test.ts | 13 + .../agent/providers/mock-load-test-agent.ts | 13 +- 19 files changed, 973 insertions(+), 327 deletions(-) create mode 100644 packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs create mode 100644 packages/app/e2e/mobile/composer-keyboard/ios.mjs create mode 100644 packages/app/src/keyboard/shift/internal/anchor.ts create mode 100644 packages/app/src/keyboard/shift/internal/measure.ts diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 39ef49fae8..8b81ca0ad9 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -115,11 +115,10 @@ quietly relying on: Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the `isPaneFocused` prop already exists and flips on pane switches; pass `visible={isYourOwnVisible && isPaneFocused}`. -- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift - SharedValue, and `useKeyboardShiftStyle()` only adapts that value into - translate/padding styles. The composer and chat content must both read that - provider-owned value. A portal'd popover is outside the composer tree — it - does not get that transform unless you apply it yourself. +- **Transforms.** The composer moves with the keyboard through a + `KeyboardTranslateView` transform. A portal'd popover is outside that view, so + it does not move with the composer unless it renders its own + `KeyboardTranslateView` with the same translation. - **Layering.** The default root host renders after app content, so it sits above compact sidebars. Content overlays that must sit below sidebars should use the current `FloatingPanelPortalHost`. @@ -158,7 +157,7 @@ and the stream keeps its existing flick-to-dismiss gesture. The dock's private viewport and capacity model are extracted from chat. The app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams, terminal, explorer, and dialogs. Import only its public entry point: the provider, -shift hooks, shift style, translate view, settled stream inset, and explorer padding +shift hooks, shift style, translate view, keyboard anchor, settled stream inset, and explorer padding policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider` at its root mount point outside the portal host when changing dock hosts. @@ -256,11 +255,27 @@ portal geometry, plus the `isMoving` worklet value. It reconciles native animation-end events because the controller can retain a nonzero value after the keyboard closes. -Measure portal anchors while the dock is at rest. If a popover opens during -motion, re-measure when `isMoving` settles. `measureInWindow` includes the -dock's current layout and transform. Snapshot the shift at measurement time and -apply only the subsequent shift delta to the animated `bottom`. Adding the full -shift moves the portal twice and can place it over the composer controls. +Anchor a portal to content inside a `KeyboardTranslateView` with +`useKeyboardAnchor()`. Read it on the anchor's side of the Portal: the +translation scope is React context and does not cross it (Gotcha 7). It returns: + +- `measureAtRest(element)`: the element's window rect with the keyboard + translation left out, or null once the anchor unmounts. Lay the popover out + from this rect. It assumes the translate view's parent does not move with the + keyboard, which holds for the composer dock. +- `translation`: spread it onto a `KeyboardTranslateView` inside the Portal, so + the popover moves with the anchor on the same driver, including centered + clearance and disabled translation. +- `reservedLift`: the anchor's lift at keyboard boundaries. Bound the popover's + height with it; never animate height per frame. + +Do not measure an anchor with `measureInWindow` and correct it by the keyboard +shift. On iOS the translate view uses the native driver, and `measureInWindow` +includes its transform only when the view happened to re-render since the +keyboard moved. Both results were observed, so any correction double-lifts the +popover or leaves it behind the keyboard. `measureAtRest` measures through +Fabric's transform-free `measureLayout` instead. Re-measure when `isMoving` +settles; the composer's resting position changes as the draft grows. The provider also reconciles iOS from the controller's native `onEnd` event. The controller's stock iOS shared values update at move start and during an @@ -273,7 +288,8 @@ not be required to repair the offset. The generic anchored-overlay rule is: -1. Measure the anchor with `measureInWindow`. +1. Measure the anchor with `measureInWindow`, or with `measureAtRest` when it + moves with the keyboard (Gotcha 3). 2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`. 3. Position with anchor coordinates relative to the host: @@ -405,8 +421,8 @@ Before you write a new one, ask: 3. **Is the panel rendered in a Portal host?** Measure the host too. Never use raw window coordinates as local Portal coordinates. 4. **Does the panel sit above something that moves with the keyboard?** If - yes, slave a Reanimated transform to the same SharedValue (Gotcha 3). - If no, you can probably skip the transform entirely. + yes, anchor it with `useKeyboardAnchor()` and wrap it in a + `KeyboardTranslateView` with the anchor's translation (Gotcha 3). 5. **Will the panel's content height vary?** If yes, you need both `anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return null until anchor, then opacity-0 until contentSize). If no — content has diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md index 375da30708..917ebf9b53 100644 --- a/docs/mobile-testing.md +++ b/docs/mobile-testing.md @@ -53,7 +53,18 @@ hold-to-delete and background-tap dismissal scenario on chat, a workspace draft tab, and New workspace. It checks empty-content taps and header taps at the cap without changing the input height. Each host also opens command/file autocomplete, the model selector, the attachment -menu, and the forge picker from both keyboard states. It then submits a long +menu, and the forge picker from both keyboard states. Autocomplete opens with +the keyboard closed, before the keyboard opens, and after it settles. Each time +the expected best match must be drawn whole between the header and the composer, +above the IME frame, and tapping it there must produce the exact composer text. +Accessibility reports rows behind the keyboard too, so +`packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs` compares +bounds. Android leaves the chat and composer out of the accessibility tree while +suggestions are open, so the composer bounds come from a snapshot with an empty +input in the same keyboard state. The queries are partial names with known best +matches: in chat `/mo` finds the mock provider's `/mock` command, in drafts +`/release` finds this repository's `release-beta` skill for the first Claude model, +and `@app` finds `packages/app`. It then submits a long fixture from each host and checks native stream displacement twice: after the response settles, and after returning to the bottom and letting the scrollbar fade. The image comparison excludes the scrollbar; keyboard dismissal alone @@ -64,6 +75,25 @@ Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in `packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard; the headless input helper cannot verify the [visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors). +To rerun only the autocomplete, model selector, attachment menu, and forge picker +checks on each host, skipping the chat baseline and the growth, dismissal, and +stream scenarios: + +```bash +PASEO_COMPOSER_KEYBOARD_SCENARIO=overlays npm run test:e2e:composer-keyboard:android +``` + +`node packages/app/e2e/mobile/composer-keyboard/ios.mjs` runs the same +autocomplete checks on iOS for chat, a workspace draft tab, and New workspace, +with commands, files, and files under a grown draft. Suggestions open while the +keyboard opens (`fill` taps and types at once), after it settles, with it closed, +and before it reopens. `test:e2e:mobile` cannot host this: its `.ad` flows assert +accessibility state and cannot compare bounds. Run it where `agent-device` +reaches the simulator, with the development app connected to the fixture host +and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`, +`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`, +`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to +`.dev/agent-device-artifacts/composer-keyboard-ios`. The Android sidebar scroll-retention flow requires a connected host with enough workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index 49c907dba9..809483ceff 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -19,6 +19,13 @@ MESSAGE=$'keyboard invariant line one\nline two\nline three\nline four' LONG_MESSAGE="$(node -e 'process.stdout.write(Array.from({ length: 180 }, (_, index) => `line${index + 1}`).join(" "))')" BLANK_LINE_DRAFT="$(node -e 'process.stdout.write("\n".repeat(22) + "ddjdj")')" AGENT_TITLE="Keyboard dismiss QA $(date +%s)" +# `overlays` runs only each host's autocomplete, model selector, attachment menu, and +# forge picker checks; `all` also runs the chat baseline and the full host scenario. +SCENARIO="${PASEO_COMPOSER_KEYBOARD_SCENARIO:-all}" +if [[ "${SCENARIO}" != all && "${SCENARIO}" != overlays ]]; then + echo "PASEO_COMPOSER_KEYBOARD_SCENARIO must be all or overlays" >&2 + exit 1 +fi if [[ -z "${SERVER_ID}" ]]; then if [[ ! -f "${DAEMON_HOME}/server-id" ]]; then @@ -173,8 +180,10 @@ sleep 3 # Create the fixture while the client is connected so its directory replica observes the insert. ad wait 'id="message-input-root"' 90000 +# The compact header names the workspace, not the agent, so the chat waits below find +# the fixture by its prompt bubble. run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \ - npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \ + npm run --silent cli -- run "${AGENT_TITLE}" \ --background \ --title "${AGENT_TITLE}" \ --provider mock \ @@ -206,238 +215,241 @@ clear_input ad keyboard dismiss || true ad wait 'label="Message, @files, /commands"' 10000 -snapshot_json "${ARTIFACTS_DIR}/baseline.json" -capture_screen "${ARTIFACTS_DIR}/baseline.png" -read -r input_x input_y baseline_input_height < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands" -) -read -r model_x model_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector" -) -read -r attach_x attach_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button" -) -read -r _ header_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger" -) +# Chat-only invariants, then the long-draft checks on New workspace. +run_chat_baseline() { + snapshot_json "${ARTIFACTS_DIR}/baseline.json" + capture_screen "${ARTIFACTS_DIR}/baseline.png" + read -r input_x input_y baseline_input_height < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "Message, @files, /commands" + ) + read -r model_x model_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "combined-model-selector" + ) + read -r attach_x attach_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "message-input-attach-button" + ) + read -r _ header_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/baseline.json" "workspace-tab-switcher-trigger" + ) -adb shell input keyevent HOME -adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null -sleep 1 -open_gboard "${input_x}" "${input_y}" -clear_focused_input_with_adb -keyboard_shift="$(read_keyboard_shift)" -adb shell input keyevent 76 -sleep 1 -capture_screen "${ARTIFACTS_DIR}/command-popover.png" -ad wait 'id="composer-autocomplete-popover"' 10000 -ad wait 'text="/exit"' 10000 -model_open_y="$((model_y - keyboard_shift))" -adb shell input tap "${model_x}" "${model_open_y}" -sleep 1 -capture_screen "${ARTIFACTS_DIR}/model-selector-open.png" -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'id="compact-provider-list"' 10000 -ad press 'label="Close"' --settle -adb shell input tap "${input_x}" "${input_y}" -clear_focused_input_with_adb -ad wait 'editable=true' 10000 + adb shell input keyevent HOME + adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null + sleep 1 + open_gboard "${input_x}" "${input_y}" + clear_focused_input_with_adb + keyboard_shift="$(read_keyboard_shift)" + adb shell input keyevent 76 + sleep 1 + capture_screen "${ARTIFACTS_DIR}/command-popover.png" + ad wait 'id="composer-autocomplete-popover"' 10000 + ad wait 'text="/exit"' 10000 + model_open_y="$((model_y - keyboard_shift))" + adb shell input tap "${model_x}" "${model_open_y}" + sleep 1 + capture_screen "${ARTIFACTS_DIR}/model-selector-open.png" + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'id="compact-provider-list"' 10000 + ad press 'label="Close"' --settle + adb shell input tap "${input_x}" "${input_y}" + clear_focused_input_with_adb + ad wait 'editable=true' 10000 -adb shell input keyevent HOME -adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null -sleep 1 -open_gboard "${input_x}" "${input_y}" -clear_focused_input_with_adb -adb shell input keyevent 76 -sleep 1 -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'id="composer-autocomplete-popover"' 10000 -ad wait 'text="/exit"' 10000 -snapshot_json "${ARTIFACTS_DIR}/command-popover.json" -node "${ASSERT}" above-y \ - "${ARTIFACTS_DIR}/command-popover.json" \ - "/exit, Archive the current agent" \ - "$((input_y - baseline_input_height / 2))" -adb shell input keyevent 67 -ad wait 'label="Message, @files, /commands"' 10000 + adb shell input keyevent HOME + adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null + sleep 1 + open_gboard "${input_x}" "${input_y}" + clear_focused_input_with_adb + adb shell input keyevent 76 + sleep 1 + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'id="composer-autocomplete-popover"' 10000 + ad wait 'text="/exit"' 10000 + snapshot_json "${ARTIFACTS_DIR}/command-popover.json" + node "${ASSERT}" above-y \ + "${ARTIFACTS_DIR}/command-popover.json" \ + "/exit, Archive the current agent" \ + "$((input_y - baseline_input_height / 2))" + adb shell input keyevent 67 + ad wait 'label="Message, @files, /commands"' 10000 -ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle -snapshot_json "${ARTIFACTS_DIR}/multiline-first.json" -ad fill 'editable=true focused=true' "${MESSAGE}" --settle -snapshot_json "${ARTIFACTS_DIR}/multiline-second.json" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/multiline-first.json" \ - "${ARTIFACTS_DIR}/multiline-second.json" + ad fill 'label="Message, @files, /commands"' "${MESSAGE}" --settle + snapshot_json "${ARTIFACTS_DIR}/multiline-first.json" + ad fill 'editable=true focused=true' "${MESSAGE}" --settle + snapshot_json "${ARTIFACTS_DIR}/multiline-second.json" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/multiline-first.json" \ + "${ARTIFACTS_DIR}/multiline-second.json" -adb shell input keyevent HOME -adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null -sleep 1 -open_gboard "${input_x}" "${input_y}" + adb shell input keyevent HOME + adb shell am start -n "${APP_ID}/.MainActivity" >/dev/null + sleep 1 + open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" -capture_screen "${ARTIFACTS_DIR}/keyboard-open.png" + keyboard_shift="$(read_keyboard_shift)" + capture_screen "${ARTIFACTS_DIR}/keyboard-open.png" -node "${ASSERT}" same-header \ - "${ARTIFACTS_DIR}/baseline.png" \ - "${ARTIFACTS_DIR}/keyboard-open.png" \ - "$((header_y + 48))" + node "${ASSERT}" same-header \ + "${ARTIFACTS_DIR}/baseline.png" \ + "${ARTIFACTS_DIR}/keyboard-open.png" \ + "$((header_y + 48))" -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle -open_gboard "${input_x}" "${input_y}" -capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" -capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png" -node "${ASSERT}" xml-above-y \ - "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ - "Add attachment" \ - "$(read_ime_top)" -display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')" -node "${ASSERT}" xml-composer-contained \ - "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ - "$(read_ime_top)" \ - "${display_density}" -# Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to -# its baseline height. Fabric only re-measures the Android input when its text prop changes, -# so the editing input renders again after every edit -# (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the -# report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the -# draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from -# the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key -# row on the paseo-api35 layout. -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle -open_gboard "${input_x}" "${input_y}" -adb shell input keyevent BACK -wait_for_ime false -open_gboard "${input_x}" "${input_y}" -adb shell input keycombination 113 122 -adb shell input keyevent 20 20 20 20 20 20 20 20 -screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)" -backspace_x="$((screen_width - 85))" -backspace_y="$(($(read_ime_top) + 507))" -adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000 -adb shell input keycombination 113 123 -adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000 -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'label="Message, @files, /commands"' 10000 -snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/baseline.json" \ - "${ARTIFACTS_DIR}/after-hold-delete.json" + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true focused=true' "${LONG_MESSAGE}" --settle + open_gboard "${input_x}" "${input_y}" + capture_ui_xml "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" + capture_screen "${ARTIFACTS_DIR}/long-draft-keyboard-open.png" + node "${ASSERT}" xml-above-y \ + "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ + "Add attachment" \ + "$(read_ime_top)" + display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')" + node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" + # Emptying a capped blank-line draft with Gboard's hold-to-delete must return the input to + # its baseline height. Fabric only re-measures the Android input when its text prop changes, + # so the editing input renders again after every edit + # (packages/app/src/components/ui/text-input/text-input.native.tsx). The sequence mirrors the + # report: grow to the cap, close and reopen the keyboard, hold delete from the middle of the + # draft, jump to the end, hold delete again. The first hold-delete must start mid-draft: from + # the end, the pre-fix input also returns to baseline. Backspace sits on Gboard's third key + # row on the paseo-api35 layout. + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle + open_gboard "${input_x}" "${input_y}" + adb shell input keyevent BACK + wait_for_ime false + open_gboard "${input_x}" "${input_y}" + adb shell input keycombination 113 122 + adb shell input keyevent 20 20 20 20 20 20 20 20 + screen_width="$(adb shell wm size | sed -n 's/.*: \([0-9]*\)x.*/\1/p' | tail -1)" + backspace_x="$((screen_width - 85))" + backspace_y="$(($(read_ime_top) + 507))" + adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 8000 + adb shell input keycombination 113 123 + adb shell input swipe "${backspace_x}" "${backspace_y}" "${backspace_x}" "${backspace_y}" 12000 + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'label="Message, @files, /commands"' 10000 + snapshot_json "${ARTIFACTS_DIR}/after-hold-delete.json" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/baseline.json" \ + "${ARTIFACTS_DIR}/after-hold-delete.json" -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "${MESSAGE}" --settle -open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "${MESSAGE}" --settle + open_gboard "${input_x}" "${input_y}" + keyboard_shift="$(read_keyboard_shift)" -read -r changes_x changes_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab" -) -adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))" -ad wait 'id="changes-header"' 10000 -capture_screen "${ARTIFACTS_DIR}/changes-control-open.png" -ad press 'label="Close Explorer sidebar"' --settle -ad wait "text=\"${AGENT_TITLE}\"" 10000 -ad wait 'editable=true' 10000 + read -r changes_x changes_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/multiline-second.json" "Open Changes tab" + ) + adb shell input tap "${changes_x}" "$((changes_y - keyboard_shift))" + ad wait 'id="changes-header"' 10000 + capture_screen "${ARTIFACTS_DIR}/changes-control-open.png" + ad press 'label="Close Explorer sidebar"' --settle + ad wait "text=\"${AGENT_TITLE}\"" 10000 + ad wait 'editable=true' 10000 -open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" -adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))" -sleep 1 -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait 'id="message-input-attachment-menu-content"' 10000 -ad press 540 600 --settle + open_gboard "${input_x}" "${input_y}" + keyboard_shift="$(read_keyboard_shift)" + adb shell input tap "${attach_x}" "$((attach_y - keyboard_shift))" + sleep 1 + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait 'id="message-input-attachment-menu-content"' 10000 + ad press 540 600 --settle -open_gboard "${input_x}" "${input_y}" -keyboard_shift="$(read_keyboard_shift)" -adb shell input tap 983 "$((attach_y - keyboard_shift))" -wait_for_ime false -adb shell ime set "${HELPER_IME}" >/dev/null -ad wait text "keyboard invariant line one" 10000 -snapshot_json "${ARTIFACTS_DIR}/after-submit.json" -node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/baseline.json" \ - "${ARTIFACTS_DIR}/after-submit.json" + open_gboard "${input_x}" "${input_y}" + keyboard_shift="$(read_keyboard_shift)" + adb shell input tap 983 "$((attach_y - keyboard_shift))" + wait_for_ime false + adb shell ime set "${HELPER_IME}" >/dev/null + ad wait text "keyboard invariant line one" 10000 + snapshot_json "${ARTIFACTS_DIR}/after-submit.json" + node "${ASSERT}" has-exact-text "${ARTIFACTS_DIR}/after-submit.json" "${MESSAGE}" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/baseline.json" \ + "${ARTIFACTS_DIR}/after-submit.json" -open_gboard "${input_x}" "${input_y}" -adb shell input keyevent BACK -wait_for_ime false -sleep 0.5 -capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png" + open_gboard "${input_x}" "${input_y}" + adb shell input keyevent BACK + wait_for_ime false + sleep 0.5 + capture_screen "${ARTIFACTS_DIR}/hidden-focused-baseline.png" -open_gboard "${input_x}" "${input_y}" -node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" & -stall_pid="$!" -sleep 0.25 -adb shell input keyevent BACK -wait_for_ime false -sleep 0.5 -capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png" -wait "${stall_pid}" -capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png" -node "${ASSERT}" same-region \ - "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ - "${ARTIFACTS_DIR}/hidden-during-js-stall.png" \ - 1400 \ - 430 -node "${ASSERT}" same-region \ - "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ - "${ARTIFACTS_DIR}/hidden-after-js-stall.png" \ - 1400 \ - 430 + open_gboard "${input_x}" "${input_y}" + node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" & + stall_pid="$!" + sleep 0.25 + adb shell input keyevent BACK + wait_for_ime false + sleep 0.5 + capture_screen "${ARTIFACTS_DIR}/hidden-during-js-stall.png" + wait "${stall_pid}" + capture_screen "${ARTIFACTS_DIR}/hidden-after-js-stall.png" + node "${ASSERT}" same-region \ + "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ + "${ARTIFACTS_DIR}/hidden-during-js-stall.png" \ + 1400 \ + 430 + node "${ASSERT}" same-region \ + "${ARTIFACTS_DIR}/hidden-focused-baseline.png" \ + "${ARTIFACTS_DIR}/hidden-after-js-stall.png" \ + 1400 \ + 430 -ad press 'id="menu-button"' --settle -ad press 'id="sidebar-global-new-workspace"' --settle -ad wait 'id="workspace-create-submit"' 10000 -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "x" --settle -snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json" -read -r new_input_x new_input_y _ < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable" -) -open_gboard "${new_input_x}" "${new_input_y}" -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" -# Typing more lines moves the setup fields up with the composer instead of -# hiding their bottom rows behind it. -adb shell input keycombination 113 123 -adb shell input keyevent 66 66 66 -adb shell input text grown -sleep 1 -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" -capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png" -node "${ASSERT}" xml-fields-follow-growth \ - "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \ - "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" + ad press 'id="menu-button"' --settle + ad press 'id="sidebar-global-new-workspace"' --settle + ad wait 'id="workspace-create-submit"' 10000 + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "x" --settle + snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json" + read -r new_input_x new_input_y _ < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable" + ) + open_gboard "${new_input_x}" "${new_input_y}" + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" + # Typing more lines moves the setup fields up with the composer instead of + # hiding their bottom rows behind it. + adb shell input keycombination 113 123 + adb shell input keyevent 66 66 66 + adb shell input text grown + sleep 1 + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" + capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png" + node "${ASSERT}" xml-fields-follow-growth \ + "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \ + "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml" -adb shell ime set "${HELPER_IME}" >/dev/null -ad fill 'editable=true' "${LONG_MESSAGE}" --settle -snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json" -read -r new_input_x new_input_y new_input_height < <( - node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable" -) -# The editor shrinks when the keyboard opens. Its bottom stays anchored, while -# its old center can move into the setup fields after the height constraint. -new_input_y="$((new_input_y + new_input_height / 2 - 8))" -open_gboard "${new_input_x}" "${new_input_y}" -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" -capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png" -node "${ASSERT}" xml-composer-contained \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ - "$(read_ime_top)" \ - "${display_density}" + adb shell ime set "${HELPER_IME}" >/dev/null + ad fill 'editable=true' "${LONG_MESSAGE}" --settle + snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json" + read -r new_input_x new_input_y new_input_height < <( + node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable" + ) + # The editor shrinks when the keyboard opens. Its bottom stays anchored, while + # its old center can move into the setup fields after the height constraint. + new_input_y="$((new_input_y + new_input_height / 2 - 8))" + open_gboard "${new_input_x}" "${new_input_y}" + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" + capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png" + node "${ASSERT}" xml-composer-contained \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ + "$(read_ime_top)" \ + "${display_density}" -# Dismissing the keyboard moves the composer down at its editing height and -# brings the setup fields back fully visible above it. -adb shell input keyevent BACK -wait_for_ime false -capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" -capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png" -node "${ASSERT}" same-input-height \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" -node "${ASSERT}" xml-fields-above-composer \ - "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" + # Dismissing the keyboard moves the composer down at its editing height and + # brings the setup fields back fully visible above it. + adb shell input keyevent BACK + wait_for_ime false + capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" + capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png" + node "${ASSERT}" same-input-height \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" + node "${ASSERT}" xml-fields-above-composer \ + "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml" +} # All hosts run the same scenario; only navigation differs. # For the shared scenario, tap the editor's blank right edge once. Tapping @@ -470,20 +482,80 @@ press_composer_control() { adb shell input tap "${x}" "${y}" } +# Opens suggestions with the keyboard closed, before it opens, or after it settled; +# checks their bounds and taps the best match where it is drawn. Accessibility keeps +# reporting rows behind the keyboard, so existence is not evidence. It also drops the +# composer while suggestions are open, so the composer bounds come from a reference +# snapshot with an empty input in the keyboard state the suggestions are checked in. +check_autocomplete() { + local host="$1" order="$2" kind="$3" query="$4" expected="$5" inserted="$6" + local prefix="${ARTIFACTS_DIR}/${host}-${kind}-${order}" + if [[ "${order}" == keyboard-closed ]]; then + prepare_composer closed + snapshot_json "${prefix}-reference.json" + adb shell input text "${query}" + else + prepare_composer open + # The composer follows the keyboard animation, which ends after the IME frame settles. + sleep 1 + snapshot_json "${prefix}-reference.json" + if [[ "${order}" == before-keyboard ]]; then + adb shell ime set "${HELPER_IME}" >/dev/null + wait_for_ime false + adb shell input text "${query}" + open_editor_keyboard "${overlay_x}" "${overlay_y}" + else + adb shell input text "${query}" + fi + fi + ad wait 'id="composer-autocomplete-popover"' 10000 + # The popover remeasures once keyboard motion settles. + sleep 1 + capture_screen "${prefix}.png" + snapshot_json "${prefix}.json" + local ime_top="" + if [[ "${order}" != keyboard-closed ]]; then ime_top="$(read_ime_top)"; fi + local best_x best_y + read -r best_x best_y < <( + node "${ASSERT}" autocomplete-best "${prefix}.json" "${prefix}-reference.json" "${expected}" "${ime_top}" + ) + adb shell input tap "${best_x}" "${best_y}" + sleep 1 + snapshot_json "${prefix}-selected.json" + node "${ASSERT}" composer-text "${prefix}-selected.json" "${inserted}" + clear_focused_input_with_adb + echo "PASS host=${host}: ${kind} ${order}, ${expected} drawn above composer and keyboard and inserted" +} + +# Drafts list commands for their selected provider, and a fresh install selects none. +select_claude_model() { + local x y h + press_composer_control combined-model-selector + ad wait 'id="agent-controls-model"' 10000 + ad press 'id="agent-controls-model"' --settle + ad press 'id="model-provider-claude"' --settle + snapshot_json "${ARTIFACTS_DIR}/model-list.json" + read -r x y h < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/model-list.json" 'model-row-claude-*') + adb shell input tap "${x}" "${y}" + ad wait 'id="agent-controls-thinking"' 10000 + ad press 'label="Close"' --settle +} + run_host_overlays() { local host="$1" + if [[ "${host}" != chat ]]; then + prepare_composer closed + select_claude_model + fi + # Chat lists the mock provider's command; drafts list the release skills in + # this repository's .claude/skills for the selected Claude model. + local command_query=/release command_match=/release-beta command_inserted="/release-beta " + if [[ "${host}" == chat ]]; then command_query=/mo command_match=/mock command_inserted="/mock "; fi + for order in keyboard-closed before-keyboard after-keyboard; do + check_autocomplete "${host}" "${order}" commands "${command_query}" "${command_match}" "${command_inserted}" + check_autocomplete "${host}" "${order}" files @app packages/app '"packages/app"' + done for mode in closed open; do - for query in / @README; do - prepare_composer closed - adb shell input text "${query}" - if [[ "${mode}" == open ]]; then open_editor_keyboard "${overlay_x}" "${overlay_y}"; fi - ad wait 'id="composer-autocomplete-popover"' 10000 - local kind=commands - if [[ "${query}" == @* ]]; then kind=files; fi - capture_screen "${ARTIFACTS_DIR}/${host}-${kind}-${mode}.png" - clear_focused_input_with_adb - echo "PASS host=${host}: ${kind} popover keyboard=${mode}" - done prepare_composer "${mode}" press_composer_control combined-model-selector ad wait 'id="compact-provider-list"' 10000 @@ -656,19 +728,29 @@ run_host_scenario() { run_host_scroll "${host}" } +run_host() { + if [[ "${SCENARIO}" == overlays ]]; then + run_host_overlays "$1" + else + run_host_scenario "$1" + fi +} + +if [[ "${SCENARIO}" == all ]]; then run_chat_baseline; fi adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agent/${agent_id}" "${APP_ID}" >/dev/null ad wait "text=\"${AGENT_TITLE}\"" 10000 -run_host_scenario chat +run_host chat ad keyboard dismiss || true ad press 'id="workspace-tab-switcher-trigger"' --settle ad wait 'id="workspace-tab-switcher-new-agent"' 10000 ad press 'id="workspace-tab-switcher-new-agent"' --settle -ad wait 'text="New Agent"' 10000 -run_host_scenario workspace-draft +# Only a draft offers to import a session. +ad wait 'id="composer-import-agent-pill"' 10000 +run_host workspace-draft ad keyboard dismiss || true ad press 'id="menu-button"' --settle ad press 'id="sidebar-global-new-workspace"' --settle ad wait 'id="workspace-create-submit"' 10000 -run_host_scenario new-workspace +run_host new-workspace echo "Composer keyboard invariants passed" diff --git a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs index 6bd13a5da5..637a162682 100644 --- a/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs +++ b/packages/app/e2e/mobile/composer-keyboard/assert-composer-keyboard.mjs @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import process from "node:process"; import sharp from "sharp"; +import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs"; const [, , command, ...args] = process.argv; @@ -115,11 +116,14 @@ if (command === "stream-moved") { `Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`, ); } else if (command === "rect") { + // A trailing `*` matches the first identifier with that prefix. const [snapshotPath, identifier, edge] = args; const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); + const prefix = identifier.endsWith("*") ? identifier.slice(0, -1) : null; const node = snapshot.data.nodes.find( (candidate) => candidate.identifier === identifier || + (prefix !== null && candidate.identifier?.startsWith(prefix)) || candidate.label === identifier || (identifier === "editable" && candidate.type.endsWith("EditText")), ); @@ -232,6 +236,27 @@ if (command === "stream-moved") { if (!snapshot.data.nodes.some((candidate) => candidate.label === expected)) { throw new Error(`Missing exact text in ${snapshotPath}: ${expected}`); } +} else if (command === "autocomplete-best") { + // Prints the center of the expected best match after checking its placement + // against the composer in the reference snapshot. + const [snapshotPath, referencePath, expected, imeTopArgument] = args; + const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); + const reference = JSON.parse(await fs.readFile(referencePath, "utf8")); + const best = resolveAutocompletePlacement(snapshot, { + expected, + keyboardTop: imeTopArgument ? Number(imeTopArgument) : undefined, + reference, + }); + process.stdout.write(`${best.x} ${best.y}\n`); +} else if (command === "composer-text") { + // Accessibility drops a trailing space, so compare without it. + const [snapshotPath, expected] = args; + const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8")); + const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText")); + const text = input?.value ?? input?.label ?? ""; + if (text.trimEnd() !== expected.trimEnd()) { + throw new Error(`Composer text ${JSON.stringify(text)}, expected ${JSON.stringify(expected)}`); + } } else if (command === "same-region") { const [firstPath, secondPath, topArgument, heightArgument, failureMessage] = args; const crop = { diff --git a/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs b/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs new file mode 100644 index 0000000000..22c0b42e8f --- /dev/null +++ b/packages/app/e2e/mobile/composer-keyboard/autocomplete-placement.mjs @@ -0,0 +1,94 @@ +// Shared by the iOS and Android composer keyboard harnesses. Reads an agent-device +// `snapshot --raw --json` result. Accessibility keeps reporting suggestion rows that +// sit behind the keyboard, so existence is not evidence: compare their screen bounds. + +const bottomOf = (rect) => rect.y + rect.height; + +function findById(nodes, identifier) { + const node = nodes.find((candidate) => candidate.identifier === identifier && candidate.rect); + if (!node) throw new Error(`Missing node: ${identifier}`); + return node; +} + +function contains(outer, inner) { + return ( + inner.x >= outer.x - 1 && + inner.y >= outer.y - 1 && + inner.x + inner.width <= outer.x + outer.width + 1 && + bottomOf(inner) <= bottomOf(outer) + 1 + ); +} + +/** + * The best match is the list row nearest the composer. Check that the popover sits + * between the app's header controls and the composer, that the keyboard (when shown) + * is below both, and that the row nearest the composer is fully drawn and is + * `expected`. A clipped best match fails: the nearest whole row is then another one. + * Return where to tap it. Android leaves the composer out of the accessibility tree + * while suggestions are open, so its callers read the composer from `reference`: a + * snapshot in the same keyboard state with an empty input. + */ +export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) { + if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot"); + const nodes = snapshot.data.nodes; + const popover = findById(nodes, "composer-autocomplete-popover").rect; + const composer = findById((reference ?? snapshot).data.nodes, "message-input-root").rect; + // The popover may cover the compact header, never the status bar above it. + const minimumTop = + nodes.find((node) => node.identifier === "menu-button" && node.rect)?.rect.y ?? 0; + // iOS reports the keyboard in the app's tree; Android callers pass the IME frame. + const keyboardY = + keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y; + const popoverBottom = bottomOf(popover); + if (popoverBottom > composer.y + 1) { + throw new Error( + `Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`, + ); + } + if (popover.y < minimumTop - 1) { + throw new Error( + `Autocomplete extends above the header: top=${popover.y}, minimum=${minimumTop}`, + ); + } + if (keyboardY !== undefined && popoverBottom > keyboardY + 1) { + throw new Error( + `Autocomplete sits behind the keyboard: popover bottom=${popoverBottom}, keyboard top=${keyboardY}`, + ); + } + // Rows are the innermost labelled boxes spanning the popover. The list scrolls to + // its end, so the lowest row is the best match nearest the composer. + const candidates = nodes.filter( + (node) => + node.rect && + node.label && + node.identifier !== "composer-autocomplete-popover" && + !/scroll bar/i.test(node.label) && + node.rect.height > 0 && + // The popover border insets rows by a few pixels; their labels are narrower. + node.rect.width >= popover.width * 0.97 && + contains(popover, node.rect), + ); + const area = (rect) => rect.width * rect.height; + const rows = candidates.filter( + (outer) => + !candidates.some( + (inner) => area(inner.rect) < area(outer.rect) - 1 && contains(outer.rect, inner.rect), + ), + ); + if (rows.length === 0) throw new Error("Autocomplete has no visible rows"); + const best = rows.reduce((lowest, row) => + bottomOf(row.rect) > bottomOf(lowest.rect) ? row : lowest, + ); + const label = best.label.split(", ")[0]; + if (label !== expected) { + throw new Error(`Best visible match is ${label}, expected ${expected}`); + } + return { + label, + x: Math.round(best.rect.x + best.rect.width / 2), + y: Math.round(best.rect.y + best.rect.height / 2), + popover, + composer, + keyboardTop: keyboardY ?? null, + }; +} diff --git a/packages/app/e2e/mobile/composer-keyboard/ios.mjs b/packages/app/e2e/mobile/composer-keyboard/ios.mjs new file mode 100644 index 0000000000..e3fc4af072 --- /dev/null +++ b/packages/app/e2e/mobile/composer-keyboard/ios.mjs @@ -0,0 +1,229 @@ +// Composer autocomplete placement on iOS with the software keyboard: chat, workspace +// draft tab, and New workspace. Run where agent-device reaches the simulator, with the +// development app connected to the fixture host. See docs/mobile-testing.md. +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { resolveAutocompletePlacement } from "./autocomplete-placement.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../.."); +const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?? fallback; +const serverId = env("SERVER_ID"); +const agentId = env("AGENT_ID"); +const workspaceId = env("WORKSPACE_ID"); +if (!serverId || !agentId || !workspaceId) { + throw new Error( + "Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)", + ); +} +const draftId = env("DRAFT_ID", "composer-keyboard"); +const session = env("SESSION", "composer-keyboard-ios"); +const device = env("DEVICE", "iPhone 16 Pro"); +const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"); +const grownPrefix = "grown line one\ngrown line two\ngrown line three\n"; +// Partial queries with known best matches. `inserted` is the composer text after +// tapping the best match. +const fileCase = { text: "@app", expected: "packages/app", inserted: '"packages/app"' }; +// The release skills ship in this repository's .claude/skills. +const skillCase = { text: "/release", expected: "/release-beta", inserted: "/release-beta " }; +// The chat fixture's mock provider lists one command. +const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " }; + +fs.mkdirSync(artifactsDir, { recursive: true }); + +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); +} + +function snapshot(name) { + const output = ad("snapshot", "--raw", "--json"); + fs.writeFileSync(path.join(artifactsDir, `${name}.json`), output); + ad("screenshot", path.join(artifactsDir, `${name}.png`)); + return JSON.parse(output); +} + +const nodeById = (snap, id) => snap.data.nodes.find((node) => node.identifier === id && node.rect); +const keyboardShown = (snap) => snap.data.nodes.some((node) => node.type === "Keyboard"); +const composerText = (snap) => + snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.value ?? ""; + +async function waitFor(name, predicate, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const snap = snapshot(name); + if (predicate(snap)) return snap; + if (Date.now() > deadline) throw new Error(`Timed out waiting for ${name}`); + await sleep(500); + } +} + +// Keyboard motion has settled once two snapshots agree on the composer position. +async function settledPlacement(name, keyboard) { + let previousTop; + return waitFor(name, (snap) => { + const composer = nodeById(snap, "message-input-root"); + if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false; + if (keyboardShown(snap) !== keyboard) return false; + const stable = composer.rect.y === previousTop; + previousTop = composer.rect.y; + return stable; + }); +} + +function assertPlacement(name, snap, expected) { + const placement = resolveAutocompletePlacement(snap, { expected }); + process.stdout.write( + `PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round( + placement.popover.y + placement.popover.height, + )} above composer ${Math.round(placement.composer.y)}, ${expected} drawn\n`, + ); + return placement; +} + +// iOS has no generic keyboard dismissal. Tapping the chat stream dismisses it; a +// draft has no stream, so open and close the model sheet, which takes input focus. +async function dismissKeyboard(snap) { + const composer = nodeById(snap, "message-input-root").rect; + ad("press", "4", String(Math.round(composer.y - 8))); + await sleep(1_000); + if (!keyboardShown(snapshot("dismiss-keyboard"))) return; + const sheetOpen = (sheet) => + sheet.data.nodes.some((node) => node.type === "Button" && node.label === "Close"); + ad("press", 'id="combined-model-selector"'); + await waitFor("dismiss-keyboard-sheet", sheetOpen); + ad("press", 'label="Close"'); + // The input is not hittable until the sheet has gone. + await waitFor("dismiss-keyboard-done", (done) => !sheetOpen(done) && !keyboardShown(done)); +} + +// Tap past the end of the (short) last line so the caret lands after the mention. +// The input's right padding holds the fullscreen toggle; stay clear of it. +function focusAtEnd(snap) { + const input = snap.data.nodes.find((node) => node.type === "TextView" && node.rect)?.rect; + if (!input) throw new Error("Missing composer input"); + ad( + "press", + String(Math.round(input.x + input.width * 0.7)), + String(Math.round(input.y + input.height - 6)), + ); +} + +// `fill` cannot replace a multi-line draft or write an empty one, so delete from the end. +async function clearInput(name) { + focusAtEnd(snapshot(`${name}-before-clear`)); + const keyboard = await waitFor(`${name}-clear`, keyboardShown); + const deleteKey = keyboard.data.nodes.find( + (node) => node.type === "Key" && node.label === "delete" && node.rect, + )?.rect; + if (!deleteKey) throw new Error("Missing keyboard delete key"); + const length = composerText(keyboard).length; + ad( + "press", + String(Math.round(deleteKey.x + deleteKey.width / 2)), + String(Math.round(deleteKey.y + deleteKey.height / 2)), + "--count", + String(length + 2), + "--interval-ms", + "40", + ); +} + +// The accessibility value drops a trailing space. +const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd(); + +async function runCase(host, kind, { text, expected, inserted }) { + const name = `${host}-${kind}`; + await clearInput(name); + await dismissKeyboard(snapshot(`${name}-empty`)); + // fill taps the input and types at once, so suggestions open while the keyboard opens. + ad("fill", "editable=true", text); + assertPlacement( + `${name} opened while keyboard opens`, + await settledPlacement(`${name}-opening`, true), + expected, + ); + await clearInput(`${name}-settled`); + await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === ""); + ad("type", text); + assertPlacement( + `${name} opened after keyboard settled`, + await settledPlacement(`${name}-open`, true), + expected, + ); + await dismissKeyboard(snapshot(`${name}-before-close`)); + assertPlacement( + `${name} keyboard closed`, + await settledPlacement(`${name}-closed`, false), + expected, + ); + focusAtEnd(snapshot(`${name}-before-reopen`)); + const reopened = assertPlacement( + `${name} keyboard opened under open suggestions`, + await settledPlacement(`${name}-reopened`, true), + expected, + ); + ad("press", String(reopened.x), String(reopened.y)); + const selected = await waitFor(`${name}-selected`, (snap) => + sameText(composerText(snap), inserted), + ); + if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`); + process.stdout.write( + `PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`, + ); +} + +// Drafts list commands for their selected provider, and a fresh install selects none. +async function selectClaudeModel(host) { + ad("press", 'id="combined-model-selector"'); + await waitFor(`${host}-model-sheet`, (snap) => Boolean(nodeById(snap, "agent-controls-model"))); + ad("press", 'id="agent-controls-model"'); + await waitFor(`${host}-providers`, (snap) => Boolean(nodeById(snap, "model-provider-claude"))); + ad("press", 'id="model-provider-claude"'); + const models = await waitFor(`${host}-models`, (snap) => + snap.data.nodes.some((node) => node.identifier?.startsWith("model-row-claude-") && node.rect), + ); + const row = models.data.nodes.find( + (node) => node.identifier?.startsWith("model-row-claude-") && node.rect, + ).rect; + ad( + "press", + String(Math.round(row.x + row.width / 2)), + String(Math.round(row.y + row.height / 2)), + ); + await waitFor(`${host}-model-selected`, (snap) => + Boolean(nodeById(snap, "agent-controls-thinking")), + ); + ad("press", 'label="Close"'); + await waitFor(`${host}-model-closed`, (snap) => !nodeById(snap, "agent-controls-model")); +} + +async function runHost(host) { + if (host !== "chat") await selectClaudeModel(host); + await runCase(host, "commands", host === "chat" ? chatCommandCase : skillCase); + await runCase(host, "files", fileCase); + await runCase(host, "files-grown", { + text: `${grownPrefix}${fileCase.text}`, + expected: fileCase.expected, + inserted: `${grownPrefix}${fileCase.inserted}`, + }); + await clearInput(`${host}-done`); + await dismissKeyboard(snapshot(`${host}-done`)); +} + +const hosts = [ + ["chat", `paseo://h/${serverId}/agent/${agentId}`], + ["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`], + ["new-workspace", `paseo://new?serverId=${serverId}`], +]; +for (const [host, url] of hosts) { + ad("open", url, "--platform", "ios", "--device", device); + await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000); + await runHost(host); +} +process.stdout.write(`Composer autocomplete placement passed. Artifacts: ${artifactsDir}\n`); diff --git a/packages/app/src/assistant-file-links/use-file-link.test.tsx b/packages/app/src/assistant-file-links/use-file-link.test.tsx index 386cb429f1..18d2727e82 100644 --- a/packages/app/src/assistant-file-links/use-file-link.test.tsx +++ b/packages/app/src/assistant-file-links/use-file-link.test.tsx @@ -2,9 +2,9 @@ * @vitest-environment jsdom */ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook, waitFor } from "@testing-library/react"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import React, { useCallback, useMemo, useState, type ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ToastApi } from "@/components/toast-host"; import type { InlinePathTarget } from "./parse"; import { AssistantFileLinkResolverProvider } from "./provider"; @@ -99,6 +99,12 @@ function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; t } describe("useFileLink", () => { + // Lookups resolve after a test's last assertion; an unmounted hook cannot re-render + // once jsdom is torn down. + afterEach(() => { + cleanup(); + }); + it("returns the same object across no-op parent rerenders", () => { const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([])); const queryClient = createQueryClient(); diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index 9b1dcaf23d..fa5e724944 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -9,32 +9,20 @@ import { } from "react"; import { View, useWindowDimensions } from "react-native"; import { Portal } from "@gorhom/portal"; -import Animated, { - useAnimatedReaction, - useAnimatedStyle, - useSharedValue, -} from "react-native-reanimated"; +import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet } from "react-native-unistyles"; import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; import { measureFloatingPanelPortalHost, useFloatingPanelPortalHostName, } from "@/components/ui/floating-panel-portal"; -import { useKeyboardShift } from "@/keyboard/shift"; +import { KeyboardTranslateView, useKeyboardAnchor, useKeyboardShift } from "@/keyboard/shift"; import { SPACING } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; const OFFSET_FROM_ANCHOR = SPACING[3]; -interface Rect { - x: number; - y: number; - width: number; - height: number; -} - interface RelativeAnchorRect { x: number; y: number; @@ -42,14 +30,6 @@ interface RelativeAnchorRect { hostHeight: number; } -function measureElement(element: View): Promise { - return new Promise((resolve) => { - element.measureInWindow((x, y, width, height) => { - resolve({ x, y, width, height }); - }); - }); -} - interface AutocompletePopoverProps { visible: boolean; anchorRef: RefObject; @@ -79,8 +59,9 @@ export function AutocompletePopover({ const windowDimensions = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); const portalHostName = useFloatingPanelPortalHostName(); - const { shift, isMoving } = useKeyboardShift(); - const measuredShift = useSharedValue(0); + const { isMoving } = useKeyboardShift(); + const anchor = useKeyboardAnchor(); + const { measureAtRest } = anchor; const measurementGeneration = useRef(0); const canMeasure = visible && (options.length === 0 || selectedIndex >= 0); @@ -90,20 +71,21 @@ export function AutocompletePopover({ if (!anchorElement) return; const generation = measurementGeneration.current; void Promise.all([ - measureElement(anchorElement), + measureAtRest(anchorElement), measureFloatingPanelPortalHost(portalHostName), ]).then(([anchorRect, hostRect]) => { - if (generation !== measurementGeneration.current || !hostRect) return undefined; + if (generation !== measurementGeneration.current || !anchorRect || !hostRect) { + return undefined; + } setRelativeAnchorRect({ x: anchorRect.x - hostRect.x, y: anchorRect.y - hostRect.y, width: anchorRect.width, hostHeight: hostRect.height, }); - measuredShift.value = shift.value; return undefined; }); - }, [anchorRef, canMeasure, measuredShift, portalHostName, shift]); + }, [anchorRef, canMeasure, measureAtRest, portalHostName]); useEffect(() => { measurementGeneration.current += 1; @@ -131,37 +113,38 @@ export function AutocompletePopover({ [isMoving, remeasure], ); + // Lay out at the anchor's resting position; the KeyboardTranslateView below lifts + // it with the anchor. Height changes only at keyboard boundaries. const baseStyle = useMemo(() => { if (!relativeAnchorRect) return null; return inlineUnistylesStyle({ position: "absolute" as const, left: relativeAnchorRect.x, width: relativeAnchorRect.width, + bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR, }); }, [relativeAnchorRect]); const anchorY = relativeAnchorRect?.y ?? 0; - const baseBottom = relativeAnchorRect - ? relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR - : 0; - const keyboardLayoutStyle = useAnimatedStyle(() => { - const shiftDelta = shift.value - measuredShift.value; - return { - bottom: baseBottom + shiftDelta, - maxHeight: Math.max(0, anchorY - shiftDelta - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2), - }; - }, [anchorY, baseBottom, safeAreaInsets.top]); + const reservedLift = anchor.reservedLift; + const heightStyle = useAnimatedStyle( + () => ({ + maxHeight: Math.max( + 0, + anchorY - reservedLift.value - safeAreaInsets.top - OFFSET_FROM_ANCHOR * 2, + ), + }), + [anchorY, reservedLift, safeAreaInsets.top], + ); if (!visible || !relativeAnchorRect || !baseStyle) return null; if (options.length > 0 && selectedIndex < 0) return null; + // The translate view is the popover's own frame, so it lifts only the popover. return ( - - + + - + ); } - -const styles = StyleSheet.create(() => ({ - overlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - }, -})); diff --git a/packages/app/src/keyboard/shift/index.ts b/packages/app/src/keyboard/shift/index.ts index 5f057d4ecd..e54fc7ace9 100644 --- a/packages/app/src/keyboard/shift/index.ts +++ b/packages/app/src/keyboard/shift/index.ts @@ -1,4 +1,5 @@ export { KeyboardShiftProvider, useKeyboardShiftStyle } from "./internal/provider"; export { useKeyboardShift, useSettledKeyboardShift } from "./internal/context"; export { KeyboardTranslateView } from "./internal/translate"; +export { useKeyboardAnchor } from "./internal/anchor"; export { useKeyboardStreamInset } from "./internal/stream"; diff --git a/packages/app/src/keyboard/shift/internal/anchor.ts b/packages/app/src/keyboard/shift/internal/anchor.ts new file mode 100644 index 0000000000..ff34216682 --- /dev/null +++ b/packages/app/src/keyboard/shift/internal/anchor.ts @@ -0,0 +1,31 @@ +import { useContext, useMemo } from "react"; +import { useDerivedValue, type DerivedValue } from "react-native-reanimated"; +import { + KeyboardTranslationContext, + useKeyboardShift, + type KeyboardTranslationScope, +} from "./context"; +import { resolveKeyboardLift } from "./policy"; + +export interface KeyboardAnchor extends KeyboardTranslationScope { + /** The anchor's lift at keyboard boundaries. Bound overlay height with this, never per frame. */ + reservedLift: DerivedValue; +} + +/** + * Position portal content against an element inside a `KeyboardTranslateView`. + * Lay the content out from `measureAtRest` and pass `translation` to a portal'd + * `KeyboardTranslateView`: the content then rides the anchor's own animation + * driver, frame for frame. + */ +export function useKeyboardAnchor(): KeyboardAnchor { + const { translation, measureAtRest } = useContext(KeyboardTranslationContext); + const { layoutShift } = useKeyboardShift(); + const reservedLift = useDerivedValue(() => + resolveKeyboardLift({ shift: layoutShift.value, ...translation }), + ); + return useMemo( + () => ({ measureAtRest, translation, reservedLift }), + [measureAtRest, reservedLift, translation], + ); +} diff --git a/packages/app/src/keyboard/shift/internal/context.ts b/packages/app/src/keyboard/shift/internal/context.ts index dcb0b4cf64..f010c01255 100644 --- a/packages/app/src/keyboard/shift/internal/context.ts +++ b/packages/app/src/keyboard/shift/internal/context.ts @@ -1,5 +1,7 @@ import { createContext, useContext } from "react"; +import type { View } from "react-native"; import type { SharedValue } from "react-native-reanimated"; +import { measureInWindow } from "./measure"; export interface KeyboardShiftContextValue { shift: SharedValue; @@ -10,6 +12,35 @@ export interface KeyboardShiftContextValue { } export const KeyboardShiftContext = createContext(null); + +/** How the nearest KeyboardTranslateView moves its descendants with the keyboard. */ +export interface KeyboardTranslation { + enabled: boolean; + bottomClearance: number; +} + +export interface WindowRect { + x: number; + y: number; + width: number; + height: number; +} + +/** What the nearest KeyboardTranslateView tells the content it moves. */ +export interface KeyboardTranslationScope { + translation: KeyboardTranslation; + /** + * The element's window rect with the keyboard closed, whatever the keyboard is doing + * now; null once the element or the translate view has unmounted. + */ + measureAtRest: (element: View) => Promise; +} + +/** Views outside a KeyboardTranslateView stay put. */ +export const KeyboardTranslationContext = createContext({ + translation: { enabled: false, bottomClearance: 0 }, + measureAtRest: measureInWindow, +}); export const SettledKeyboardShiftContext = createContext(null); /** Read the app-wide keyboard inset without loading its native provider implementation. */ diff --git a/packages/app/src/keyboard/shift/internal/measure.ts b/packages/app/src/keyboard/shift/internal/measure.ts new file mode 100644 index 0000000000..95b07e4ae8 --- /dev/null +++ b/packages/app/src/keyboard/shift/internal/measure.ts @@ -0,0 +1,60 @@ +import { useMemo, type RefObject } from "react"; +import type { View } from "react-native"; +import type { KeyboardTranslation, KeyboardTranslationScope, WindowRect } from "./context"; + +export function measureInWindow(element: View): Promise { + return new Promise((resolve) => { + element.measureInWindow((x, y, width, height) => resolve({ x, y, width, height })); + }); +} + +/** + * The element's window rect with `frame`'s own transform left out. Whether + * `measureInWindow` includes a transform depends on the driver: Reanimated commits + * it to the shadow tree, RN's native driver commits it only when the view happens + * to re-render. Fabric's `measureLayout` and a view's `measure` origin ignore + * transforms. The frame's parent must not move with the keyboard; the composer dock + * is the only anchor host, and its parent is the stationary dock viewport. + */ +function measureInUntranslatedWindow(frame: View, element: View): Promise { + const parent = frame.parentElement; + if (!parent) return Promise.resolve(null); + const parentRect = parent.getBoundingClientRect(); + const frameOrigin = new Promise<{ x: number; y: number }>((resolve) => { + frame.measure((x, y) => resolve({ x, y })); + }); + const offset = new Promise((resolve) => { + element.measureLayout( + frame, + (x, y, width, height) => resolve({ x, y, width, height }), + () => resolve(null), + ); + }); + return Promise.all([frameOrigin, offset]).then(([origin, rect]) => + rect + ? { + x: parentRect.x + origin.x + rect.x, + y: parentRect.y + origin.y + rect.y, + width: rect.width, + height: rect.height, + } + : null, + ); +} + +/** What a KeyboardTranslateView publishes to the content it moves. */ +export function useKeyboardTranslationScope( + frameRef: RefObject, + translation: KeyboardTranslation, +): KeyboardTranslationScope { + return useMemo( + () => ({ + translation, + measureAtRest: (element: View) => + frameRef.current + ? measureInUntranslatedWindow(frameRef.current, element) + : Promise.resolve(null), + }), + [frameRef, translation], + ); +} diff --git a/packages/app/src/keyboard/shift/internal/policy.test.ts b/packages/app/src/keyboard/shift/internal/policy.test.ts index 7c4902afe1..6867f3ffa1 100644 --- a/packages/app/src/keyboard/shift/internal/policy.test.ts +++ b/packages/app/src/keyboard/shift/internal/policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, + resolveKeyboardLift, resolveKeyboardShift, reserveKeyboardLayoutShift, } from "./policy"; @@ -89,6 +90,21 @@ describe("resolveKeyboardShift", () => { }); }); +describe("resolveKeyboardLift", () => { + it("lifts a docked view by the whole shift", () => { + expect(resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0 })).toBe(302); + }); + + it("lets the keyboard fill a centered form's clearance before lifting it", () => { + expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267 })).toBe(136); + expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455 })).toBe(0); + }); + + it("keeps a disabled view in place", () => { + expect(resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0 })).toBe(0); + }); +}); + describe("shouldReconcileHiddenKeyboardEnd", () => { it("closes stale iOS keyboard state without letting a late visible end resurrect it", () => { expect( diff --git a/packages/app/src/keyboard/shift/internal/policy.ts b/packages/app/src/keyboard/shift/internal/policy.ts index fe496fd110..4e618c8a49 100644 --- a/packages/app/src/keyboard/shift/internal/policy.ts +++ b/packages/app/src/keyboard/shift/internal/policy.ts @@ -52,6 +52,16 @@ export function resolveKeyboardShift(input: { return Math.max(0, input.rawKeyboardHeight - input.bottomInset); } +/** Distance a KeyboardTranslateView lifts its content for a given keyboard shift. */ +export function resolveKeyboardLift(input: { + shift: number; + enabled: boolean; + bottomClearance: number; +}): number { + "worklet"; + return input.enabled ? Math.max(0, input.shift - input.bottomClearance) : 0; +} + export function shouldReconcileHiddenKeyboardEnd(input: { height: number; progress: number; diff --git a/packages/app/src/keyboard/shift/internal/translate.android.tsx b/packages/app/src/keyboard/shift/internal/translate.android.tsx index 2a9e9d581d..cec2f0fcfd 100644 --- a/packages/app/src/keyboard/shift/internal/translate.android.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.android.tsx @@ -1,7 +1,9 @@ -import type { ReactNode } from "react"; -import type { ViewProps } from "react-native"; +import { useMemo, useRef, type ReactNode } from "react"; +import type { View, ViewProps } from "react-native"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { useKeyboardShift } from "./context"; +import { KeyboardTranslationContext, useKeyboardShift } from "./context"; +import { useKeyboardTranslationScope } from "./measure"; +import { resolveKeyboardLift } from "./policy"; interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; @@ -18,13 +20,18 @@ export function KeyboardTranslateView({ ...props }: KeyboardTranslateViewProps) { const { shift } = useKeyboardShift(); + const frameRef = useRef(null); + const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]); + const scope = useKeyboardTranslationScope(frameRef, translation); const keyboardStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }], + transform: [{ translateY: -resolveKeyboardLift({ shift: shift.value, ...translation }) }], })); return ( - - {children} + + + {children} + ); } diff --git a/packages/app/src/keyboard/shift/internal/translate.ios.tsx b/packages/app/src/keyboard/shift/internal/translate.ios.tsx index 1fea4fab0c..8628ba8b8c 100644 --- a/packages/app/src/keyboard/shift/internal/translate.ios.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.ios.tsx @@ -1,7 +1,9 @@ -import { useMemo, useEffect, type ReactNode } from "react"; -import { Animated, useAnimatedValue, type ViewProps } from "react-native"; +import { useMemo, useEffect, useRef, type ReactNode } from "react"; +import { Animated, useAnimatedValue, type View, type ViewProps } from "react-native"; import { useKeyboardAnimation } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { KeyboardTranslationContext } from "./context"; +import { useKeyboardTranslationScope } from "./measure"; interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; @@ -35,7 +37,10 @@ export function KeyboardTranslateView({ // Fullscreen resizes this view while disabling translation. iOS Fabric can // reapply its previous transform during that layout commit. Keep the graph // attached and apply the mode on the native animation frame; a static zero or - // setValue during the React commit gets overwritten by the resize. + // setValue during the React commit gets overwritten by the resize. A rebuilt + // graph (a new bottomClearance after rotation) starts from JS-side keyboard + // values, so run it on the native frame too or it stays stale until the + // keyboard next moves. const enabledValue = useAnimatedValue(enabled ? 1 : 0); useEffect(() => { const animation = Animated.timing(enabledValue, { @@ -45,15 +50,21 @@ export function KeyboardTranslateView({ }); animation.start(); return () => animation.stop(); - }, [enabled, enabledValue]); + }, [enabled, enabledValue, translateY]); const keyboardStyle = useMemo( () => ({ transform: [{ translateY: Animated.multiply(translateY, enabledValue) }] }), [enabledValue, translateY], ); + const frameRef = useRef(null); + const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]); + const scope = useKeyboardTranslationScope(frameRef, translation); + return ( - - {children} + + + {children} + ); } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 0599281f15..2d066efdc9 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1375,17 +1375,17 @@ describe("terminal MCP tools", () => { output += message.data; } }); + // Every key travels through the PTY and back; on loaded Windows runners one round + // trip can take seconds, so each wait gets the same budget as the first. + const roundTrip = { timeout: 5000 }; try { - await vi.waitFor( - async () => { - const capture = await terminalManager.captureTerminal(terminal.id); - expect(capture.lines).toContain("READY"); - }, - { timeout: 5000 }, - ); + await vi.waitFor(async () => { + const capture = await terminalManager.captureTerminal(terminal.id); + expect(capture.lines).toContain("READY"); + }, roundTrip); const tool = registeredTool(server, "send_terminal_keys"); await tool.handler({ terminalId: terminal.id, keys: "application", literal: true }); - await vi.waitFor(() => expect(output).toContain("APPLICATION_READY")); + await vi.waitFor(() => expect(output).toContain("APPLICATION_READY"), roundTrip); for (const [keys, hex] of [ ["Down", "1b4f42"], ["Up", "1b4f41"], @@ -1400,15 +1400,15 @@ describe("terminal MCP tools", () => { await vi.waitFor(async () => { const capture = await terminalManager.captureTerminal(terminal.id); expect(capture.lines.join("\n")).toContain(`HEX:${hex}`); - }); + }, roundTrip); } await tool.handler({ terminalId: terminal.id, keys: "normal", literal: true }); - await vi.waitFor(() => expect(output).toContain("NORMAL_READY")); + await vi.waitFor(() => expect(output).toContain("NORMAL_READY"), roundTrip); await tool.handler({ terminalId: terminal.id, keys: "Down" }); await vi.waitFor(async () => { const capture = await terminalManager.captureTerminal(terminal.id); expect(capture.lines).toContain("HEX:1b5b42"); - }); + }, roundTrip); } finally { unsubscribe(); await server.close(); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index c9c0f07161..da10deb869 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -8,6 +8,7 @@ import { AgentManager } from "../agent-manager.js"; import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js"; import { MOCK_LOAD_TEST_DEFAULT_MODEL_ID, + MOCK_LOAD_TEST_HANDLED_COMMAND, MockLoadTestAgentClient, } from "./mock-load-test-agent.js"; @@ -49,6 +50,18 @@ describe("MockLoadTestAgentClient", () => { }); }); + test("lists the command it handles without starting a turn", async () => { + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ provider: "mock", cwd: process.cwd() }); + + const commands = (await session.listCommands?.()) ?? []; + + expect(commands.map((command) => `/${command.name} ${command.argumentHint}`)).toEqual([ + MOCK_LOAD_TEST_HANDLED_COMMAND, + ]); + expect(session.tryHandleOutOfBand?.(MOCK_LOAD_TEST_HANDLED_COMMAND)).not.toBeNull(); + }); + test("rejects the configured number of prompts before starting a retry", async () => { const client = new MockLoadTestAgentClient(); const session = await client.createSession({ diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index ce9714c5f2..b7b301540a 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -18,6 +18,7 @@ import type { AgentRuntimeInfo, AgentSession, AgentSessionConfig, + AgentSlashCommand, AgentStreamEvent, AgentTimelineItem, FetchCatalogOptions, @@ -35,7 +36,13 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest export const MOCK_LOAD_TEST_PROVIDER_ID = "mock"; export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream"; -export const MOCK_LOAD_TEST_HANDLED_COMMAND = "/mock handled-command"; +const MOCK_LOAD_TEST_COMMAND: AgentSlashCommand = { + name: "mock", + description: "Reply without starting a turn", + argumentHint: "handled-command", + kind: "command", +}; +export const MOCK_LOAD_TEST_HANDLED_COMMAND = `/${MOCK_LOAD_TEST_COMMAND.name} ${MOCK_LOAD_TEST_COMMAND.argumentHint}`; const MOCK_LOAD_TEST_MODE_ID = "load-test"; const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000; const MOCK_LOAD_TEST_INTERVAL_MS = 40; @@ -954,6 +961,10 @@ export class MockLoadTestAgentSession implements AgentSession { return { turnId }; } + async listCommands(): Promise { + return [MOCK_LOAD_TEST_COMMAND]; + } + tryHandleOutOfBand( prompt: AgentPromptInput, ): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise } | null { From 89727f43bc47caecc893ac3ad3326039915e15f0 Mon Sep 17 00:00:00 2001 From: Nicholas Maglowsch Salgueiro Date: Fri, 23 Oct 2026 03:15:14 -0300 Subject: [PATCH 38/62] fix(server): require the daemon password on relay connections (#7668) * fix(server): require the daemon password on relay connections A password-protected daemon still admitted relay clients that sent no credential in their encrypted hello, so a leaked pairing link alone gave full control. Remove the COMPAT(relayPasswordOptional) shim: relay clients now need the password like direct ones. Fixes #4087 * test(server): cover relay password admission end to end Run a password-protected daemon behind a local relay and connect with the real host client over relay://: no password and a wrong password are refused, the right password is admitted and serves RPCs. * test(server): run relay password regression in CI * test(ci): include relay password regression in command expectation --------- Co-authored-by: Nicholas Maglowsch Salgueiro <21068323+nickmaglowsch@users.noreply.github.com> Co-authored-by: Mohamed Boudra --- SECURITY.md | 2 +- packages/server/package.json | 2 +- packages/server/src/server/auth.test.ts | 12 ---- .../daemon-e2e/relay-transport.e2e.test.ts | 55 ++++++++++++++++++- .../src/server/session-admission-auth.ts | 7 +-- .../websocket-server.relay-reconnect.test.ts | 5 +- .../server/src/server/websocket-server.ts | 1 - public-docs/security.md | 4 +- public-docs/security.zh-CN.md | 4 +- scripts/ci-workflow.test.mjs | 2 +- 10 files changed, 65 insertions(+), 29 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 8fbd768c46..e17118459e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,7 +42,7 @@ The daemon requires a valid cryptographic handshake before processing any comman The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly. -When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available. +When a daemon password is configured, relay clients send it in the encrypted `hello` message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon. ## Local daemon trust boundary diff --git a/packages/server/package.json b/packages/server/package.json index 2f06f218cd..046e979c86 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/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\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/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\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/auth.test.ts b/packages/server/src/server/auth.test.ts index 0adf442fb6..6e99bd3860 100644 --- a/packages/server/src/server/auth.test.ts +++ b/packages/server/src/server/auth.test.ts @@ -134,7 +134,6 @@ describe("hello admission", () => { credential: { kind: "localCredential", token: "stale" }, passwordHash: undefined, localCredential: "current", - transport: "direct", }), ).toMatchObject({ admission: { principalId: "owner" } }); }); @@ -146,7 +145,6 @@ describe("hello admission", () => { const input = { passwordHash: CORRECT_PASSWORD_HASH, localCredential: second, - transport: "direct" as const, }; expect( await resolveSessionAdmission({ @@ -172,19 +170,9 @@ describe("hello admission", () => { credential: { kind: "password", password: "wrong" }, }), ).toEqual({ rejection: "incorrect_password" }); - expect( - await resolveSessionAdmission({ - ...input, - transport: "relay", - credential: { kind: "password", password: "wrong" }, - }), - ).toEqual({ rejection: "incorrect_password" }); expect(await resolveSessionAdmission({ ...input, credential: undefined })).toEqual({ rejection: "password_required", }); - expect( - await resolveSessionAdmission({ ...input, transport: "relay", credential: undefined }), - ).toMatchObject({ admission: { principalId: "owner" } }); } finally { await deleteLocalCredential(home); await rm(home, { recursive: true, force: true }); diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index dd0ac2fbdb..32d3a1f36c 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -7,7 +7,10 @@ import { encodeTerminalStreamFrame, TerminalStreamOpcode, } from "@getpaseo/protocol/binary-frames/index"; -import type { DaemonClient as HostClient } from "@getpaseo/client/internal/daemon-client"; +import { + getDaemonAuthFailureReason, + type DaemonClient as HostClient, +} from "@getpaseo/client/internal/daemon-client"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, test } from "vitest"; import { withTimeout } from "../../utils/promise-timeout.js"; @@ -540,6 +543,56 @@ async function waitForCapturedLog( 120000, ); + test("password-protected daemon refuses a relay client without the password", async () => { + let daemon: Awaited> | undefined; + const clients: HostClient[] = []; + try { + await startRelay({ useLocalRelay: true }); + daemon = await createTestPaseoDaemon({ + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + relayUseTls: false, + relayPublicUseTls: false, + auth: { password: hashSync("relay-password", 4) }, + }); + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: true, + relayEndpoint: daemon.config.relayEndpoint, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + const connectOverRelay = (password?: string): Promise => { + const connection = new URL(`relay://127.0.0.1:${relayPort}/${serverId}`); + connection.searchParams.set("key", daemonPublicKeyB64); + if (password) connection.searchParams.set("password", password); + const client = createHostClient(connection.toString(), { + clientId: crypto.randomUUID(), + reconnect: { enabled: false }, + }); + clients.push(client); + return client.connect(); + }; + + // The pairing offer alone must not grant access to a password-protected daemon. + const rejection = await connectOverRelay().then( + () => null, + (error: unknown) => error, + ); + expect(getDaemonAuthFailureReason(rejection)).toBe("password_required"); + await expect(connectOverRelay("wrong-password")).rejects.toThrow("Incorrect password"); + + await connectOverRelay("relay-password"); + expect((await createPaseoApi(clients.at(-1)!).workspaces.list()).entries).toEqual([]); + } finally { + try { + await Promise.all(clients.map((client) => client.close())); + await daemon?.close(); + } finally { + await stopRelay(); + } + } + }, 90000); + test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => { await startRelay({ useLocalRelay: true }); const daemon = await createTestPaseoDaemon({ diff --git a/packages/server/src/server/session-admission-auth.ts b/packages/server/src/server/session-admission-auth.ts index b4195b8d3f..8c523c44a6 100644 --- a/packages/server/src/server/session-admission-auth.ts +++ b/packages/server/src/server/session-admission-auth.ts @@ -12,17 +12,12 @@ export async function resolveSessionAdmission(input: { credential: WSHelloMessage["auth"]; passwordHash: string | undefined; localCredential: string | null; - transport: "direct" | "relay"; }): Promise { - const { credential, passwordHash, localCredential, transport } = input; + const { credential, passwordHash, localCredential } = input; if (!passwordHash) { return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } }; } if (!credential) { - // COMPAT(relayPasswordOptional): added in v0.9.1, remove once release N mobile builds are live on App Store and Play. - if (transport === "relay") { - return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } }; - } return { rejection: "password_required" }; } if (credential.kind === "localCredential") { diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 859b5007fb..2d48a93c87 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -541,7 +541,7 @@ describe("relay external socket reconnect behavior", () => { await server.close(); } }); - test("admits a hello password and an old relay hello, but rejects a wrong password", async () => { + test("admits a relay hello password, but rejects a wrong or missing password", async () => { const server = createServer({ auth: { password: "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW", @@ -563,7 +563,8 @@ describe("relay external socket reconnect behavior", () => { const legacySocket = new MockSocket(); await server.attachExternalSocket(legacySocket, { transport: "relay" }); legacySocket.emit("message", JSON.stringify(createHelloMessage("relay-legacy"))); - await vi.waitFor(() => expect(sentServerInfoEnvelopes(legacySocket)).toHaveLength(1)); + await vi.waitFor(() => expect(legacySocket.readyState).toBe(3)); + expect(sentServerInfoEnvelopes(legacySocket)).toHaveLength(0); const wrongSocket = new MockSocket(); await server.attachExternalSocket(wrongSocket, { transport: "relay" }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index adbc0bd38f..88fb2c327d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1816,7 +1816,6 @@ export class VoiceAssistantWebSocketServer { credential: message.auth, passwordHash: this.passwordHash, localCredential: this.credentialSource?.localCredential?.() ?? null, - transport: pending.identity.transport === "relay" ? "relay" : "direct", }); if (this.pendingConnections.get(ws) !== pending) return false; if ("rejection" in resolved) { diff --git a/public-docs/security.md b/public-docs/security.md index d1b6ee229a..6354c55db3 100644 --- a/public-docs/security.md +++ b/public-docs/security.md @@ -86,7 +86,7 @@ Configure via `daemon.hostnames` in `config.json`: By default, anyone who can reach the daemon's listening address can connect. On localhost this is fine, only local processes have access. But if you bind to a network interface (e.g. your LAN IP or `0.0.0.0`), or if you don't fully trust your local network, you can require a password. -When a password is configured, all HTTP requests must include an `Authorization: Bearer ` header and all WebSocket connections must authenticate via subprotocol. Unauthenticated requests receive a `401 Unauthorized` response. Only the `/api/health` liveness endpoint is exempt, so that process supervisors and load balancers can probe without credentials. +When a password is configured, all HTTP requests must include an `Authorization: Bearer ` header, and every WebSocket client, including relay clients, must send the password when it connects. Relay clients send it inside the end-to-end encrypted channel, so the relay never sees it. Unauthenticated HTTP requests receive a `401 Unauthorized` response. Only the `/api/health` liveness endpoint is exempt, so that process supervisors and load balancers can probe without credentials. If you enable the [bundled web UI](/docs/web-ui), its static files are also served without the password so the login screen can render. This is by design, the API and WebSocket still require authentication before any agent data is returned or any command runs. Set a password before binding the daemon to a network so the data behind the page stays protected. @@ -152,7 +152,7 @@ Hub CLI credentials are bearer secrets. Remote Hub origins must use HTTPS; clear ## Recommendations - **Use the relay** for mobile access, it's the simplest option and all traffic is end-to-end encrypted -- **Treat the QR code like a password**, anyone with the pairing offer can connect to your daemon +- **Treat the QR code like a password**, anyone with the pairing offer can connect to your daemon unless you also set a password - **Set a password** if you bind to a network address, it prevents unauthorized clients from controlling your agents - **Never bind to 0.0.0.0 without a password**, without one, any device on your network can connect - **Scope Docker mounts tightly**, agents can access mounted workspaces and provider credentials diff --git a/public-docs/security.zh-CN.md b/public-docs/security.zh-CN.md index 4333193bf7..41ae74e07e 100644 --- a/public-docs/security.zh-CN.md +++ b/public-docs/security.zh-CN.md @@ -83,7 +83,7 @@ Paseo 使用主机白名单校验传入请求的 `Host` 请求头。主机无法 默认情况下,任何能访问 daemon 监听地址的人都可以连接。在 localhost 上这没有问题,只有本地进程能访问。但如果你绑定到某个网络接口(例如你的局域网 IP 或 `0.0.0.0`),或者你不完全信任本地网络,可以要求提供密码。 -配置密码后,所有 HTTP 请求都必须包含 `Authorization: Bearer ` 请求头,所有 WebSocket 连接都必须通过子协议进行验证。未经验证的请求会收到 `401 Unauthorized` 响应。只有 `/api/health` 存活检查端点例外,以便进程管理器和负载均衡器无需凭据即可探测。 +配置密码后,所有 HTTP 请求都必须包含 `Authorization: Bearer ` 请求头,所有 WebSocket 客户端(包括中继客户端)都必须在连接时发送密码。中继客户端在端到端加密通道内发送密码,中继无法看到它。未经验证的 HTTP 请求会收到 `401 Unauthorized` 响应。只有 `/api/health` 存活检查端点例外,以便进程管理器和负载均衡器无需凭据即可探测。 如果你启用了[内置的 Web UI](/docs/web-ui),它的静态文件同样无需密码即可提供,以便显示登录界面。这是有意设计的:API 和 WebSocket 在返回任何 Agent 数据或执行任何命令之前仍然需要验证。在把 daemon 绑定到网络之前请设置密码,让页面背后的数据受到保护。 @@ -149,7 +149,7 @@ Hub CLI 凭据是 bearer 密钥。远程 Hub 地址必须使用 HTTPS;只有 ## 建议 {#recommendations} - **使用中继**进行移动端访问,这是最简单的选择,且所有流量都经过端到端加密 -- **像对待密码一样对待二维码**,任何拿到配对信息的人都可以连接你的 daemon +- **像对待密码一样对待二维码**,除非你同时设置了密码,否则任何拿到配对信息的人都可以连接你的 daemon - **设置密码**,如果你绑定到网络地址,它可以阻止未经授权的客户端控制你的 Agent - **不要在没有密码的情况下绑定到 0.0.0.0**,否则你网络中的任何设备都可以连接 - **严格限定 Docker 挂载范围**,Agent 可以访问挂载的工作区和 Provider 凭据 diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 7401206108..c16e772172 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -487,7 +487,7 @@ test("Windows units and integration run independently with complete routed cover assert.doesNotMatch(invocations[0], / -t /); assert.equal( invocations[1], - 'vitest run --maxWorkers=1 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"', + 'vitest run --maxWorkers=1 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"', ); const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8"); assert.doesNotMatch(serverConfig, /maxWorkers/); From 21e7ccb036dce72c313114121e3ad82fdd917b15 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 13:21:29 +0700 Subject: [PATCH 39/62] Restore agent finish notifications after daemon restart (#7683) * fix(server): restore agent finish notifications after daemon restart Finish observers lived only in memory, so a restarted daemon silently lost the wakeup parents relied on. Persist subscription intent through notification dispatch acknowledgment and reconcile local and remote children after bootstrap. * fix(server): reject finish subscriptions for unsent remote prompts Connection and snapshot preflight failures happen before remote prompt transmission. Reject those candidates while retaining ambiguous sends, and preserve the host identity in callbacks. Exercise local permission cycles and storage errors through MCP. * fix(server): notify parents when remote finish observation is exhausted Three failed observations or a removed registration consume the watch after the parent accepts dispatch. Report unknown child status, retain the captured outcome through delivery failures, and keep connecting attempts outside the failure budget. * fix(server): describe exhausted child observation accurately Remote RPC failures do not prove the host is offline. Report the failed observation and unknown child status, and give Windows integration polling more margin. --- docs/agent-lifecycle.md | 25 +- docs/connected-hosts.md | 23 +- .../src/server/agent/agent-manager.test.ts | 14 + .../server/src/server/agent/agent-manager.ts | 6 +- .../src/server/agent/agent-prompt.test.ts | 554 +----------------- .../server/src/server/agent/agent-prompt.ts | 426 +------------- .../src/server/agent/create-agent/create.ts | 46 +- .../finish-subscriptions.test.ts | 414 +++++++++++++ .../agent/finish-subscriptions/index.ts | 363 ++++++++++++ .../internal/json-store.test.ts | 51 ++ .../internal/json-store.ts | 58 ++ .../internal/notification.ts | 70 +++ .../internal/observation-buffer.ts | 83 +++ .../internal/observation-stream.test.ts | 217 +++++++ .../internal/observation-stream.ts | 121 ++++ .../internal/observation.ts | 103 ++++ .../src/server/agent/host-operations/index.ts | 285 ++++++--- .../src/server/agent/mcp-server.test.ts | 212 ++++++- .../src/server/agent/tools/paseo-tools.ts | 226 ++++--- packages/server/src/server/bootstrap.ts | 36 ++ .../server/connected-hosts/hosts.e2e.test.ts | 317 ++++++++++ .../daemon-restart-resume.e2e.test.ts | 156 +++++ packages/server/src/server/session.ts | 2 +- .../src/server/session.workspaces.test.ts | 1 + .../test-utils/finish-subscription-daemon.ts | 40 ++ 25 files changed, 2617 insertions(+), 1232 deletions(-) create mode 100644 packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts create mode 100644 packages/server/src/server/agent/finish-subscriptions/index.ts create mode 100644 packages/server/src/server/agent/finish-subscriptions/internal/json-store.test.ts create mode 100644 packages/server/src/server/agent/finish-subscriptions/internal/json-store.ts create mode 100644 packages/server/src/server/agent/finish-subscriptions/internal/notification.ts create mode 100644 packages/server/src/server/agent/finish-subscriptions/internal/observation-buffer.ts create mode 100644 packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.test.ts create mode 100644 packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.ts create mode 100644 packages/server/src/server/agent/finish-subscriptions/internal/observation.ts create mode 100644 packages/server/src/server/test-utils/finish-subscription-daemon.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index ffcec92ddd..535a6dd712 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -79,7 +79,30 @@ Users can also detach an existing subagent from the subagents track. Detach is d Agent-originated messages and automatic notifications share a recipient-daemon delivery module. A queued receipt acknowledges admission before provider dispatch, allowing a child to report and finish in the same two-second quiet window. Human prompts dispatch directly. Conflicting explicit interruption or session-mode choices retain separate ordered dispatches. -The pending window is in memory. Clean shutdown flushes it before closing agents; an abrupt crash can lose it. A durable send receipt remains pending until actual dispatch acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued admission within their original timeout and cancellation scope. +The pending message window is in memory. Clean shutdown flushes it before closing agents; an abrupt +crash can lose queued prompts. A durable send receipt remains pending until actual dispatch +acknowledgment, so a lost window never becomes a completed receipt. Completion waits cover queued +admission within their original timeout and cancellation scope. + +Finish notification intent has a separate daemon-owned lifetime. The finish-subscriptions module +persists caller, child, host, and ownership policy through an injectable store; its JSON adapter uses +`$PASEO_HOME/finish-subscriptions.json`. Registration must commit before creation or queued-prompt +acknowledgment promises a notification. A pending replacement retains its predecessor until dispatch +accepts; rejected dispatch removes only the candidate. Recovery coalesces registrations for the same +caller, child, and host into one watch. + +Bootstrap restores these watches after repairing stale running records to `closed` and before opening +ingress. Reading a closed child's result does not load its provider. A finish notification reports that +the child's run has settled; the parent decides whether its task is complete. Recovery never resumes +child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop +reporting after detachment. + +Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure +retries the captured notification; it does not change the child's reported outcome. Remote host loss +follows the [connected-host observation policy](connected-hosts.md#delegating-agents). Shutdown stops observation before flushing message delivery, retaining +outstanding intent. A crash after successful dispatch but before removal commits can repeat the +notification. Clean shutdown can also flush an already queued callback while retaining its intent, +so that callback can repeat after restart. This is an at-least-once wakeup, without turn attribution or exactly-once delivery. Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md index 1844456007..20c86c1295 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -44,10 +44,25 @@ connection candidates, including direct connections and routes discovered throug Adding a direct connection to a discovered host adds another candidate. If no candidate connects, the destination workspace keeps its existing disconnected or reconnecting behavior. -Finish notifications use the existing idle wait, including a pending initial run or an already idle -child. They name the server and agent. A daemon restart or route loss ends an outstanding notification -wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain -no additional turn attribution or delivery guarantee. +Finish notifications name the destination server and child agent. The caller's daemon owns durable +subscription intent and restores observation through the same routed wait and permission observation +used during ordinary operation. Restarting the caller's daemon does not imply that a remote child +stopped. Three failed remote observation attempts end the subscription with a notification that the +child could not be observed and its status is unknown. This does not prove the host is offline: remote +RPC failures also prevent observation. Attempts use capped backoff; a connection still +in progress does not spend that budget. A confirmed removed registration reports host unavailability +immediately through the same path. Neither notice claims the child finished. Intent remains stored +until the parent accepts dispatch, then is consumed; reconnecting does not restore that watch. The +parent decides whether to inspect the child or delegate again. + +A definitely unsent prompt returns a tool error and creates no notification. An ambiguous prompt +response retains intent because the destination may have admitted it; observation then follows the +same retry policy. A confirmed missing child reports unavailability. + +Remote creation and local subscription persistence are separate commits. A failed local write reports +failure even if the destination already created the child; inspect it before retrying creation. +See [finish notification lifetime](agent-lifecycle.md#relationships) for registration, acknowledgment, +and duplicate-delivery semantics. Concurrent prompts gain no turn attribution. ## Connections and recovery diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ef5b361962..c01d477004 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -12069,3 +12069,17 @@ test.each(["live", "history", "accepted"] as const)( } }, ); + +test("reads the last durable assistant response without loading a stored-only child", async () => { + const store = new RecordingTimelineStore(); + await store.appendCommitted("stored-child", { + type: "assistant_message", + text: "Partial work saved before restart", + }); + const manager = new AgentManager({ clients: {}, durableTimelineStore: store, logger }); + expect(manager.getAgent("stored-child")).toBeNull(); + expect(await manager.getLastAssistantMessage("stored-child")).toBe( + "Partial work saved before restart", + ); + expect(manager.getAgent("stored-child")).toBeNull(); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index de7fe1d986..b39fe782b8 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3322,11 +3322,9 @@ export class AgentManager { } async getLastAssistantMessage(agentId: string): Promise { - const agent = this.agents.get(agentId); - if (!agent) { - return null; + if (!this.timelineStore.has(agentId)) { + return (await this.durableTimelineStore?.getLastAssistantMessage(agentId)) ?? null; } - return await this.getLastAssistantMessageFromStores(agentId); } diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 6ec66d10e0..00e70756de 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -1,15 +1,12 @@ -import { createTestAgentMessages } from "../test-utils/agent-messages.js"; import { formatSystemNotificationPrompt, isSystemInjectedEnvelope, parseAgentMessage, - parseAgentMessages, formatAgentMessage, prepareAgentMessage, projectAgentMessages, } from "./agent-messages/index.js"; -import { expect, it, test, vi } from "vitest"; -import pino, { type Logger } from "pino"; +import { expect, test, vi } from "vitest"; import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -17,9 +14,7 @@ import { join } from "node:path"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; -import { AgentStorage } from "./agent-storage.js"; -import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js"; -import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; +import { waitForAgentRunStartWithTimeout } from "./agent-prompt.js"; import type { AgentClient, AgentRunResult, @@ -27,555 +22,12 @@ import type { AgentStreamEvent, } from "./agent-sdk-types.js"; -interface CapturedLogger { - logger: Logger; - records: Array>; - nextRecord: Promise; -} - -function createCapturedLogger(): CapturedLogger { - const records: Array> = []; - let resolveNextRecord!: () => void; - const nextRecord = new Promise((resolve) => { - resolveNextRecord = resolve; - }); - const logger = pino( - { level: "error" }, - { - write(line: string) { - records.push(JSON.parse(line) as Record); - resolveNextRecord(); - }, - }, - ); - return { logger, records, nextRecord }; -} - -interface FinishNotificationScenarioOptions { - childLastAssistantMessage?: string | null; - childParentAgentId?: string | null; - requireParentOwnership?: boolean; - parentPromptError?: Error; - logger?: Logger; -} - -interface FinishNotificationScenario { - startWatchingChild(): void; - requestChildPermission(requestId?: string): void; - resolveChildPermission(requestId?: string): void; - resolveChildPermissionFromState(requestId?: string): void; - resolveChildPermissionWhileIdle(requestId?: string): void; - finishChild(): void; - finishChildAndReadParentPrompt(): Promise; - closeChildAndReadParentPrompt(): Promise; - parentPrompts(): string[]; - steerAttemptCount(): number; - wasParentPrompted(): boolean; -} - -function createFinishNotificationScenario( - options?: FinishNotificationScenarioOptions, -): FinishNotificationScenario { - let subscriber: ((event: AgentManagerEvent) => void) | null = null; - let resolveParentPrompt: ((prompt: string) => void) | null = null; - let parentPrompted = false; - let steerAttemptCount = 0; - const parentPrompts: string[] = []; - - const childAgent: ManagedAgent = Object.create(null); - Reflect.set(childAgent, "id", "child-agent"); - Reflect.set(childAgent, "lifecycle", "idle"); - Reflect.set(childAgent, "config", { title: "Child Agent" }); - Reflect.set(childAgent, "pendingPermissions", new Map()); - - const callerAgent: ManagedAgent = Object.create(null); - Reflect.set(callerAgent, "id", "caller-agent"); - Reflect.set(callerAgent, "lifecycle", "idle"); - Reflect.set(callerAgent, "config", { title: "Caller Agent" }); - - const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() }); - Reflect.set(agentManager, "getAgent", (agentId: string) => { - if (agentId === "child-agent") { - return childAgent; - } - if (agentId === "caller-agent") { - return callerAgent; - } - return null; - }); - Reflect.set(agentManager, "subscribe", (callback: (event: AgentManagerEvent) => void) => { - subscriber = callback; - return () => { - subscriber = null; - }; - }); - Reflect.set(agentManager, "getLastAssistantMessage", async () => { - return options?.childLastAssistantMessage ?? null; - }); - Reflect.set(agentManager, "tryRunOutOfBand", () => false); - Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError)); - Reflect.set(agentManager, "steerOrReplaceActiveTurn", async () => { - steerAttemptCount += 1; - return { status: "inactive" }; - }); - Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => { - parentPrompted = true; - parentPrompts.push(prompt); - resolveParentPrompt?.(prompt); - return (async function* noop() {})(); - }); - Reflect.set(agentManager, "replaceAgentRun", async (_agentId: string, prompt: string) => { - resolveParentPrompt?.(prompt); - throw options?.parentPromptError; - }); - - const agentStorage: AgentStorage = Object.create(AgentStorage.prototype); - Reflect.set(agentStorage, "get", async (agentId: string) => { - if (agentId === "child-agent") { - const parentAgentId = - options?.childParentAgentId === undefined ? "caller-agent" : options.childParentAgentId; - return { - title: "Child Agent", - labels: parentAgentId ? { "paseo.parent-agent-id": parentAgentId } : {}, - }; - } - return null; - }); - - return { - startWatchingChild() { - setupFinishNotification({ - agentMessages: createTestAgentMessages({ - agentManager, - agentStorage, - logger: options?.logger ?? createTestLogger(), - }), - agentManager, - agentStorage, - childAgentId: "child-agent", - callerAgentId: "caller-agent", - requireParentOwnership: options?.requireParentOwnership, - logger: options?.logger ?? createTestLogger(), - }); - }, - requestChildPermission(requestId = "permission-1") { - childAgent.lifecycle = "running"; - childAgent.pendingPermissions.set(requestId, { - id: requestId, - provider: "claude", - kind: "tool", - name: "Run command", - description: "Write the QA sentinel", - input: { - file_path: "/tmp/permission-qa.txt", - content: "PASEO_PERMISSION_NOTIFY_QA_OK\n", - }, - }); - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - subscriber?.({ - type: "agent_stream", - agentId: "child-agent", - event: { - type: "permission_requested", - provider: "codex", - request: childAgent.pendingPermissions.get(requestId)!, - }, - }); - }, - resolveChildPermission(requestId = "permission-1") { - childAgent.pendingPermissions.delete(requestId); - subscriber?.({ - type: "agent_stream", - agentId: "child-agent", - event: { - type: "permission_resolved", - provider: "codex", - requestId, - resolution: { behavior: "allow" }, - }, - }); - }, - resolveChildPermissionFromState(requestId = "permission-1") { - childAgent.pendingPermissions.delete(requestId); - subscriber?.({ type: "agent_state", agent: childAgent }); - }, - resolveChildPermissionWhileIdle(requestId = "permission-1") { - childAgent.pendingPermissions.delete(requestId); - childAgent.lifecycle = "idle"; - subscriber?.({ type: "agent_state", agent: childAgent }); - subscriber?.({ - type: "agent_stream", - agentId: "child-agent", - event: { - type: "permission_resolved", - provider: "codex", - requestId, - resolution: { behavior: "allow" }, - }, - }); - }, - finishChild() { - childAgent.lifecycle = "running"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - - childAgent.lifecycle = "idle"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - subscriber?.({ - type: "agent_stream", - agentId: "child-agent", - event: { type: "turn_completed", provider: "codex" }, - }); - }, - async finishChildAndReadParentPrompt() { - const parentPrompt = new Promise((resolve) => { - resolveParentPrompt = resolve; - }); - this.finishChild(); - - return parentPrompt; - }, - async closeChildAndReadParentPrompt() { - const parentPrompt = new Promise((resolve) => { - resolveParentPrompt = resolve; - }); - - childAgent.lifecycle = "running"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - - childAgent.lifecycle = "closed"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - - return parentPrompt; - }, - parentPrompts() { - return parentPrompts; - }, - steerAttemptCount() { - return steerAttemptCount; - }, - wasParentPrompted() { - return parentPrompted; - }, - }; -} - test("isSystemInjectedEnvelope matches the envelope formatSystemNotificationPrompt produces", () => { expect(isSystemInjectedEnvelope(formatSystemNotificationPrompt("child finished"))).toBe(true); expect(isSystemInjectedEnvelope("hello world")).toBe(false); }); -test("finish notifications tell the parent the child's last assistant message", async () => { - const scenario = createFinishNotificationScenario({ - childLastAssistantMessage: "Implemented the cleanup and all checks pass.", - }); - - scenario.startWatchingChild(); - const parentPrompt = await scenario.finishChildAndReadParentPrompt(); - - expect(parseAgentMessage(parentPrompt)).toEqual({ - id: expect.any(String), - source: { - kind: "agent-notification", - agentId: "child-agent", - title: "Child Agent", - event: "finished", - }, - text: "Agent child-agent (Child Agent) finished.\n\n\nImplemented the cleanup and all checks pass.\n", - }); - expect(scenario.steerAttemptCount()).toBe(1); -}); - -test("finish notifications truncate oversized child responses", async () => { - const included = "x".repeat(4000); - const omitted = "TAIL-MARKER".repeat(50); - const scenario = createFinishNotificationScenario({ - childLastAssistantMessage: included + omitted, - }); - - scenario.startWatchingChild(); - const parentPrompt = await scenario.finishChildAndReadParentPrompt(); - - expect(parentPrompt).toContain(included); - expect(parentPrompt).toContain( - `[truncated ${omitted.length} chars; use get_agent_activity for the full response]`, - ); - expect(parentPrompt).not.toContain("TAIL-MARKER"); -}); - -test("closing a watched child notifies the caller", async () => { - const scenario = createFinishNotificationScenario(); - - scenario.startWatchingChild(); - const parentPrompt = await scenario.closeChildAndReadParentPrompt(); - - expect(parseAgentMessage(parentPrompt)).toEqual({ - id: expect.any(String), - source: { - kind: "agent-notification", - agentId: "child-agent", - title: "Child Agent", - event: "closed", - }, - text: "Agent child-agent (Child Agent) was closed.", - }); -}); - -test("finish notifications survive permission responses", async () => { - const scenario = createFinishNotificationScenario(); - - scenario.startWatchingChild(); - scenario.requestChildPermission(); - - await vi.waitFor(() => { - expect(scenario.parentPrompts()).toHaveLength(1); - }); - expect(scenario.parentPrompts()[0]).toContain("needs permission."); - const permissionPayload = parseAgentMessage(scenario.parentPrompts()[0])!.text.match( - /\n([\s\S]+?)\n<\/permission-request>/, - )?.[1]; - expect(permissionPayload).toBeDefined(); - expect(JSON.parse(permissionPayload!)).toEqual({ - agentId: "child-agent", - requestId: "permission-1", - request: { - id: "permission-1", - provider: "claude", - kind: "tool", - name: "Run command", - description: "Write the QA sentinel", - input: { - file_path: "/tmp/permission-qa.txt", - content: "PASEO_PERMISSION_NOTIFY_QA_OK\n", - }, - }, - }); - - scenario.resolveChildPermission(); - scenario.finishChild(); - - await vi.waitFor(() => { - expect(scenario.parentPrompts()).toHaveLength(2); - }); - expect(scenario.parentPrompts()[1]).toContain("finished."); -}); - -test("an idle permission resolution waits for the resumed run to finish", async () => { - const scenario = createFinishNotificationScenario(); - - scenario.startWatchingChild(); - scenario.requestChildPermission(); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1)); - - scenario.resolveChildPermissionWhileIdle(); - scenario.requestChildPermission("permission-2"); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); - expect(scenario.parentPrompts().every((prompt) => prompt.includes("needs permission."))).toBe( - true, - ); - - scenario.resolveChildPermission("permission-2"); - scenario.finishChild(); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3)); - expect(scenario.parentPrompts()[2]).toContain("finished."); -}); - -test("finish notifications report every concurrently pending permission", async () => { - const scenario = createFinishNotificationScenario(); - - scenario.startWatchingChild(); - scenario.requestChildPermission("permission-1"); - scenario.requestChildPermission("permission-2"); - - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1)); - expect( - scenario - .parentPrompts() - .flatMap((prompt) => parseAgentMessages(prompt)!) - .map((message) => { - const payload = message.text.match( - /\n([\s\S]+?)\n<\/permission-request>/, - )?.[1]; - return JSON.parse(payload!).requestId; - }), - ).toEqual(["permission-1", "permission-2"]); - - scenario.resolveChildPermission("permission-1"); - scenario.resolveChildPermission("permission-2"); - scenario.finishChild(); - - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); - expect(scenario.parentPrompts()[1]).toContain("finished."); -}); - -test("finish notifications survive repeated permission cycles", async () => { - const scenario = createFinishNotificationScenario(); - - scenario.startWatchingChild(); - scenario.requestChildPermission(); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1)); - scenario.resolveChildPermissionFromState(); - - scenario.requestChildPermission(); - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); - scenario.resolveChildPermission(); - scenario.finishChild(); - - await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3)); - expect( - scenario.parentPrompts().map((prompt) => prompt.match(/(needs permission|finished)\./)?.[1]), - ).toEqual(["needs permission", "needs permission", "finished"]); -}); - -test("detaching a child ends its parent-owned finish notification", async () => { - const scenario = createFinishNotificationScenario({ - childParentAgentId: null, - requireParentOwnership: true, - }); - scenario.startWatchingChild(); - scenario.finishChild(); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(scenario.wasParentPrompted()).toBe(false); -}); - -test("follow-up finish notifications do not require a parent relationship", async () => { - const scenario = createFinishNotificationScenario({ childParentAgentId: "another-agent" }); - - scenario.startWatchingChild(); - const parentPrompt = await scenario.finishChildAndReadParentPrompt(); - - expect(parentPrompt).toContain("Agent child-agent (Child Agent) finished."); -}); - -test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => { - const captured = createCapturedLogger(); - const scenario = createFinishNotificationScenario({ - parentPromptError: new Error("parent provider rejected replacement"), - logger: captured.logger, - }); - - scenario.startWatchingChild(); - await scenario.finishChildAndReadParentPrompt(); - await captured.nextRecord; - - expect(captured.records).toEqual([ - expect.objectContaining({ - msg: "Agent message batch dispatch failed", - agentId: "caller-agent", - messageIds: [expect.any(String)], - err: expect.objectContaining({ message: "parent provider rejected replacement" }), - }), - ]); -}); - -it("does not notify archived callers", async () => { - let subscriber: ((event: AgentManagerEvent) => void) | null = null; - - const childAgent: ManagedAgent = Object.create(null); - Reflect.set(childAgent, "id", "child-agent"); - Reflect.set(childAgent, "lifecycle", "idle"); - Reflect.set(childAgent, "config", { title: "Child Agent" }); - Reflect.set(childAgent, "pendingPermissions", new Map()); - - const callerAgent: ManagedAgent = Object.create(null); - Reflect.set(callerAgent, "id", "caller-agent"); - Reflect.set(callerAgent, "lifecycle", "idle"); - Reflect.set(callerAgent, "config", { title: "Caller Agent" }); - - const streamAgentSpy = vi.fn(() => (async function* noop() {})()); - const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})()); - - const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() }); - Reflect.set( - agentManager, - "getAgent", - vi.fn((agentId: string) => { - if (agentId === "child-agent") { - return childAgent; - } - if (agentId === "caller-agent") { - return callerAgent; - } - return null; - }), - ); - Reflect.set( - agentManager, - "subscribe", - vi.fn((callback: (event: AgentManagerEvent) => void) => { - subscriber = callback; - return () => { - subscriber = null; - }; - }), - ); - Reflect.set(agentManager, "hasInFlightRun", vi.fn().mockReturnValue(false)); - Reflect.set(agentManager, "streamAgent", streamAgentSpy); - Reflect.set(agentManager, "replaceAgentRun", replaceAgentRunSpy); - - const agentStorageGetSpy = vi.fn(async (agentId: string) => - agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null, - ); - const agentStorage: AgentStorage = Object.create(AgentStorage.prototype); - Reflect.set(agentStorage, "get", agentStorageGetSpy); - - setupFinishNotification({ - agentMessages: createTestAgentMessages({ - agentManager, - agentStorage, - logger: createTestLogger(), - }), - agentManager, - agentStorage, - childAgentId: "child-agent", - callerAgentId: "caller-agent", - logger: createTestLogger(), - }); - - expect(subscriber).not.toBeNull(); - - childAgent.lifecycle = "running"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - - childAgent.lifecycle = "idle"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - subscriber?.({ - type: "agent_stream", - agentId: "child-agent", - event: { type: "turn_completed", provider: "codex" }, - }); - - await vi.waitFor(() => { - expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent"); - }); - - expect(streamAgentSpy).not.toHaveBeenCalled(); - expect(replaceAgentRunSpy).not.toHaveBeenCalled(); -}); - -// Deliberately independent literals rather than the production constants these tests -// guard: deriving the boundaries from AGENT_RUN_START_TIMEOUT_MS would keep the tests -// green if that constant were shortened back under a provider's startup budget. +// Independent of the production constant so shortening the budget breaks this test. const EXPECTED_RUN_START_BUDGET_MS = 60_000; // The slowest provider startup budget the run-start wait has to sit outside of today // (OpenCode's OPENCODE_SERVER_STARTUP_TIMEOUT_MS). diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 63c2309956..a8fd2ebd4b 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -1,24 +1,11 @@ -import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; -import { - prepareAgentMessage, - type AgentPromptSource, - type AgentMessageDelivery, -} from "./agent-messages/index.js"; +import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js"; import type { Logger } from "pino"; -import type { - AgentPermissionRequest, - AgentPromptInput, - AgentRunOptions, -} from "./agent-sdk-types.js"; +import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; -import { - getParentAgentIdFromLabels, - getParentAddressFromLabels, -} from "@getpaseo/protocol/agent-labels"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; export type AgentUnarchiveController = Pick; @@ -412,412 +399,3 @@ export async function startCreatedAgentInitialPrompt( } return refreshedSnapshot; } - -export interface SetupFinishNotificationParams { - agentMessages: AgentMessageDelivery; - agentManager: AgentManager; - agentStorage: AgentStorage; - childAgentId: string; - callerAgentId: string; - childServerId?: string; - requireParentOwnership?: boolean; - /** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */ - dispatchResult?: Promise; - /** A replacement must observe its new start, not the displaced running snapshot. */ - waitForTurnStart?: boolean; - logger: Logger; -} - -type FinishNotificationReason = - | "finished" - | "errored" - | "needs permission" - | "was closed" - | "was canceled"; - -const finishNotificationEvents = { - finished: "finished", - errored: "errored", - "needs permission": "permission-required", - "was closed": "closed", - // Use the existing failure event so older clients still parse agent-message metadata. - "was canceled": "errored", -} as const; - -const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; - -interface FinishNotificationBodyInput { - childAgentId: string; - serverId?: string; - title: string; - reason: FinishNotificationReason; - lastAssistantMessage: string | null; - permissionRequest?: AgentPermissionRequest; -} - -function formatFinishNotificationBody(params: FinishNotificationBodyInput): string { - const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`; - const sections = [statusLine]; - if (params.serverId) sections.push(`Server: ${params.serverId}`); - if (params.reason === "needs permission" && params.permissionRequest) { - sections.push( - "Respond with `respond_to_permission` using the `agentId` and `requestId` below.", - `\n${JSON.stringify( - { - agentId: params.childAgentId, - ...(params.serverId ? { serverId: params.serverId } : {}), - requestId: params.permissionRequest.id, - request: params.permissionRequest, - }, - null, - 2, - )}\n`, - ); - } - let lastAssistantMessage = params.lastAssistantMessage?.trim(); - if (lastAssistantMessage) { - if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) { - const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT; - lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`; - } - sections.push(`\n${lastAssistantMessage}\n`); - } - return sections.join("\n\n"); -} - -interface NotifySafelyOptions { - terminal?: boolean; - permissionRequest?: AgentPermissionRequest; -} - -// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces -// every earlier observation for the caller/child pair; a failure removes only itself. -interface ArmedFinishNotification { - stop(): void; - supersedeWhen(accepted: Promise): void; -} - -const armedFinishNotifications = new WeakMap< - AgentManager, - Map> ->(); - -/** Creation waits and accepted follow-ups share notification ownership. */ -export function setupFinishWait(params: { - agentMessages: AgentMessageDelivery; - agentManager: AgentManager; - agentStorage: AgentStorage; - originServerId?: string; - childServerId?: string; - childAgentId: string; - callerAgentId: string; - wait(signal: AbortSignal): Promise; - afterPermission(requestId: string, signal: AbortSignal): Promise; - logger: Logger; -}): () => void { - const controller = new AbortController(); - const byManager = - armedFinishNotifications.get(params.agentManager) ?? - new Map>(); - armedFinishNotifications.set(params.agentManager, byManager); - const key = JSON.stringify([ - params.childServerId ?? null, - params.childAgentId, - params.callerAgentId, - ]); - const registrations = byManager.get(key) ?? new Set(); - byManager.set(key, registrations); - for (const previous of registrations) previous.stop(); - byManager.set(key, registrations); - let superseded = Promise.resolve(false); - const stop = () => { - controller.abort(); - registrations.delete(registration); - if (registrations.size === 0 && byManager.get(key) === registrations) byManager.delete(key); - }; - const registration: ArmedFinishNotification = { - stop, - supersedeWhen(accepted) { - superseded = superseded.then(async (prior) => prior || (await accepted)); - }, - }; - registrations.add(registration); - async function watch(): Promise { - while (!controller.signal.aborted) { - const result = await params.wait(controller.signal); - if (controller.signal.aborted || (await superseded) || result.status === "timeout") return; - const parent = getParentAddressFromLabels( - result.final?.labels, - params.childServerId ?? "self", - ); - if ( - parent?.agentId !== params.callerAgentId || - parent.serverId !== (params.originServerId ?? "self") - ) - return; - if ((await params.agentStorage.get(params.callerAgentId))?.archivedAt) return; - const reason = { idle: "finished", error: "errored", permission: "needs permission" }[ - result.status - ] as FinishNotificationReason; - const permissionRequest = result.final?.pendingPermissions[0]; - params.agentMessages.enqueue({ - recipientId: params.callerAgentId, - prompt: formatFinishNotificationBody({ - childAgentId: params.childAgentId, - serverId: params.childServerId, - title: result.final?.title ?? params.childAgentId, - reason, - lastAssistantMessage: result.lastMessage, - permissionRequest, - }), - source: { - kind: "agent-notification", - agentId: params.childAgentId, - serverId: params.childServerId, - title: result.final?.title ?? params.childAgentId, - event: finishNotificationEvents[reason], - }, - activeTurnBehavior: "steer", - unarchive: false, - }); - if (result.status !== "permission" || !permissionRequest) return; - await params.afterPermission(permissionRequest.id, controller.signal); - } - } - void watch() - .catch((error) => { - if (!controller.signal.aborted) - params.logger.warn( - { err: error, agentId: params.childAgentId }, - "Child finish wait stopped", - ); - }) - .finally(stop); - return stop; -} - -export function setupFinishNotification(params: SetupFinishNotificationParams): () => void { - const { - agentManager, - agentStorage, - childAgentId, - callerAgentId, - requireParentOwnership = false, - waitForTurnStart = false, - logger, - } = params; - let hasSeenRunning = false; - let hasSeenTurn = false; - let stopped = false; - const notifiedPermissionRequestIds = new Set(); - let unsubscribe: (() => void) | null = null; - let notificationQueue = Promise.resolve(); - - const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map(); - armedFinishNotifications.set(agentManager, armedByManager); - const armedKey = JSON.stringify([params.childServerId ?? null, childAgentId, callerAgentId]); - const registrations = armedByManager.get(armedKey) ?? new Set(); - armedByManager.set(armedKey, registrations); - const previous = [...registrations]; - let superseded = Promise.resolve(false); - let dispatchPending = Boolean(params.dispatchResult); - const registration: ArmedFinishNotification = { - stop, - supersedeWhen(accepted) { - superseded = superseded.then( - async (alreadySuperseded) => alreadySuperseded || (await accepted), - ); - }, - }; - registrations.add(registration); - if (params.dispatchResult) { - const dispatchAccepted = params.dispatchResult.then((result) => result !== false); - for (const observer of previous) observer.supersedeWhen(dispatchAccepted); - void params.dispatchResult.then((accepted) => { - dispatchPending = false; - if (accepted) { - for (const observer of previous) observer.stop(); - if (stopped || accepted === "turn_started") stop(); - } else { - stop(); - } - return undefined; - }); - } else { - for (const observer of previous) { - observer.supersedeWhen(Promise.resolve(true)); - observer.stop(); - } - } - - function stop(): void { - stopped = true; - unsubscribe?.(); - // Pending observations remain visible to overlapping sends until acknowledgement. - if (!dispatchPending) { - registrations.delete(registration); - if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) { - armedByManager.delete(armedKey); - } - } - } - - async function notify( - reason: FinishNotificationReason, - permissionRequest?: AgentPermissionRequest, - ): Promise { - if (params.dispatchResult) { - const result = await params.dispatchResult; - if (result === false || result === "turn_started") return; - } - if (await superseded) return; - const callerRecord = await agentStorage.get(callerAgentId); - if (callerRecord?.archivedAt) { - return; - } - - const record = await agentStorage.get(childAgentId); - if (requireParentOwnership && getParentAgentIdFromLabels(record?.labels) !== callerAgentId) { - return; - } - const title = record?.title ?? childAgentId; - const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId); - const body = formatFinishNotificationBody({ - childAgentId, - title, - reason, - lastAssistantMessage, - permissionRequest, - }); - - params.agentMessages.enqueue({ - recipientId: callerAgentId, - prompt: body, - source: { - kind: "agent-notification", - agentId: childAgentId, - title, - event: finishNotificationEvents[reason], - }, - activeTurnBehavior: "steer", - unarchive: false, - }); - } - - function notifySafely(reason: FinishNotificationReason, options: NotifySafelyOptions = {}): void { - if (stopped) return; - if (options.terminal ?? true) stop(); - notificationQueue = notificationQueue - .then(() => notify(reason, options.permissionRequest)) - .catch((error) => { - logger.error( - { err: error, childAgentId, callerAgentId, reason }, - "Failed to notify caller agent", - ); - }); - } - - function observeState(agent: ManagedAgent): void { - for (const requestId of notifiedPermissionRequestIds) { - if (!agent.pendingPermissions.has(requestId)) { - notifiedPermissionRequestIds.delete(requestId); - } - } - if (agent.lifecycle === "running") { - if (waitForTurnStart && !hasSeenTurn) return; - hasSeenTurn = true; - if (agent.pendingPermissions.size === 0) { - hasSeenRunning = true; - } - return; - } - // Prior terminal state is not this task's outcome. A failure before turn start - // is reported by the dispatch acknowledgement instead of a finish callback. - if (agent.lifecycle === "error" && hasSeenTurn) { - notifySafely("errored"); - return; - } - // Idle is published before the terminal stream event for both completion - // and cancellation. Let that event identify the outcome instead. - if (agent.lifecycle === "closed") { - notifySafely("was closed"); - return; - } - return; - } - - unsubscribe = agentManager.subscribe( - (event) => { - if (stopped) { - return; - } - - if (event.type === "agent_state") { - observeState(event.agent); - return; - } - - if (event.type === "timeline_replacement") { - return; - } - - if (event.event.type === "turn_started") { - hasSeenTurn = true; - hasSeenRunning = true; - return; - } - - if (event.event.type === "turn_completed" || event.event.type === "turn_canceled") { - // Replacing a turn keeps the child running through the displaced turn's - // cancellation. The task settles only when the child becomes idle. - if (agentManager.getAgent(childAgentId)?.lifecycle !== "idle") return; - if (event.event.type === "turn_completed" && hasSeenRunning) { - notifySafely("finished"); - } else if (event.event.type === "turn_canceled" && hasSeenTurn) { - notifySafely("was canceled"); - } - return; - } - - if (event.event.type === "permission_requested") { - // A permission pause is an intermediate checkpoint. Forget the run - // observed before it so an idle state during follow-up startup cannot - // masquerade as the final completion. - hasSeenRunning = false; - if (!notifiedPermissionRequestIds.has(event.event.request.id)) { - notifiedPermissionRequestIds.add(event.event.request.id); - notifySafely("needs permission", { - terminal: false, - permissionRequest: event.event.request, - }); - } - return; - } - - if (event.event.type === "permission_resolved") { - notifiedPermissionRequestIds.delete(event.event.requestId); - const childAgent = agentManager.getAgent(childAgentId); - if (childAgent?.pendingPermissions.size === 0) { - hasSeenRunning = childAgent.lifecycle === "running"; - } - } - }, - { agentId: childAgentId, replayState: false }, - ); - - // Check if the child is already running (catches the case where - // the lifecycle flipped before our subscribe call was processed). - // Do NOT treat an immediate "idle" as "finished" — the agent may - // not have started yet (streamAgent sets a pending run before - // transitioning to "running"). - const childSnapshot = agentManager.getAgent(childAgentId); - if (!childSnapshot || childSnapshot.lifecycle === "closed") { - stop(); - return stop; - } - if (childSnapshot.lifecycle === "running" && !waitForTurnStart) { - hasSeenTurn = true; - hasSeenRunning = true; - } - return stop; -} diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 79f674e389..77cdde673b 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -1,4 +1,5 @@ -import type { AgentPromptSource, AgentMessageDelivery } from "../agent-messages/index.js"; +import type { FinishSubscriptions } from "../finish-subscriptions/index.js"; +import type { AgentPromptSource } from "../agent-messages/index.js"; import type { Logger } from "pino"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; @@ -15,7 +16,7 @@ import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../a import type { AgentStorage } from "../agent-storage.js"; import type { AgentOwner } from "../agent-owner.js"; import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; -import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js"; +import { startCreatedAgentInitialPrompt } from "../agent-prompt.js"; import { resolveCreateAgentTitles } from "../create-agent-title.js"; import { buildAgentPrompt } from "../prompt-attachments.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; @@ -23,7 +24,7 @@ import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp import { resolveCreateAgentIntent } from "./intent.js"; export interface CreateAgentCommandDependencies { - agentMessages?: AgentMessageDelivery; + finishSubscriptions?: FinishSubscriptions; agentManager: AgentManager; agentStorage: AgentStorage; logger: Logger; @@ -175,28 +176,29 @@ export async function createAgentCommand( if (input.kind === "mcp") { input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null }); } - const stopNotification = + const notification = input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && resolved.prompt !== undefined - ? setupFinishNotification({ - agentMessages: requireAgentMessages(dependencies), - agentManager: dependencies.agentManager, - agentStorage: dependencies.agentStorage, - childAgentId: snapshot.id, - callerAgentId: input.callerAgentId, - // Detached agents have no parent label, but still notify their creator. - requireParentOwnership: !(input.detached ?? false), - logger: dependencies.logger, - }) + ? await requireFinishSubscriptions(dependencies).register( + { + childAgentId: snapshot.id, + callerAgentId: input.callerAgentId, + requireParentOwnership: !(input.detached ?? false), + }, + true, + ) : undefined; if (resolved.prompt !== undefined) { - const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => { - stopNotification?.(); - throw error; - }); + const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch( + async (error) => { + await notification?.reject(); + throw error; + }, + ); + notification?.accept(); initialPromptStarted = sendResult.started; liveSnapshot = sendResult.liveSnapshot; initialPromptError = sendResult.error ?? null; @@ -561,7 +563,9 @@ async function resolveMcpCwd(params: { }; } -function requireAgentMessages(dependencies: CreateAgentCommandDependencies): AgentMessageDelivery { - if (!dependencies.agentMessages) throw new Error("Agent message delivery is not configured"); - return dependencies.agentMessages; +function requireFinishSubscriptions( + dependencies: CreateAgentCommandDependencies, +): FinishSubscriptions { + if (!dependencies.finishSubscriptions) throw new Error("Finish subscriptions are not configured"); + return dependencies.finishSubscriptions; } diff --git a/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts b/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts new file mode 100644 index 0000000000..872961111b --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts @@ -0,0 +1,414 @@ +import { HostObservationError } from "../host-operations/index.js"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; +import type { AgentMessageDelivery } from "../agent-messages/index.js"; +import { buildStoredAgentPayload } from "../agent-projections.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { + createFinishSubscriptions, + createJsonFinishSubscriptionStore, + type FinishObservation, + type FinishSubscriptions, +} from "./index.js"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +class Observation implements FinishObservation { + waits: Array<{ signal: AbortSignal; result: ReturnType> }> = + []; + permission = deferred(); + opening: Promise = Promise.resolve(); + openedSignals: AbortSignal[] = []; + async open(_record: unknown, signal: AbortSignal) { + this.openedSignals.push(signal); + await this.opening; + return { next: () => this.wait(signal), close: async () => {} }; + } + async wait(signal: AbortSignal) { + signal.throwIfAborted(); + const result = deferred(); + signal.addEventListener("abort", () => result.reject(new Error("Stopped")), { once: true }); + this.waits.push({ signal, result }); + return result.promise; + } + afterPermission() { + return this.permission.promise; + } +} +class Delivery implements AgentMessageDelivery { + prompts: string[] = []; + acknowledgments: Array>> = []; + enqueue(message: Parameters[0]) { + this.prompts.push(String(message.prompt)); + const acknowledgment = deferred<"steered">(); + this.acknowledgments.push(acknowledgment); + return { + status: "queued" as const, + messageId: "notification", + dispatched: acknowledgment.promise, + }; + } + async close() {} + async waitForDispatch() {} +} +const homes: string[] = []; +const services: FinishSubscriptions[] = []; +afterEach(async () => { + for (const service of services.splice(0)) await service.close(); + await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))); +}); +async function scenario(directory?: string, removalFailures = 0) { + const home = directory ?? (await mkdtemp(join(tmpdir(), "finish-recovery-"))); + if (!directory) homes.push(home); + const durableStore = createJsonFinishSubscriptionStore(home); + const store = { + ...durableStore, + async remove(id: string) { + if (removalFailures > 0) { + removalFailures--; + throw new Error("Subscription removal unavailable"); + } + await durableStore.remove(id); + }, + }; + const observation = new Observation(); + const delivery = new Delivery(); + let active = true; + const service = createFinishSubscriptions({ + store, + messages: () => delivery, + open: observation.open.bind(observation), + callerIsActive: async () => active, + serverId: "origin", + logger: createTestLogger(), + retryDelayMs: 1, + }); + services.push(service); + await service.start(); + return { + home, + store, + observation, + delivery, + service, + archive: () => { + active = false; + }, + }; +} +const intent = { callerAgentId: "parent", childAgentId: "child" }; +function outcome(status: "idle" | "running" | "closed" | "error" = "closed"): WaitForFinishResult { + return { + status: status === "error" ? "error" : "idle", + final: buildStoredAgentPayload( + { + id: "child", + provider: "codex", + cwd: "/tmp", + title: "Child", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + labels: { "paseo.parent-agent-id": "parent" }, + lastStatus: status, + }, + ["codex"], + ), + lastMessage: "Partial work saved", + error: null, + }; +} +async function until(check: () => boolean) { + await expect.poll(check).toBe(true); +} + +test("retains intent until parent dispatch acknowledgment, then restart sends nothing", async () => { + const first = await scenario(); + await first.service.register(intent); + await until(() => first.observation.waits.length === 1); + first.observation.waits[0]!.result.resolve(outcome()); + await until(() => first.delivery.prompts.length === 1); + expect(first.delivery.prompts[0]).toContain("was closed"); + expect(first.delivery.prompts[0]).toContain("Partial work saved"); + expect(await first.store.list()).toHaveLength(1); + first.delivery.acknowledgments[0]!.resolve("steered"); + await expect.poll(async () => (await first.store.list()).length).toBe(0); + await first.service.close(); + const restarted = await scenario(first.home); + expect(restarted.observation.waits).toHaveLength(0); +}); + +test("restart coalesces predecessor and queued candidate into one settled notification", async () => { + const first = await scenario(); + await first.service.register(intent); + await first.service.register(intent, true); + expect(await first.store.list()).toHaveLength(2); + await first.service.close(); + const restarted = await scenario(first.home); + await until(() => restarted.observation.waits.length === 1); + restarted.observation.waits[0]!.result.resolve(outcome()); + await until(() => restarted.delivery.prompts.length === 1); + restarted.delivery.acknowledgments[0]!.resolve("steered"); + await expect.poll(async () => (await restarted.store.list()).length).toBe(0); + expect(restarted.delivery.prompts).toHaveLength(1); +}); + +test("rejected queued follow-up preserves the predecessor and its buffered finish", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + const pending = await state.service.register(intent, true); + state.observation.waits[0]!.result.resolve(outcome("idle")); + await Promise.resolve(); + expect(state.delivery.prompts).toHaveLength(0); + await pending.reject(); + await until(() => state.delivery.prompts.length === 1); + expect(await state.store.list()).toHaveLength(1); +}); + +test("accepted replacement ignores old idle and stale acknowledgment cannot delete new intent", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 1); + const next = await state.service.register(intent, true); + next.accept(); + await until(() => state.observation.waits.length === 2); + state.delivery.acknowledgments[0]!.resolve("steered"); + await expect.poll(async () => (await state.store.list()).length).toBe(1); + state.observation.waits[1]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 2); +}); + +test("shutdown before dispatch acknowledgment leaves intent for recovery", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.resolve(outcome()); + await until(() => state.delivery.prompts.length === 1); + await state.service.close(); + state.delivery.acknowledgments[0]!.resolve("steered"); + const restarted = await scenario(state.home); + await until(() => restarted.observation.waits.length === 1); + restarted.observation.waits[0]!.result.resolve(outcome()); + await until(() => restarted.delivery.prompts.length === 1); +}); + +test("remote recovery before three failed observations still reports child completion", async () => { + const first = await scenario(); + await first.service.register({ ...intent, serverId: "remote" }); + await first.service.close(); + const state = await scenario(first.home); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.reject(new HostObservationError("unreachable")); + await until(() => state.observation.waits.length === 2); + expect(await state.store.list()).toHaveLength(1); + expect(state.delivery.prompts).toHaveLength(0); + state.observation.waits[1]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("Server: remote"); +}); + +test("archived parent suppresses delivery and removes obsolete intent", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.archive(); + state.observation.waits[0]!.result.resolve(outcome()); + await expect.poll(async () => (await state.store.list()).length).toBe(0); + expect(state.delivery.prompts).toHaveLength(0); +}); + +test("permission checkpoint retains intent and completion reports after permission clears", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + const result = outcome("running"); + result.status = "permission"; + result.final!.pendingPermissions = [ + { + id: "permission", + name: "Write", + input: { path: "file" }, + kind: "tool", + } as (typeof result.final.pendingPermissions)[number], + ]; + state.observation.waits[0]!.result.resolve(result); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("respond_to_permission"); + expect(await state.store.list()).toHaveLength(1); + state.delivery.acknowledgments[0]!.resolve("steered"); + state.observation.permission.resolve(); + await until(() => state.observation.waits.length === 2); + state.observation.waits[1]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 2); +}); + +test("coalesced unconditional follow-up survives detachment of a parent-owned creation", async () => { + const first = await scenario(); + await first.service.register({ ...intent, requireParentOwnership: true }); + await first.service.register(intent, true); + await first.service.close(); + const restarted = await scenario(first.home); + await until(() => restarted.observation.waits.length === 1); + const detached = outcome(); + detached.final!.labels = {}; + restarted.observation.waits[0]!.result.resolve(detached); + await until(() => restarted.delivery.prompts.length === 1); +}); + +test("detached child suppresses a parent-owned creation subscription", async () => { + const state = await scenario(); + await state.service.register({ ...intent, requireParentOwnership: true }); + await until(() => state.observation.waits.length === 1); + const detached = outcome(); + detached.final!.labels = {}; + state.observation.waits[0]!.result.resolve(detached); + await expect.poll(async () => (await state.store.list()).length).toBe(0); + expect(state.delivery.prompts).toHaveLength(0); +}); + +test("dispatch failure retains intent and retries the ordinary finish notification", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.resolve(outcome()); + await until(() => state.delivery.prompts.length === 1); + state.delivery.acknowledgments[0]!.reject(new Error("Provider unavailable")); + await until(() => state.delivery.prompts.length === 2); + expect(await state.store.list()).toHaveLength(1); + expect(state.observation.waits).toHaveLength(1); + state.delivery.acknowledgments[1]!.resolve("steered"); + await expect.poll(async () => (await state.store.list()).length).toBe(0); +}); + +test("error and missing-child notifications never claim successful completion", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.resolve(outcome("error")); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("errored"); + await state.service.register({ ...intent, childAgentId: "missing-child" }); + await until(() => state.observation.waits.length === 2); + state.observation.waits[1]!.result.resolve({ + status: "error", + final: null, + error: "Agent not found: missing-child", + lastMessage: null, + }); + await until(() => state.delivery.prompts.length === 2); + expect(state.delivery.prompts[1]).toContain("is unavailable"); + expect(state.delivery.prompts[1]).not.toContain("finished"); +}); + +test("finish notification limits a long last response and points to activity for the remainder", async () => { + const state = await scenario(); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + const result = outcome("idle"); + result.lastMessage = "x".repeat(4100); + state.observation.waits[0]!.result.resolve(result); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("x".repeat(4000)); + expect(state.delivery.prompts[0]).not.toContain("x".repeat(4001)); + expect(state.delivery.prompts[0]).toContain("get_agent_activity"); +}); + +test("three failed remote observations consume intent only after unavailable-notification acknowledgment", async () => { + const state = await scenario(); + await state.service.register({ ...intent, serverId: "remote" }); + for (let attempt = 0; attempt < 3; attempt++) { + await until(() => state.observation.waits.length === attempt + 1); + expect(state.delivery.prompts).toEqual([]); + state.observation.waits[attempt]!.result.reject(new HostObservationError("unreachable")); + } + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain( + "Could not observe child agent child on host remote after three failed attempts.", + ); + expect(state.delivery.prompts[0]).toContain("Status of child agent child (child) is unknown."); + expect(state.delivery.prompts[0]).not.toContain("finished."); + expect(await state.store.list()).toHaveLength(1); + expect(state.observation.waits).toHaveLength(3); + state.delivery.acknowledgments[0]!.resolve("steered"); + await expect.poll(async () => (await state.store.list()).length).toBe(0); + await state.service.close(); + const restarted = await scenario(state.home); + expect(restarted.observation.waits).toEqual([]); + expect(restarted.delivery.prompts).toEqual([]); +}); + +test("connecting does not spend the remote failure budget and recovery before three failures still completes", async () => { + const state = await scenario(); + await state.service.register({ ...intent, serverId: "remote" }); + for (let attempt = 0; attempt < 4; attempt++) { + await until(() => state.observation.waits.length === attempt + 1); + expect(state.delivery.prompts).toEqual([]); + state.observation.waits[attempt]!.result.reject(new HostObservationError("connecting")); + } + for (let attempt = 4; attempt < 6; attempt++) { + await until(() => state.observation.waits.length === attempt + 1); + expect(state.delivery.prompts).toEqual([]); + state.observation.waits[attempt]!.result.reject(new HostObservationError("unreachable")); + } + await until(() => state.observation.waits.length === 7); + expect(state.delivery.prompts).toEqual([]); + state.observation.waits[6]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("finished."); +}); + +test("removed-host notice retries parent dispatch without observing again or deleting unacknowledged intent", async () => { + const state = await scenario(); + await state.service.register({ ...intent, serverId: "remote" }); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.reject(new HostObservationError("removed")); + await until(() => state.delivery.prompts.length === 1); + expect(state.delivery.prompts[0]).toContain("Host remote was removed or is unavailable."); + expect(state.delivery.prompts[0]).toContain("Status of child agent child (child) is unknown."); + state.delivery.acknowledgments[0]!.reject(new Error("Parent provider unavailable")); + await until(() => state.delivery.prompts.length === 2); + expect(state.delivery.prompts[1]).toBe(state.delivery.prompts[0]); + expect(state.observation.waits).toHaveLength(1); + expect(await state.store.list()).toHaveLength(1); + state.delivery.acknowledgments[1]!.resolve("steered"); + await expect.poll(async () => (await state.store.list()).length).toBe(0); +}); + +test("failed removal after dispatch acknowledgment retries only storage, preserving the accepted outcome", async () => { + const state = await scenario(undefined, 1); + await state.service.register(intent); + await until(() => state.observation.waits.length === 1); + state.observation.waits[0]!.result.resolve(outcome("idle")); + await until(() => state.delivery.prompts.length === 1); + state.delivery.acknowledgments[0]!.resolve("steered"); + await expect.poll(async () => (await state.store.list()).length).toBe(0); + expect(state.delivery.prompts).toHaveLength(1); + expect(state.delivery.prompts[0]).toContain("finished."); + expect(state.observation.waits).toHaveLength(1); +}); + +test("shutdown during observation setup owns rejection of the already-canceled next result", async () => { + const state = await scenario(); + const opening = deferred(); + state.observation.opening = opening.promise; + await state.service.register(intent); + await until(() => state.observation.openedSignals.length === 1); + const closing = state.service.close(); + await until(() => state.observation.openedSignals[0]!.aborted); + opening.resolve(); + await closing; + expect(await state.store.list()).toHaveLength(1); + expect(state.delivery.prompts).toEqual([]); +}); diff --git a/packages/server/src/server/agent/finish-subscriptions/index.ts b/packages/server/src/server/agent/finish-subscriptions/index.ts new file mode 100644 index 0000000000..58c3c8eff1 --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/index.ts @@ -0,0 +1,363 @@ +import { setTimeout as pause } from "node:timers/promises"; +import { HostObservationError } from "../host-operations/index.js"; +import { randomUUID } from "node:crypto"; +import type { Logger } from "pino"; +import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; +import type { FinishObservationResult } from "./internal/observation-buffer.js"; +import type { AgentMessageDelivery } from "../agent-messages/index.js"; +import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels"; +import { + formatFinishNotificationBody, + finishNotificationEvents, + type FinishNotificationReason, +} from "./internal/notification.js"; +export { createJsonFinishSubscriptionStore } from "./internal/json-store.js"; + +export interface FinishSubscription { + id: string; + callerAgentId: string; + childAgentId: string; + serverId?: string; + /** Creation subscriptions stop reporting after the child is detached. */ + requireParentOwnership?: boolean; +} +export interface FinishSubscriptionStore { + list(): Promise; + put(subscription: FinishSubscription): Promise; + remove(id: string): Promise; +} +export interface FinishObservationSession { + next(): Promise; + close(): Promise; +} +export interface FinishObservation { + open(subscription: FinishSubscription, signal: AbortSignal): Promise; +} +interface Dependencies extends FinishObservation { + store: FinishSubscriptionStore; + messages(): AgentMessageDelivery; + callerIsActive(agentId: string): Promise; + serverId: string; + logger: Logger; + retryDelayMs?: number; +} +export interface FinishRegistration { + /** The prompt was accepted. Replaces preceding registrations for this pair. */ + accept(): void; + /** Rejected dispatch preserves preceding registrations. */ + reject(): Promise; +} +export interface FinishSubscriptions { + start(): Promise; + register( + input: Omit, + pendingDispatch?: boolean, + ): Promise; + close(): Promise; +} +interface Watch { + records: FinishSubscription[]; + controller: AbortController; + decision: Promise; + decide(accepted: boolean): void; + pending: boolean; + task?: Promise; +} +function key(record: FinishSubscription): string { + return JSON.stringify([record.callerAgentId, record.childAgentId, record.serverId]); +} + +/** Owns durable intent, live observation, supersession, and dispatch acknowledgment. */ +export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptions { + const tasks = new Set>(); + const watches = new Map>(); + let closed = false; + let mutations = Promise.resolve(); + function serialize(action: () => Promise): Promise { + const result = mutations.then(action); + mutations = result.then( + () => undefined, + () => undefined, + ); + return result; + } + function attach(records: FinishSubscription[], pending: boolean): Watch { + let decide!: Watch["decide"]; + const decision = new Promise((resolve) => { + decide = resolve; + }); + const watch: Watch = { records, controller: new AbortController(), pending, decision, decide }; + const group = watches.get(key(records[0]!)) ?? new Set(); + group.add(watch); + watches.set(key(records[0]!), group); + if (!pending) decide(true); + watch.task = run(watch, group).catch((error) => + deps.logger.error({ err: error }, "Finish subscription failed"), + ); + tasks.add(watch.task); + void watch.task.then(() => tasks.delete(watch.task!)); + return watch; + } + function stop(watch: Watch, group: Set): void { + watch.controller.abort(); + watch.decide(false); + group.delete(watch); + if (!group.size && watches.get(key(watch.records[0]!)) === group) + watches.delete(key(watch.records[0]!)); + } + async function remove(watch: Watch): Promise { + // Only IDs captured by this observer are eligible; newer registrations survive. + for (const record of watch.records) await deps.store.remove(record.id); + } + async function delay(signal: AbortSignal, attempt: number): Promise { + try { + await pause( + Math.min((deps.retryDelayMs ?? 1000) * 2 ** Math.min(attempt, 3), 5000), + undefined, + { signal }, + ); + } catch (error) { + if (!signal.aborted) throw error; + } + } + async function abortable(operation: Promise, signal: AbortSignal): Promise { + let abort!: () => void; + const cancellation = new Promise((_resolve, reject) => { + abort = () => reject(signal.reason); + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + }); + try { + return await Promise.race([operation, cancellation]); + } finally { + signal.removeEventListener("abort", abort); + } + } + async function decisions(group: Set): Promise { + // New candidates may arrive while an older dispatch decision is pending. + while (true) { + if (closed) return; + const pending = [...group].filter((watch) => watch.pending); + if (!pending.length) return; + await Promise.all(pending.map((watch) => watch.decision)); + } + } + async function suppressed( + record: FinishSubscription, + result?: WaitForFinishResult, + ): Promise { + if (!(await deps.callerIsActive(record.callerAgentId))) return true; + if (!record.requireParentOwnership || !result?.final) return false; + const parent = getParentAddressFromLabels( + result.final.labels, + record.serverId ?? deps.serverId, + ); + return parent?.agentId !== record.callerAgentId || parent.serverId !== deps.serverId; + } + function reasonFor(result: FinishObservationResult): FinishNotificationReason { + if (result.reason === "host unreachable" || result.reason === "host removed") + return result.reason; + if (!result.final) return "is unavailable"; + if (result.final.status === "closed") return "was closed"; + if (result.status === "error") return "errored"; + if (result.status === "permission") return "needs permission"; + return result.reason ?? "finished"; + } + async function deliver( + record: FinishSubscription, + result: FinishObservationResult, + permissions: Set, + signal: AbortSignal, + ): Promise { + const requests = result.final?.pendingPermissions ?? []; + const reason = reasonFor(result); + for (const id of permissions) + if (!requests.some((request) => request.id === id)) permissions.delete(id); + const acknowledgments: Promise[] = []; + for (const request of reason === "needs permission" ? requests : [undefined]) { + if (signal.aborted) return false; + if (request && permissions.has(request.id)) continue; + const title = result.final?.title ?? record.childAgentId; + const receipt = deps.messages().enqueue({ + recipientId: record.callerAgentId, + prompt: formatFinishNotificationBody({ + childAgentId: record.childAgentId, + serverId: record.serverId ?? deps.serverId, + title, + reason, + lastAssistantMessage: result.lastMessage, + permissionRequest: request, + }), + source: { + kind: "agent-notification", + agentId: record.childAgentId, + serverId: record.serverId ?? deps.serverId, + title, + event: finishNotificationEvents[reason], + }, + activeTurnBehavior: "steer", + unarchive: false, + }); + acknowledgments.push( + receipt.dispatched.then(() => { + if (request) permissions.add(request.id); + return undefined; + }), + ); + } + await abortable(Promise.all(acknowledgments), signal); + if (signal.aborted) return false; + if (reason !== "needs permission") return true; + return false; + } + async function run(watch: Watch, group: Set): Promise { + const signal = watch.controller.signal; + if (!(await watch.decision) || signal.aborted) return; + const record = { + ...watch.records[0]!, + requireParentOwnership: watch.records.every( + (intent) => intent.requireParentOwnership === true, + ), + }; + const permissions = new Set(); + let observation: FinishObservationSession | undefined; + let pending: FinishObservationResult | undefined; + let acknowledged = false; + let remoteFailures = 0; + let retries = 0; + let warned = false; + async function observeNext(): Promise { + try { + observation ??= await deps.open(record, signal); + const result = await abortable(observation.next(), signal); + remoteFailures = 0; + return result; + } catch (error) { + await observation?.close(); + observation = undefined; + signal.throwIfAborted(); + if (!(error instanceof HostObservationError) || !record.serverId) throw error; + if (error.reason === "connecting") return undefined; + if (error.reason === "unreachable" && ++remoteFailures < 3) return undefined; + // Host visibility ends this subscription; it says nothing about child completion. + return { + status: "error", + final: null, + lastMessage: null, + error: null, + reason: error.reason === "removed" ? "host removed" : "host unreachable", + }; + } + } + try { + while (!signal.aborted) { + try { + if (await suppressed(record)) { + await remove(watch); + stop(watch, group); + return; + } + if (!pending) pending = await observeNext(); + if (!pending) { + await delay(signal, retries++); + continue; + } + if (signal.aborted) return; + await decisions(group); + if (signal.aborted) return; + if (await suppressed(record, pending)) { + await remove(watch); + stop(watch, group); + return; + } + if (!acknowledged) acknowledged = await deliver(record, pending, permissions, signal); + if (acknowledged) { + // A removal failure retries only the commit, never the already accepted callback. + await remove(watch); + stop(watch, group); + return; + } + pending = undefined; + retries = 0; + warned = false; + } catch (error) { + if (signal.aborted) return; + // Delivery and storage failures retain the captured outcome and durable intent. + // They do not spend the remote-observation budget or change the child's outcome. + if (!warned) { + deps.logger.warn( + { err: error, subscription: record.id }, + "Retrying finish subscription", + ); + warned = true; + } + await delay(signal, retries++); + } + } + } finally { + await observation?.close(); + } + } + + return { + start: () => + serialize(async () => { + const groups = new Map(); + for (const record of await deps.store.list()) { + const records = groups.get(key(record)) ?? []; + records.push(record); + groups.set(key(record), records); + } + for (const records of groups.values()) attach(records, false); + }), + register: (input, pendingDispatch = false) => + serialize(async () => { + if (closed) throw new Error("Finish subscriptions are closed"); + const record: FinishSubscription = { + ...input, + serverId: input.serverId === deps.serverId ? undefined : input.serverId, + id: randomUUID(), + }; + await deps.store.put(record); + const group = watches.get(key(record)) ?? new Set(); + const predecessors = [...group]; + const watch = attach([record], true); + const actualGroup = watches.get(key(record))!; + const registration: FinishRegistration = { + accept() { + if (!watch.pending || closed) return; + watch.pending = false; + for (const prior of predecessors) stop(prior, actualGroup); + watch.decide(true); + void serialize(async () => { + for (const prior of predecessors) await remove(prior); + }).catch((error) => + deps.logger.error({ err: error }, "Failed to remove superseded finish subscriptions"), + ); + }, + reject: () => + serialize(async () => { + stop(watch, actualGroup); + await remove(watch); + }), + }; + if (!pendingDispatch) registration.accept(); + return registration; + }), + async close() { + closed = true; + await mutations; + for (const group of watches.values()) + for (const watch of group) { + watch.controller.abort(); + watch.decide(false); + } + await mutations; + await Promise.all(tasks); + await deps.store.list(); + // Dispatch may still be pending. The delivery owner drains it after observers stop. + }, + }; +} + +export { createFinishObservation } from "./internal/observation.js"; diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/json-store.test.ts b/packages/server/src/server/agent/finish-subscriptions/internal/json-store.test.ts new file mode 100644 index 0000000000..5cf696393c --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/json-store.test.ts @@ -0,0 +1,51 @@ +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { createJsonFinishSubscriptionStore } from "../index.js"; + +const homes: string[] = []; +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))); +}); +async function temporaryHome() { + const result = await mkdtemp(join(tmpdir(), "finish-subscriptions-")); + homes.push(result); + return result; +} +const subscription = { + id: "subscription", + callerAgentId: "parent", + childAgentId: "child", + serverId: "remote", +}; + +test("acknowledged concurrent writes survive reopening and removal", async () => { + const directory = await temporaryHome(); + const store = createJsonFinishSubscriptionStore(directory); + await Promise.all([store.put(subscription), store.put({ ...subscription, id: "second" })]); + const restarted = createJsonFinishSubscriptionStore(directory); + expect(await restarted.list()).toEqual([subscription, { ...subscription, id: "second" }]); + await restarted.remove(subscription.id); + expect(await createJsonFinishSubscriptionStore(directory).list()).toEqual([ + { ...subscription, id: "second" }, + ]); +}); + +test("write failure rejects registration and does not publish an unwritten record", async () => { + const directory = await temporaryHome(); + const store = createJsonFinishSubscriptionStore(directory); + expect(await store.list()).toEqual([]); + await mkdir(join(directory, "finish-subscriptions.json")); + await expect(store.put(subscription)).rejects.toThrow(); + expect(await store.list()).toEqual([]); +}); + +test("invalid persisted records fail visibly", async () => { + const directory = await temporaryHome(); + await writeFile( + join(directory, "finish-subscriptions.json"), + JSON.stringify([{ callerAgentId: 42 }]), + ); + await expect(createJsonFinishSubscriptionStore(directory).list()).rejects.toThrow(); +}); diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/json-store.ts b/packages/server/src/server/agent/finish-subscriptions/internal/json-store.ts new file mode 100644 index 0000000000..02b9635521 --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/json-store.ts @@ -0,0 +1,58 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { writeJsonFileAtomic } from "../../../atomic-file.js"; +import type { FinishSubscription, FinishSubscriptionStore } from "../index.js"; + +const recordsSchema = z.array( + z.object({ + id: z.string().min(1), + callerAgentId: z.string().min(1), + childAgentId: z.string().min(1), + serverId: z.string().min(1).optional(), + requireParentOwnership: z.boolean().optional(), + }), +); + +export function createJsonFinishSubscriptionStore(paseoHome: string): FinishSubscriptionStore { + const file = join(paseoHome, "finish-subscriptions.json"); + let records: FinishSubscription[] | undefined; + let pending = Promise.resolve(); + async function load(): Promise { + if (records) return records; + try { + records = recordsSchema.parse(JSON.parse(await readFile(file, "utf8"))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + records = []; + } + return records; + } + function serialized(action: () => Promise): Promise { + const result = pending.then(action); + pending = result.then( + () => undefined, + () => undefined, + ); + return result; + } + async function mutate( + update: (current: FinishSubscription[]) => FinishSubscription[], + ): Promise { + const next = update(await load()); + await writeJsonFileAtomic(file, next); + records = next; + } + return { + list: () => serialized(async () => (await load()).map((record) => Object.assign({}, record))), + put: (subscription) => + serialized(() => + mutate((current) => [ + ...current.filter((record) => record.id !== subscription.id), + { ...subscription }, + ]), + ), + remove: (id) => + serialized(() => mutate((current) => current.filter((record) => record.id !== id))), + }; +} diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/notification.ts b/packages/server/src/server/agent/finish-subscriptions/internal/notification.ts new file mode 100644 index 0000000000..d1cdc9bad7 --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/notification.ts @@ -0,0 +1,70 @@ +import type { AgentPermissionRequest } from "../../agent-sdk-types.js"; +export type FinishNotificationReason = + | "host unreachable" + | "host removed" + | "finished" + | "errored" + | "needs permission" + | "is unavailable" + | "was closed" + | "was canceled"; + +export const finishNotificationEvents = { + "host unreachable": "errored", + "host removed": "errored", + finished: "finished", + errored: "errored", + "needs permission": "permission-required", + "was closed": "closed", + "is unavailable": "closed", + // Use the existing failure event so older clients still parse agent-message metadata. + "was canceled": "errored", +} as const; + +const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; + +interface FinishNotificationBodyInput { + childAgentId: string; + serverId?: string; + title: string; + reason: FinishNotificationReason; + lastAssistantMessage: string | null; + permissionRequest?: AgentPermissionRequest; +} + +export function formatFinishNotificationBody(params: FinishNotificationBodyInput): string { + if (params.reason === "host unreachable" || params.reason === "host removed") { + const host = + params.reason === "host removed" + ? `Host ${params.serverId} was removed or is unavailable.` + : `Could not observe child agent ${params.childAgentId} on host ${params.serverId} after three failed attempts.`; + return `${host}\n\nStatus of child agent ${params.childAgentId} (${params.title}) is unknown.\n\nServer: ${params.serverId}\n\nThis finish subscription has ended. Reassess the delegated work; reconnecting will not resume notifications.`; + } + const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`; + const sections = [statusLine]; + if (params.serverId) sections.push(`Server: ${params.serverId}`); + if (params.reason === "needs permission" && params.permissionRequest) { + sections.push( + "Respond with `respond_to_permission` using the `agentId` and `requestId` below.", + `\n${JSON.stringify( + { + agentId: params.childAgentId, + ...(params.serverId ? { serverId: params.serverId } : {}), + requestId: params.permissionRequest.id, + request: params.permissionRequest, + }, + null, + 2, + )}\n`, + ); + } + let lastAssistantMessage = params.lastAssistantMessage?.trim(); + if (lastAssistantMessage) { + if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) { + const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT; + lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`; + } + sections.push(`\n${lastAssistantMessage}\n`); + } + return sections.join("\n\n"); +} diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/observation-buffer.ts b/packages/server/src/server/agent/finish-subscriptions/internal/observation-buffer.ts new file mode 100644 index 0000000000..95e80dd3a9 --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/observation-buffer.ts @@ -0,0 +1,83 @@ +import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; + +export type FinishObservationResult = WaitForFinishResult & { + reason?: "was canceled" | "host unreachable" | "host removed"; +}; + +/** Coalesces duplicate checkpoints and retains one terminal result while delivery is pending. */ +export function createObservationBuffer(signal: AbortSignal) { + const delivered = new Set(); + const permissions = new Map(); + const resolvedBeforeInitial = new Set(); + let initialPending = true; + let terminal: FinishObservationResult | undefined; + let failure: unknown; + let wake: (() => void) | undefined; + const abort = () => { + failure = signal.reason; + wake?.(); + }; + signal.addEventListener("abort", abort, { once: true }); + function push(result: FinishObservationResult): void { + if (result.status === "timeout") return; + if (result.status === "permission") { + for (const request of result.final?.pendingPermissions ?? []) + if (!delivered.has(request.id)) permissions.set(request.id, result); + } else terminal = result; + wake?.(); + } + return { + pushInitial(result: FinishObservationResult) { + initialPending = false; + if (result.status === "permission" && result.final) { + const pendingPermissions = result.final.pendingPermissions.filter( + (request) => !resolvedBeforeInitial.has(request.id), + ); + if (pendingPermissions.length) + push({ ...result, final: { ...result.final, pendingPermissions } }); + } else if ( + !terminal && + result.final?.status !== "running" && + result.final?.status !== "initializing" + ) + push(result); + resolvedBeforeInitial.clear(); + }, + push, + resolved(id: string) { + if (initialPending) resolvedBeforeInitial.add(id); + delivered.delete(id); + permissions.delete(id); + }, + fail(error: unknown) { + failure = error; + wake?.(); + }, + async next(): Promise { + while (true) { + signal.throwIfAborted(); + const first = permissions.entries().next().value; + if (first) { + for (const request of first[1].final?.pendingPermissions ?? []) { + delivered.add(request.id); + permissions.delete(request.id); + } + return first[1]; + } + if (terminal) { + const result = terminal; + terminal = undefined; + return result; + } + if (failure) throw failure; + await new Promise((resolve) => { + wake = resolve; + }); + wake = undefined; + } + }, + dispose() { + signal.removeEventListener("abort", abort); + }, + }; +} diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.test.ts b/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.test.ts new file mode 100644 index 0000000000..1d15a890df --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.test.ts @@ -0,0 +1,217 @@ +import { expect, test } from "vitest"; +import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { + HostObservationError, + createHostObservationResolver, + type ToolHosts, + type HostObservation, +} from "../../host-operations/index.js"; +import { buildStoredAgentPayload } from "../../agent-projections.js"; +import { openFinishObservation } from "./observation-stream.js"; + +class Host implements HostObservation { + handler?: Parameters[1]; + released = false; + failure?: (error: unknown) => void; + baseline = Promise.withResolvers(); + async wait() { + return this.baseline.promise; + } + async observe( + _id: string, + handler: Parameters[1], + _signal: AbortSignal, + onError: (error: unknown) => void, + ) { + this.handler = handler; + this.failure = onError; + return async () => { + this.released = true; + }; + } + emit(status: "idle" | "running", event?: AgentStreamEvent) { + this.handler!( + buildStoredAgentPayload( + { + id: "child", + cwd: "/tmp", + provider: "codex", + labels: {}, + lastStatus: status, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + ["codex"], + ), + event, + "Last response", + ); + } +} +function permission(id: string): AgentStreamEvent { + return { + type: "permission_requested", + provider: "codex", + request: { id, provider: "codex", name: "Write", kind: "tool", input: { path: id } }, + }; +} +async function scenario() { + const host = new Host(); + const controller = new AbortController(); + const observation = await openFinishObservation( + { use: async (_server, action) => action(host) }, + { id: "watch", callerAgentId: "parent", childAgentId: "child" }, + controller.signal, + ); + return { host, observation, controller }; +} + +test("permission resolution followed by transient idle waits for resumed work to settle", async () => { + const { host, observation } = await scenario(); + try { + host.emit("running", permission("first")); + expect((await observation.next()).status).toBe("permission"); + host.emit("idle", { type: "permission_resolved", provider: "codex", requestId: "first" }); + let settled = false; + const next = observation.next().then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + expect(settled).toBe(false); + host.emit("running", { type: "turn_started", provider: "codex" }); + await Promise.resolve(); + expect(settled).toBe(false); + host.emit("idle", { type: "turn_completed", provider: "codex" }); + expect(await next).toMatchObject({ status: "idle", lastMessage: "Last response" }); + } finally { + await observation.close(); + } + expect(host.released).toBe(true); +}); + +test("second checkpoint and terminal outcome remain buffered while the first callback is pending", async () => { + const { host, observation } = await scenario(); + try { + host.emit("running", permission("first")); + expect((await observation.next()).status).toBe("permission"); + // The recipient has not acknowledged the first callback; no next() is in flight. + host.emit("running", permission("second")); + host.emit("idle", { type: "turn_completed", provider: "codex" }); + expect((await observation.next()).final?.pendingPermissions[0]?.id).toBe("second"); + expect((await observation.next()).status).toBe("idle"); + } finally { + await observation.close(); + } +}); + +test("abort releases continuous observation even with a pending initial wait", async () => { + const { host, observation, controller } = await scenario(); + const next = observation.next(); + controller.abort(); + await expect(next).rejects.toThrow(); + await observation.close(); + expect(host.released).toBe(true); +}); + +test("late initial permission snapshot cannot resurrect an already resolved request", async () => { + const { host, observation } = await scenario(); + try { + const requestEvent = permission("resolved"); + host.emit("running", requestEvent); + await observation.next(); + host.emit("idle", { type: "permission_resolved", provider: "codex", requestId: "resolved" }); + const final = buildStoredAgentPayload( + { + id: "child", + cwd: "/tmp", + provider: "codex", + labels: {}, + lastStatus: "running", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + }, + ["codex"], + ); + if (requestEvent.type !== "permission_requested") throw new Error("Expected permission"); + final.pendingPermissions = [requestEvent.request]; + host.baseline.resolve({ status: "permission", final, lastMessage: null, error: null }); + await host.baseline.promise; + await Promise.resolve(); + host.emit("idle", { type: "turn_completed", provider: "codex" }); + expect((await observation.next()).status).toBe("idle"); + } finally { + await observation.close(); + } +}); + +test("lookup failure uses its error channel and never fabricates a missing-child result", async () => { + const { host, observation } = await scenario(); + try { + host.failure!(new Error("Route disconnected")); + await expect(observation.next()).rejects.toThrow("Route disconnected"); + } finally { + await observation.close(); + } +}); + +test("a terminal event observed after newer work starts waits for that running work", async () => { + const { host, observation } = await scenario(); + try { + host.emit("running", { type: "turn_completed", provider: "codex" }); + let settled = false; + const next = observation.next().then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + expect(settled).toBe(false); + host.emit("idle", { type: "turn_completed", provider: "codex" }); + expect((await next).status).toBe("idle"); + } finally { + await observation.close(); + } +}); + +test.each([ + { status: "connecting", reason: "connecting" }, + { status: "offline", reason: "unreachable" }, + { status: "disabled", reason: "unreachable" }, + { status: "removed", reason: "removed" }, +] as const)( + "remote observation classifies $status from the authoritative directory, ignoring arbitrary error codes", + async ({ status, reason }) => { + const row = { + registrationId: "registration", + serverId: "remote", + label: null, + source: { id: "owner", key: "remote" }, + metadata: {}, + enabled: status !== "disabled", + status: status === "removed" ? ("offline" as const) : status, + targetDescription: "Remote", + lastOnlineAt: null, + nextRetryAt: null, + error: null, + }; + const hosts: ToolHosts = { + directory: async () => ({ + self: { serverId: "origin", label: "Origin" }, + hosts: status === "removed" ? [] : [row], + }), + async use() { + throw Object.assign(new Error("Untrusted remote error"), { code: "host_unknown" }); + }, + }; + const local = new Host(); + const resolver = createHostObservationResolver({ + serverId: "origin", + local, + hosts: () => hosts, + }); + const error = await resolver.use("remote", async () => {}).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(HostObservationError); + expect(error).toMatchObject({ reason }); + }, +); diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.ts b/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.ts new file mode 100644 index 0000000000..34c059810f --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/observation-stream.ts @@ -0,0 +1,121 @@ +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { HostObservation } from "../../host-operations/index.js"; +import type { FinishSubscription, FinishObservationSession } from "../index.js"; +import { createObservationBuffer } from "./observation-buffer.js"; + +interface ObservationScope { + use( + serverId: string | undefined, + action: (host: HostObservation) => Promise, + signal?: AbortSignal, + ): Promise; +} +function isTerminal(snapshot: AgentSnapshotPayload, event?: AgentStreamEvent): boolean { + if (snapshot.status === "closed" || snapshot.status === "error") return true; + return ( + snapshot.status === "idle" && + (event?.type === "turn_completed" || event?.type === "turn_canceled") + ); +} +function observeState( + buffer: ReturnType, + snapshot: AgentSnapshotPayload | null, + event?: AgentStreamEvent, + lastMessage: string | null = null, +): void { + if (!snapshot) { + buffer.push({ status: "idle", final: null, lastMessage: null, error: null }); + return; + } + if (event?.type === "permission_resolved") { + buffer.resolved(event.requestId); + return; + } + if (event?.type === "turn_started") return; + if (event?.type === "permission_requested") { + buffer.push({ + status: "permission", + final: { ...snapshot, pendingPermissions: [event.request] }, + lastMessage, + error: null, + }); + } else if (isTerminal(snapshot, event)) { + buffer.push({ + status: snapshot.status === "error" ? "error" : "idle", + final: snapshot, + lastMessage, + error: snapshot.lastError ?? null, + ...(event?.type === "turn_canceled" ? { reason: "was canceled" as const } : {}), + }); + } +} + +/** One continuous source feeds a bounded buffer while the caller awaits dispatch. */ +export async function openFinishObservation( + resolver: ObservationScope, + record: FinishSubscription, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const controller = new AbortController(); + const lifetime = AbortSignal.any([signal, controller.signal]); + const buffer = createObservationBuffer(lifetime); + let opened!: () => void; + let failed!: (error: unknown) => void; + const ready = new Promise((resolve, reject) => { + opened = resolve; + failed = reject; + }); + const route = resolver + .use( + record.serverId, + async (host) => { + const release = await host.observe( + record.childAgentId, + (snapshot, event, lastMessage) => observeState(buffer, snapshot, event, lastMessage), + lifetime, + (error) => buffer.fail(error), + ); + opened(); + // The ordinary wait supplies the initial settled snapshot and covers queued admission. + // Continuous observation buffers checkpoints and terminal events throughout delivery. + void host + .wait(record.childAgentId, lifetime) + .then((result) => { + buffer.pushInitial(result); + return undefined; + }) + .catch((error) => buffer.fail(error)); + try { + if (!lifetime.aborted) + await new Promise((resolve) => + lifetime.addEventListener("abort", () => resolve(), { once: true }), + ); + } finally { + await release(); + } + }, + lifetime, + ) + .catch((error) => { + failed(error); + buffer.fail(error); + }); + try { + await ready; + } catch (error) { + controller.abort(); + buffer.dispose(); + await route; + throw error; + } + return { + next: () => buffer.next(), + async close() { + controller.abort(); + buffer.dispose(); + await route; + }, + }; +} diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/observation.ts b/packages/server/src/server/agent/finish-subscriptions/internal/observation.ts new file mode 100644 index 0000000000..887a4ba075 --- /dev/null +++ b/packages/server/src/server/agent/finish-subscriptions/internal/observation.ts @@ -0,0 +1,103 @@ +import type { Logger } from "pino"; +import type { AgentManager } from "../../agent-manager.js"; +import type { AgentStorage } from "../../agent-storage.js"; +import type { AgentMessageDelivery } from "../../agent-messages/index.js"; +import { buildStoredAgentPayload, toAgentPayload } from "../../agent-projections.js"; +import { serializeSnapshotWithMetadata } from "../../mcp-shared.js"; +import { createHostObservationResolver, type ToolHosts } from "../../host-operations/index.js"; +import { openFinishObservation } from "./observation-stream.js"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import type { FinishObservation } from "../index.js"; + +export function createFinishObservation(input: { + agentManager: AgentManager; + agentStorage: AgentStorage; + messages(): AgentMessageDelivery; + hosts(): ToolHosts; + serverId: string; + logger: Logger; +}): FinishObservation { + const { agentManager, agentStorage } = input; + const resolver = createHostObservationResolver({ + serverId: input.serverId, + hosts: input.hosts, + local: { + async wait(agentId, signal) { + await input.messages().waitForDispatch(agentId, signal); + signal.throwIfAborted(); + const live = agentManager.getAgent(agentId); + if (!live) { + const stored = await agentStorage.get(agentId); + return { + status: stored?.lastStatus === "error" ? "error" : "idle", + final: stored ? buildStoredAgentPayload(stored, [stored.provider]) : null, + lastMessage: await agentManager.getLastAssistantMessage(agentId), + error: stored?.lastError ?? null, + }; + } + const result = await agentManager.waitForAgentEvent(agentId, { + signal, + waitForActive: true, + }); + const current = agentManager.getAgent(agentId); + const status = result.permission ? "permission" : result.status; + return { + status: status === "permission" || status === "error" ? status : "idle", + final: current + ? await serializeSnapshotWithMetadata(agentStorage, current, input.logger) + : null, + lastMessage: result.lastMessage, + error: current?.lastError ?? null, + }; + }, + async observe(agentId, handler, signal, onError) { + const stored = await agentStorage.get(agentId); + let updates = Promise.resolve(); + const publish = (snapshot: AgentSnapshotPayload | null, event?: AgentStreamEvent) => { + updates = updates + .then(async () => + handler(snapshot, event, await agentManager.getLastAssistantMessage(agentId)), + ) + .catch(onError); + }; + signal.throwIfAborted(); + const unsubscribe = agentManager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.lifecycle !== "idle") + publish(toAgentPayload(event.agent, { title: stored?.title })); + else if ( + event.type === "agent_stream" && + [ + "permission_requested", + "permission_resolved", + "turn_started", + "turn_completed", + "turn_canceled", + "turn_failed", + ].includes(event.event.type) + ) { + const current = agentManager.getAgent(agentId); + if (current) publish(toAgentPayload(current, { title: stored?.title }), event.event); + } + }, + { agentId, replayState: false }, + ); + const current = agentManager.getAgent(agentId); + if (current) + publish(toAgentPayload(current, { title: stored?.title }), { + type: "turn_started", + provider: current.provider, + }); + else publish(stored ? buildStoredAgentPayload(stored, [stored.provider]) : null); + const stop = () => unsubscribe(); + signal.addEventListener("abort", stop, { once: true }); + return async () => { + signal.removeEventListener("abort", stop); + unsubscribe(); + }; + }, + }, + }); + return { open: (record, signal) => openFinishObservation(resolver, record, signal) }; +} diff --git a/packages/server/src/server/agent/host-operations/index.ts b/packages/server/src/server/agent/host-operations/index.ts index 294e869555..2242c3b872 100644 --- a/packages/server/src/server/agent/host-operations/index.ts +++ b/packages/server/src/server/agent/host-operations/index.ts @@ -1,9 +1,11 @@ +import type { AgentStreamEvent } from "../agent-sdk-types.js"; import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; import type { ActiveTurnBehavior, AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { randomUUID } from "node:crypto"; import { z } from "zod"; import { DaemonClient, + DaemonConnectionError, type FetchWorkspacesEntry, type WaitForFinishResult, } from "@getpaseo/client/internal/daemon-client"; @@ -17,6 +19,18 @@ import { summarizeAgentActivity } from "../activity-curator.js"; import { expandUserPath } from "../../path-utils.js"; import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js"; +export class HostOperationRejectedError extends Error { + readonly code: unknown; + constructor(error: unknown) { + super(error instanceof Error ? error.message : String(error), { cause: error }); + this.name = "HostOperationRejectedError"; + this.code = error instanceof Error && "code" in error ? error.code : undefined; + } +} +function isAmbiguousPromptFailure(error: unknown): boolean { + if (error instanceof DaemonConnectionError) return true; + return error instanceof Error && error.message.startsWith("Timeout waiting for message"); +} export type HostOperation = ( input: Record, context: PaseoToolExecutionContext, @@ -29,24 +43,46 @@ export interface HostOperations { getStatus: HostOperation; getActivity: HostOperation; listAgents: HostOperation; +} +/** Connection authority at the host boundary, independent of child lifecycle. */ +export class HostObservationError extends Error { + constructor( + readonly reason: "connecting" | "unreachable" | "removed", + cause?: unknown, + ) { + super(`Host observation ${reason}`, { cause }); + this.name = "HostObservationError"; + } +} +export interface HostObservation { wait(agentId: string, signal: AbortSignal): Promise; observe( agentId: string, - handler: (snapshot: AgentSnapshotPayload | null) => void, + handler: ( + snapshot: AgentSnapshotPayload | null, + event?: AgentStreamEvent, + lastMessage?: string | null, + ) => void, signal: AbortSignal, + onError: (error: unknown) => void, ): Promise<() => Promise>; } export interface ToolHosts { directory(): Promise; - use(serverId: string, action: (client: DaemonClient) => Promise): Promise; + use( + serverId: string, + action: (client: DaemonClient) => Promise, + signal?: AbortSignal, + ): Promise; } /** Daemon MCP has the daemon's authority, bounded by the destination's admission of its link. */ export function createToolHosts(hosts: ConnectedHosts): ToolHosts { return { directory: () => hosts.list(), - async use(serverId, action) { + async use(serverId, action, signal) { const lifetime = new AbortController(); + let routeFailure: unknown; const transportFactory: DaemonTransportFactory = () => { const opens = new Set<() => void>(); const closes = new Set<(event?: unknown) => void>(); @@ -61,7 +97,7 @@ export function createToolHosts(hosts: ConnectedHosts): ToolHosts { sourceId: "mcp", originSessionId: "mcp", permissions: OWNER_PERMISSIONS, - signal: lifetime.signal, + signal: signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal, frame: (data) => { for (const listener of messages) listener(data, typeof data !== "string"); }, @@ -78,6 +114,7 @@ export function createToolHosts(hosts: ConnectedHosts): ToolHosts { return undefined; }) .catch((error) => { + routeFailure = error; for (const listener of errors) listener(error); for (const listener of closes) listener(error); }); @@ -114,10 +151,17 @@ export function createToolHosts(hosts: ConnectedHosts): ToolHosts { transportFactory, reconnect: { enabled: false }, }); + const stop = () => void client.close(); + signal?.addEventListener("abort", stop, { once: true }); try { - await client.connect(); + signal?.throwIfAborted(); + await client.connect().catch((error) => { + // The action has not run; no application request could have been transmitted. + throw new HostOperationRejectedError(routeFailure ?? error); + }); return await action(client); } finally { + signal?.removeEventListener("abort", stop); lifetime.abort(); await client.close(); } @@ -212,18 +256,29 @@ export function createHostResolver(input: { if (!input.callerAgentId && args.sessionMode !== undefined) { await client.setAgentMode(agentId, args.sessionMode as string); } - const receipt = await client.sendMessage(agentId, args.prompt as string, { - sessionMode: input.callerAgentId - ? (args.sessionMode as string | undefined) - : undefined, - sourceAgentId: input.callerAgentId, - sourceAgentServerId: input.callerAgentId ? input.serverId : undefined, - activeTurnBehavior: - (args.activeTurnBehavior as ActiveTurnBehavior | undefined) ?? - (input.callerAgentId ? "steer" : "interrupt"), - }); - const agent = (await client.fetchAgent(agentId))?.agent; - if (!agent) throw new Error(`Agent ${agentId} not found`); + const agent = ( + await client.fetchAgent(agentId).catch((error) => { + // Snapshot preflight precedes sendMessage, so failure is definitely unsent. + throw new HostOperationRejectedError(error); + }) + )?.agent; + if (!agent) + throw new HostOperationRejectedError(new Error(`Agent ${agentId} not found`)); + const receipt = await client + .sendMessage(agentId, args.prompt as string, { + sessionMode: input.callerAgentId + ? (args.sessionMode as string | undefined) + : undefined, + sourceAgentId: input.callerAgentId, + sourceAgentServerId: input.callerAgentId ? input.serverId : undefined, + activeTurnBehavior: + (args.activeTurnBehavior as ActiveTurnBehavior | undefined) ?? + (input.callerAgentId ? "steer" : "interrupt"), + }) + .catch((error) => { + if (isAmbiguousPromptFailure(error)) throw error; + throw new HostOperationRejectedError(error); + }); return result({ success: true, ...receipt, @@ -310,45 +365,6 @@ export function createHostResolver(input: { throw new Error(response.error ?? "Workspace creation failed"); return result(workspaceSummary(response.workspace)); }, - async observe(agentId, handler, signal) { - const observation = client.observeTimeline([agentId], { signal }); - const refresh = async () => handler((await client.fetchAgent(agentId))?.agent ?? null); - const unsubscribe = observation.subscribe({ - snapshot: () => {}, - update: (message) => { - if (message.type !== "agent_stream" || message.payload.agentId !== agentId) return; - const event = message.payload.event; - if ( - event.type === "permission_resolved" || - event.type === "turn_started" || - event.type === "turn_completed" || - event.type === "turn_canceled" || - event.type === "turn_failed" - ) - void refresh().catch(() => handler(null)); - }, - error: () => handler(null), - }); - try { - await observation.ready; - await refresh(); - } catch (error) { - unsubscribe(); - await observation.release(); - throw error; - } - return async () => { - unsubscribe(); - await observation.release(); - }; - }, - wait: (agentId, signal) => { - const close = () => void client.close(); - signal.addEventListener("abort", close, { once: true }); - return client - .waitForFinish(agentId, 0) - .finally(() => signal.removeEventListener("abort", close)); - }, }), ); }, @@ -366,36 +382,129 @@ function workspaceSummary(workspace: FetchWorkspacesEntry) { }; } -/** Wait pauses at permissions; observe only until that request clears before re-arming it. */ -export async function waitForPermissionChange( - host: HostOperations, - agentId: string, - requestId: string, - signal: AbortSignal, -): Promise { - signal.throwIfAborted(); - let changed!: () => void; - let failed!: (error: unknown) => void; - const ready = new Promise((resolve, reject) => { - changed = resolve; - failed = reject; - }); - const abort = () => failed(signal.reason ?? new Error("Finish wait stopped")); - signal.addEventListener("abort", abort, { once: true }); - let release: (() => Promise) | undefined; - try { - release = await host.observe( - agentId, - (snapshot) => { - if (!snapshot) failed(new Error(`Agent ${agentId} is unavailable`)); - else if (!snapshot.pendingPermissions.some((request) => request.id === requestId)) - changed(); - }, - signal, - ); - await ready; - } finally { - signal.removeEventListener("abort", abort); - await release?.(); - } +function remoteObservation(client: DaemonClient): HostObservation { + return { + async observe(agentId, handler, signal, onError) { + const observation = client.observeTimeline([agentId], { signal }); + const refresh = async (event?: AgentStreamEvent) => { + let snapshot = (await client.fetchAgent(agentId))?.agent ?? null; + let lastMessage: string | null = null; + if ( + snapshot && + (snapshot.status === "idle" || + snapshot.status === "closed" || + snapshot.status === "error" || + snapshot.pendingPermissions.length > 0) + ) { + // A new turn may start between the snapshot and the current-response read. + // Bound that wait and refresh state instead of attributing the old event to it. + const current = await client.waitForFinish(agentId, 100); + if (current.status === "timeout") + snapshot = (await client.fetchAgent(agentId))?.agent ?? null; + else { + if (!current.final && current.error && current.error !== `Agent not found: ${agentId}`) + throw new Error(current.error); + snapshot = current.final; + lastMessage = current.lastMessage; + } + } + handler(snapshot, event, lastMessage); + }; + let updates = Promise.resolve(); + const unsubscribe = observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "agent_stream" || message.payload.agentId !== agentId) return; + const event = message.payload.event; + if ( + event.type === "permission_requested" || + event.type === "permission_resolved" || + event.type === "turn_started" || + event.type === "turn_completed" || + event.type === "turn_canceled" || + event.type === "turn_failed" + ) + updates = updates.then(() => refresh(event)).catch(onError); + }, + error: onError, + }); + try { + await observation.ready; + await refresh(); + } catch (error) { + unsubscribe(); + await observation.release(); + throw error; + } + return async () => { + unsubscribe(); + await observation.release(); + }; + }, + wait: (agentId, signal) => { + const close = () => void client.close(); + signal.addEventListener("abort", close, { once: true }); + return client + .waitForFinish(agentId, 0) + .then((outcome) => { + if (!outcome.final && outcome.error && outcome.error !== `Agent not found: ${agentId}`) + throw new Error(outcome.error); + return outcome; + }) + .finally(() => signal.removeEventListener("abort", close)); + }, + }; +} + +/** Shares routed status observation with daemon services independently of MCP execution. */ +export function createHostObservationResolver(input: { + serverId: string; + local: HostObservation; + hosts(): ToolHosts; +}) { + return { + async use( + serverId: string | undefined, + action: (host: HostObservation) => Promise, + signal?: AbortSignal, + ): Promise { + if (!serverId || serverId === input.serverId) return action(input.local); + const hosts = input.hosts(); + async function classify(error: unknown): Promise { + // This is the origin's authoritative registration directory, not a destination RPC error. + const row = (await hosts.directory()).hosts.find((host) => host.serverId === serverId); + if (!row) return new HostObservationError("removed", error); + if (row.status === "connecting") return new HostObservationError("connecting", error); + return new HostObservationError("unreachable", error); + } + async function fail(error: unknown): Promise { + throw await classify(error); + } + try { + return await hosts.use( + serverId, + (client) => { + const remote = remoteObservation(client); + return action({ + wait: (agentId, lifetime) => remote.wait(agentId, lifetime).catch(fail), + observe(agentId, handler, lifetime, onError) { + async function report(error: unknown): Promise { + try { + onError(await classify(error)); + } catch (failure) { + onError(failure); + } + } + return remote.observe(agentId, handler, lifetime, report).catch(fail); + }, + }); + }, + signal, + ); + } catch (error) { + if (error instanceof HostObservationError) throw error; + return fail(error); + } + }, + }; } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 2d066efdc9..b8a0d820ec 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,12 +1,20 @@ +import { + createFinishSubscriptions, + createFinishObservation, + createJsonFinishSubscriptionStore, + type FinishSubscriptionStore, + type FinishSubscriptions, + type FinishSubscription, +} from "./finish-subscriptions/index.js"; import type { AgentMessageDelivery } from "./agent-messages/index.js"; import { createTestAgentMessages } from "../test-utils/agent-messages.js"; -import { setupFinishNotification, type PromptDispatchDisposition } from "./agent-prompt.js"; -import { parseAgentMessage } from "./agent-messages/index.js"; +import { type PromptDispatchDisposition } from "./agent-prompt.js"; +import { parseAgentMessage, parseAgentMessages } from "./agent-messages/index.js"; import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { realpathSync } from "node:fs"; import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join, resolve as resolvePath } from "node:path"; @@ -23,6 +31,7 @@ import { FakeOmp } from "./providers/omp/test-utils/fake-omp.js"; import type { AgentClient, AgentMode, + AgentPermissionRequest, AgentPromptInput, AgentProvider, AgentRunResult, @@ -125,6 +134,12 @@ interface RegisteredMcpToolWithHandler extends RegisteredMcpTool { }>; } +const subscriptionServices: FinishSubscriptions[] = []; +afterEach(async () => { + for (const service of subscriptionServices.splice(0)) await service.close(); +}); +const serverSubscriptionRecords = new WeakMap>(); +const serverSubscriptions = new WeakMap(); const serverDeliveries = new WeakMap(); const toolDeliveries = new WeakMap(); @@ -840,13 +855,43 @@ function createPaseoWorktreeForMcpTest(options: { // Typed in-memory workspace port for catalogue tests without workspace setup. async function createAgentMcpServer( options: Parameters[0], + subscriptionStore?: FinishSubscriptionStore, ) { const messages = createTestAgentMessages(options); + const records = new Map(); + const subscriptions = createFinishSubscriptions({ + store: subscriptionStore ?? { + list: async () => [...records.values()], + put: async (record) => { + records.set(record.id, record); + }, + remove: async (id) => { + records.delete(id); + }, + }, + messages: () => messages, + callerIsActive: async (id) => !(await options.agentStorage.get(id))?.archivedAt, + serverId: options.serverId ?? "self", + logger: options.logger, + ...createFinishObservation({ + ...options, + messages: () => messages, + serverId: options.serverId ?? "self", + hosts: () => { + if (!options.hosts) throw new Error("No hosts"); + return options.hosts(); + }, + }), + }); + subscriptionServices.push(subscriptions); const server = await createAgentMcpServerWithDependencies({ agentMessages: () => messages, + finishSubscriptions: () => subscriptions, workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} }, ...options, }); + serverSubscriptionRecords.set(server, records); + serverSubscriptions.set(server, subscriptions); serverDeliveries.set(server, messages); return server; } @@ -3399,6 +3444,7 @@ class HeldTurnAgentSession implements AgentSession { readonly prompts: string[] = []; readonly steeredPrompts: AgentPromptInput[] = []; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private readonly permissions = new Map(); private activeTurnId: string | null = null; private turnStartGate: Promise | null = null; private nextStartError: Error | null = null; @@ -3527,11 +3573,25 @@ class HeldTurnAgentSession implements AgentSession { async setMode(): Promise {} - getPendingPermissions() { - return []; + requestPermission(id: string): void { + const request: AgentPermissionRequest = { + id, + name: "Write", + kind: "tool", + input: { path: id }, + }; + this.permissions.set(id, request); + this.pushEvent({ type: "permission_requested", provider: this.provider, request }); } - async respondToPermission(): Promise {} + getPendingPermissions() { + return [...this.permissions.values()]; + } + + async respondToPermission(requestId: string): Promise { + this.permissions.delete(requestId); + this.pushEvent({ type: "permission_resolved", provider: this.provider, requestId }); + } describePersistence() { return { provider: this.provider, sessionId: this.id }; @@ -3842,14 +3902,13 @@ describe("send_agent_prompt MCP tool", () => { secondResolve = resolve; }); for (const dispatchResult of [first, second]) { - setupFinishNotification({ - agentMessages: serverDeliveries.get(server)!, - agentManager: manager, - agentStorage: storage, - childAgentId: child.id, - callerAgentId: parent.id, - dispatchResult, - logger, + const registration = await serverSubscriptions + .get(server)! + .register({ childAgentId: child.id, callerAgentId: parent.id }, true); + void dispatchResult.then((result) => { + if (result) registration.accept(); + else void registration.reject(); + return undefined; }); } firstResolve(firstAccepted ? "steered" : false); @@ -3925,8 +3984,7 @@ describe("send_agent_prompt MCP tool", () => { await serverDeliveries .get(server)! .waitForDispatch("child-agent", new AbortController().signal); - expect(spies.agentManager.subscribe).toHaveBeenCalled(); - expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); + expect(serverSubscriptionRecords.get(server)!.size).toBe(1); expect(response.structuredContent.guidance).toContain("You will get notified"); }); @@ -6735,3 +6793,125 @@ it("rejects hidden agent-worktree placement and directs callers to workspace cre }), ).rejects.toThrow("create_workspace"); }); + +describe("local durable finish subscription MCP wiring", () => { + const logger = createTestLogger(); + const existingCwd = process.cwd(); + it.each(["concurrent", "repeated"] as const)( + "reports %s permission requests and final completion", + async (sequence) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-local-permissions-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const manager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + let mcp: Client | undefined; + try { + const parent = await manager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager: manager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + mcp = await connectInMemoryMcpClient(server); + await sendMcpAndAwaitDispatch(mcp, server, { + agentId: child.id, + prompt: "Permission work", + }); + const prompts = () => + parentClient.sessions[0]!.prompts.flatMap((prompt) => parseAgentMessages(prompt) ?? []) + .map((message) => message.text) + .join("\n"); + const permissionIds = () => + [...prompts().matchAll(/\n([\s\S]+?)\n<\/permission-request>/g)].map( + (match) => JSON.parse(match[1]!).requestId, + ); + const respond = async (requestId: string) => { + const result = await mcp!.callTool({ + name: "respond_to_permission", + arguments: { agentId: child.id, requestId, response: { behavior: "allow" } }, + }); + expect(result.isError).not.toBe(true); + }; + childClient.sessions[0]!.requestPermission("first"); + if (sequence === "concurrent") { + childClient.sessions[0]!.requestPermission("second"); + await vi.waitFor(() => expect(permissionIds()).toEqual(["first", "second"])); + await respond("first"); + await respond("second"); + } else { + await vi.waitFor(() => expect(permissionIds()).toEqual(["first"])); + await respond("first"); + childClient.sessions[0]!.requestPermission("second"); + await vi.waitFor(() => expect(permissionIds()).toEqual(["first", "second"])); + await respond("second"); + } + expect(serverSubscriptionRecords.get(server)!.size).toBe(1); + expect(prompts()).not.toContain("finished."); + childClient.sessions[0]!.finishTurn("Permission work complete"); + await vi.waitFor(() => expect(prompts()).toContain("finished.")); + expect(prompts()).toContain("Permission work complete"); + expect(permissionIds()).toEqual(["first", "second"]); + await vi.waitFor(() => expect(serverSubscriptionRecords.get(server)!.size).toBe(0)); + } finally { + await mcp?.close(); + await removeAgentStateDir(manager, storage, workdir); + } + }, + ); + + it("returns a tool error before sending a prompt when subscription storage cannot commit", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-registration-failure-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldTurnAgentClient("codex", true); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const subscriptions = createJsonFinishSubscriptionStore(workdir); + let mcp: Client | undefined; + try { + const parent = await manager.createAgent({ provider: "codex", cwd: existingCwd }, undefined, { + workspaceId: "wks_parent", + }); + const child = await manager.createAgent({ provider: "codex", cwd: existingCwd }, undefined, { + workspaceId: "wks_parent", + }); + expect(await subscriptions.list()).toEqual([]); + await mkdir(join(workdir, "finish-subscriptions.json")); + const server = await createAgentMcpServer( + { + agentManager: manager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }, + subscriptions, + ); + mcp = await connectInMemoryMcpClient(server); + const result = await mcp.callTool({ + name: "send_agent_prompt", + arguments: { agentId: child.id, prompt: "Must not dispatch" }, + }); + expect(result.isError).toBe(true); + expect(client.sessions[1]!.prompts).toEqual([]); + expect(await subscriptions.list()).toEqual([]); + } finally { + await mcp?.close(); + await removeAgentStateDir(manager, storage, workdir); + } + }); +}); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 74c4361c9e..6b84f17161 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,7 +1,8 @@ +import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js"; import type { AgentMessageDelivery } from "../agent-messages/index.js"; import { createHostResolver, - waitForPermissionChange, + HostOperationRejectedError, type HostOperation, type HostOperations, type ToolHosts, @@ -77,8 +78,6 @@ import { import { type PromptDispatchDisposition, sendPromptToAgent, - setupFinishNotification, - setupFinishWait, waitForAgentRunStartWithTimeout, } from "../agent-prompt.js"; import { respondToAgentPermission } from "../permission-response.js"; @@ -118,6 +117,7 @@ import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; export interface PaseoToolHostDependencies { agentMessages?: () => AgentMessageDelivery; + finishSubscriptions?: () => FinishSubscriptions; serverId?: string; hosts?: () => ToolHosts; agentManager: AgentManager; @@ -175,10 +175,6 @@ export interface PaseoToolHostDependencies { logger: Logger; } -function settledStatus(status: string): "error" | "idle" { - return status === "error" ? "error" : "idle"; -} - interface ProviderSummary { id: AgentProvider; label: string; @@ -663,6 +659,49 @@ function createToolCatalog( if (!options.agentMessages) throw new Error("Agent message delivery is not configured"); return options.agentMessages(); } + function finishSubscriptions(): FinishSubscriptions { + if (!options.finishSubscriptions) throw new Error("Finish subscriptions are not configured"); + return options.finishSubscriptions(); + } + async function prepareRemoteNotification( + name: string, + destination: string | undefined, + input: Record, + ): Promise { + if ( + name !== "send_agent_prompt" || + destination === options.serverId || + !callerAgentId || + input.notifyOnFinish === false + ) + return undefined; + return finishSubscriptions().register( + { childAgentId: input.agentId as string, callerAgentId, serverId: destination }, + true, + ); + } + async function registerRemoteCreation( + name: string, + destination: string | undefined, + input: Record, + response: PaseoToolResult, + ): Promise { + if ( + name !== "create_agent" || + destination === options.serverId || + !callerAgentId || + input.notifyOnFinish === false || + response.isError + ) + return; + const childAgentId = (response.structuredContent as { agentId: string }).agentId; + await finishSubscriptions().register({ + childAgentId, + callerAgentId, + serverId: destination, + requireParentOwnership: true, + }); + } const localHostOperations = new Map(); const tools = new Map(); const hostTools = new Map([ @@ -691,36 +730,6 @@ function createToolCatalog( getStatus: (args, context) => localHostOperations.get("get_agent_status")!(args, context), getActivity: (args, context) => localHostOperations.get("get_agent_activity")!(args, context), listAgents: (args, context) => localHostOperations.get("list_agents")!(args, context), - async observe(agentId, handler, signal) { - const unsubscribe = agentManager.subscribe( - (event) => { - if (event.type === "agent_state") handler(toAgentPayload(event.agent)); - }, - { agentId, replayState: true }, - ); - const stop = () => unsubscribe(); - signal.addEventListener("abort", stop, { once: true }); - return async () => { - signal.removeEventListener("abort", stop); - unsubscribe(); - }; - }, - async wait(agentId, signal) { - const settled = await agentManager.waitForAgentEvent(agentId, { - signal, - waitForActive: true, - }); - const snapshot = agentManager.getAgent(agentId); - const final = snapshot - ? await serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger) - : null; - return { - status: settled.permission ? "permission" : settledStatus(settled.status), - final, - lastMessage: settled.lastMessage, - error: final?.lastError ?? null, - }; - }, }, }); const registerTool = ( @@ -752,34 +761,14 @@ function createToolCatalog( }; handler = async ({ serverId, ...input }, context) => { const destination = serverId ?? options.serverId; + const remoteNotification = await prepareRemoteNotification(name, destination, input); try { const response = await hostResolver.use(serverId, (host) => host[operation](input, context), ); - let childAgentId: string | undefined; - if (name === "create_agent") { - childAgentId = (response.structuredContent as { agentId: string }).agentId; - } else if (name === "send_agent_prompt" && destination !== options.serverId) { - childAgentId = input.agentId as string; - } - if (childAgentId && callerAgentId && input.notifyOnFinish !== false) { - setupFinishWait({ - agentMessages: agentMessages(), - agentManager, - agentStorage, - originServerId: options.serverId, - childServerId: destination, - childAgentId, - callerAgentId, - logger: childLogger, - wait: (signal) => - hostResolver.use(destination, (host) => host.wait(childAgentId, signal)), - afterPermission: (requestId, signal) => - hostResolver.use(destination, (host) => - waitForPermissionChange(host, childAgentId, requestId, signal), - ), - }); - } + if (response.isError) await remoteNotification?.reject(); + else remoteNotification?.accept(); + await registerRemoteCreation(name, destination, input, response); return { ...response, structuredContent: { @@ -788,6 +777,11 @@ function createToolCatalog( }, }; } catch (error) { + // A lost response does not establish whether the remote daemon admitted the prompt. + // Reconcile the child rather than silently dropping notification intent. + if (error instanceof PaseoToolInputError || error instanceof HostOperationRejectedError) + await remoteNotification?.reject(); + else remoteNotification?.accept(); if (!options.serverId) throw error; const failure = { serverId: destination, @@ -1621,6 +1615,7 @@ function createToolCatalog( { agentManager, agentStorage, + finishSubscriptions: options.finishSubscriptions?.(), logger: childLogger, paseoHome: options.paseoHome, worktreesRoot: options.worktreesRoot, @@ -1649,7 +1644,7 @@ function createToolCatalog( }, mode: parsedArgs.settings?.modeId, promptFailure: "throw", - notifyOnFinish: false, + notifyOnFinish, detached: resolvedArgs.detached, callerAgentId, callerContext, @@ -1966,68 +1961,56 @@ function createToolCatalog( notifyOnFinish = Boolean(callerAgentId), activeTurnBehavior = callerAgentId ? "steer" : "interrupt", }) => { - let stopNotification: (() => void) | undefined; - let acceptDispatch!: (result: PromptDispatchDisposition | false) => void; - const dispatchResult = new Promise((resolve) => { - acceptDispatch = resolve; - }); - const notifying = Boolean(callerAgentId && notifyOnFinish); - function prepareDispatch(): void { - if (!callerAgentId || !notifyOnFinish) return; - stopNotification = setupFinishNotification({ - agentMessages: agentMessages(), - agentManager, - agentStorage, - childAgentId: agentId, - childServerId: options.serverId, - callerAgentId, - waitForTurnStart: false, - dispatchResult, - logger: childLogger, - }); + if (callerAgentId && !agentManager.getAgent(agentId) && !(await agentStorage.get(agentId))) { + throw new PaseoToolInputError(`Agent ${agentId} not found`); } - function confirmDispatch(disposition: PromptDispatchDisposition): void { - // A new turn must ignore outcomes from the displaced turn. Its iterator - // has not started yet, so rearm here; steering keeps the early observer. - if (disposition !== "turn_started") { - acceptDispatch(disposition); - return; - } - acceptDispatch(disposition); - if (!callerAgentId || !notifyOnFinish) return; - stopNotification?.(); - stopNotification = setupFinishNotification({ - agentMessages: agentMessages(), - agentManager, - agentStorage, - childAgentId: agentId, - childServerId: options.serverId, - callerAgentId, - waitForTurnStart: true, - logger: childLogger, - }); + const notifying = Boolean(callerAgentId && notifyOnFinish); + const notification = + notifying && callerAgentId + ? await (() => { + if (!options.finishSubscriptions) + throw new Error("Finish subscriptions are not configured"); + return options + .finishSubscriptions() + .register({ childAgentId: agentId, callerAgentId }, true); + })() + : undefined; + function confirmDispatch(_disposition: PromptDispatchDisposition): void { + notification?.accept(); + } + function rejectNotification(): void { + void notification + ?.reject() + .catch((error) => + childLogger.error( + { err: error, agentId }, + "Failed to remove rejected finish subscription", + ), + ); } if (callerAgentId) { - if (!agentManager.getAgent(agentId) && !(await agentStorage.get(agentId))) { - throw new PaseoToolInputError(`Agent ${agentId} not found`); - } - const receipt = agentMessages().enqueue({ - recipientId: agentId, - prompt, - source: { - kind: "agent-message", - agentId: callerAgentId, - title: resolveCallerAgent()?.config.title ?? undefined, - }, - sessionMode, - activeTurnBehavior, - beforeDispatch: prepareDispatch, - onDispatch: confirmDispatch, - }); + const receipt = (() => { + try { + return agentMessages().enqueue({ + recipientId: agentId, + prompt, + source: { + kind: "agent-message", + agentId: callerAgentId, + title: resolveCallerAgent()?.config.title ?? undefined, + }, + sessionMode, + activeTurnBehavior, + onDispatch: confirmDispatch, + }); + } catch (error) { + rejectNotification(); + throw error; + } + })(); void receipt.dispatched.catch(() => { - acceptDispatch(false); - stopNotification?.(); + rejectNotification(); }); return { content: [], @@ -2054,8 +2037,7 @@ function createToolCatalog( onDispatch: confirmDispatch, logger: childLogger, }).catch((error) => { - acceptDispatch(false); - stopNotification?.(); + rejectNotification(); if (error instanceof AgentNotFoundError) { throw new PaseoToolInputError(error.message); } @@ -2065,7 +2047,7 @@ function createToolCatalog( // Return once the provider has accepted the turn, so the status reports it running. if (disposition === "turn_started") { await waitForAgentRunStartWithTimeout(agentManager, agentId).catch((error) => { - stopNotification?.(); + rejectNotification(); throw error; }); } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index d8d611be70..03decb5f67 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,8 @@ +import { + createFinishSubscriptions, + createFinishObservation, + createJsonFinishSubscriptionStore, +} from "./agent/finish-subscriptions/index.js"; import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type { PluginRegistries } from "@getpaseo/protocol/plugin-registry"; import { describeHookWorkspace } from "./plugins/lifecycle/index.js"; @@ -1412,9 +1417,38 @@ export async function createPaseoDaemon( ); logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); + const finishSubscriptions = createFinishSubscriptions({ + store: createJsonFinishSubscriptionStore(config.paseoHome), + serverId, + logger, + messages: () => { + if (!wsServer) throw new Error("Daemon is not accepting agent messages"); + return wsServer.agentMessages; + }, + callerIsActive: async (agentId) => { + const record = await agentStorage.get(agentId); + return record ? !record.archivedAt : Boolean(agentManager.getAgent(agentId)); + }, + ...createFinishObservation({ + agentManager, + agentStorage, + serverId, + logger, + messages: () => { + if (!wsServer) throw new Error("Daemon is not accepting agent messages"); + return wsServer.agentMessages; + }, + hosts: () => { + if (!wsServer) throw new Error("Daemon is not accepting host operations"); + return wsServer.toolHosts(); + }, + }), + }); + const createAgentToolHostDependencies = ( runtime: PaseoToolRuntimeContext, ): PaseoToolHostDependencies => ({ + finishSubscriptions: () => finishSubscriptions, agentMessages: () => { if (!wsServer) throw new Error("Daemon is not accepting agent messages"); return wsServer.agentMessages; @@ -1790,6 +1824,7 @@ export async function createPaseoDaemon( pluginRuntime.bindPaseoSessionHost(wsServer); await pluginRuntime.start(); providerSnapshotManager.settlePluginProviders(); + await finishSubscriptions.start(); wsServer.beginAcceptingConnections(); const relaySecurityLogger = logger.child({ module: "relay-security" }); relayRuntime = createRelayRuntime({ @@ -1874,6 +1909,7 @@ export async function createPaseoDaemon( scriptHealthMonitor.stop(); // Freeze both ingress and registration before taking the agent closure snapshot. wsServer?.prepareForShutdown(); + await finishSubscriptions.close(); await wsServer?.agentMessages.close(); agentManager.prepareForShutdown(); await closeAllAgents(logger, agentManager); diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index e24e431cd0..8a2d4f9c39 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1715,3 +1715,320 @@ test( expect(requests[1]).not.toHaveProperty("sessionMode"); }, ); + +test( + "durable remote finish intent survives origin restart and unavailable-host recovery", + { timeout: 60000 }, + async () => { + const root = await mkdtemp(join(tmpdir(), "durable-remote-origin-")); + cleanup.push(() => rm(root, { recursive: true, force: true })); + const parentPrompts: string[] = []; + const finish = Promise.withResolvers(); + cleanup.push(async () => { + finish.resolve(); + }); + const b = await daemon({ + agentClients: createTestAgentClients({ beforeTurnComplete: () => finish.promise }), + }); + const clients = () => + createTestAgentClients({ + onStartTurn: (prompt) => { + if (String(prompt).includes('kind="agent-notification"')) + parentPrompts.push(String(prompt)); + }, + }); + const a = await daemon({ + paseoHomeRoot: root, + cleanup: false, + agentClients: clients(), + mcpEnabled: true, + }); + const origin = await api(a); + const destination = await api(b); + const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const directory = await online(origin.paseo); + const remoteId = directory.hosts[0]!.serverId!; + const workspace = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const remoteWorkspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Remote parent", + }); + const mcp = new Client({ name: "durable-remote-parent", version: "1" }); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + cleanup.push(() => mcp.close()); + const response = await mcp.callTool({ + name: "create_agent", + arguments: { + serverId: remoteId, + workspaceId: remoteWorkspace.id, + provider: "claude/claude-test", + title: "Remote child", + initialPrompt: "Keep working while origin restarts", + settings: { modeId: "full-access" }, + }, + }); + expect(response.isError).not.toBe(true); + const childId = (response.structuredContent as { agentId: string }).agentId; + expect((await destination.client.fetchAgent(childId))?.agent.status).toBe("running"); + const records = async (home: string) => + JSON.parse(await readFile(join(home, "finish-subscriptions.json"), "utf8")); + expect(await records(a.paseoHome)).toHaveLength(1); + // An unavailable saved route must retain intent, including through a restart. + await origin.paseo.hosts.update(saved.registrationId, { enabled: false }); + await origin.paseo.dispose(); + await origin.client.close(); + await mcp.close(); + await a.close(); + const restarted = await daemon({ + paseoHomeRoot: root, + cleanup: false, + agentClients: clients(), + mcpEnabled: true, + }); + const recovered = await api(restarted); + expect(await records(restarted.paseoHome)).toHaveLength(1); + expect(parentPrompts).toEqual([]); + expect((await destination.client.fetchAgent(childId))?.agent.status).toBe("running"); + await recovered.paseo.hosts.update(saved.registrationId, { enabled: true }); + await online(recovered.paseo); + expect(parentPrompts).toEqual([]); + expect(await records(restarted.paseoHome)).toHaveLength(1); + finish.resolve(); + await expect.poll(() => parentPrompts, { timeout: 15000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain(`Agent ${childId}`); + expect(parentPrompts[0]).toContain(`Server: ${remoteId}`); + expect(parentPrompts[0]).toContain('event="finished"'); + await expect.poll(() => records(restarted.paseoHome), { timeout: 5000 }).toEqual([]); + }, +); + +test( + "remote finish observation survives a permission checkpoint until resumed completion", + { timeout: 30000 }, + async () => { + const parentPrompts: string[] = []; + const finish = Promise.withResolvers(); + cleanup.push(async () => { + finish.resolve(); + }); + const a = await daemon({ + mcpEnabled: true, + agentClients: createTestAgentClients({ + onStartTurn: (prompt) => { + if (String(prompt).includes('kind="agent-notification"')) + parentPrompts.push(String(prompt)); + }, + }), + }); + const b = await daemon({ + agentClients: createTestAgentClients({ beforeTurnComplete: () => finish.promise }), + }); + const origin = await api(a); + const destination = await api(b); + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const directory = await online(origin.paseo); + const workspace = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const remoteWorkspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Permission parent", + }); + const mcp = new Client({ name: "permission-parent", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const response = await mcp.callTool({ + name: "create_agent", + arguments: { + serverId: directory.hosts[0]!.serverId, + workspaceId: remoteWorkspace.id, + provider: "claude/claude-test", + title: "Permission child", + initialPrompt: "Run rm -f permission.txt", + settings: { modeId: "default" }, + }, + }); + expect(response.isError).not.toBe(true); + const childId = (response.structuredContent as { agentId: string }).agentId; + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain('event="permission-required"'); + expect(parentPrompts[0]).toContain("respond_to_permission"); + const child = (await destination.client.fetchAgent(childId))!.agent; + expect(child.pendingPermissions).toHaveLength(1); + expect( + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + ).toHaveLength(1); + await destination.client.respondToPermission(childId, child.pendingPermissions[0]!.id, { + behavior: "allow", + }); + expect(parentPrompts).toHaveLength(1); + expect((await destination.client.fetchAgent(childId))?.agent.status).toBe("running"); + finish.resolve(); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); + expect(parentPrompts[1]).toContain('event="finished"'); + await expect + .poll(async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + ) + .toEqual([]); + }, +); + +test( + "disabled remote send rejects unsent finish intent before reconnecting to an idle child", + { timeout: 30000 }, + async () => { + const parentPrompts: string[] = []; + const a = await daemon({ + mcpEnabled: true, + agentClients: createTestAgentClients({ + onStartTurn: (prompt) => { + if (String(prompt).includes('kind="agent-notification"')) + parentPrompts.push(String(prompt)); + }, + }), + }); + const b = await daemon({ agentClients: createTestAgentClients() }); + const origin = await api(a); + const destination = await api(b); + const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const directory = await online(origin.paseo); + const serverId = directory.hosts[0]!.serverId!; + const workspace = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const remoteWorkspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Parent", + }); + const child = await remoteWorkspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Idle child", + }); + const mcp = new Client({ name: "offline-parent", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + await origin.paseo.hosts.update(saved.registrationId, { enabled: false }); + const response = await mcp.callTool({ + name: "send_agent_prompt", + arguments: { serverId, agentId: child.id, prompt: "Never sent" }, + }); + expect(response.isError).toBe(true); + const records = () => + readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8").then(JSON.parse); + expect(await records()).toEqual([]); + await origin.paseo.hosts.update(saved.registrationId, { enabled: true }); + await online(origin.paseo); + expect((await destination.client.fetchAgent(child.id))?.agent.status).toBe("idle"); + expect(await records()).toEqual([]); + expect(parentPrompts).toEqual([]); + }, +); + +test.each(["removed", "disabled"] as const)( + "%s remote destination reports unknown child status and consumes its finish subscription", + { timeout: 30000 }, + async (availability) => { + const parentPrompts: string[] = []; + const finish = Promise.withResolvers(); + cleanup.push(async () => finish.resolve()); + const a = await daemon({ + mcpEnabled: true, + agentClients: createTestAgentClients({ + onStartTurn: (prompt) => { + if (String(prompt).includes('kind="agent-notification"')) + parentPrompts.push(String(prompt)); + }, + }), + }); + const b = await daemon({ + agentClients: createTestAgentClients({ beforeTurnComplete: () => finish.promise }), + }); + const origin = await api(a); + const destination = await api(b); + const saved = await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const directory = await online(origin.paseo); + const serverId = directory.hosts[0]!.serverId!; + const workspace = await origin.paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const remoteWorkspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Parent", + }); + const mcp = new Client({ name: "removed-host-parent", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const response = await mcp.callTool({ + name: "create_agent", + arguments: { + serverId, + workspaceId: remoteWorkspace.id, + provider: "claude/claude-test", + title: "Child", + initialPrompt: "Held work", + settings: { modeId: "full-access" }, + }, + }); + expect(response.isError).not.toBe(true); + const childId = (response.structuredContent as { agentId: string }).agentId; + if (availability === "removed") await origin.paseo.hosts.remove(saved.registrationId); + else await origin.paseo.hosts.update(saved.registrationId, { enabled: false }); + await expect.poll(() => parentPrompts, { timeout: 15000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain(`Status of child agent ${childId}`); + expect(parentPrompts[0]).toContain(`Server: ${serverId}`); + expect(parentPrompts[0]).toContain( + availability === "removed" + ? `Host ${serverId} was removed or is unavailable.` + : `Could not observe child agent ${childId} on host ${serverId} after three failed attempts.`, + ); + expect(parentPrompts[0]).toContain("is unknown."); + expect(parentPrompts[0]).not.toContain("finished."); + await expect + .poll(async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + ) + .toEqual([]); + if (availability === "removed") + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + else await origin.paseo.hosts.update(saved.registrationId, { enabled: true }); + await online(origin.paseo); + finish.resolve(); + const completed = await destination.client.waitForFinish(childId, 5000); + expect(completed.final?.status).toBe("idle"); + expect(parentPrompts).toHaveLength(1); + expect( + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + ).toEqual([]); + }, +); diff --git a/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts b/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts index 6486f8e04a..efc99c9b59 100644 --- a/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts @@ -1,3 +1,5 @@ +import { AgentStorage } from "../agent/agent-storage.js"; +import { createTestLogger } from "../../test-utils/test-logger.js"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -64,3 +66,157 @@ describe("daemon restart resume", () => { } }, 30_000); }); + +// These run in separate OS processes so SIGKILL cannot invoke daemon shutdown hooks. +import { fork, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { readFile, mkdtemp, rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +async function restartFixture(root: string) { + const child = fork( + fileURLToPath(new URL("../test-utils/finish-subscription-daemon.ts", import.meta.url)), + [root], + { + execArgv: ["--conditions=source", "--import", "tsx"], + stdio: ["ignore", "pipe", "pipe", "ipc"], + }, + ); + let output = ""; + child.stdout?.on("data", (data) => { + output += data; + }); + child.stderr?.on("data", (data) => { + output += data; + }); + const ready = await Promise.race([ + once(child, "message").then( + ([message]) => message as { port: number; paseoHome: string; cwd: string }, + ), + once(child, "exit").then(([code]) => { + throw new Error(`Fixture exited ${code}: ${output}`); + }), + ]); + return { child, ...ready }; +} +async function stopFixture(child: ChildProcess, abrupt: boolean) { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, "exit"); + if (abrupt) child.kill("SIGKILL"); + else child.send("stop"); + await exited; +} +async function readPrompts(root: string): Promise { + try { + return (await readFile(path.join(root, "parent-prompts.jsonl"), "utf8")) + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +test.each([false, true])( + "durable finish subscriptions recover a running child after restart (abrupt=%s)", + async (abrupt) => { + const root = await mkdtemp(path.join(tmpdir(), "durable-finish-process-")); + const processes: ChildProcess[] = []; + let client: DaemonClient | undefined; + let mcp: Client | undefined; + try { + const first = await restartFixture(root); + processes.push(first.child); + client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const parent = await client.createAgent({ + provider: "claude", + cwd: first.cwd, + title: "Durable parent", + modeId: "full-access", + }); + mcp = new Client({ name: "durable-parent", version: "1" }); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${first.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const result = await mcp.callTool({ + name: "create_agent", + arguments: { + provider: "claude/claude-test", + title: "Held child", + initialPrompt: "HOLD_CHILD", + }, + }); + expect(result.isError).not.toBe(true); + const childId = (result.structuredContent as { agentId: string }).agentId; + expect((await client.fetchAgent(childId))?.agent.status).toBe("running"); + // Kill only after the running snapshot commits, so this exercises bootstrap repair + // rather than recovery of the earlier idle snapshot while its write is pending. + await expect + .poll( + async () => + ( + await new AgentStorage(path.join(first.paseoHome, "agents"), createTestLogger()).get( + childId, + ) + )?.lastStatus, + ) + .toBe("running"); + expect( + JSON.parse(await readFile(path.join(first.paseoHome, "finish-subscriptions.json"), "utf8")), + ).toHaveLength(1); + await mcp.close(); + mcp = undefined; + await client.close(); + client = undefined; + await stopFixture(first.child, abrupt); + const second = await restartFixture(root); + processes.push(second.child); + await expect.poll(() => readPrompts(root), { timeout: 15000 }).toHaveLength(1); + const prompts = await readPrompts(root); + expect(prompts[0]).toContain(`Agent ${childId}`); + expect(prompts[0]).toContain("was closed"); + await expect + .poll( + async () => + JSON.parse( + await readFile(path.join(second.paseoHome, "finish-subscriptions.json"), "utf8"), + ), + { timeout: 5000 }, + ) + .toEqual([]); + client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + expect((await client.fetchAgent(childId))?.agent.status).toBe("closed"); + await client.close(); + client = undefined; + await stopFixture(second.child, false); + const third = await restartFixture(root); + processes.push(third.child); + expect(await readPrompts(root)).toHaveLength(1); + expect( + JSON.parse(await readFile(path.join(third.paseoHome, "finish-subscriptions.json"), "utf8")), + ).toEqual([]); + } catch (error) { + console.error( + await readFile(path.join(root, "fixture.log"), "utf8").catch(() => "No fixture log"), + ); + throw error; + } finally { + await mcp?.close(); + await client?.close(); + for (const child of processes.toReversed()) await stopFixture(child, true); + await rm(root, { recursive: true, force: true }); + } + }, + 60000, +); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index e5fdbca32f..9a2b2c0820 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5360,7 +5360,7 @@ export class Session { if (!record || record.internal) { return null; } - return { record, lastMessage: null }; + return { record, lastMessage: await this.agentManager.getLastAssistantMessage(agentId) }; } private async getAgentPayloadById(agentId: string): Promise { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index dcc1d65b40..e4a0ca7668 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -598,6 +598,7 @@ function createSessionForWorkspaceTests( listAgents: () => [], listProviderSubagentActivity: () => [], getAgent: () => null, + getLastAssistantMessage: async () => null, archiveAgent: async () => ({ archivedAt: new Date().toISOString() }), archiveSnapshot: async () => ({}), unarchiveSnapshot: async () => true, diff --git a/packages/server/src/server/test-utils/finish-subscription-daemon.ts b/packages/server/src/server/test-utils/finish-subscription-daemon.ts new file mode 100644 index 0000000000..d35bc55ec8 --- /dev/null +++ b/packages/server/src/server/test-utils/finish-subscription-daemon.ts @@ -0,0 +1,40 @@ +import pino from "pino"; +// Isolated process fixture for restart/termination tests. Never targets a developer home. +import { appendFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestPaseoDaemon } from "./paseo-daemon.js"; +import { createTestAgentClients } from "./fake-agent-client.js"; + +const root = process.argv[2]; +if (!root || !process.send) throw new Error("Expected isolated test home and IPC parent"); +await mkdir(join(root, "static"), { recursive: true }); +const held = Promise.withResolvers(); +const daemon = await createTestPaseoDaemon({ + paseoHomeRoot: root, + staticDir: join(root, "static"), + logger: pino({ level: "warn" }, pino.destination(join(root, "fixture.log"))), + cleanup: false, + mcpEnabled: true, + agentClients: createTestAgentClients({ + onStartTurn: (prompt) => { + if (String(prompt).includes('kind="agent-notification"')) { + void appendFile(join(root, "parent-prompts.jsonl"), `${JSON.stringify(prompt)}\n`); + } + }, + beforeTurnComplete: (prompt) => + String(prompt).includes("HOLD_CHILD") ? held.promise : Promise.resolve(), + }), +}); +process.send({ port: daemon.port, paseoHome: daemon.paseoHome, cwd: daemon.staticDir }); +process.on("message", (message) => { + if (message === "finish") held.resolve(); + if (message === "stop") { + void daemon.close().then( + () => process.exit(0), + (error) => { + console.error(error); + process.exit(1); + }, + ); + } +}); From 4fe006df86fc6d605383d81604fb77940813ae77 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 13:26:19 +0700 Subject: [PATCH 40/62] feat(plugins): unify panel props and persist tab state (#7685) * feat(plugins): unify panel props and persist tab state * refactor(plugins): make agent panel registration canonical * docs(plugins): update Chinese panel API reference and migration --- CHANGELOG.md | 8 ++ docs/plugins.md | 2 +- .../plugin-file-panel-examples.spec.ts | 14 ++++ packages/app/src/file-pane/pane.tsx | 37 ++++----- packages/app/src/file-panels/builtin.tsx | 24 +++--- packages/app/src/file-panels/panels.ts | 9 --- packages/app/src/file-panels/plugin-host.tsx | 50 +++--------- packages/app/src/file-panels/source/panel.tsx | 58 +++++++++----- packages/app/src/file-panels/view.tsx | 42 +++++++++- packages/app/src/panels/file-panel.tsx | 4 +- packages/app/src/panels/pane-context.tsx | 2 +- packages/app/src/plugins/evaluate.test.ts | 17 +++++ packages/app/src/plugins/evaluate.ts | 63 ++++++++------- packages/app/src/plugins/types.ts | 5 +- .../src/plugins/workspace-panels/panel.tsx | 33 +++++++- .../plugins/workspace-panels/resolution.ts | 4 +- .../workspace/workspace-pane-content.tsx | 10 +-- .../screens/workspace/workspace-screen.tsx | 39 +++------- .../src/stores/workspace-layout-store.test.ts | 33 ++++++++ .../app/src/stores/workspace-layout-store.ts | 40 +++++++++- packages/plugin/src/boundaries.test.ts | 50 ++++++++++++ packages/plugin/src/client/contracts.ts | 60 +++++++++++---- packages/plugin/src/client/file.ts | 13 ---- packages/plugin/src/client/index.ts | 10 ++- packages/plugin/src/client/tab.ts | 16 ++++ .../markdown-panel/client/rich-markdown.tsx | 76 ++++++++++++++++--- public-docs/plugins/migration.md | 17 +++++ public-docs/plugins/migration.zh-CN.md | 17 +++++ public-docs/plugins/reference.md | 54 +++++++++---- public-docs/plugins/reference.zh-CN.md | 53 +++++++++---- 30 files changed, 618 insertions(+), 242 deletions(-) create mode 100644 packages/plugin/src/client/tab.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c2861aecc..d69f6cb492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ paseo host add 'relay://...' --label laptop - Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8)) - Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8)) +### Deprecated + +- Deprecated plugin panel `context` and flat `agentId` props, and `addWorkspacePanel`'s `context` field (including the agent registration alias). Removal after 2027-04-23; use `params.agentId` and `addAgentPanel`. + +### Changed + +- Changed beta file panel props to `workspaceId`, read-only `params`, `revision`, `file`, and `tab` with persisted per-tab state. Removed beta-only `location`, `PluginFileLocation`, and `PluginFileTab`. + ### Plugins - Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366)) diff --git a/docs/plugins.md b/docs/plugins.md index bf83669d55..2a223bfe85 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -570,7 +570,7 @@ and resolves conflicts. The host has no bar and no notion of a panel's views: th panels own their Preview and Source views, toolbar and editor buffer, and the tab only shows the dot a panel sets through `tab.setModified`. Source is the floor for text files no other built-in claims, never a candidate beside one. Built-in panels (Markdown, HTML, Image, and Source, Paseo's -editor) take the same handle as plugin panels, so a fix to reading, watching, or writing lands in one place: +editor) take exactly `PluginFilePanelProps`, including params and persisted tab state, so a fix to reading, watching, or writing lands in one place: `packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel; make that panel faster instead. Source highlights a large file on native in idle-time slices, the lines around the opening line first. diff --git a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts index 9159a60644..460faeb4ef 100644 --- a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts +++ b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts @@ -105,6 +105,17 @@ async function selectFileView(page: Page, view: "Preview" | "Source") { await expect(option).toHaveAttribute("aria-selected", "true"); } +async function rememberMarkdownViewAcrossReload(page: Page, view: "Preview" | "Source") { + const button = page.getByRole("button", { name: `Rich Markdown ${view}`, exact: true }); + await button.click(); + await page.reload(); + if (view === "Source") { + await expectMarkdownEditor(page, "Edited by Rich Markdown"); + } else { + await expect(page.getByRole("heading", { name: "Edited by Rich Markdown" })).toBeVisible(); + } +} + /** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */ async function pdfViewer(page: Page): Promise { const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/; @@ -220,6 +231,9 @@ test("directory examples mount the PDF frame and write Markdown through the file await expectMarkdownEditor(page, "Edited by Rich Markdown"); }); + await rememberMarkdownViewAcrossReload(page, "Preview"); + await rememberMarkdownViewAcrossReload(page, "Source"); + // "Open with…" switches to Paseo's Markdown panel; its Source view is the editor, with the // cursor and vim mode in the panel's toolbar. await openWithFromTabMenu(page, "notes.md", "Markdown — Paseo"); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 606b27368e..5f70030819 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,5 +1,6 @@ +import { usePaneContext } from "@/panels/pane-context"; import { useEffect, useMemo, useState } from "react"; -import type { PluginFile, PluginFileTab } from "@getpaseo/plugin/client"; +import type { PluginFile, PluginTab } from "@getpaseo/plugin/client"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -69,13 +70,9 @@ export function FilePane({ () => ({ lineStart: location.lineStart, lineEnd: location.lineEnd, - revision: navigationRevision, + path, }), - [location.lineEnd, location.lineStart, navigationRevision], - ); - const panelTarget = useMemo( - () => ({ serverId, workspaceRoot: root, path }), - [path, root, serverId], + [location.lineEnd, location.lineStart, path], ); let body; @@ -113,8 +110,8 @@ export function FilePane({ key={`${panel.id}:${targetKey}`} panel={panel} file={handle.file} - location={panelLocation} - target={panelTarget} + params={panelLocation} + revision={navigationRevision} onModifiedChange={setModified} /> @@ -131,21 +128,27 @@ export function FilePane({ function MountedFilePanel({ panel, file, - location, - target, + params, + revision, onModifiedChange, }: { panel: FilePanel; file: PluginFile; - location: { lineStart?: number; lineEnd?: number; revision: number }; - target: { serverId: string; workspaceRoot: string; path: string }; + params: { path: string; lineStart?: number; lineEnd?: number }; + revision: number; onModifiedChange: (modified: boolean) => void; }) { - const [tab] = useState(() => ({ - setModified: (modified) => onModifiedChange(modified), - })); + const { state, setCurrentTabState } = usePaneContext(); + const tab = useMemo( + () => ({ + setModified: onModifiedChange, + state, + setState: setCurrentTabState, + }), + [onModifiedChange, state, setCurrentTabState], + ); useEffect(() => () => onModifiedChange(false), [onModifiedChange]); - return ; + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/file-panels/builtin.tsx b/packages/app/src/file-panels/builtin.tsx index f47f11d029..5407719ab4 100644 --- a/packages/app/src/file-panels/builtin.tsx +++ b/packages/app/src/file-panels/builtin.tsx @@ -1,4 +1,4 @@ -import { useFileUrl } from "@getpaseo/plugin/client"; +import { useWorkspace, useFileUrl, type PluginFilePanelProps } from "@getpaseo/plugin/client"; import { useCallback, useState } from "react"; import { ScrollView, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -7,28 +7,28 @@ import { useSessionStore } from "@/stores/session-store"; import { FileHtmlPreview } from "./html-preview"; import { FilePanelLoading } from "./loading"; import { FileMarkdownPreview } from "./markdown-preview"; -import type { BuiltinFilePanelProps } from "./panels"; import { TextFilePanel, type TextFilePreviewProps } from "./source/panel"; import type { RenderingFilePanel, SourceFilePanel } from "./catalog"; /** Source: the text file panel with no Preview, for text files no other built-in panel claims. */ -function SourcePanel(props: BuiltinFilePanelProps) { +function SourcePanel(props: PluginFilePanelProps) { return ; } /** Markdown: the text file panel with the rendered document as its Preview. */ -function MarkdownPanel(props: BuiltinFilePanelProps) { +function MarkdownPanel(props: PluginFilePanelProps) { return ; } /** HTML: the text file panel with the sandboxed page as its Preview. */ -function HtmlPanel(props: BuiltinFilePanelProps) { +function HtmlPanel(props: PluginFilePanelProps) { return ; } /** Shows Loading until the rendered Markdown has laid out once; later edits keep the content. */ -function MarkdownPreview({ text, target }: TextFilePreviewProps) { - const client = useSessionStore((state) => state.sessions[target.serverId]?.client ?? null); +function MarkdownPreview({ text, params, workspaceId, host }: TextFilePreviewProps) { + const client = useSessionStore((state) => state.sessions[host.id]?.client ?? null); + const workspaceRoot = useWorkspace(workspaceId, (workspace) => workspace.directory); const [rendered, setRendered] = useState(false); const handleLayout = useCallback(() => setRendered(true), []); return ( @@ -37,10 +37,10 @@ function MarkdownPreview({ text, target }: TextFilePreviewProps) { @@ -58,7 +58,7 @@ function HtmlPreview({ text }: TextFilePreviewProps) { ); } -function ImagePanel({ file }: BuiltinFilePanelProps) { +function ImagePanel({ file }: PluginFilePanelProps) { const url = useFileUrl(file); if (url.status === "loading") return ; if (url.status === "error") return ; @@ -83,7 +83,7 @@ const components = { export function BuiltinFilePanelView({ panel, ...props -}: BuiltinFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) { +}: PluginFilePanelProps & { panel: RenderingFilePanel | SourceFilePanel }) { const Component = components[panel.id]; return ; } diff --git a/packages/app/src/file-panels/panels.ts b/packages/app/src/file-panels/panels.ts index 7646755074..b3f33e040a 100644 --- a/packages/app/src/file-panels/panels.ts +++ b/packages/app/src/file-panels/panels.ts @@ -1,5 +1,4 @@ import { useMemo } from "react"; -import type { PluginFile, PluginFileLocation, PluginFileTab } from "@getpaseo/plugin/client"; import { useInstalledPlugins } from "@/plugins/registry"; import type { PluginFilePanelContribution, InstalledPlugin } from "@/plugins/types"; import { filePanelCatalog, type RenderingFilePanel, type SourceFilePanel } from "./catalog"; @@ -13,14 +12,6 @@ export interface PluginFilePanel { } export type FilePanel = RenderingFilePanel | PluginFilePanel | SourceFilePanel; -/** A built-in panel gets the plugin panel's file, tab and location, plus where the file lives. */ -export interface BuiltinFilePanelProps { - file: PluginFile; - tab: PluginFileTab; - location: PluginFileLocation; - target: { serverId: string; workspaceRoot: string; path: string }; -} - export function useFilePanels(serverId: string): readonly FilePanel[] { const plugins = useInstalledPlugins(); return useMemo( diff --git a/packages/app/src/file-panels/plugin-host.tsx b/packages/app/src/file-panels/plugin-host.tsx index 06b2260a8b..4cc96cdc3d 100644 --- a/packages/app/src/file-panels/plugin-host.tsx +++ b/packages/app/src/file-panels/plugin-host.tsx @@ -1,54 +1,28 @@ -import { useMemo } from "react"; import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; -import type { PluginTheme } from "@getpaseo/plugin"; -import { withUnistyles } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { usePaneContext } from "@/panels/pane-context"; -import { useHosts } from "@/runtime/host-runtime"; +import type { PluginFilePanelProps } from "@getpaseo/plugin/client"; +import { useMemo } from "react"; import { createPluginClientStateSource } from "@/plugins/client-state/source"; -import { usePluginHostNavigation } from "@/plugins/host-navigation"; import { PluginInstallationProvider } from "@/plugins/installation-provider"; import { SurfaceErrorBoundary } from "@/plugins/surface-error-boundary"; -import { resolvePluginPlatform } from "@/plugins/platform"; -import { toPluginTheme } from "@/plugins/theme"; -import type { Theme } from "@/styles/theme"; -import type { BuiltinFilePanelProps, PluginFilePanel } from "./panels"; +import type { PluginFilePanel } from "./panels"; -type Props = Pick & { panel: PluginFilePanel }; - -function PluginHost({ panel, file, tab, location, theme }: Props & { theme: PluginTheme }) { - const { workspaceId } = usePaneContext(); +export function PluginFilePanelHost({ + panel, + ...props +}: PluginFilePanelProps & { panel: PluginFilePanel }) { const { plugin, contribution } = panel; - const serverId = plugin.serverId; - const compact = useIsCompactFormFactor(); - const hosts = useHosts(); - const navigation = usePluginHostNavigation(serverId); - const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); - const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; - const host = useMemo(() => ({ id: serverId, label: hostLabel }), [serverId, hostLabel]); - const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); + const stateSource = useMemo( + () => createPluginClientStateSource(plugin.serverId), + [plugin.serverId], + ); const Component = contribution.Component; return ( - + ); } -const ThemedHost = withUnistyles(PluginHost); -const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); -export function PluginFilePanelHost(props: Props) { - return ; -} diff --git a/packages/app/src/file-panels/source/panel.tsx b/packages/app/src/file-panels/source/panel.tsx index 7339cc50e7..23ea42f875 100644 --- a/packages/app/src/file-panels/source/panel.tsx +++ b/packages/app/src/file-panels/source/panel.tsx @@ -7,7 +7,11 @@ import { type ComponentProps, type ComponentType, } from "react"; -import { useFileText, type PluginFileLocation } from "@getpaseo/plugin/client"; +import { + useFileText, + type PluginFilePanelProps, + type PluginFilePanelParams, +} from "@getpaseo/plugin/client"; import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -18,7 +22,6 @@ import { confirmDialog } from "@/utils/confirm-dialog"; import { formatFileSize } from "@/utils/format-file-size"; import type { Theme } from "@/styles/theme"; import { FilePanelLoading } from "../loading"; -import type { BuiltinFilePanelProps } from "../panels"; import type { FileConflictAlertState } from "./conflict-alert"; import { FileEditorModel, getFileConflictCallout } from "./editor/model"; import { FileEditorView } from "./editor/view"; @@ -51,10 +54,12 @@ const MAX_EDITABLE_BYTES = 1024 * 1024; /** A rendered view of the file's text: the panel's Preview, shown instead of its source. */ export interface TextFilePreviewProps { text: string; - target: BuiltinFilePanelProps["target"]; + params: PluginFilePanelProps["params"]; + workspaceId: string; + host: PluginFilePanelProps["host"]; } -type TextFilePanelProps = BuiltinFilePanelProps & { +type TextFilePanelProps = PluginFilePanelProps & { /** The panel's Preview; with one, the toolbar switches between Preview and Source. */ preview?: ComponentType; }; @@ -78,9 +83,10 @@ export function TextFilePanel(props: TextFilePanelProps) { * each later line target switches to Source again. */ function useTextFileView( - location: PluginFileLocation, + params: PluginFilePanelParams, + revision: number, ): [TextFileView, (view: TextFileView) => void] { - const lineTarget = location.lineStart ? location.revision : null; + const lineTarget = params.lineStart ? revision : null; const [view, setView] = useState(lineTarget === null ? "preview" : "source"); const [shownLineTarget, setShownLineTarget] = useState(lineTarget); if (lineTarget !== null && lineTarget !== shownLineTarget) { @@ -102,17 +108,19 @@ function TooLargeTextFile({ size }: { size: number }) { function ReadonlyTextFile({ file, - location, - target, + params, + revision, + workspaceId, + host, preview: Preview, presentation, }: TextFilePanelProps & { presentation: "highlighted" | "plain" }) { const text = useFileText(file); const { t } = useTranslation(); - const [view, setView] = useTextFileView(location); + const [view, setView] = useTextFileView(params, revision); const sourceLocation = useMemo( - () => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }), - [location.lineEnd, location.lineStart, target.path], + () => ({ path: params.path, lineStart: params.lineStart, lineEnd: params.lineEnd }), + [params.lineEnd, params.lineStart, params.path], ); if (text.status === "loading") return ; if (text.status === "error") { @@ -132,13 +140,13 @@ function ReadonlyTextFile({ onViewChange={Preview ? setView : undefined} /> {showPreview ? ( - + ) : ( )} @@ -146,11 +154,19 @@ function ReadonlyTextFile({ ); } -function EditableTextFile({ file, tab, location, target, preview: Preview }: TextFilePanelProps) { +function EditableTextFile({ + file, + tab, + params, + revision, + workspaceId, + host, + preview: Preview, +}: TextFilePanelProps) { const { settings } = useAppSettings(); const { t } = useTranslation(); const [model] = useState(() => new FileEditorModel({ file })); - const [view, setView] = useTextFileView(location); + const [view, setView] = useTextFileView(params, revision); const [cursor, setCursor] = useState({ line: 1, column: 1 }); const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); @@ -181,8 +197,8 @@ function EditableTextFile({ file, tab, location, target, preview: Preview }: Tex const handleOverwrite = useCallback(() => void model.overwrite(), [model]); const handleRetry = useCallback(() => void model.reload(), [model]); const editorLocation = useMemo( - () => ({ path: target.path, lineStart: location.lineStart, lineEnd: location.lineEnd }), - [location.lineEnd, location.lineStart, target.path], + () => ({ path: params.path, lineStart: params.lineStart, lineEnd: params.lineEnd }), + [params.lineEnd, params.lineStart, params.path], ); if (snapshot.status === "loading") return ; @@ -222,13 +238,13 @@ function EditableTextFile({ file, tab, location, target, preview: Preview }: Tex conflict={conflict} /> {showPreview ? ( - + ) : ( ; +type Props = Pick & { + panel: FilePanel; +}; +function FilePanelHost({ panel, theme, ...input }: Props & { theme: PluginTheme }) { + const { workspaceId, serverId } = usePaneContext(); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const label = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const host = useMemo(() => ({ id: serverId, label }), [serverId, label]); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); + const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); + const navigation = usePluginHostNavigation(serverId); + const props: PluginFilePanelProps = { ...input, workspaceId, theme, host, layout, navigation }; + if (panel.builtin) + return ( + + + + ); return ; } +const ThemedHost = withUnistyles(FilePanelHost); +const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); +export function FilePanelView(props: Props) { + return ; +} diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index 1d5112359e..d26bd3271d 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -30,7 +30,7 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) { function FilePanel() { const { t } = useTranslation(); - const { serverId, workspaceId, target, fileNavigationRevision } = usePaneContext(); + const { serverId, workspaceId, target, panelRevision } = usePaneContext(); const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId); invariant(target.kind === "file", "FilePanel requires file target"); if (!workspaceDirectory) { @@ -45,7 +45,7 @@ function FilePanel() { serverId={serverId} workspaceRoot={workspaceDirectory} location={target} - navigationRevision={fileNavigationRevision ?? 0} + navigationRevision={panelRevision ?? 0} /> ); } diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 5e716b006a..52b33e5960 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -13,7 +13,7 @@ export interface PaneContextValue { tabId: string; target: WorkspaceTabTarget; state?: JsonValue; - fileNavigationRevision?: number; + panelRevision?: number; openTab: (target: WorkspaceTabTarget) => void; openPreferredTarget: (target: WorkspaceTabTarget, source: OpenInSidePaneSource) => void; openTargetToSide?: (target: WorkspaceTabTarget) => void; diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index f0c768139a..c1f95b87fa 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -381,6 +381,23 @@ describe("evaluatePluginClientBundle", () => { ).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]); }); + it("registers workspace and agent panels with stable aliases", () => { + const plugin = evaluatePluginClientBundle( + "panels", + bundle(` + const Component = () => null; + plugin.addWorkspacePanel({ id: "workspace", title: "Workspace", icon: "File", Component }); + plugin.addAgentPanel({ id: "agent", title: "Agent", icon: "File", Component }); + plugin.addWorkspacePanel({ id: "legacy", title: "Legacy", icon: "File", context: "agent", Component }); + `), + ); + expect(plugin.workspacePanels.map(({ id, context }) => ({ id, context }))).toEqual([ + { id: "workspace", context: "workspace" }, + { id: "agent", context: "agent" }, + { id: "legacy", context: "agent" }, + ]); + }); + it("registers inline SVG and component icons", () => { const svg = ''; const plugin = evaluatePluginClientBundle( diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index db5fece8c7..44bcbec57d 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -15,6 +15,8 @@ import * as Zod from "zod"; import { type PluginCleanup } from "@getpaseo/plugin"; import { type PluginClientContext, + type PluginAgentPanelContribution, + type PluginWorkspacePanelContribution as WorkspacePanelInput, type PluginScreenProps, type PluginScreenTitle, type PluginText, @@ -206,6 +208,31 @@ export function runPluginClientBundle( () => ids.delete(normalizedId), ); } + function registerPanel(contribution: PluginWorkspacePanelContribution): PluginCleanup { + const normalizedId = requireId(contribution.id, "workspace panel id"); + if (workspacePanelIds.has(normalizedId)) { + throw new Error(`Duplicate workspace panel: ${normalizedId}`); + } + const title = normalizePluginText(contribution.title); + if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`); + const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon); + if (typeof contribution.Component !== "function") { + throw new Error(`Workspace panel ${normalizedId} is not a component`); + } + const locations = normalizePanelLocations(normalizedId, contribution.locations); + workspacePanelIds.add(normalizedId); + return register( + collector.workspacePanels, + { + ...contribution, + id: normalizedId, + title, + icon, + locations, + }, + () => workspacePanelIds.delete(normalizedId), + ); + } const pluginContext: PluginClientContext = { ...runtime, addSettingsScreen(contribution) { @@ -313,33 +340,17 @@ export function runPluginClientBundle( () => filePanelIds.delete(panelId), ); }, - addWorkspacePanel(contribution: PluginWorkspacePanelContribution) { - const normalizedId = requireId(contribution.id, "workspace panel id"); - if (workspacePanelIds.has(normalizedId)) { - throw new Error(`Duplicate workspace panel: ${normalizedId}`); + addAgentPanel(contribution: PluginAgentPanelContribution) { + return registerPanel({ ...contribution, context: "agent" }); + }, + addWorkspacePanel(input: WorkspacePanelInput) { + // COMPAT(pluginAgentPanelAlias): added in v0.13.0, remove after 2027-04-23 + if (input.context === "agent") return pluginContext.addAgentPanel(input); + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + if (input.context !== undefined && input.context !== "workspace") { + throw new Error(`Workspace panel ${input.id} has invalid context`); } - const title = normalizePluginText(contribution.title); - if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`); - const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon); - if (contribution.context !== "workspace" && contribution.context !== "agent") { - throw new Error(`Workspace panel ${normalizedId} has invalid context`); - } - if (typeof contribution.Component !== "function") { - throw new Error(`Workspace panel ${normalizedId} is not a component`); - } - const locations = normalizePanelLocations(normalizedId, contribution.locations); - workspacePanelIds.add(normalizedId); - return register( - collector.workspacePanels, - { - ...contribution, - id: normalizedId, - title, - icon, - locations, - }, - () => workspacePanelIds.delete(normalizedId), - ); + return registerPanel({ ...input, context: "workspace" }); }, addCommandCenterItem(contribution: PluginCommandCenterItemContribution) { const normalizedId = requireId(contribution.id, "Command Center item id"); diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 63d5f1c9f9..9c2a88a210 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -32,7 +32,10 @@ type PluginSidebarContribution = SDKPluginSidebarContribution; type PluginSidebarItemContribution = SDKPluginSidebarItemContribution; type PluginScreenContribution = SDKPluginScreenContribution; type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution; -type PluginWorkspacePanelContribution = SDKPluginWorkspacePanelContribution; +type WorkspacePanelInput = SDKPluginWorkspacePanelContribution; +type PluginWorkspacePanelContribution = + | (Extract & { context: "workspace" }) + | Extract; export type PluginSidebarSection = "header" | "footer"; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 5534f78113..096400fb66 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -1,8 +1,13 @@ -import type { PluginAgentPanelProps, PluginWorkspacePanelProps } from "@getpaseo/plugin/client"; +import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; +import type { + PluginAgentPanelProps, + PluginWorkspacePanelProps, + PluginTab, +} from "@getpaseo/plugin/client"; import type { PluginTheme } from "@getpaseo/plugin"; import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; import { CircleAlert } from "lucide-react-native"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; @@ -29,7 +34,20 @@ const pluginThemeMapping = (theme: Theme) => ({ }); function PluginPanelBody({ theme }: { theme: PluginTheme }) { - const { serverId, workspaceId, target } = usePaneContext(); + const { + serverId, + workspaceId, + target, + state: tabState, + setCurrentTabState, + panelRevision, + } = usePaneContext(); + const [modified, setModified] = useState(false); + usePublishPanelInstanceAttributes({ modified }); + const tab = useMemo( + () => ({ state: tabState, setState: setCurrentTabState, setModified }), + [tabState, setCurrentTabState], + ); invariant(target.kind === "plugin", "PluginPanel requires plugin target"); const plugin = useInstalledPlugin(serverId, target.pluginId); const contribution = resolvePluginWorkspacePanel(plugin, target); @@ -63,7 +81,11 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { let Surface: unknown; if (contribution.context === "workspace") { const props: PluginWorkspacePanelProps = { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 context: "workspace", + params: {}, + revision: panelRevision ?? 0, + tab, theme, host, layout, @@ -75,12 +97,17 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { panel = ; } else if (agentExists && target.context === "agent") { const props: PluginAgentPanelProps = { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 context: "agent", + params: { agentId: target.agentId }, + revision: panelRevision ?? 0, + tab, theme, host, layout, navigation, workspaceId, + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 agentId: target.agentId, }; const Component = contribution.Component; diff --git a/packages/app/src/plugins/workspace-panels/resolution.ts b/packages/app/src/plugins/workspace-panels/resolution.ts index 4c3af57c35..4f0703564f 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.ts @@ -1,11 +1,11 @@ -import type { PluginWorkspacePanelContribution } from "../types"; +import type { EvaluatedPluginWorkspacePanelContribution } from "../types"; import type { InstalledPlugin } from "../types"; import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model"; export function resolvePluginWorkspacePanel( plugin: InstalledPlugin | null, target: PluginWorkspaceTabTarget, -): PluginWorkspacePanelContribution | null { +): EvaluatedPluginWorkspacePanelContribution | null { return ( plugin?.workspacePanels.find( (panel) => panel.id === target.panelId && panel.context === target.context, diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 0ef496a94b..21476a85a4 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -27,7 +27,7 @@ export interface BuildWorkspacePaneContentModelInput { normalizedServerId: string; normalizedWorkspaceId: string; host: PaneHost; - fileNavigationRevision?: number; + panelRevision?: number; onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; onOpenPreferredTarget: ( target: WorkspaceTabDescriptor["target"], @@ -46,7 +46,7 @@ export function buildWorkspacePaneContentModel({ normalizedServerId, normalizedWorkspaceId, host, - fileNavigationRevision, + panelRevision, onOpenTab, onOpenPreferredTarget, onOpenTargetToSide, @@ -69,7 +69,7 @@ export function buildWorkspacePaneContentModel({ tabId: tab.tabId, target: tab.target, state: tab.state, - fileNavigationRevision, + panelRevision, openTab: onOpenTab, openPreferredTarget: onOpenPreferredTarget, openTargetToSide: onOpenTargetToSide, @@ -112,7 +112,7 @@ export function WorkspacePaneContent({ tabId: paneContextValue.tabId, target: paneContextValue.target, state: paneContextValue.state, - fileNavigationRevision: paneContextValue.fileNavigationRevision, + panelRevision: paneContextValue.panelRevision, openTab, openPreferredTarget, openTargetToSide: paneContextValue.openTargetToSide ? openTargetToSide : undefined, @@ -130,7 +130,7 @@ export function WorkspacePaneContent({ openPreferredTarget, openTargetToSide, paneContextValue.serverId, - paneContextValue.fileNavigationRevision, + paneContextValue.panelRevision, paneContextValue.tabId, paneContextValue.target, paneContextValue.state, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 8c1adf7520..ed1bbfdfcd 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1130,17 +1130,9 @@ function WorkspaceScreenContent({ ) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }), [openTab], ); - // File targets stay identity-stable so the same path reuses its tab. Keep navigation - // requests separate so clicking an unchanged path:line can still recenter the pane. - const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState< - Record - >({}); - const requestFileNavigation = useCallback((tabId: string) => { - setFileNavigationRevisionByTabId((current) => ({ - ...current, - [tabId]: (current[tabId] ?? 0) + 1, - })); - }, []); + const panelRevisions = useWorkspaceLayoutStore((state) => + persistenceKey ? state.panelRevisionByWorkspace[persistenceKey] : undefined, + ); const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); const hasHydratedWorkspaces = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, @@ -1680,7 +1672,6 @@ function WorkspaceScreenContent({ ? revealWorkspaceChildTab(persistenceKey, target, parentTabId, FOCUSED_PANE_PLACEMENT) : openWorkspaceTabFocused(persistenceKey, target, FOCUSED_PANE_PLACEMENT); if (tabId) { - requestFileNavigation(tabId); navigateToTabId(tabId); } }, @@ -1690,7 +1681,6 @@ function WorkspaceScreenContent({ openWorkspaceTabFocused, revealWorkspaceChildTab, persistenceKey, - requestFileNavigation, showMobileAgent, ], ); @@ -1705,18 +1695,15 @@ function WorkspaceScreenContent({ return; } - const tabId = openPreferredWorkspaceTarget({ + openPreferredWorkspaceTarget({ workspaceKey: persistenceKey, target: createWorkspaceFileTabTarget(location), source: "chatFiles", preferences: openInSidePane, parentTabId: input.parentTabId, }); - if (tabId) { - requestFileNavigation(tabId); - } }, - [openInSidePane, persistenceKey, requestFileNavigation], + [openInSidePane, persistenceKey], ); const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({ @@ -1736,14 +1723,11 @@ function WorkspaceScreenContent({ if (request.disposition === "side") { const location = normalizeWorkspaceFileLocation(request.location); if (!location || !persistenceKey) return; - const tabId = openWorkspaceTargetBeside({ + openWorkspaceTargetBeside({ workspaceKey: persistenceKey, target: createWorkspaceFileTabTarget(location), parentTabId, }); - if (tabId) { - requestFileNavigation(tabId); - } return; } if (request.disposition === "preferred") { @@ -2947,7 +2931,7 @@ function WorkspaceScreenContent({ host: input.host ?? (input.paneId !== null && input.paneId === explorerSidebarPaneId ? "explorer" : "main"), - fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0, + panelRevision: panelRevisions?.[input.tab.tabId] ?? 0, onOpenTab: (target) => { if (!persistenceKey) { return; @@ -2964,7 +2948,7 @@ function WorkspaceScreenContent({ }, onOpenPreferredTarget: (target, source) => { if (!persistenceKey) return; - const tabId = openPreferredWorkspacePreview({ + openPreferredWorkspacePreview({ workspaceKey: persistenceKey, serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -2974,7 +2958,6 @@ function WorkspaceScreenContent({ source, preferences: openInSidePane, }); - if (tabId && target.kind === "file") requestFileNavigation(tabId); }, onOpenTargetToSide: canRenderDesktopPaneSplits && @@ -2982,12 +2965,11 @@ function WorkspaceScreenContent({ input.paneId === explorerSidebarPaneId ? (target) => { if (!persistenceKey) return; - const tabId = openWorkspaceTargetBeside({ + openWorkspaceTargetBeside({ workspaceKey: persistenceKey, target, parentTabId: input.tab.tabId, }); - if (tabId && target.kind === "file") requestFileNavigation(tabId); } : undefined, onCloseCurrentTab: () => { @@ -3016,7 +2998,7 @@ function WorkspaceScreenContent({ }), [ handleCloseTabById, - fileNavigationRevisionByTabId, + panelRevisions, handleOpenWorkspaceFileFromPane, navigateToTabId, normalizedServerId, @@ -3024,7 +3006,6 @@ function WorkspaceScreenContent({ canRenderDesktopPaneSplits, openImportSheet, openInSidePane, - requestFileNavigation, revealWorkspaceChildTab, persistenceKey, replaceWorkspaceTabTarget, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index cfac2f999a..431d550823 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -79,6 +79,39 @@ function createDeterministicWorkspaceLayoutIds() { const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds(); const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds); +it("bumps panel params revision when the same tab is opened again", () => { + const storage = { + getItem: async () => null, + setItem: async () => {}, + removeItem: async () => {}, + }; + const store = createWorkspaceLayoutStore(workspaceLayoutIds, storage); + const workspaceKey = "server-1:workspace-params"; + const targets = [ + { kind: "file" as const, path: "notes.md", lineStart: 3 }, + { + kind: "plugin" as const, + pluginId: "notes", + panelId: "workspace", + context: "workspace" as const, + }, + { + kind: "plugin" as const, + pluginId: "notes", + panelId: "agent", + context: "agent" as const, + agentId: "agent-1", + }, + ]; + for (const target of targets) { + const first = store.getState().openTab({ workspaceKey, target, intent: "reveal" })!; + const revision = store.getState().panelRevisionByWorkspace[workspaceKey][first]; + const second = store.getState().openTab({ workspaceKey, target, intent: "reveal" }); + expect(second).toBe(first); + expect(store.getState().panelRevisionByWorkspace[workspaceKey][first]).toBe(revision + 1); + } +}); + it("observes open chats across unmounted workspaces until their tabs close", () => { const store = createWorkspaceLayoutStore(workspaceLayoutIds); store.setState({ layoutByWorkspace: {} }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 4091b7d53c..60f96ae330 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -128,6 +128,7 @@ export type WorkspaceTabDestination = | { kind: "sheet"; tabId: string }; interface WorkspaceLayoutStore { + panelRevisionByWorkspace: Record>; /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; pendingOpenByWorkspace: Record; @@ -997,6 +998,22 @@ function createPersistedStateProjection() { }; } +function advancePanelRevision( + state: WorkspaceLayoutStore, + workspaceKey: string, + tabId: string, + target: WorkspaceTabTarget, +) { + if (target.kind !== "file" && target.kind !== "plugin") return {}; + const revisions = state.panelRevisionByWorkspace[workspaceKey] ?? {}; + return { + panelRevisionByWorkspace: { + ...state.panelRevisionByWorkspace, + [workspaceKey]: { ...revisions, [tabId]: (revisions[tabId] ?? 0) + 1 }, + }, + }; +} + export function createWorkspaceLayoutStore( ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds, storage: StateStorage = AsyncStorage, @@ -1023,6 +1040,7 @@ export function createWorkspaceLayoutStore( } return tabId; }, + panelRevisionByWorkspace: {}, pendingOpenByWorkspace: {}, deferOpenTab: (input) => { set((state) => ({ @@ -1095,7 +1113,15 @@ export function createWorkspaceLayoutStore( ? [existing] : []; }); - set((state) => withSheets(state, normalizedWorkspaceKey, next)); + set((state) => ({ + ...withSheets(state, normalizedWorkspaceKey, next), + ...advancePanelRevision( + state, + normalizedWorkspaceKey, + sheet.tab.tabId, + normalizedTarget, + ), + })); return sheet.tab.tabId; } const placement = getOpenTabPlacement( @@ -1151,6 +1177,7 @@ export function createWorkspaceLayoutStore( const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent"; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), + ...advancePanelRevision(state, normalizedWorkspaceKey, result.tabId, normalizedTarget), // A tab brought to the front dismisses the sheet over it. ...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)), hiddenAgentIdsByWorkspace: @@ -1530,7 +1557,15 @@ export function createWorkspaceLayoutStore( })), }; if (policy) { - set({ sheetsByWorkspace: next.sheetsByWorkspace }); + set((state) => ({ + sheetsByWorkspace: next.sheetsByWorkspace, + ...advancePanelRevision( + state, + normalizedWorkspaceKey, + normalizedTabId, + normalizedTarget, + ), + })); return normalizedTabId; } // A target sheets do not host becomes the tab it would have opened as. @@ -1548,6 +1583,7 @@ export function createWorkspaceLayoutStore( if (!result) return null; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), + ...advancePanelRevision(state, normalizedWorkspaceKey, result.tabId, normalizedTarget), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index d69f5f44b0..e2547154bc 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -70,6 +70,26 @@ function boundaryViolations(entry: string, runtime: "shared" | "server" | "clien return violations; } +function typeErrors(code: string): string[] { + const configPath = path.resolve(sourceDirectory, "../tsconfig.examples.json"); + const { config } = ts.readConfigFile(configPath, ts.sys.readFile); + const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath)); + const file = path.resolve( + sourceDirectory, + "../../../plugin-examples/skill-example/client/example.tsx", + ); + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile; + host.getSourceFile = (name, language, ...rest) => + path.resolve(name) === file + ? ts.createSourceFile(name, code, language) + : getSourceFile.call(host, name, language, ...rest); + host.fileExists = (name) => path.resolve(name) === file || ts.sys.fileExists(name); + const program = ts.createProgram([file], options, host); + return ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); +} describe("plugin SDK import boundaries", () => { it("publishes process capabilities without their Windows implementation helpers", () => { expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); @@ -142,3 +162,33 @@ describe("plugin example import boundaries", () => { expect(violations).toEqual([]); }); }); + +describe("plugin panel SDK boundary", () => { + it("accepts uniform params and persisted tab state alongside 0.12 panels", () => { + expect( + typeErrors(` + import { type PluginClientContext, type PluginFilePanelProps, type PluginWorkspacePanelProps, type PluginAgentPanelProps, type PluginTab, useTabState } from "@getpaseo/plugin/client"; + import { z } from "zod"; + function FilePanel({ params, revision, tab, file }: PluginFilePanelProps) { + const [state, setState] = useTabState(tab, z.object({ view: z.enum(["preview", "source"]) }), { view: "preview" }); + setState({ view: "source" }); + tab.setState({ nested: [true, null, 3] }); + // @ts-expect-error only JSON state crosses the host boundary + tab.setState(() => {}); + // @ts-expect-error params belong to the opener + params.path = "other"; + return null; + } + function OldWorkspace({ context, workspaceId }: PluginWorkspacePanelProps) { return null; } + function OldAgent({ context, workspaceId, agentId }: PluginAgentPanelProps) { return null; } + function Agent({ params }: PluginAgentPanelProps) { params.agentId.toUpperCase(); return null; } + function contribute(client: PluginClientContext) { + client.addWorkspacePanel({ id: "old-workspace", title: "Old", icon: "File", context: "workspace", Component: OldWorkspace }); + client.addWorkspacePanel({ id: "old-agent", title: "Old", icon: "File", context: "agent", Component: OldAgent }); + client.addWorkspacePanel({ id: "workspace", title: "Workspace", icon: "File", Component: OldWorkspace }); + client.addAgentPanel({ id: "agent", title: "Agent", icon: "File", Component: Agent }); + } + `), + ).toEqual([]); + }); +}); diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 5e268d0849..7675a655ff 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -1,9 +1,9 @@ import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; import type { PaseoApi } from "@getpaseo/client"; -import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; -import type { PluginFile, PluginFileLocation, PluginFileTab } from "./file.js"; +import type { PluginFile } from "./file.js"; import type { PluginRpcContract } from "../rpc.js"; import type { PluginButtonRegistration, @@ -111,23 +111,44 @@ interface PluginWorkspacePanelBase { locations?: readonly PluginPanelLocation[]; } -export interface PluginWorkspacePanelProps extends PluginNavigableHostProps { - context: "workspace"; - workspaceId: string; +export interface PluginTab { + /** Shows the tab's unsaved dot and asks before closing. */ + setModified(modified: boolean): void; + /** Opaque persisted JSON. Undefined until written; the panel validates it. */ + readonly state: unknown; + setState(state: JsonValue): void; } -export interface PluginAgentPanelProps extends PluginNavigableHostProps { - context: "agent"; +export interface PluginPanelProps extends PluginNavigableHostProps { workspaceId: string; + readonly params: Readonly; + revision: number; + tab: PluginTab; +} +export interface PluginWorkspacePanelParams {} +export interface PluginAgentPanelParams { + readonly agentId: string; +} +export interface PluginFilePanelParams { + readonly path: string; + readonly lineStart?: number; + readonly lineEnd?: number; +} +export interface PluginWorkspacePanelProps extends PluginPanelProps { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Use the panel registration to select the context. */ + context: "workspace"; +} +export interface PluginAgentPanelProps extends PluginPanelProps { + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Use the panel registration to select the context. */ + context: "agent"; + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Read `params.agentId`. */ agentId: string; } - -export interface PluginFilePanelProps extends PluginNavigableHostProps { - workspaceId: string; - /** The file itself. The panel decides how to read it, show it, and save it. */ +export interface PluginFilePanelProps extends PluginPanelProps { file: PluginFile; - tab: PluginFileTab; - location: PluginFileLocation; } export interface PluginFilePanelContribution { @@ -160,6 +181,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl addScreen(contribution: PluginScreenContribution): PluginCleanup; addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; + addAgentPanel(contribution: PluginAgentPanelContribution): PluginCleanup; addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; addCommandCenterItem( contribution: PluginCommandCenterItemContribution, @@ -187,14 +209,24 @@ export type PluginClientContribution = (client: PluginClientContext) => PluginCl export type PluginWorkspacePanelContribution = | (PluginWorkspacePanelBase & { - context: "workspace"; + // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Omit context. */ + context?: "workspace"; Component: ComponentType; }) | (PluginWorkspacePanelBase & { + // COMPAT(pluginAgentPanelAlias): added in v0.13.0, remove after 2027-04-23 + /** @deprecated Register with `addAgentPanel`. */ context: "agent"; Component: ComponentType; }); +export interface PluginAgentPanelContribution< + Text extends PluginText = string, +> extends PluginWorkspacePanelBase { + Component: ComponentType; +} + export interface PluginSettingsScreenContribution { id: string; title: Text; diff --git a/packages/plugin/src/client/file.ts b/packages/plugin/src/client/file.ts index 2c11fcdd0a..7f37e31c9c 100644 --- a/packages/plugin/src/client/file.ts +++ b/packages/plugin/src/client/file.ts @@ -44,19 +44,6 @@ export interface PluginFileWriteOptions { /** What happened to the file on disk. Switch on `type`; later versions may add fields. */ export type PluginFileEvent = { type: "changed" } | { type: "deleted" }; -/** What the panel reports back to its tab. */ -export interface PluginFileTab { - /** Shows the tab's unsaved dot and asks before the tab closes. */ - setModified(modified: boolean): void; -} - -/** The line a link or search result opened the file at. `revision` bumps on every new target. */ -export interface PluginFileLocation { - lineStart?: number; - lineEnd?: number; - revision: number; -} - /** The file changed on disk since the handle last read or wrote it. */ export class PluginFileConflictError extends Error { constructor(message = "The file changed on disk.") { diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index db4292453e..62cf7e2dc3 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -1,5 +1,11 @@ export type { PluginHostProps, + PluginTab, + PluginPanelProps, + PluginWorkspacePanelParams, + PluginAgentPanelParams, + PluginFilePanelParams, + PluginAgentPanelContribution, PluginFilePanelContribution, PluginFilePanelProps, PluginSurfaceProps, @@ -52,8 +58,6 @@ export type { PluginFile, PluginFileBytesState, PluginFileEvent, - PluginFileLocation, - PluginFileTab, PluginFileTextState, PluginFileUrlState, PluginFileWriteOptions, @@ -91,3 +95,5 @@ export declare function openExternalUrl(url: string): Promise; export { defineTranslations, useTranslation } from "./translations.js"; export type { PluginI18n } from "./translations.js"; export type { PluginText, PluginMessage, PluginTranslations } from "../text.js"; + +export { useTabState } from "./tab.js"; diff --git a/packages/plugin/src/client/tab.ts b/packages/plugin/src/client/tab.ts new file mode 100644 index 0000000000..a0d11058a0 --- /dev/null +++ b/packages/plugin/src/client/tab.ts @@ -0,0 +1,16 @@ +import { useCallback, useMemo } from "react"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { ZodType } from "zod"; +import type { PluginTab } from "./contracts.js"; + +/** Validates the panel's persisted state, using the default for missing or invalid values. */ +export function useTabState( + tab: PluginTab, + schema: ZodType, + defaultState: T, +): [T, (next: T) => void] { + const parsed = useMemo(() => schema.safeParse(tab.state), [schema, tab.state]); + const writeState = tab.setState; + const setState = useCallback((next: T) => writeState(next), [writeState]); + return [parsed.success ? parsed.data : defaultState, setState]; +} diff --git a/plugin-examples/markdown-panel/client/rich-markdown.tsx b/plugin-examples/markdown-panel/client/rich-markdown.tsx index b11eb4bf51..765e1692c6 100644 --- a/plugin-examples/markdown-panel/client/rich-markdown.tsx +++ b/plugin-examples/markdown-panel/client/rich-markdown.tsx @@ -1,10 +1,12 @@ +import { z } from "zod"; import { PluginFileConflictError, PluginFileDeletedError, type PluginFilePanelProps, useFileText, + useTabState, } from "@getpaseo/plugin/client"; -import { Loading } from "@getpaseo/plugin/client/ui"; +import { Loading, Markdown } from "@getpaseo/plugin/client/ui"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Pressable, ScrollView, Text, View } from "react-native"; import { type MarkdownEditorHandle, mountEditor } from "./web"; @@ -17,7 +19,7 @@ type EditorState = "loading" | "ready" | "conflict" | "deleted"; * Reads the file once, writes 500 ms after the last edit, and follows the disk while nothing is * unsaved. With unsaved edits, a change on disk is a conflict: Reload or Overwrite. */ -function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { +function MarkdownEditor({ file, tab: { setModified }, theme }: PluginFilePanelProps) { const parent = useRef(null); const editor = useRef(null); const text = useRef(""); @@ -34,11 +36,11 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { return; } unsaved.current = null; - tab.setModified(false); + setModified(false); editor.current?.setText(text.current); setError(null); setState("ready"); - }, [file, tab]); + }, [file, setModified]); const save = useCallback( async (force: boolean) => { @@ -54,19 +56,19 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { } if (unsaved.current === next) { unsaved.current = null; - tab.setModified(false); + setModified(false); } setError(null); setState("ready"); }, - [file, tab], + [file, setModified], ); useLayoutEffect(() => { editor.current = mountEditor(parent.current, text.current, theme, (next) => { text.current = next; unsaved.current = next; - tab.setModified(true); + setModified(true); clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => void save(false), SAVE_DELAY_MS); }); @@ -74,7 +76,7 @@ function MarkdownEditor({ file, tab, theme }: PluginFilePanelProps) { editor.current?.destroy(); editor.current = null; }; - }, [save, tab, theme]); + }, [save, setModified, theme]); useEffect(() => { void load(); @@ -166,9 +168,63 @@ function MarkdownText({ file, theme }: PluginFilePanelProps) { ); } +const viewSchema = z.object({ view: z.enum(["preview", "source"]) }); +const defaultView = { view: "source" } as const; + +function MarkdownPreview({ file, theme }: PluginFilePanelProps) { + const text = useFileText(file); + const errorStyle = useMemo(() => ({ color: theme.colors.statusDanger }), [theme]); + if (text.status === "loading") return ; + if (text.status === "error") return {text.error.message}; + return ( + + {text.text} + + ); +} + +const toolbarStyle = { flexDirection: "row", gap: 16, padding: 12 } as const; +const selected = { selected: true }; +const unselected = { selected: false }; + export function RichMarkdownPanel(props: PluginFilePanelProps) { - if (props.layout.platform === "web") return ; - return ; + const [state, setState] = useTabState(props.tab, viewSchema, defaultView); + const styles = useMemo( + () => ({ + panel: { flex: 1, minHeight: 0, backgroundColor: props.theme.colors.surface0 }, + label: { color: props.theme.colors.foreground }, + }), + [props.theme], + ); + const showPreview = useCallback(() => setState({ view: "preview" }), [setState]); + const showSource = useCallback(() => setState({ view: "source" }), [setState]); + let content; + if (state.view === "preview") content = ; + else if (props.layout.platform === "web") content = ; + else content = ; + return ( + + + + Preview + + + Source + + + {content} + + ); } function messageOf(cause: unknown): string { diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index 8d08343ec0..f430dff92a 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -75,6 +75,7 @@ Use this table as the complete registration checklist. | New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` | | New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` | | `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` | +| Agent panel with `context: "agent"` | `client.addAgentPanel(panel)` in `index.client.tsx`; omit `context` | | `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` | | `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` | | `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function | @@ -96,6 +97,22 @@ Import `PluginClientContext` from `@getpaseo/plugin/client` and `PluginServerCon `@getpaseo/plugin/server`. Remove imports of the old context type. Client registrations return idempotent removal functions, except header buttons and composer pills, which return `{ update, remove }` handles. Preserve any remover the plugin calls before teardown; Paseo removes outstanding registrations after the entry cleanup runs. +### Panel props in 0.13 + +All panel kinds share [params, revision, and persisted tab state](./reference.md#workspace-panels). + +| Previous API | Replacement | +| ----------------------------------------------------------------------- | ------------------------------------------------ | +| Workspace/agent `context` prop | Select the kind at registration. | +| Agent `agentId` prop | `params.agentId` | +| `addWorkspacePanel({ context: "workspace", ... })` | `addWorkspacePanel({ ... })` | +| `addWorkspacePanel({ context: "agent", ... })` | `addAgentPanel({ ... })` | +| Beta file `location.lineStart`, `location.lineEnd`, `location.revision` | `params.lineStart`, `params.lineEnd`, `revision` | +| Beta `PluginFileTab`, `PluginFileLocation` | `PluginTab`, `PluginFilePanelParams` | + +The 0.12 aliases remain available until removal after 2027-04-23. The beta file API changes +outright. Declare `requirements.paseo` as `>=0.13.0` when adopting the new API. + ### Composer pills Update the plugin project's `@getpaseo/plugin` dependency, then run `npm run typecheck`. diff --git a/public-docs/plugins/migration.zh-CN.md b/public-docs/plugins/migration.zh-CN.md index cb38831b67..600b65f4d9 100644 --- a/public-docs/plugins/migration.zh-CN.md +++ b/public-docs/plugins/migration.zh-CN.md @@ -68,6 +68,7 @@ my-plugin/ | 新的侧边栏底部贡献 | `index.client.tsx` 中的 `client.addSidebarFooterItem({ id, title, Component })` | | 新的文件面板贡献 | `index.client.tsx` 中的 `client.addFilePanel(panel)` | | 旧根入口中的 `plugin.addWorkspacePanel(panel)` | `index.client.tsx` 中的 `client.addWorkspacePanel(panel)` | +| 带有 `context: "agent"` 的 Agent 面板 | `index.client.tsx` 中的 `client.addAgentPanel(panel)`;省略 `context` | | 旧根入口中的 `plugin.addCommandCenterItem(item)` | `index.client.tsx` 中的 `client.addCommandCenterItem(item)` | | 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` | | 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 | @@ -85,6 +86,22 @@ my-plugin/ 从 `@getpaseo/plugin/client` 导入 `PluginClientContext`,从 `@getpaseo/plugin/server` 导入 `PluginServerContext`。删除对旧 context 类型的导入。客户端注册返回幂等的移除函数,但标题栏按钮和 Composer 标签除外,它们返回 `{ update, remove }` 句柄。保留插件在销毁之前调用的所有移除函数;入口的清理函数运行后,Paseo 会移除尚未移除的注册。 +### 0.13 的面板属性 {#panel-props-in-013} + +所有面板类型共享 [params、revision 和持久化的标签页状态](./reference.md#workspace-panels)。 + +| 旧 API | 替代方式 | +| ----------------------------------------------------------------------------- | ------------------------------------------------ | +| 工作区/Agent 面板的 `context` 属性 | 在注册时选择面板类型。 | +| Agent 面板的 `agentId` 属性 | `params.agentId` | +| `addWorkspacePanel({ context: "workspace", ... })` | `addWorkspacePanel({ ... })` | +| `addWorkspacePanel({ context: "agent", ... })` | `addAgentPanel({ ... })` | +| Beta 文件面板的 `location.lineStart`、`location.lineEnd`、`location.revision` | `params.lineStart`、`params.lineEnd`、`revision` | +| Beta 的 `PluginFileTab`、`PluginFileLocation` | `PluginTab`、`PluginFilePanelParams` | + +0.12 的别名会保留到 2027-04-23 之后移除。Beta 文件 API 直接替换。采用新 API 时,将 +`requirements.paseo` 声明为 `>=0.13.0`。 + ### Composer 标签 {#composer-pills} 更新插件项目的 `@getpaseo/plugin` 依赖,然后运行 `npm run typecheck`。旧的贡献缺少必需的 `button` 字段,`PluginComposerPillProps` 不再导出,并且把新的注册当作函数调用会产生 TypeScript 错误。固定在旧 SDK 上的项目仍然按旧的契约检查;安装或重新加载插件并不会运行 TypeScript 的类型检查器。 diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index c35a62ca33..81b99b0dd0 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1771,7 +1771,8 @@ for immediate controls, a draft editor with validation, custom content, and Comm ## Workspace panels -Register one panel for workspace or agent context: +Register workspace panels with `addWorkspacePanel` and agent panels with `addAgentPanel`. +Both receive the same panel contract as [file panels](#file-panels). `client/review.tsx`: @@ -1780,9 +1781,9 @@ import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/pl import { useMemo } from "react"; import { Text, View } from "react-native"; -export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) { +export function ReviewPanel({ theme, layout, workspaceId, params }: PluginAgentPanelProps) { const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name); - const agent = useAgent(agentId, ({ id, title }) => ({ id, title })); + const agent = useAgent(params.agentId, ({ id, title }) => ({ id, title })); const styles = useMemo( () => ({ screen: { @@ -1811,11 +1812,10 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ReviewPanel } from "./client/review"; export default function contribute(client: PluginClientContext) { - client.addWorkspacePanel({ + client.addAgentPanel({ id: "review", title: "Review", icon: "Scan", - context: "agent", locations: ["workspace", "explorer"], Component: ReviewPanel, }); @@ -1823,20 +1823,43 @@ export default function contribute(client: PluginClientContext) { } ``` -`addWorkspacePanel` fields: +`addWorkspacePanel` and `addAgentPanel` fields: | Field | Required | Meaning | | ----------- | -------- | ------------------------------------------------------------- | | `id` | Yes | Plugin-local panel ID. | | `title` | Yes | Workspace-tab title. | | `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). | -| `context` | Yes | `workspace` or `agent`. | | `locations` | No | `workspace` and/or `explorer`. Defaults to `workspace`. | | `Component` | Yes | React Native component matching the selected context's props. | -A workspace panel receives `PluginWorkspacePanelProps`: `context: "workspace"`, `theme`, `host`, `layout`, and `workspaceId`. An agent panel receives `PluginAgentPanelProps`: `context: "agent"`, the same common fields and `workspaceId`, plus `agentId`. +Every panel receives `PluginPanelProps` with `theme`, `host`, `layout`, `navigation`, +`workspaceId`, read-only `params`, `revision`, and `tab`. Workspace panels have empty +`PluginWorkspacePanelParams`; agent panels have `PluginAgentPanelParams` containing `agentId`. +`revision` increments whenever params are re-sent to the same tab, including an identical request. -Read cached state with `useWorkspace(workspaceId, selector)` and `useAgent(agentId, selector)`. A selector is required. Paseo compares its result shallowly, so selecting `{ name, status }` does not re-render when unrelated fields change. Select every field the component renders in one call; do not select the whole snapshot. +`tab.setModified(boolean)` marks unsaved work and asks before closing. `tab.state` is opaque +persisted JSON, initially `undefined`; `tab.setState(value)` replaces it. Each tab owns its state. +Paseo stores it without interpreting it. Validate it with `useTabState(tab, schema, defaultState)`: + +```tsx +const schema = z.object({ view: z.enum(["preview", "source"]) }); +const [state, setState] = useTabState(tab, schema, { view: "preview" }); +// Missing or invalid persisted state returns the default. +// In an event handler, call setState({ view: "source" }). +``` + +Import `useTabState` from `@getpaseo/plugin/client` and `z` from `zod`. The setter replaces the +entire JSON value. State survives tab restoration and app reloads. + +The unified props, `addAgentPanel`, and `useTabState` require Paseo 0.13.0 or later. + +**Deprecated until removal after 2027-04-23:** the 0.12 panel `context` and flat `agentId` props, +`addWorkspacePanel({ context: "workspace" })`, and `addWorkspacePanel({ context: "agent" })` +continue to work. Omit workspace `context`, register agent panels with `addAgentPanel`, and read +`params.agentId`. + +Read cached state with `useWorkspace(workspaceId, selector)` and `useAgent(params.agentId, selector)`. A selector is required. Paseo compares its result shallowly, so selecting `{ name, status }` does not re-render when unrelated fields change. Select every field the component renders in one call; do not select the whole snapshot. Both hooks return `null` when the record is unavailable. Otherwise they run synchronously against normalized client state. Snapshot DTOs and their nested values are deeply readonly and frozen at runtime. Do not call plugin RPC to discover the current workspace or agent. Fetch optional or vendor-specific enrichment after the component renders. @@ -1923,12 +1946,13 @@ for a filename-only selector. At least one extension or filename is required. `PluginFilePanelProps` includes the common `theme`, `host`, `layout`, and optional `navigation` props described under [Theme and layout](#theme-and-layout), plus: -| Prop | Type | Meaning | -| ------------- | -------------------- | ------------------------------------------------------------------------------- | -| `workspaceId` | `string` | Workspace containing the file tab. | -| `file` | `PluginFile` | Handle to the file. Stable while the tab shows this file. | -| `tab` | `PluginFileTab` | `setModified(modified)` shows the tab's unsaved dot and confirms before close. | -| `location` | `PluginFileLocation` | `lineStart?`, `lineEnd?`, and `revision`, which increments on every navigation. | +| Prop | Type | Meaning | +| ------------- | ----------------------- | --------------------------------------------------------------------------------------------------- | +| `workspaceId` | `string` | Workspace containing the file tab. | +| `params` | `PluginFilePanelParams` | Read-only `path`, `lineStart?`, and `lineEnd?` from the opener. | +| `revision` | `number` | Increments each time params are re-sent to this tab, even for the same line. | +| `file` | `PluginFile` | File capability resolved from `params.path`, stable while the tab shows this file. | +| `tab` | `PluginTab` | `setModified`, opaque persisted `state`, and `setState`. See [Workspace panels](#workspace-panels). | Paseo mounts your panel once it knows the file exists and its size, without reading it. The `file` object is stable, and its stat fields hold the latest version Paseo knows. Read them while diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 9b9f443cba..62caec83e5 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1525,7 +1525,8 @@ if (current.status === "ready") { ## 工作区面板 {#workspace-panels} -为工作区或 Agent 上下文注册一个面板: +使用 `addWorkspacePanel` 注册工作区面板,使用 `addAgentPanel` 注册 Agent 面板。 +两者与[文件面板](#file-panels)使用相同的面板契约。 `client/review.tsx`: @@ -1534,9 +1535,9 @@ import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/pl import { useMemo } from "react"; import { Text, View } from "react-native"; -export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) { +export function ReviewPanel({ theme, layout, workspaceId, params }: PluginAgentPanelProps) { const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name); - const agent = useAgent(agentId, ({ id, title }) => ({ id, title })); + const agent = useAgent(params.agentId, ({ id, title }) => ({ id, title })); const styles = useMemo( () => ({ screen: { @@ -1565,11 +1566,10 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; import { ReviewPanel } from "./client/review"; export default function contribute(client: PluginClientContext) { - client.addWorkspacePanel({ + client.addAgentPanel({ id: "review", title: "Review", icon: "Scan", - context: "agent", locations: ["workspace", "explorer"], Component: ReviewPanel, }); @@ -1577,20 +1577,42 @@ export default function contribute(client: PluginClientContext) { } ``` -`addWorkspacePanel` 的字段: +`addWorkspacePanel` 和 `addAgentPanel` 的字段: | 字段 | 是否必填 | 含义 | | ----------- | -------- | --------------------------------------------------- | | `id` | 是 | 插件内部的面板 ID。 | | `title` | 是 | 工作区标签页的标题。 | | `icon` | 是 | Lucide 名称、`{ svg }` 或组件。参见[图标](#icons)。 | -| `context` | 是 | `workspace` 或 `agent`。 | | `locations` | 否 | `workspace` 和/或 `explorer`。默认为 `workspace`。 | | `Component` | 是 | 与所选上下文的属性相匹配的 React Native 组件。 | -工作区面板接收 `PluginWorkspacePanelProps`:`context: "workspace"`、`theme`、`host`、`layout` 和 `workspaceId`。Agent 面板接收 `PluginAgentPanelProps`:`context: "agent"`、相同的通用字段和 `workspaceId`,外加 `agentId`。 +每个面板都接收 `PluginPanelProps`,包含 `theme`、`host`、`layout`、`navigation`、 +`workspaceId`、只读的 `params`、`revision` 和 `tab`。工作区面板使用空的 +`PluginWorkspacePanelParams`;Agent 面板的 `PluginAgentPanelParams` 包含 `agentId`。 +每次向同一标签页重新发送 params 时,`revision` 都会递增,即使请求与上次相同。 -用 `useWorkspace(workspaceId, selector)` 和 `useAgent(agentId, selector)` 读取缓存的状态。selector 是必填的。Paseo 对它的结果做浅比较,因此选择 `{ name, status }` 时,无关字段的变化不会触发重新渲染。在一次调用中选择组件渲染的所有字段;不要选择整个快照。 +`tab.setModified(boolean)` 标记未保存的更改,并在关闭前询问。`tab.state` 是由面板定义的持久化 +JSON,初始为 `undefined`;`tab.setState(value)` 替换整个值。每个标签页拥有独立状态。 +Paseo 只保存状态,不解释其内容。用 `useTabState(tab, schema, defaultState)` 验证它: + +```tsx +const schema = z.object({ view: z.enum(["preview", "source"]) }); +const [state, setState] = useTabState(tab, schema, { view: "preview" }); +// Missing or invalid persisted state returns the default. +// In an event handler, call setState({ view: "source" }). +``` + +从 `@getpaseo/plugin/client` 导入 `useTabState`,从 `zod` 导入 `z`。setter 替换整个 JSON 值。 +标签页恢复和应用重新加载后,状态仍然保留。 + +统一属性、`addAgentPanel` 和 `useTabState` 需要 Paseo 0.13.0 或更高版本。 + +**已弃用,将在 2027-04-23 之后移除**:0.12 面板的 `context` 和顶层 `agentId` 属性、 +`addWorkspacePanel({ context: "workspace" })` 以及 `addWorkspacePanel({ context: "agent" })` +仍然有效。省略工作区的 `context`,用 `addAgentPanel` 注册 Agent 面板,并读取 `params.agentId`。 + +用 `useWorkspace(workspaceId, selector)` 和 `useAgent(params.agentId, selector)` 读取缓存的状态。selector 是必填的。Paseo 对它的结果做浅比较,因此选择 `{ name, status }` 时,无关字段的变化不会触发重新渲染。在一次调用中选择组件渲染的所有字段;不要选择整个快照。 当记录不可用时,两个 hook 都返回 `null`。否则它们会针对规范化的客户端状态同步运行。快照 DTO 及其嵌套值都是深度只读的,并在运行时被冻结。不要调用插件 RPC 来获取当前的工作区或 Agent。可选的或特定于厂商的补充数据,请在组件渲染之后再获取。 @@ -1670,12 +1692,13 @@ export default function contribute(client: PluginClientContext) { `PluginFilePanelProps` 包含[主题和布局](#theme-and-layout)中介绍的通用属性 `theme`、`host`、`layout` 和可选的 `navigation`,另外还有: -| 属性 | 类型 | 含义 | -| ------------- | -------------------- | ------------------------------------------------------------------ | -| `workspaceId` | `string` | 包含该文件标签页的工作区。 | -| `file` | `PluginFile` | 文件的句柄。标签页显示该文件期间保持不变。 | -| `tab` | `PluginFileTab` | `setModified(modified)` 会显示标签页的未保存圆点,并在关闭前确认。 | -| `location` | `PluginFileLocation` | `lineStart?`、`lineEnd?` 和 `revision`,后者在每次导航时递增。 | +| 属性 | 类型 | 含义 | +| ------------- | ----------------------- | ------------------------------------------------------------------------------------------ | +| `workspaceId` | `string` | 包含该文件标签页的工作区。 | +| `params` | `PluginFilePanelParams` | 打开请求提供的只读 `path`、`lineStart?` 和 `lineEnd?`。 | +| `revision` | `number` | 每次向该标签页重新发送 params 时递增,即使是同一行。 | +| `file` | `PluginFile` | 从 `params.path` 解析的文件能力;标签页显示该文件期间保持不变。 | +| `tab` | `PluginTab` | `setModified`、持久化的自定义 `state` 和 `setState`。参见[工作区面板](#workspace-panels)。 | Paseo 在知道文件存在及其大小后就会挂载你的面板,不会先读取文件。`file` 对象保持不变,它的 stat 字段保存 Paseo 已知的最新版本。请在渲染时读取这些字段;它们变化时不会重新渲染你的面板。面板要通过 `watch` 监听器,或 `useFileText`、`useFileBytes`、`useFileUrl` 引起的重新渲染,得知它们已变化: From 4b9b2fe13006f8966ca7b27e65331c5b11101f34 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 14:04:43 +0700 Subject: [PATCH 41/62] fix(app): preserve manual Command Center scrolling during live updates (#7688) Treat selection and permission to reveal it separately. Result publication and layout updates must not pull a manually scrolled viewport back to the active row; user navigation requests a one-shot reveal. --- .../command-center-file-search.spec.ts | 65 +++++++ .../e2e/browser/command-center-scroll.spec.ts | 15 +- .../helpers/command-center-file-search.ts | 27 +++ .../support/helpers/command-center-scroll.ts | 39 ++++ .../app/e2e/support/helpers/seed-client.ts | 1 + .../app/src/command-center/command-center.tsx | 134 ++++++------- .../src/command-center/interaction.test.ts | 178 ++++++++++++++++++ .../app/src/command-center/interaction.ts | 131 +++++++++++++ 8 files changed, 517 insertions(+), 73 deletions(-) create mode 100644 packages/app/src/command-center/interaction.test.ts create mode 100644 packages/app/src/command-center/interaction.ts diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts index 5964dcc978..5949882492 100644 --- a/packages/app/e2e/browser/command-center-file-search.spec.ts +++ b/packages/app/e2e/browser/command-center-file-search.spec.ts @@ -2,11 +2,17 @@ import { expect, test } from "../support/fixtures"; import { openCommandCenter } from "../support/helpers/command-center"; import { delayDirectorySuggestionResponses, + holdDirectorySuggestionResponses, expectOneLineTruncatedFileResult, expectStableCommandCenterLayout, failDirectorySuggestionRequests, startCommandCenterLayoutObservation, } from "../support/helpers/command-center-file-search"; +import { + scrollCommandCenterAwayFromSelection, + expectCommandCenterScrollToStayAt, + expectKeyboardNavigationToResume, +} from "../support/helpers/command-center-scroll"; import { expectFileTabOpen } from "../support/helpers/file-explorer"; import { gotoWorkspace } from "../support/helpers/launcher"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; @@ -137,3 +143,62 @@ test("unscoped command search keeps commands visible and reports file-search fai await Promise.allSettled(seeded.map((workspace) => workspace.cleanup())); } }); + +test("manual scrolling cancels a pending file-search reveal and query editing restores it", async ({ + page, +}) => { + const seeded = await seedWorkspace({ + repoPrefix: "command-center-delayed-scroll-", + title: "Delayed file search scroll", + repo: { files: paseoSizedFiles() }, + }); + try { + const responses = await holdDirectorySuggestionResponses(page); + await gotoWorkspace(page, seeded.workspaceId); + await page.keyboard.press("Meta+P"); + const panel = page.getByTestId("command-center-panel"); + await expect(panel).toBeVisible(); + await panel.getByTestId("command-center-input").fill("index"); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden(); + await expect( + panel.getByRole("button", { + name: "index.ts packages/app/src/features/feature-000", + exact: true, + }), + ).toBeVisible(); + + responses.hold(); + await panel.getByTestId("command-center-input").fill("index."); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible(); + const offset = await scrollCommandCenterAwayFromSelection(page, panel); + await responses.release(); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden(); + await expectCommandCenterScrollToStayAt(panel, offset); + await expectKeyboardNavigationToResume(page, panel, offset); + + const nextOffset = await scrollCommandCenterAwayFromSelection(page, panel); + await panel.getByTestId("command-center-input").fill("index.ts"); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden(); + await expect + .poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop)) + .toBeLessThan(nextOffset); + await expect( + panel.getByRole("button", { + name: "index.ts packages/app/src/features/feature-000", + exact: true, + }), + ).toBeInViewport({ ratio: 1 }); + await page.keyboard.press("Escape"); + await page.keyboard.press("Meta+P"); + await expect(panel.getByTestId("command-center-input")).toHaveValue(""); + await panel.getByTestId("command-center-input").fill("index"); + await expect( + panel.getByRole("button", { + name: "index.ts packages/app/src/features/feature-000", + exact: true, + }), + ).toBeInViewport({ ratio: 1 }); + } finally { + await seeded.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/command-center-scroll.spec.ts b/packages/app/e2e/browser/command-center-scroll.spec.ts index f6762bb4cc..993dd60835 100644 --- a/packages/app/e2e/browser/command-center-scroll.spec.ts +++ b/packages/app/e2e/browser/command-center-scroll.spec.ts @@ -1,7 +1,11 @@ +import { expect } from "@playwright/test"; import { test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { expectCommandCenterToRemainAtTheTop, + scrollCommandCenterAwayFromSelection, + expectCommandCenterScrollToStayAt, + expectKeyboardNavigationToResume, expectPrimaryCommandCenterActions, expectTightTwoLineResultSpacing, expectVisibleKeyboardNavigationNotToScroll, @@ -16,12 +20,15 @@ test.use({ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36", }); -test("opening the command center keeps its first result fully visible", async ({ page }) => { +test("Command Center preserves manual scrolling through live updates and resumes keyboard navigation", async ({ + page, +}) => { const seeded = await seedWorkspace({ repoPrefix: "command-center-scroll-", title: "Command Center Scroll 00", }); + let newestWorkspaceId = seeded.workspaceId; try { for (let index = 1; index <= 8; index += 1) { const created = await seeded.client.createWorkspace({ @@ -36,6 +43,7 @@ test("opening the command center keeps its first result fully visible", async ({ if (!created.workspace) { throw new Error(created.error ?? `Failed to seed workspace ${index}`); } + newestWorkspaceId = created.workspace.id; } await gotoAppShell(page); @@ -48,6 +56,11 @@ test("opening the command center keeps its first result fully visible", async ({ // The empty query lists the most recent workspaces, so the last one seeded leads it. await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08"); await expectWorkspaceResultsToBeLimitedUntilSearch(panel); + const offset = await scrollCommandCenterAwayFromSelection(page, panel); + await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed"); + await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1); + await expectCommandCenterScrollToStayAt(panel, offset); + await expectKeyboardNavigationToResume(page, panel, offset); } finally { await seeded.cleanup(); } diff --git a/packages/app/e2e/support/helpers/command-center-file-search.ts b/packages/app/e2e/support/helpers/command-center-file-search.ts index ec6ade4102..eb0b888d24 100644 --- a/packages/app/e2e/support/helpers/command-center-file-search.ts +++ b/packages/app/e2e/support/helpers/command-center-file-search.ts @@ -210,3 +210,30 @@ export async function expectOneLineTruncatedFileResult( expect(overflow.overflow).toBe("hidden"); expect(overflow.whiteSpace).toBe("nowrap"); } + +/** Hold real daemon responses until the user has finished the interaction under test. */ +export async function holdDirectorySuggestionResponses(page: Page) { + let holding = false; + const pending: Array<() => void> = []; + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + if (holding && parseSessionMessage(message)?.type === "directory_suggestions_response") { + pending.push(() => ws.send(message)); + } else { + ws.send(message); + } + }); + }); + return { + hold() { + holding = true; + }, + async release() { + await expect.poll(() => pending.length).toBeGreaterThan(0); + holding = false; + for (const send of pending.splice(0)) send(); + }, + }; +} diff --git a/packages/app/e2e/support/helpers/command-center-scroll.ts b/packages/app/e2e/support/helpers/command-center-scroll.ts index dd4712fb36..42287ca693 100644 --- a/packages/app/e2e/support/helpers/command-center-scroll.ts +++ b/packages/app/e2e/support/helpers/command-center-scroll.ts @@ -169,3 +169,42 @@ export async function expectTightTwoLineResultSpacing( .evaluate((element) => element.getBoundingClientRect().top); expect(subtitleTop - titleTop).toBeLessThanOrEqual(18); } + +/** Wait for the browser to deliver wheel events before publishing a daemon update. */ +export async function scrollCommandCenterAwayFromSelection(page: Page, panel: Locator) { + await page.setViewportSize({ width: 1280, height: 500 }); + const viewport = panel.getByTestId("command-center-results"); + await viewport.hover(); + await page.mouse.wheel(0, 300); + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100); + const samples = await sampleCommandCenterScroll(panel); + const offset = samples.at(-1)!; + expect(offset).toBeGreaterThan(100); + return offset; +} + +async function sampleCommandCenterScroll(panel: Locator): Promise { + return panel.getByTestId("command-center-results").evaluate(async (viewport) => { + const samples: number[] = []; + for (let frame = 0; frame < 30; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + samples.push(viewport.scrollTop); + } + return samples; + }); +} + +export async function expectCommandCenterScrollToStayAt(panel: Locator, offset: number) { + const samples = await sampleCommandCenterScroll(panel); + expect(Math.min(...samples)).toBe(offset); + expect(Math.max(...samples)).toBe(offset); +} + +export async function expectKeyboardNavigationToResume(page: Page, panel: Locator, offset: number) { + await page.keyboard.press("ArrowDown"); + await expect + .poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop)) + .toBeLessThan(offset); + const second = panel.getByTestId("command-center-results").getByRole("button").nth(1); + await expect(second).toBeInViewport({ ratio: 1 }); +} diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 4aec3fff01..8cf2bebb81 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -44,6 +44,7 @@ export interface SeedDaemonClient { fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{ entries: SeedWorkspaceDescriptor[]; }>; + setWorkspaceTitle(workspaceId: string, title: string | null): Promise<{ title: string | null }>; setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>; clearWorkspaceAttention(workspaceId: string): Promise; setWorkspaceLabel(input: { diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 58f1a4ce54..88ee10d8bb 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -10,7 +10,16 @@ import { type NativeSyntheticEvent, type PressableStateCallbackType, } from "react-native"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronRight, Folder, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -69,9 +78,7 @@ import { buildContributionSections, filterAndRankBuiltInResults, joinSubtitleParts, - moveActiveResultId, PINNED_SECTION_BAND, - preserveActiveResultId, projectCommandCenterRows, type CommandCenterAgentResult, type CommandCenterFileResult, @@ -81,6 +88,7 @@ import { type CommandCenterSearchFields, type CommandCenterWorkspaceResult, } from "./results"; +import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction"; import { useWorkspaceFileSearch } from "./workspace-file-search"; const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({ @@ -227,8 +235,6 @@ interface CommandCenterState { setQuery(query: string): void; activeId: string | null; rows: readonly CommandCenterListRow[]; - results: readonly CommandCenterResult[]; - rowIndexByResultId: ReadonlyMap; offsets: readonly number[]; inputRef: React.RefObject; fileSearchLoading: boolean; @@ -238,7 +244,10 @@ interface CommandCenterState { key(key: string): boolean; } -function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState { +function useCommandCenterState( + listMetrics: CommandCenterListMetrics, + interaction: CommandCenterInteraction, +): CommandCenterState { const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen); @@ -248,8 +257,7 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe const snapshot = useCommandCenterContributions(); const inputRef = useRef(null); const previousOpenRef = useRef(open); - const [query, setQueryState] = useState(""); - const [activeId, setActiveId] = useState(null); + const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot); const builtInSections = useBuiltInSections(open, query); const { entries: fileSearchEntries, @@ -296,15 +304,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe ), [builtInSections, contributionSections, fileSections, listMetrics, scope], ); - const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults); - - // Editing the query re-ranks everything, so an arrow-key selection made under the previous - // query is no longer meaningful. Reset it in the same update as the query rather than in an - // effect, so there is no intermediate render holding the stale selection. - const setQuery = useCallback((next: string) => { - setQueryState(next); - setActiveId(null); - }, []); + useLayoutEffect(() => { + interaction.update({ open, scope, query, projection, loading: fileSearchLoading }); + }, [interaction, open, scope, query, projection, fileSearchLoading]); const close = useCallback(() => setOpen(false), [setOpen]); const select = useCallback( @@ -327,18 +329,25 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe return true; } if (pressed === "Enter") { - const selected = results.find((result) => result.id === resolvedActiveId); + const selected = results.find((result) => result.id === activeId); if (!selected) return false; select(selected); return true; } if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false; - if (results.length === 0) return false; - const direction = pressed === "ArrowDown" ? "next" : "previous"; - setActiveId(moveActiveResultId(resolvedActiveId, results, direction)); - return true; + return interaction.move(pressed === "ArrowDown" ? "next" : "previous"); }, - [close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope], + [ + close, + open, + projection.selectableResults, + query, + activeId, + scope, + select, + setScope, + interaction, + ], ); useEffect(() => { @@ -348,8 +357,6 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe const timer = setTimeout(() => inputRef.current?.focus(), 0); return () => clearTimeout(timer); } - setQueryState(""); - setActiveId(null); if (!wasOpen) return; const element = takeCommandCenterFocusRestoreElement(); if (!element) return; @@ -367,11 +374,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe scope, clearScope: () => setScope(null), query, - setQuery, - activeId: resolvedActiveId, + setQuery: interaction.setQuery, + activeId, rows: projection.rows, - results: projection.selectableResults, - rowIndexByResultId: projection.rowIndexByResultId, offsets: projection.offsets, inputRef, fileSearchLoading, @@ -669,62 +674,39 @@ function SectionRow({ row }: { row: Extract>(null); const bottomSheetListRef = useRef(null); const bottomSheetInputRef = useRef(null); - const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 }); + const [interaction] = useState(() => + createCommandCenterInteraction((offset) => { + const list = listRef.current ?? bottomSheetListRef.current; + list?.scrollToOffset({ offset, animated: false }); + }), + ); + const state = useCommandCenterState( + isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop, + interaction, + ); + const showBottomSheet = isCompact && isNative; + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible: state.open, isEnabled: showBottomSheet, onClose: state.close, }); - const revealActiveResult = useCallback(() => { - if (!state.open || !state.activeId) return; - const index = state.rowIndexByResultId.get(state.activeId); - if (index === undefined) return; - const { offset, visibleLength } = scrollMetricsRef.current; - if (visibleLength <= 0) return; - const rowTop = state.offsets[index]; - const rowBottom = rowTop + state.rows[index].height; - let nextOffset: number | null = null; - if (rowTop < offset) nextOffset = rowTop; - if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength; - if (nextOffset === null) return; - const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current; - const boundedOffset = Math.max(0, nextOffset); - scrollMetricsRef.current.offset = boundedOffset; - ref?.scrollToOffset({ offset: boundedOffset, animated: false }); - }, [ - showBottomSheet, - state.activeId, - state.offsets, - state.open, - state.rowIndexByResultId, - state.rows, - ]); - useEffect(() => { - if (!state.open) { - scrollMetricsRef.current = { offset: 0, visibleLength: 0 }; - return; - } - revealActiveResult(); - }, [revealActiveResult, state.open]); const handleListLayout = useCallback( (event: LayoutChangeEvent) => { - scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height; - revealActiveResult(); + interaction.layout(event.nativeEvent.layout.height); }, - [revealActiveResult], + [interaction], + ); + const handleListScroll = useCallback( + (event: NativeSyntheticEvent) => { + interaction.scrolled(event.nativeEvent.contentOffset.y); + }, + [interaction], ); - const handleListScroll = useCallback((event: NativeSyntheticEvent) => { - scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y; - }, []); useEffect(() => { if (!showBottomSheet || !state.open) return; const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300); @@ -790,6 +772,14 @@ export function CommandCenter() { windowSize: 5, onLayout: handleListLayout, onScroll: handleListScroll, + onScrollBeginDrag: interaction.beginManualScroll, + onTouchStart: interaction.beginManualScroll, + ...(isWeb + ? { + onWheel: interaction.beginManualScroll, + onMouseDown: interaction.beginManualScroll, + } + : {}), scrollEventThrottle: 16, }; const keyPress = useCallback( diff --git a/packages/app/src/command-center/interaction.test.ts b/packages/app/src/command-center/interaction.test.ts new file mode 100644 index 0000000000..5b5d8bdef9 --- /dev/null +++ b/packages/app/src/command-center/interaction.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import { createCommandCenterInteraction } from "./interaction"; +import { projectCommandCenterRows, type CommandCenterFileResult } from "./results"; +import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store"; + +function results(ids = ["a", "b", "c", "d"]) { + return projectCommandCenterRows( + [ + { + id: "files", + band: 0, + rank: 0, + title: "Files", + results: ids.map((id) => ({ + id, + kind: "file", + filePath: id, + title: id, + subtitle: "", + run() {}, + })), + }, + ], + { singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 }, + ); +} + +function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) { + const offsets: number[] = []; + const model = createCommandCenterInteraction((offset) => offsets.push(offset)); + const publish = (ids = ["a", "b", "c", "d"], loading = false) => + model.update({ + open: true, + scope: options.scope ?? null, + query: model.getSnapshot().query, + projection: results(ids), + loading, + }); + publish(); + if (options.measured !== false) model.layout(80); + return { model, offsets, publish }; +} + +describe("Command Center interaction", () => { + it("keeps manual scrolling through result refresh, removal, and resizing", () => { + const { model, offsets, publish } = setup(); + model.beginManualScroll(); + model.scrolled(100); + publish(); + publish(["b", "c", "d"]); + model.layout(60); + expect(model.getSnapshot().activeId).toBe("b"); + expect(offsets).toEqual([]); + }); + + it("reveals only clipped keyboard selections and wraps around", () => { + const { model, offsets } = setup(); + expect(model.move("next")).toBe(true); + expect(offsets).toEqual([20]); + model.scrolled(20); // The programmatic scroll callback is only a measurement. + model.move("next"); + expect(offsets).toEqual([20, 60]); + model.move("previous"); + expect(offsets).toEqual([20, 60]); + model.move("previous"); + expect(offsets).toEqual([20, 60, 20]); + model.move("previous"); + expect(model.getSnapshot().activeId).toBe("d"); + expect(offsets).toEqual([20, 60, 20, 100]); + }); + + it("keyboard navigation takes control again after manual scrolling", () => { + const { model, offsets } = setup(); + model.beginManualScroll(); + model.scrolled(100); + model.move("next"); + expect(model.getSnapshot().activeId).toBe("b"); + expect(offsets).toEqual([60]); + }); + + it("a new query reveals even when the active ID stays the same", () => { + const { model, offsets, publish } = setup(); + model.scrolled(100); + model.setQuery("a"); + expect(offsets).toEqual([]); + publish(["a", "b"]); + expect(model.getSnapshot()).toEqual({ query: "a", activeId: "a" }); + expect(offsets).toEqual([20]); + model.scrolled(100); + publish(["a", "b"]); + expect(offsets).toEqual([20]); + }); + + it("an arrow command reveals a single result even when its ID cannot change", () => { + const { model, offsets, publish } = setup(); + publish(["a"]); + model.scrolled(30); + model.move("next"); + expect(offsets).toEqual([20]); + }); + + it("defers reveals until layout exists and consumes them only once", () => { + const { model, offsets, publish } = setup({ measured: false }); + model.move("previous"); + expect(offsets).toEqual([]); + model.layout(80); + expect(offsets).toEqual([100]); + model.scrolled(0); + publish(); + model.layout(60); + expect(offsets).toEqual([100]); + }); + + it("manual interaction cancels an unmeasured request", () => { + const { model, offsets } = setup({ measured: false }); + model.move("previous"); + model.beginManualScroll(); + model.layout(80); + expect(offsets).toEqual([]); + }); + + it("waits for scoped search results instead of revealing the previous query's rows", () => { + const { model, offsets, publish } = setup({ scope: "files" }); + model.scrolled(100); + model.setQuery("new"); + publish(["a", "b"], true); + expect(offsets).toEqual([]); + expect(model.getSnapshot().activeId).toBeNull(); + publish(["b", "a"]); + expect(model.getSnapshot().activeId).toBe("b"); + expect(offsets).toEqual([20]); + }); + + it("manual interaction cancels a reveal awaiting search results", () => { + const { model, offsets, publish } = setup({ scope: "files" }); + model.setQuery("new"); + publish([], true); + model.beginManualScroll(); + model.scrolled(100); + publish(["new"]); + expect(model.getSnapshot().activeId).toBe("new"); + expect(offsets).toEqual([]); + }); + + it("empty searches complete without granting future passive results permission to scroll", () => { + const { model, offsets, publish } = setup(); + model.setQuery("missing"); + publish([]); + expect(model.move("next")).toBe(false); + expect(model.getSnapshot().activeId).toBeNull(); + model.scrolled(100); + publish(["late"]); + expect(offsets).toEqual([]); + }); + + it("scope changes reset selection and request a reveal", () => { + const { model, offsets } = setup(); + model.move("next"); + model.scrolled(100); + model.update({ open: true, scope: "files", query: "", projection: results(), loading: false }); + expect(model.getSnapshot().activeId).toBe("a"); + expect(offsets).toEqual([20, 20]); + }); + + it("close discards the old query, metrics, and deferred reveal", () => { + const { model, offsets, publish } = setup({ measured: false }); + model.setQuery("old"); + publish(); + model.move("previous"); + model.update({ open: false, scope: null, query: "old", projection: results(), loading: false }); + model.layout(80); + expect(model.getSnapshot()).toEqual({ query: "", activeId: null }); + expect(offsets).toEqual([]); + publish(); + expect(model.getSnapshot().activeId).toBe("a"); + expect(offsets).toEqual([]); + }); +}); diff --git a/packages/app/src/command-center/interaction.ts b/packages/app/src/command-center/interaction.ts new file mode 100644 index 0000000000..8ac0acce15 --- /dev/null +++ b/packages/app/src/command-center/interaction.ts @@ -0,0 +1,131 @@ +import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store"; +import { + moveActiveResultId, + preserveActiveResultId, + type CommandCenterListProjection, +} from "./results"; + +interface Snapshot { + query: string; + activeId: string | null; +} + +interface ResultsInput { + open: boolean; + scope: CommandCenterScope; + query: string; + projection: CommandCenterListProjection; + loading: boolean; +} + +/** Selection persists; permission to move the viewport lasts for one user command. */ +export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) { + let snapshot: Snapshot = { query: "", activeId: null }; + const listeners = new Set<() => void>(); + let open = false; + let scope: CommandCenterScope = null; + let projection: CommandCenterListProjection | null = null; + let loading = false; + let revealPending = false; + let offset = 0; + let visibleLength = 0; + + function publish(next: Snapshot) { + if (next.query === snapshot.query && next.activeId === snapshot.activeId) return; + snapshot = next; + for (const listener of listeners) listener(); + } + + function reveal() { + if (!open || !revealPending || !projection || visibleLength <= 0) return; + // Scoped file search keeps its previous entries while the next query is loading. + if (scope === "files" && loading) return; + if (!snapshot.activeId) { + if (!loading) revealPending = false; + return; + } + const index = projection.rowIndexByResultId.get(snapshot.activeId); + if (index === undefined) return; + const top = projection.offsets[index]; + const bottom = top + projection.rows[index].height; + revealPending = false; + let nextOffset = offset; + if (top < offset) nextOffset = top; + if (bottom > offset + visibleLength) nextOffset = bottom - visibleLength; + nextOffset = Math.max(0, nextOffset); + if (nextOffset === offset) return; + offset = nextOffset; + scrollToOffset(nextOffset); + } + + return { + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + update(input: ResultsInput) { + const resetSelection = input.open && (!open || scope !== input.scope); + open = input.open; + scope = input.scope; + if (!open) { + projection = null; + revealPending = false; + offset = 0; + visibleLength = 0; + publish({ query: "", activeId: null }); + return; + } + if (resetSelection) { + revealPending = true; + publish({ ...snapshot, activeId: null }); + } + // A query command invalidates the old projection until React supplies its new results. + if (input.query !== snapshot.query) return; + projection = input.projection; + loading = input.loading; + if (scope !== "files" || !loading) { + publish({ + ...snapshot, + activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults), + }); + } + reveal(); + }, + setQuery(query: string) { + projection = null; + revealPending = true; + publish({ query, activeId: null }); + }, + move(direction: "next" | "previous"): boolean { + if ( + !open || + !projection || + (scope === "files" && loading) || + projection.selectableResults.length === 0 + ) + return false; + publish({ + ...snapshot, + activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction), + }); + revealPending = true; + reveal(); + return true; + }, + layout(height: number) { + visibleLength = height; + reveal(); + }, + scrolled(nextOffset: number) { + offset = nextOffset; + }, + beginManualScroll() { + revealPending = false; + }, + }; +} + +export type CommandCenterInteraction = ReturnType; From 6405edd8dc3d8ee2556b219ee22fc95fa3cc947a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 14:35:27 +0700 Subject: [PATCH 42/62] feat(app): show consistent agent and workspace status in search (#7690) Share tab agent presentation and effective workspace status with Command Center. Preserve unchanged result identities so live directory updates only rerender the changed row, with bounded retention and browser regression coverage. --- .../e2e/browser/command-center-scroll.spec.ts | 51 ++++ .../browser/command-center-workspaces.spec.ts | 29 +++ .../helpers/command-center-performance.ts | 90 ++++++++ .../app/e2e/support/helpers/seed-client.ts | 1 + packages/app/src/agent-presentation/index.ts | 84 +++++++ .../app/src/command-center/command-center.tsx | 218 +++++++++++++----- .../app/src/command-center/results.test.ts | 2 + packages/app/src/command-center/results.ts | 2 + .../command-center/workspace-search.test.ts | 2 + packages/app/src/components/status-icon.tsx | 119 ++++++++++ .../hooks/sidebar-workspaces-view-model.ts | 51 +--- .../panels/agent-panel-descriptor.test.tsx | 55 +++++ packages/app/src/panels/agent-panel.tsx | 73 +----- .../workspace/workspace-tab-presentation.tsx | 105 +-------- packages/app/src/workspace-status/index.ts | 74 ++++++ 15 files changed, 679 insertions(+), 277 deletions(-) create mode 100644 packages/app/e2e/support/helpers/command-center-performance.ts create mode 100644 packages/app/src/agent-presentation/index.ts create mode 100644 packages/app/src/components/status-icon.tsx create mode 100644 packages/app/src/workspace-status/index.ts diff --git a/packages/app/e2e/browser/command-center-scroll.spec.ts b/packages/app/e2e/browser/command-center-scroll.spec.ts index 993dd60835..0a66b59856 100644 --- a/packages/app/e2e/browser/command-center-scroll.spec.ts +++ b/packages/app/e2e/browser/command-center-scroll.spec.ts @@ -1,5 +1,15 @@ import { expect } from "@playwright/test"; import { test } from "../support/fixtures"; +import { getServerId } from "../support/helpers/server-id"; +import { + seedSearchableCommandCenter, + observeCommandCenterRowRenders, + searchCommandCenter, + warmCommandCenterRows, + expectOnlyCommandCenterRowRendered, + expectCommandCenterAgentWindow, + scrollToOldestCommandCenterAgent, +} from "../support/helpers/command-center-performance"; import { gotoAppShell } from "../support/helpers/app"; import { expectCommandCenterToRemainAtTheTop, @@ -65,3 +75,44 @@ test("Command Center preserves manual scrolling through live updates and resumes await seeded.cleanup(); } }); + +test("live search updates render only the changed row and keep agents virtualized", async ({ + page, +}) => { + test.setTimeout(120_000); + const seeded = await seedSearchableCommandCenter(); + try { + await observeCommandCenterRowRenders(page); + await gotoAppShell(page); + const panel = await openCommandCenterWithKeyboard(page); + await searchCommandCenter(panel, "Search agent"); + await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible(); + await warmCommandCenterRows(page); + await expectCommandCenterAgentWindow(panel, seeded.agents.length); + + await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" }); + await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible(); + await expectOnlyCommandCenterRowRendered( + page, + `agent:${getServerId()}:${seeded.agents[59].id}`, + ); + + await searchCommandCenter(panel, "Search workspace"); + await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible(); + await warmCommandCenterRows(page); + await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised"); + await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible(); + await expectOnlyCommandCenterRowRendered( + page, + `workspace:${getServerId()}:${seeded.workspaces[39].id}`, + ); + + await searchCommandCenter(panel, "Search agent"); + await warmCommandCenterRows(page); + await scrollToOldestCommandCenterAgent(panel); + await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible(); + await expectCommandCenterAgentWindow(panel, seeded.agents.length); + } finally { + await seeded.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/command-center-workspaces.spec.ts b/packages/app/e2e/browser/command-center-workspaces.spec.ts index 4b01b0c67d..b47f8a9a4f 100644 --- a/packages/app/e2e/browser/command-center-workspaces.spec.ts +++ b/packages/app/e2e/browser/command-center-workspaces.spec.ts @@ -9,6 +9,7 @@ import { addOfflineHostAndReload } from "../support/helpers/hosts"; import { expectAppRoute } from "../support/helpers/route-assertions"; import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; +import { seedRunningMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; const PRIMARY_HOST_LABEL = "Primary Host"; @@ -20,6 +21,34 @@ const AGENT_TITLE = "Fix checkout retries"; test.describe("Command center workspaces", () => { test.describe.configure({ timeout: 180_000 }); + test("agent and workspace results retain their icons and overlay live status", async ({ + page, + }) => { + const workspace = await seedRunningMockAgentWorkspace({ + repoPrefix: "command-center-status-", + title: "Review checkout retries", + model: "five-minute-stream", + initialPrompt: "Keep working on checkout retries", + }); + try { + await openAgentRoute(page, workspace); + await expectAgentTabActive(page, workspace.agentId); + const panel = await openCommandCenter(page); + const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`); + const row = panel.getByTestId( + `command-center-workspace-${getServerId()}:${workspace.workspaceId}`, + ); + await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible(); + await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible(); + await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible(); + await workspace.client.cancelAgent(workspace.agentId); + await expect(agent.getByRole("progressbar")).toHaveCount(0); + await expect(row.getByRole("progressbar")).toHaveCount(0); + } finally { + await workspace.cleanup(); + } + }); + test("workspace results show their title, host, and branch and open the workspace", async ({ page, }) => { diff --git a/packages/app/e2e/support/helpers/command-center-performance.ts b/packages/app/e2e/support/helpers/command-center-performance.ts new file mode 100644 index 0000000000..b61771c05e --- /dev/null +++ b/packages/app/e2e/support/helpers/command-center-performance.ts @@ -0,0 +1,90 @@ +import { mkdirSync } from "node:fs"; +import { expect, type Locator, type Page } from "@playwright/test"; +import { createMockIdleAgent } from "./archive-tab"; +import { seedWorkspace } from "./seed-client"; + +export async function seedSearchableCommandCenter() { + const seed = await seedWorkspace({ + repoPrefix: "command-center-performance-", + title: "Search workspace", + git: false, + }); + try { + const agents = []; + for (let index = 0; index < 60; index += 1) { + agents.push( + await createMockIdleAgent(seed.client, { + cwd: seed.repoPath, + workspaceId: seed.workspaceId, + title: `Search agent ${String(index).padStart(3, "0")}`, + }), + ); + } + const workspaces = []; + for (let index = 0; index < 40; index += 1) { + const path = `${seed.repoPath}/workspace-${index}`; + mkdirSync(path); + const result = await seed.client.createWorkspace({ + source: { kind: "directory", path, projectId: seed.projectId }, + title: `Search workspace ${String(index).padStart(3, "0")}`, + }); + if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed"); + workspaces.push(result.workspace); + } + return { ...seed, agents, workspaces }; + } catch (error) { + await seed.cleanup(); + throw error; + } +} + +export async function observeCommandCenterRowRenders(page: Page) { + await page.addInitScript(() => { + globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true; + }); +} + +export async function searchCommandCenter(panel: Locator, query: string) { + await panel.getByTestId("command-center-input").fill(query); +} + +export async function warmCommandCenterRows(page: Page) { + // FlatList fills its overscan window in timed batches after the first visible frame. + await page.waitForTimeout(600); + await page.evaluate(() => { + globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {}; + }); +} + +export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + const renders = await page.evaluate(() => + Object.fromEntries( + Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter(([id]) => + id.startsWith("CommandCenterResultRow:"), + ), + ), + ); + expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } }); +} + +export async function expectCommandCenterAgentWindow(panel: Locator, total: number) { + const rows = panel.locator( + '[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])', + ); + const count = await rows.count(); + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThan(total); +} + +export async function scrollToOldestCommandCenterAgent(panel: Locator) { + await panel.getByTestId("command-center-results").evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll")); + }); +} diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 8cf2bebb81..4419e5193f 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -148,6 +148,7 @@ export interface SeedDaemonClient { predicate: (snapshot: { status: string }) => boolean, timeout?: number, ): Promise<{ status: string }>; + cancelAgent(agentId: string): Promise; sendAgentMessage(agentId: string, text: string): Promise; waitForFinish( agentId: string, diff --git a/packages/app/src/agent-presentation/index.ts b/packages/app/src/agent-presentation/index.ts new file mode 100644 index 0000000000..f3c3f139f7 --- /dev/null +++ b/packages/app/src/agent-presentation/index.ts @@ -0,0 +1,84 @@ +import { useShallow } from "zustand/shallow"; +import { useProviderIcon } from "@/components/provider-icons"; +import type { PanelDescriptor } from "@/panels/panel-registry"; +import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; + +function formatProviderLabel(provider: Agent["provider"]): string { + if (!provider) { + return "Agent"; + } + return provider + .split(/[-_\s]+/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function resolveAgentLabel(title: string | null | undefined): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} + +export function useAgentDescriptor( + target: { kind: "agent"; agentId: string }, + context: { serverId: string }, +): PanelDescriptor { + const descriptorState = useSessionStore( + useShallow((state) => { + const session = state.sessions[context.serverId]; + const agent = + session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null; + return { + provider: agent?.provider ?? "codex", + title: agent?.title ?? null, + status: agent?.status ?? null, + pendingPermissionCount: agent?.pendingPermissions.length ?? 0, + requiresAttention: agent?.requiresAttention ?? false, + attentionReason: agent?.attentionReason ?? null, + isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive, + }; + }), + ); + const icon = useProviderIcon(descriptorState.provider, context.serverId); + return buildAgentDescriptor({ ...descriptorState, icon }); +} + +export function buildAgentDescriptor(input: { + provider: Agent["provider"]; + title: string | null; + status: Agent["status"] | null; + pendingPermissionCount: number; + requiresAttention: boolean; + attentionReason: Agent["attentionReason"]; + isTurnActive: boolean; + icon: PanelDescriptor["icon"]; +}): PanelDescriptor { + const provider = input.provider; + const label = resolveAgentLabel(input.title); + + return { + label: label ?? "", + subtitle: `${formatProviderLabel(provider)} agent`, + tooltip: label ?? `${formatProviderLabel(provider)} agent`, + titleState: label ? "ready" : "loading", + icon: input.icon, + statusBucket: input.status + ? deriveSidebarStateBucket({ + status: input.isTurnActive ? "running" : input.status, + pendingPermissionCount: input.pendingPermissionCount, + requiresAttention: input.requiresAttention, + attentionReason: input.attentionReason, + }) + : null, + }; +} diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 88ee10d8bb..7a44f8ccd9 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -1,3 +1,5 @@ +import equal from "fast-deep-equal"; +import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { FlatList, Modal, @@ -24,7 +26,11 @@ import { useTranslation } from "react-i18next"; import { Check, ChevronRight, Folder, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet"; -import { AgentStatusDot } from "@/components/agent-status-dot"; +import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation"; +import { StatusIcon } from "@/components/status-icon"; +import { useWorkspaceStatus } from "@/workspace-status"; +import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; +import type { Theme } from "@/styles/theme"; import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text"; import { findHighlightRanges } from "@/components/ui/highlighted-text-segments"; import { MaterialFileIcon } from "@/components/material-file-icon"; @@ -114,6 +120,20 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] }; } +function createWorkspaceResultAction(serverId: string, workspaceId: string) { + return () => { + clearCommandCenterFocusRestoreElement(); + navigateToWorkspace({ serverId, workspaceId }); + }; +} + +function createAgentResultAction(serverId: string, agentId: string) { + return () => { + clearCommandCenterFocusRestoreElement(); + navigateToAgent({ serverId, agentId }); + }; +} + /** * Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what * was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke. @@ -123,6 +143,9 @@ function useBuiltInRows(open: boolean): { agents: CommandCenterAgentResult[]; } { const { t } = useTranslation(); + const previousRows = useRef( + new Map(), + ); const { agents } = useAggregatedAgents({ demand: open }); const { projects } = useProjects({ enabled: open }); const showHost = useHosts().length > 1; @@ -144,7 +167,22 @@ function useBuiltInRows(open: boolean): { const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets }); return useMemo(() => { - if (!open) return { workspaces: [], agents: [] }; + if (!open) { + previousRows.current.clear(); + return { workspaces: [], agents: [] }; + } + // Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged + // row data so a directory update does not invalidate every mounted ResultRow. + const nextRows = new Map(); + const preserve = ( + row: T, + ): T => { + const previous = previousRows.current.get(row.id); + if (previous) row.run = previous.run; + const stable = previous && equal(previous, row) ? (previous as T) : row; + nextRows.set(row.id, stable); + return stable; + }; const allWorkspaces: CommandCenterWorkspaceResult[] = []; for (const project of projects) { const workspaceProject = { @@ -155,23 +193,24 @@ function useBuiltInRows(open: boolean): { for (const host of project.hosts) { for (const workspace of host.workspaces) { if (workspace.archivingAt) continue; - allWorkspaces.push({ - kind: "workspace", - id: `workspace:${host.serverId}:${workspace.id}`, - title: workspace.title ?? workspace.name, - subtitle: joinSubtitleParts([ - showHost ? host.serverName : null, - project.projectName, - workspace.currentBranch, - ]), - project: workspaceProject, - changeRequestNumber: workspace.changeRequestNumber, - activityAt: workspace.statusEnteredAt, - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id }); - }, - }); + allWorkspaces.push( + preserve({ + kind: "workspace", + id: `workspace:${host.serverId}:${workspace.id}`, + title: workspace.title ?? workspace.name, + subtitle: joinSubtitleParts([ + showHost ? host.serverName : null, + project.projectName, + workspace.currentBranch, + ]), + serverId: host.serverId, + workspaceId: workspace.id, + project: workspaceProject, + changeRequestNumber: workspace.changeRequestNumber, + activityAt: workspace.statusEnteredAt, + run: createWorkspaceResultAction(host.serverId, workspace.id), + }), + ); } } } @@ -182,22 +221,20 @@ function useBuiltInRows(open: boolean): { const workspaceTitle = agent.workspaceId ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) : undefined; - return { + return preserve({ kind: "agent", id: `agent:${agent.serverId}:${agent.id}`, agent, - title: agent.title || t("shell.commandCenter.newAgent"), + title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"), subtitle: joinSubtitleParts([ showHost ? agent.serverLabel : null, workspaceTitle ?? shortenPath(agent.cwd), ]), activityAt: agent.lastActivityAt, - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); - }, - }; + run: createAgentResultAction(agent.serverId, agent.id), + }); }); + previousRows.current = nextRows; return { workspaces: allWorkspaces, agents: agentRows }; }, [agents, iconDataByProjectViewKey, open, projects, showHost, t]); } @@ -402,6 +439,7 @@ const ResultRow = memo(function ResultRow({ active, onSelect, }: ResultRowProps) { + recordRenderProfileReasons(`CommandCenterResultRow:${result.id}`, ["render"]); const press = useCallback(() => onSelect(result), [onSelect, result]); const choice = result.kind === "contribution" && result.contribution.presentation.kind === "choice" @@ -435,7 +473,19 @@ const ResultRow = memo(function ResultRow({ result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId } > - + {(state) => ( + + )} ); }); @@ -489,6 +539,72 @@ function SubtitleWithTimestamp({ ); } +const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function AgentResultContent({ + result, + query, + backdrop, +}: { + result: CommandCenterAgentResult; + query: string; + backdrop: SurfaceBackdrop; +}) { + const agent = result.agent; + const descriptor = useAgentDescriptor( + { kind: "agent", agentId: agent.id }, + { serverId: agent.serverId }, + ); + const Icon = useMemo( + () => withUnistyles(descriptor.icon, providerIconMapping), + [descriptor.icon], + ); + return ( + + + + + + + + + + + + + + + + ); +} + +function WorkspaceResultIcon({ + result, + backdrop, +}: { + result: CommandCenterWorkspaceResult; + backdrop: SurfaceBackdrop; +}) { + const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId); + return ( + + + + + + ); +} + /** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */ function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) { const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase(); @@ -503,7 +619,15 @@ function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResu ); } -function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) { +function ResultContent({ + result, + query, + backdrop, +}: { + result: CommandCenterResult; + query: string; + backdrop: SurfaceBackdrop; +}) { if (result.kind === "file") { return ( @@ -530,34 +654,7 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query: ); } if (result.kind === "agent") { - const agent = result.agent; - return ( - - - - - - - - - - - - ); + return ; } if (result.kind === "workspace") { const key = result.id.slice("workspace:".length); @@ -565,13 +662,8 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query: {/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */} - - + + ({ + color: theme.colors.surface0, + fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, +}); + +export function StatusIcon({ + children, + statusBucket, + size = 14, + statusDotBorderColor, + backdrop, +}: StatusIconProps): ReactElement { + const bucket = statusBucket; + const isRunning = bucket === "running"; + let statusDotColor: string | undefined; + if (bucket === "failed") statusDotColor = styles.statusDotFailed.color; + else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color; + const showNeedsInputAlert = bucket === "needs_input"; + const agentIconWrapperStyle = useMemo( + () => [styles.agentIconWrapper, { width: size, height: size }], + [size], + ); + const statusDotStyle = useMemo( + () => [ + styles.statusDot, + { + backgroundColor: statusDotColor, + borderColor: statusDotBorderColor ?? styles[backdrop].borderColor, + }, + ], + [statusDotColor, statusDotBorderColor, backdrop], + ); + + return ( + + {children} + {isRunning ? ( + + + + ) : null} + {statusDotColor ? : null} + {showNeedsInputAlert ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + agentIconWrapper: { + position: "relative", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + statusDot: { + position: "absolute", + right: DEFAULT_STATUS_DOT_OFFSET, + bottom: DEFAULT_STATUS_DOT_OFFSET, + width: STATUS_INDICATOR_DOT_SIZE, + height: STATUS_INDICATOR_DOT_SIZE, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + }, + statusRing: { + position: "absolute", + right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), + bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), + }, + surface0: { borderColor: theme.colors.surface0 }, + surface1: { borderColor: theme.colors.surface1 }, + surface2: { borderColor: theme.colors.surface2 }, + surfaceSidebar: { borderColor: theme.colors.surfaceSidebar }, + surfaceSidebarHover: { borderColor: theme.colors.surfaceSidebarHover }, + surfaceSidebarSelected: { borderColor: theme.colors.surfaceSidebarSelected }, + statusAlertOverlay: { + position: "absolute", + right: STATUS_ALERT_OFFSET, + bottom: STATUS_ALERT_OFFSET, + }, + statusDotFailed: { + color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, + }, + statusDotAttention: { + color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, + }, +})); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index cec59e071b..3c4ce4e2da 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -1,3 +1,4 @@ +import { deriveEffectiveWorkspaceStatus } from "@/workspace-status"; import equal from "fast-deep-equal"; import type { PrHint } from "@/git/pr-hint"; import { selectPrHintFromStatus } from "@/git/pr-hint"; @@ -124,11 +125,6 @@ export function areSidebarWorkspaceSessionsEqual( return true; } -interface EffectiveWorkspaceStatus { - status: WorkspaceDescriptor["status"]; - enteredAt: Date | null; -} - function projectNameForWorkspace(workspace: WorkspaceDescriptor): string { return ( workspace.projectCustomName ?? @@ -189,51 +185,6 @@ export function createSidebarWorkspaceEntry(input: { const EMPTY_WORKSPACE_LABELS: string[] = []; -function deriveEffectiveWorkspaceStatus(input: { - serverId: string; - workspace: WorkspaceDescriptor; - pendingCreateAttempts?: Record; - workspaceAgentActivity?: ReadonlyMap; -}): EffectiveWorkspaceStatus { - if (input.workspace.status !== "done") { - return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; - } - - const pendingStartedAt = getPendingInitialAgentCreateStartedAt({ - serverId: input.serverId, - workspaceId: input.workspace.id, - pendingCreateAttempts: input.pendingCreateAttempts, - }); - if (pendingStartedAt) { - return { status: "running", enteredAt: pendingStartedAt }; - } - - const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id); - if (rootAgentActivity && rootAgentActivity.status !== "done") { - return rootAgentActivity; - } - - return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; -} - -function getPendingInitialAgentCreateStartedAt(input: { - serverId: string; - workspaceId: string; - pendingCreateAttempts: Record | undefined; -}): Date | null { - let latestStartedAt: Date | null = null; - for (const pending of Object.values(input.pendingCreateAttempts ?? {})) { - if (pending.serverId !== input.serverId) continue; - if (pending.workspaceId !== input.workspaceId) continue; - if (pending.lifecycle === "abandoned") continue; - const startedAt = new Date(pending.timestamp); - if (!latestStartedAt || startedAt > latestStartedAt) { - latestStartedAt = startedAt; - } - } - return latestStartedAt; -} - export interface ProjectStatusSession { workspaces: Map; workspaceAgentActivity: Map; diff --git a/packages/app/src/panels/agent-panel-descriptor.test.tsx b/packages/app/src/panels/agent-panel-descriptor.test.tsx index 30daf5ffa9..ac57778111 100644 --- a/packages/app/src/panels/agent-panel-descriptor.test.tsx +++ b/packages/app/src/panels/agent-panel-descriptor.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { buildAgentDescriptor, resolveAgentLabel } from "@/agent-presentation"; import { i18n } from "@/i18n/i18next"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; @@ -66,3 +67,57 @@ describe("buildDraftPanelDescriptor", () => { await i18n.changeLanguage("en"); }); }); + +describe("shared agent descriptor", () => { + function describeAgent(overrides: Partial[0]> = {}) { + return buildAgentDescriptor({ + provider: "codex", + title: " Review checkout retries ", + status: "idle", + pendingPermissionCount: 0, + requiresAttention: false, + attentionReason: null, + isTurnActive: false, + icon: TestIcon, + ...overrides, + }); + } + + it("keeps the normalized title and provider identity together", () => { + expect(describeAgent()).toMatchObject({ + label: "Review checkout retries", + subtitle: "Codex agent", + icon: TestIcon, + titleState: "ready", + statusBucket: "done", + }); + }); + + it.each([null, "", " ", "New Agent", " new agent "])( + "leaves an untitled agent loading: %s", + (title) => { + expect(resolveAgentLabel(title)).toBeNull(); + expect(describeAgent({ title })).toMatchObject({ label: "", titleState: "loading" }); + }, + ); + + it("shows an active turn as running before the lifecycle update arrives", () => { + expect(describeAgent({ isTurnActive: true }).statusBucket).toBe("running"); + }); + + it("surfaces pending permissions even while the turn is running", () => { + expect(describeAgent({ isTurnActive: true, pendingPermissionCount: 1 }).statusBucket).toBe( + "needs_input", + ); + }); + + it.each([ + ["finished", "attention"], + ["error", "failed"], + ["permission", "needs_input"], + ] as const)("preserves %s attention", (attentionReason, statusBucket) => { + expect(describeAgent({ requiresAttention: true, attentionReason }).statusBucket).toBe( + statusBucket, + ); + }); +}); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2fa2624fb4..af14b94229 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -39,7 +39,7 @@ import { } from "@/composer/pill-styles"; import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; -import { useProviderIcon } from "@/components/provider-icons"; +import { useAgentDescriptor } from "@/agent-presentation"; import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; @@ -62,7 +62,7 @@ import { TimelineSyncStatus } from "@/timeline/sync-status"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { useAgentFactsLine } from "@/panels/agent-facts-line"; import { PaneSubtitle } from "@/panels/pane-subtitle"; -import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; +import { definePanel } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; import { useHasPluginComposerPills } from "@/plugins"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; @@ -101,7 +101,6 @@ import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; -import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands"; interface ChatAgentStateShape { @@ -256,31 +255,6 @@ function renderChatAgentNonReadyView(args: { return null; } -function formatProviderLabel(provider: Agent["provider"]): string { - if (!provider) { - return "Agent"; - } - return provider - .split(/[-_\s]+/) - .filter((part) => part.length > 0) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null { - if (typeof title !== "string") { - return null; - } - const normalized = title.trim(); - if (!normalized) { - return null; - } - if (normalized.toLowerCase() === "new agent") { - return null; - } - return normalized; -} - function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean { return !agent.archivedAt && Boolean(agent.projectPlacement); } @@ -328,47 +302,6 @@ function storeFetchedAgentDetail(input: { return hydrated; } -function useAgentPanelDescriptor( - target: { kind: "agent"; agentId: string }, - context: { serverId: string }, -): PanelDescriptor { - const descriptorState = useSessionStore( - useShallow((state) => { - const session = state.sessions[context.serverId]; - const agent = - session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null; - return { - provider: agent?.provider ?? "codex", - title: agent?.title ?? null, - status: agent?.status ?? null, - pendingPermissionCount: agent?.pendingPermissions.length ?? 0, - requiresAttention: agent?.requiresAttention ?? false, - attentionReason: agent?.attentionReason ?? null, - isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive, - }; - }), - ); - const provider = descriptorState.provider; - const label = resolveWorkspaceAgentTabLabel(descriptorState.title); - const icon = useProviderIcon(provider, context.serverId); - - return { - label: label ?? "", - subtitle: `${formatProviderLabel(provider)} agent`, - tooltip: label ?? `${formatProviderLabel(provider)} agent`, - titleState: label ? "ready" : "loading", - icon, - statusBucket: descriptorState.status - ? deriveSidebarStateBucket({ - status: descriptorState.isTurnActive ? "running" : descriptorState.status, - pendingPermissionCount: descriptorState.pendingPermissionCount, - requiresAttention: descriptorState.requiresAttention, - attentionReason: descriptorState.attentionReason, - }) - : null, - }; -} - function AgentPanel() { const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isInteractive } = usePaneFocus(); @@ -438,7 +371,7 @@ export function AgentConversationPanel() { export const agentPanelRegistration = definePanel("agent", { component: AgentConversationPanel, - useDescriptor: useAgentPanelDescriptor, + useDescriptor: useAgentDescriptor, }); export function useDraftPanelDescriptor( diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index 8b499429f0..fb73376b8b 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -1,6 +1,8 @@ +import type { Theme } from "@/styles/theme"; +import { StatusIcon } from "@/components/status-icon"; import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { Check, CircleAlert } from "lucide-react-native"; +import { Check } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; @@ -9,14 +11,6 @@ import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-regist import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; -import { getStatusDotColor } from "@/utils/status-dot-color"; -import { StatusRing } from "@/components/status-ring"; -import { getStatusRingOffset } from "@/components/status-ring/geometry"; -import { - STATUS_INDICATOR_ALERT_SIZE, - STATUS_INDICATOR_DOT_SIZE, -} from "@/utils/status-indicator-geometry"; -import type { Theme } from "@/styles/theme"; import { usePanelInstanceAttributes } from "@/panels/panel-instance-attributes"; export interface WorkspaceTabPresentation { @@ -32,9 +26,6 @@ export interface WorkspaceTabPresentation { statusBucket: SidebarStateBucket | null; } -const DEFAULT_STATUS_DOT_OFFSET = -2; -const STATUS_ALERT_OFFSET = -3; - interface WorkspaceTabPresentationResolverProps { tab: WorkspaceTabDescriptor; serverId: string; @@ -129,13 +120,7 @@ interface WorkspaceTabIconProps { } const ThemedCheckIcon = withUnistyles(Check); -const ThemedCircleAlert = withUnistyles(CircleAlert); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const needsInputAlertMapping = (theme: Theme) => ({ - color: theme.colors.surface0, - fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, -}); - export function WorkspaceTabIcon({ presentation, active = false, @@ -144,48 +129,17 @@ export function WorkspaceTabIcon({ statusDotBorderColor, backdrop, }: WorkspaceTabIconProps): ReactElement { - const iconColor = active ? styles.iconActive.color : styles.iconInactive.color; - const bucket = presentation.statusBucket; - const isRunning = bucket === "running"; - let statusDotColor: string | undefined; - if (bucket === "failed") statusDotColor = styles.statusDotFailed.color; - else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color; - const showNeedsInputAlert = bucket === "needs_input"; const Icon = presentation.icon; - const agentIconWrapperStyle = useMemo( - () => [styles.agentIconWrapper, { width: size, height: size }], - [size], - ); - const statusDotStyle = useMemo( - () => [ - styles.statusDot, - { - backgroundColor: statusDotColor, - borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor, - }, - ], - [statusDotColor, statusDotBorderColor], - ); - + const iconColor = active ? styles.iconActive.color : styles.iconInactive.color; return ( - + - {isRunning ? ( - - - - ) : null} - {statusDotColor ? : null} - {showNeedsInputAlert ? ( - - - - ) : null} - + ); } @@ -262,43 +216,6 @@ export function WorkspaceTabOptionRow({ } const styles = StyleSheet.create((theme) => ({ - agentIconWrapper: { - position: "relative", - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, - statusDot: { - position: "absolute", - right: DEFAULT_STATUS_DOT_OFFSET, - bottom: DEFAULT_STATUS_DOT_OFFSET, - width: STATUS_INDICATOR_DOT_SIZE, - height: STATUS_INDICATOR_DOT_SIZE, - borderRadius: theme.borderRadius.full, - borderWidth: 1, - }, - statusRing: { - position: "absolute", - right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), - bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), - }, - statusDotBorderDefault: { - borderColor: theme.colors.surface0, - }, - statusAlertOverlay: { - position: "absolute", - right: STATUS_ALERT_OFFSET, - bottom: STATUS_ALERT_OFFSET, - }, - statusDotFailed: { - color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, - }, - statusDotRunning: { - color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, - }, - statusDotAttention: { - color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, - }, iconActive: { color: theme.colors.foreground, }, diff --git a/packages/app/src/workspace-status/index.ts b/packages/app/src/workspace-status/index.ts new file mode 100644 index 0000000000..45bf68ab8e --- /dev/null +++ b/packages/app/src/workspace-status/index.ts @@ -0,0 +1,74 @@ +import { useShallow } from "zustand/shallow"; +import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; + +interface EffectiveWorkspaceStatus { + status: WorkspaceDescriptor["status"]; + enteredAt: Date | null; +} + +export function deriveEffectiveWorkspaceStatus(input: { + serverId: string; + workspace: WorkspaceDescriptor; + pendingCreateAttempts?: Record; + workspaceAgentActivity?: ReadonlyMap; +}): EffectiveWorkspaceStatus { + if (input.workspace.status !== "done") { + return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; + } + + const pendingStartedAt = getPendingInitialAgentCreateStartedAt({ + serverId: input.serverId, + workspaceId: input.workspace.id, + pendingCreateAttempts: input.pendingCreateAttempts, + }); + if (pendingStartedAt) { + return { status: "running", enteredAt: pendingStartedAt }; + } + + const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id); + if (rootAgentActivity && rootAgentActivity.status !== "done") { + return rootAgentActivity; + } + + return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; +} + +function getPendingInitialAgentCreateStartedAt(input: { + serverId: string; + workspaceId: string; + pendingCreateAttempts: Record | undefined; +}): Date | null { + let latestStartedAt: Date | null = null; + for (const pending of Object.values(input.pendingCreateAttempts ?? {})) { + if (pending.serverId !== input.serverId) continue; + if (pending.workspaceId !== input.workspaceId) continue; + if (pending.lifecycle === "abandoned") continue; + const startedAt = new Date(pending.timestamp); + if (!latestStartedAt || startedAt > latestStartedAt) { + latestStartedAt = startedAt; + } + } + return latestStartedAt; +} + +export function useWorkspaceStatus(serverId: string, workspaceId: string) { + const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId); + return useSessionStore( + useShallow((state) => { + const session = state.sessions[serverId]; + if (!session) return null; + const key = resolveWorkspaceMapKeyByIdentity({ workspaces: session.workspaces, workspaceId }); + const workspace = key ? session.workspaces.get(key) : null; + if (!workspace) return null; + return deriveEffectiveWorkspaceStatus({ + serverId, + workspace, + pendingCreateAttempts, + workspaceAgentActivity: session.workspaceAgentActivity, + }).status; + }), + ); +} From 582beeb10bf7a0dc2297f35529f78df89dee9358 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 15:57:12 +0700 Subject: [PATCH 43/62] fix(app): preserve compact sidebar context under sheets (#7693) Keep compact sidebars open while users inspect Search, Import session, Add project, or file sheets. Reveal the resolved destination when navigation commits, and place compact workspace sheets above both sidebars while preserving shared editing and cache state. --- docs/explorer-sidebar.md | 13 +- docs/floating-panels.md | 8 +- .../app/e2e/browser/add-project-flow.spec.ts | 37 +++++ .../command-center-file-search.spec.ts | 32 +++++ .../browser/command-center-workspaces.spec.ts | 71 ++++++++++ .../e2e/browser/import-session-flow.spec.ts | 1 + .../e2e/browser/sidebar-nav-settings.spec.ts | 4 +- .../sidebar-workspace-mark-unread.spec.ts | 1 + .../e2e/support/helpers/usage-sidebar-item.ts | 7 + packages/app/src/app/_layout.tsx | 3 +- .../app/src/assistant-file-links/index.ts | 1 + .../app/src/assistant-file-links/provider.tsx | 12 ++ .../command-center/workspace-file-search.ts | 6 +- .../app/src/components/add-project-flow.tsx | 3 + packages/app/src/components/left-sidebar.tsx | 6 +- .../src/components/sidebar-workspace-list.tsx | 4 +- .../components/sidebar/sidebar-nav-rows.tsx | 5 +- .../sidebar/sidebar-status-list.tsx | 4 +- .../host-scope.tsx | 14 +- .../src/composer/editing-session/index.tsx | 14 ++ .../src/git/diff-document/workspace-cache.tsx | 12 +- packages/app/src/hooks/use-import-session.tsx | 8 +- packages/app/src/hosts/host-chooser.tsx | 9 +- .../retained-chat-content.browser.test.tsx | 127 +++++++++++++++++- .../app/src/screens/workspace/sheet-tabs.tsx | 8 +- .../workspace-file-open-command.test.ts | 88 ++++++++---- .../workspace/workspace-file-open-command.ts | 9 +- .../screens/workspace/workspace-screen.tsx | 18 +-- .../workspace/workspace-sheet-environment.tsx | 38 ++++++ .../index.ts | 8 ++ .../navigation.test.ts | 27 +++- .../navigation.ts | 13 +- .../app/src/stores/workspace-layout-store.ts | 6 +- .../app/src/workspace-tabs/navigation.test.ts | 23 ++++ packages/app/vitest.config.ts | 22 +++ 35 files changed, 563 insertions(+), 99 deletions(-) create mode 100644 packages/app/src/screens/workspace/workspace-sheet-environment.tsx diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 7207d14f87..a1778b47b8 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -43,13 +43,16 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab dismisses the sheets above it, since the tab would sit under them. The stack is not persisted. -Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag +Sheets are modal and cover both compact sidebars as well as the workspace. A sheet's header is its drag handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside -Close, so the title keeps its width. The sheets present -from the shared workspace's `SheetHostScope`, so their panels read the navigator and every -workspace provider an ordinary pane has without a context bridge -([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)). +Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local +host sits behind the app-level left sidebar. The workspace environment bridge preserves existing +editing sessions, caches, file-link resolution and supported navigation context without moving +their providers. Keep this capability at the workspace boundary; new sheet callers must not +assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts +forward the same Gorhom modal queue; neither creates another stack. See +[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site). A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it. Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 8b81ca0ad9..f17c59c7c2 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -363,10 +363,10 @@ track phase. All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on close. This includes native centered dialogs: separate React Native Modals cannot present a -root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree -(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their -panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the -portal host and container measurement while the sheet still joins the same stack. That export is +root-owned sibling dialog over an existing iOS presenter. `SheetHostScope` moves the portal host +and container measurement while forwarding the same modal queue. Compact +[workspace sheets](explorer-sidebar.md#sheets) use the app overlay host above both sidebars; +wide layouts keep local geometry. That export is what `patches/@gorhom+bottom-sheet+*.patch` adds. A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every diff --git a/packages/app/e2e/browser/add-project-flow.spec.ts b/packages/app/e2e/browser/add-project-flow.spec.ts index f66b963d5f..5770c3be9d 100644 --- a/packages/app/e2e/browser/add-project-flow.spec.ts +++ b/packages/app/e2e/browser/add-project-flow.spec.ts @@ -420,3 +420,40 @@ test.describe("Add Project command-center flow", () => { } }); }); + +test.describe("Compact Add project context", () => { + test.use({ viewport: { width: 390, height: 844 } }); + test("canceling Add project preserves the sidebar", async ({ page }) => { + await gotoAppShell(page); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await openAddProjectFlow(page); + await page.keyboard.press("Escape"); + await expect(addProjectFlow(page)).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + }); + test("successful Add project reveals the new workspace page", async ({ + page, + projectPickerFixture, + }) => { + await gotoAppShell(page); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await openAddProjectFlow(page); + await chooseAddProjectMethod(page, "directory-search"); + await addProjectFlowInput(page).fill(projectPickerFixture.fuzzyQuery); + await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName); + await page.keyboard.press("Enter"); + const projectId = await expectOpenedProject(page, projectPickerFixture.projectName); + projectPickerFixture.rememberProjectId(projectId); + await expectNewWorkspaceForAddedProject(page, { + serverId: getServerId(), + projectId, + projectName: projectPickerFixture.projectName, + projectPath: projectPickerFixture.projectPath, + }); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); + }); +}); diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts index 5949882492..2f16db6f09 100644 --- a/packages/app/e2e/browser/command-center-file-search.spec.ts +++ b/packages/app/e2e/browser/command-center-file-search.spec.ts @@ -202,3 +202,35 @@ test("manual scrolling cancels a pending file-search reveal and query editing re await seeded.cleanup(); } }); + +test.describe("Compact file search context", () => { + test.use({ viewport: { width: 390, height: 844 } }); + test("a file result opens a sheet above the left sidebar", async ({ page }) => { + const seeded = await seedWorkspace({ + repoPrefix: "compact-search-file-", + title: "Compact file lookup", + repo: { files: [{ path: "lookup.ts", content: "export const lookup = true;\n" }] }, + }); + try { + await page.goto(`/h/${process.env.E2E_SERVER_ID}/workspace/${seeded.workspaceId}`); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + const search = await openCommandCenter(page); + await search.getByTestId("command-center-input").fill("lookup.ts"); + await search.getByRole("button", { name: "lookup.ts", exact: true }).click(); + await expect(page.getByTestId("sheet-tab-body")).toBeVisible(); + await expect(page.getByTestId("sheet-tab-header")).toContainText("lookup.ts"); + await page + .getByTestId("sheet-tab-header") + .getByRole("button", { name: "Close", exact: true }) + .click(); + await expect(page.getByTestId("sheet-tab-body")).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/command-center-workspaces.spec.ts b/packages/app/e2e/browser/command-center-workspaces.spec.ts index b47f8a9a4f..38b2e33cff 100644 --- a/packages/app/e2e/browser/command-center-workspaces.spec.ts +++ b/packages/app/e2e/browser/command-center-workspaces.spec.ts @@ -185,3 +185,74 @@ test.describe("Command center workspaces", () => { } }); }); + +test.describe("Compact sidebar inspection", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + async function openSidebar(page: import("@playwright/test").Page) { + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + } + + async function expectSidebarPreserved(page: import("@playwright/test").Page) { + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(page.getByTestId("sidebar-search")).toBeVisible(); + } + + test("canceling Search retains the compact sidebar", async ({ page }) => { + await gotoAppShell(page); + await openSidebar(page); + await openCommandCenter(page); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("command-center-panel")).not.toBeVisible(); + await expectSidebarPreserved(page); + }); + test("canceling Import session retains the compact sidebar", async ({ page }) => { + await gotoAppShell(page); + await openSidebar(page); + await page + .getByTestId("sidebar-project-empty-state") + .getByRole("button", { name: "Import session", exact: true }) + .click(); + await expect(page.getByTestId("import-session-sheet")).toBeVisible(); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByTestId("import-session-sheet")).not.toBeVisible(); + await expectSidebarPreserved(page); + }); + + test("Search reveals the same workspace and a selected root agent", async ({ page }) => { + const seeded = await seedWorkspace({ + repoPrefix: "compact-search-destination-", + title: "Compact destination", + }); + try { + const agent = await createIdleAgent(seeded.client, { + cwd: seeded.repoPath, + workspaceId: seeded.workspaceId, + title: "Compact root agent", + }); + await page.goto(buildHostWorkspaceRoute(getServerId(), seeded.workspaceId)); + await openSidebar(page); + const search = await openCommandCenter(page); + await search.getByTestId("command-center-input").fill("Compact destination"); + await search + .getByTestId(`command-center-workspace-${getServerId()}:${seeded.workspaceId}`) + .click(); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); + await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId)); + await openSidebar(page); + const agentSearch = await openCommandCenter(page); + await agentSearch.getByTestId("command-center-input").fill("Compact root agent"); + await agentSearch.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`).click(); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Message agent...", exact: true }), + ).toBeVisible(); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts index 788a6f93ec..c6d34f38f2 100644 --- a/packages/app/e2e/browser/import-session-flow.spec.ts +++ b/packages/app/e2e/browser/import-session-flow.spec.ts @@ -182,6 +182,7 @@ test("captures the compact import-session journey", async ({ page }, testInfo) = await test.step("the selected row imports and opens the hydrated transcript", async () => { await flow.importSession(scenario.importSessionId); await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready."); + await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible(); await capture(page, testInfo, "08-mobile-agent-after-import.png"); }); diff --git a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts index 177ddd563e..b557b73ccb 100644 --- a/packages/app/e2e/browser/sidebar-nav-settings.spec.ts +++ b/packages/app/e2e/browser/sidebar-nav-settings.spec.ts @@ -6,6 +6,7 @@ import { claudeAndCodexReports, expectPinnedUsage, openCompactSidebar, + expectCompactSidebarOpen, } from "../support/helpers/usage-sidebar-item"; import { gotoAppShell } from "../support/helpers/app"; import { SHOWCASE_PLUGIN_ID, installSidebarPlugins } from "../support/helpers/plugin-sidebar-items"; @@ -90,12 +91,13 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy await openAddProjectFlow(page); await page.keyboard.press("Escape"); await expect(addProjectFlow(page)).toBeHidden(); + await expectCompactSidebarOpen(page); await page.setViewportSize({ width: 1440, height: 900 }); await openSidebarNavSettings(page); await setFooterItemVisible(page, "usage", true); await leaveSettings(page); await page.setViewportSize({ width: 390, height: 844 }); - await openCompactSidebar(page); + await expectCompactSidebarOpen(page); await expectFooterSeparator(page, true); await expectFooterIconRow(page); await footerScreenshot(page, "footer-compact-with-rows"); diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts index 23127bbdd9..ccf29179b8 100644 --- a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts @@ -180,6 +180,7 @@ async function addFinishedAgent(workspace: MockAgentWorkspace) { async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) { await page.setViewportSize({ width: 390, height: 844 }); await openAgentRoute(page, workspace); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); await openMobileAgentSidebar(page); } diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 95c6a4d689..2b4aa46e9b 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -301,3 +301,10 @@ export async function refreshAllUsage(page: Page): Promise { export async function openCompactSidebar(page: Page): Promise { await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); } + +export async function expectCompactSidebarOpen(page: Page): Promise { + await expect(page.getByRole("button", { name: "Close menu", exact: true })).toHaveAttribute( + "aria-expanded", + "true", + ); +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index f24df99f26..99022863f5 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -2,6 +2,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar"; import { ComposerEditingSessionsProvider } from "@/composer/editing-session"; +import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal"; import "@/styles/unistyles"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; @@ -507,7 +508,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon return ( - {surface} + {surface} ); } diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 9b66e99c18..6d80abb1a6 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -6,6 +6,7 @@ export { export { AssistantFileLinkResolverProvider, useAssistantFileLinkEnvironment, + useAssistantFileLinkEnvironmentBridge, type AssistantFileLinkResolverProviderProps, } from "./provider"; export type { AssistantFileLinkSource } from "./resolver"; diff --git a/packages/app/src/assistant-file-links/provider.tsx b/packages/app/src/assistant-file-links/provider.tsx index a8773a2398..935683bb0e 100644 --- a/packages/app/src/assistant-file-links/provider.tsx +++ b/packages/app/src/assistant-file-links/provider.tsx @@ -39,6 +39,18 @@ export interface AssistantFileLinkResolverContextValue { const AssistantFileLinkResolverContext = createContext(null); +export function useAssistantFileLinkEnvironmentBridge() { + const value = useContext(AssistantFileLinkResolverContext); + return useCallback( + (children: ReactNode) => ( + + {children} + + ), + [value], + ); +} + export function AssistantFileLinkResolverProvider({ client, serverId, diff --git a/packages/app/src/command-center/workspace-file-search.ts b/packages/app/src/command-center/workspace-file-search.ts index 1008604bec..8864da5ea8 100644 --- a/packages/app/src/command-center/workspace-file-search.ts +++ b/packages/app/src/command-center/workspace-file-search.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { usePanelStore } from "@/stores/panel-store"; +import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; @@ -131,8 +131,6 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string openWorkspaceFileFromExplorer({ filePath: path, persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), - closeExplorerAfterOpen: true, - showMobileAgent: usePanelStore.getState().showMobileAgent, openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) => useWorkspaceLayoutStore.getState().openTab({ workspaceKey, @@ -140,7 +138,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string intent: "reveal", placement, }), - focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab, + revealWorkspaceTab, }); }, [serverId, workspaceId], diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 06f7872995..a1cb5cf779 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,3 +1,4 @@ +import { usePanelStore } from "@/stores/panel-store"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { router } from "expo-router"; import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages"; @@ -474,6 +475,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const openNewWorkspaceForProject = useCallback( (serverId: string, project: WorkspaceProjectDescriptorPayload) => { onClose(); + usePanelStore.getState().showMobileAgent(); router.push( buildNewWorkspaceRoute({ serverId, @@ -621,6 +623,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { testID: "add-project-flow-add-host", select: () => { onClose(); + usePanelStore.getState().showMobileAgent(); router.push(buildSettingsAddHostRoute(Date.now())); }, }); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 79f836d1d4..3f5d218150 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -166,9 +166,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole const { open: openImportSession, sheet: importSessionSheet } = useImportSession(); const handleOpenProjectMobile = useCallback(() => { - showMobileAgent(); void openProjectPicker(); - }, [showMobileAgent, openProjectPicker]); + }, [openProjectPicker]); const handleOpenProjectDesktop = useCallback(() => { void openProjectPicker(); @@ -205,9 +204,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole }, []); const handleImportSessionMobile = useCallback(() => { - showMobileAgent(); openImportSession(); - }, [openImportSession, showMobileAgent]); + }, [openImportSession]); const labels = useMemo( (): SidebarLabels => ({ diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 1c7b3f28d0..cc248adeff 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1401,7 +1401,6 @@ function WorkspaceRowItem({ isCreating = false, selectionEnabled, activeWorkspaceSelection, - onWorkspacePress, drag, isDragging = false, dragHandleProps, @@ -1410,9 +1409,8 @@ function WorkspaceRowItem({ if (!workspace.serverId) { return; } - onWorkspacePress?.(); navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId }); - }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]); + }, [workspace.serverId, workspace.workspaceId]); return ( state.setCommandCenterOpen); const handlePress = useCallback(() => { - onBeforeNavigate?.(); setCommandCenterOpen(true); - }, [onBeforeNavigate, setCommandCenterOpen]); + }, [setCommandCenterOpen]); return ( { if (!workspace.serverId) return; - onWorkspacePress?.(); navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId }); - }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]); + }, [workspace.serverId, workspace.workspaceId]); return ( ; + /** Disabled scopes keep their mounted children and use the enclosing host. */ + enabled?: boolean; }) { const parent = useBottomSheetModalInternal(); const hostName = `sheet-host-${useId()}`; @@ -69,12 +72,14 @@ export function SheetHostScope({ ); return ( - + {children} - - - + {enabled ? ( + + + + ) : null} ); @@ -87,5 +92,6 @@ const styles = StyleSheet.create({ }, host: { ...StyleSheet.absoluteFill, + zIndex: 1, }, }); diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx index 6ad84d632d..4776b5af3d 100644 --- a/packages/app/src/composer/editing-session/index.tsx +++ b/packages/app/src/composer/editing-session/index.tsx @@ -119,6 +119,20 @@ const SessionsContext = createContext(null); const WorkspaceContext = createContext(null); const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null); +/** Keep the workspace's editing identity when a sheet renders in the app overlay host. */ +export function useComposerEditingEnvironmentBridge() { + const sessions = useContext(SessionsContext); + const workspaceKey = useContext(WorkspaceContext); + return useCallback( + (children: ReactNode) => ( + + {children} + + ), + [sessions, workspaceKey], + ); +} + /** App-container ownership survives replacement of both the gesture shell and tab layout. */ export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) { const [sessions] = useState(() => new ComposerEditingSessions()); diff --git a/packages/app/src/git/diff-document/workspace-cache.tsx b/packages/app/src/git/diff-document/workspace-cache.tsx index bd52000af3..5b9921e31d 100644 --- a/packages/app/src/git/diff-document/workspace-cache.tsx +++ b/packages/app/src/git/diff-document/workspace-cache.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, type ReactNode } from "react"; +import React, { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import invariant from "tiny-invariant"; import { buildDiffDocumentModel, retainReusableModels, reviewGeometryKey } from "./model"; import type { @@ -162,6 +162,16 @@ function paletteKey(palette: DiffPalette): string { const WorkspaceCacheContext = createContext(null); +export function useDiffDocumentWorkspaceCacheBridge() { + const cache = useDiffDocumentWorkspaceCache(); + return useCallback( + (children: ReactNode) => ( + {children} + ), + [cache], + ); +} + export function DiffDocumentWorkspaceCacheProvider({ children }: { children: ReactNode }) { const [cache] = useState(createDiffDocumentWorkspaceCache); return {children}; diff --git a/packages/app/src/hooks/use-import-session.tsx b/packages/app/src/hooks/use-import-session.tsx index bdcf5c4563..f08d152605 100644 --- a/packages/app/src/hooks/use-import-session.tsx +++ b/packages/app/src/hooks/use-import-session.tsx @@ -1,10 +1,9 @@ import { useCallback, useState, type ReactNode } from "react"; -import { type Href, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useHostChooser } from "@/hosts/host-chooser"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; import { useOpenProject } from "@/hooks/use-open-project"; interface UseImportSessionOptions { @@ -31,7 +30,6 @@ interface ImportedAgentTarget { export function useNavigateToImportedAgent( serverId: string | null | undefined, ): (agent: ImportedAgentTarget) => Promise { - const router = useRouter(); const normalizedServerId = serverId?.trim() || null; const openProject = useOpenProject(normalizedServerId); @@ -40,10 +38,10 @@ export function useNavigateToImportedAgent( if (!normalizedServerId) return; const project = await openProject(agent.cwd); if (project.ok) { - router.push(buildHostAgentDetailRoute(normalizedServerId, agent.id) as Href); + navigateToAgent({ serverId: normalizedServerId, agentId: agent.id }); } }, - [normalizedServerId, openProject, router], + [normalizedServerId, openProject], ); } diff --git a/packages/app/src/hosts/host-chooser.tsx b/packages/app/src/hosts/host-chooser.tsx index c0b62697e0..5e4d7f053c 100644 --- a/packages/app/src/hosts/host-chooser.tsx +++ b/packages/app/src/hosts/host-chooser.tsx @@ -1,3 +1,4 @@ +import { usePanelStore } from "@/stores/panel-store"; import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Modal, @@ -81,7 +82,13 @@ export function useHostChooser() { ); if (availableHosts.length === 0) { - (input.onNoHosts ?? (() => router.push(buildSettingsAddHostRoute(Date.now()))))(); + ( + input.onNoHosts ?? + (() => { + usePanelStore.getState().showMobileAgent(); + router.push(buildSettingsAddHostRoute(Date.now())); + }) + )(); return false; } diff --git a/packages/app/src/panels/retained-chat-content.browser.test.tsx b/packages/app/src/panels/retained-chat-content.browser.test.tsx index cba12e3cbd..07b1ceca75 100644 --- a/packages/app/src/panels/retained-chat-content.browser.test.tsx +++ b/packages/app/src/panels/retained-chat-content.browser.test.tsx @@ -1,11 +1,39 @@ -import { act, useLayoutEffect, useState, useSyncExternalStore } from "react"; +import React, { + act, + useCallback, + useEffect, + useLayoutEffect, + useState, + useSyncExternalStore, +} from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it } from "vitest"; -import { RetainedPanelActivity } from "@/components/retained-panel"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel"; import { RetainedChatContent as NativeChat } from "./retained-chat-content.native"; import { RetainedChatContent as WebChat } from "./retained-chat-content.web"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; +import { + ComposerEditingScope, + ComposerEditingSessionsProvider, + WorkspaceComposerEditingSessionsProvider, + useComposerEditingSession, +} from "@/composer/editing-session"; +import { + DiffDocumentWorkspaceCacheProvider, + useDiffDocumentWorkspaceCache, +} from "@/git/diff-document/workspace-cache"; +import { + AssistantFileLinkResolverProvider, + useAssistantFileLinkEnvironment, +} from "@/assistant-file-links"; +import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal"; + +import { render as renderEnvironment } from "@testing-library/react"; +import { useWorkspaceSheetEnvironment } from "@/screens/workspace/workspace-sheet-environment"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useAssistantFileLinkActions, type InlinePathTarget } from "@/assistant-file-links"; + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); let root: Root; let container: HTMLDivElement; @@ -99,3 +127,96 @@ it("retains modified panels beyond the LRU cap and evicts them normally after sa render("e"); expect(mounted).toEqual(["e", "a", "b"]); }); + +describe("workspace sheet environment", () => { + it("shares editing sessions and diff cache across the host, keeping resolver updates live", async () => { + let bridge: ContextBridge = (children) => children; + const observations = new Map>(); + let mounts = 0; + const queryClient = new QueryClient(); + const opened: Array<{ root: string; path: string }> = []; + function useEnvironment() { + return { + session: useComposerEditingSession("chat"), + cache: useDiffDocumentWorkspaceCache(), + files: useAssistantFileLinkEnvironment(), + active: useRetainedPanelActive(), + actions: useAssistantFileLinkActions(), + }; + } + function Probe({ name }: { name: string }) { + observations.set(name, useEnvironment()); + useEffect(() => { + if (name === "sheet") mounts++; + }, [name]); + return null; + } + function Capture() { + bridge = useWorkspaceSheetEnvironment(); + return ( + + + + ); + } + function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) { + const onOpenFile = useCallback( + (target: InlinePathTarget) => { + opened.push({ root: workspaceRoot, path: target.path }); + }, + [workspaceRoot], + ); + return ( + + + + + + + + + + + + + + ); + } + const source = renderEnvironment(); + const sheetContent = ( + + + + ); + const sheet = renderEnvironment( + {bridge(sheetContent)}, + ); + const pane = observations.get("pane")!; + expect(observations.get("sheet")?.session).toBe(pane.session); + expect(observations.get("sheet")?.cache).toBe(pane.cache); + expect(observations.get("sheet")?.files.workspaceRoot).toBe("/before"); + const actions = observations.get("sheet")!.actions; + act(() => pane.session.setFullscreen(true)); + source.rerender(); + // An action captured before the provider update must already use the live resolver value. + await act(async () => actions.open({ href: "/after/linked.ts" }, "preferred")); + expect(opened).toEqual([{ root: "/after", path: "/after/linked.ts" }]); + sheet.rerender( + {bridge(sheetContent)}, + ); + expect(observations.get("sheet")?.session).toBe(pane.session); + expect(observations.get("sheet")?.session.getFullscreen()).toBe(true); + expect(observations.get("sheet")?.cache).toBe(pane.cache); + expect(observations.get("sheet")?.files.workspaceRoot).toBe("/after"); + expect(observations.get("sheet")?.active).toBe(false); + expect(mounts).toBe(1); + sheet.unmount(); + source.unmount(); + }); +}); diff --git a/packages/app/src/screens/workspace/sheet-tabs.tsx b/packages/app/src/screens/workspace/sheet-tabs.tsx index de13097503..e7c8005233 100644 --- a/packages/app/src/screens/workspace/sheet-tabs.tsx +++ b/packages/app/src/screens/workspace/sheet-tabs.tsx @@ -37,6 +37,7 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; +import { useWorkspaceSheetEnvironment } from "./workspace-sheet-environment"; const SNAP_POINTS = ["92%"]; const NO_SHEETS: readonly SheetTab[] = []; @@ -101,8 +102,8 @@ interface SheetTabSheetProps extends Omit(null); const isScreenFocused = useIsFocused(); + const contextBridge = useWorkspaceSheetEnvironment(); ensurePanelsRegistered(); const registration = getPanelRegistration(tab.target.kind); if (!registration) { @@ -236,7 +238,7 @@ function SheetTabSheet({ return ( "file-tab"), - focusWorkspaceTab: vi.fn(), - }; -} - -describe("openWorkspaceFileFromExplorer", () => { - it("closes the phone overlay after opening a file", () => { - const input = createInput(true); - - openWorkspaceFileFromExplorer(input); - - expect(input.showMobileAgent).toHaveBeenCalledOnce(); +const workspaceKey = "server:workspace"; +function openFile(filePath: string, persistenceKey: string | null = workspaceKey) { + openWorkspaceFileFromExplorer({ + filePath, + persistenceKey, + openWorkspaceTabInFocusedPane: (key, target, placement) => + useWorkspaceLayoutStore + .getState() + .openTab({ workspaceKey: key, target, placement, intent: "reveal" }), + revealWorkspaceTab, }); - - it("keeps the tablet dock open after opening a file", () => { - const input = createInput(false); - - openWorkspaceFileFromExplorer(input); - - expect(input.showMobileAgent).not.toHaveBeenCalled(); - expect(input.openWorkspaceTabInFocusedPane).toHaveBeenCalledOnce(); - expect(input.focusWorkspaceTab).toHaveBeenCalledWith("server:workspace", "file-tab"); +} +beforeEach(() => { + useWorkspaceLayoutStore.setState({ + layoutMode: "compact", + layoutByWorkspace: {}, + sheetsByWorkspace: {}, + explorerSidebarPaneIdByWorkspace: {}, + }); + usePanelStore.getState().showMobileFileExplorer(); +}); +describe("file search destination", () => { + it("preserves Explorer under a new file sheet", () => { + openFile("new.ts"); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + expect(useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]).toHaveLength(1); + }); + it.each([null, workspaceKey])("preserves context when no file opens (%s)", (key) => { + openFile("", key); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + }); + it("reveals an existing main tab", () => { + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("split-panes"); + store.openTab({ + workspaceKey, + target: { kind: "file", path: "existing.ts" }, + intent: "reveal", + }); + store.setLayoutMode("compact"); + openFile("existing.ts"); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }); + it("reveals the existing Explorer tab from the left sidebar", () => { + const store = useWorkspaceLayoutStore.getState(); + const paneId = store.showExplorerSidebar(workspaceKey)!; + store.openTab({ + workspaceKey, + target: { kind: "file", path: "existing.ts" }, + intent: "reveal", + placement: { mode: "pane", paneId }, + }); + usePanelStore.getState().showMobileAgentList(); + openFile("existing.ts"); + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); }); }); diff --git a/packages/app/src/screens/workspace/workspace-file-open-command.ts b/packages/app/src/screens/workspace/workspace-file-open-command.ts index 0d990f3557..db96fd1204 100644 --- a/packages/app/src/screens/workspace/workspace-file-open-command.ts +++ b/packages/app/src/screens/workspace/workspace-file-open-command.ts @@ -11,20 +11,15 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; interface OpenWorkspaceFileFromExplorerInput { filePath: string; persistenceKey: string | null; - closeExplorerAfterOpen: boolean; - showMobileAgent: () => void; openWorkspaceTabInFocusedPane: ( workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement, ) => string | null; - focusWorkspaceTab: (workspaceKey: string, tabId: string) => void; + revealWorkspaceTab: (workspaceKey: string, tabId: string) => void; } export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void { - if (input.closeExplorerAfterOpen) { - input.showMobileAgent(); - } if (!input.persistenceKey) { return; } @@ -38,6 +33,6 @@ export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplor FOCUSED_PANE_PLACEMENT, ); if (tabId) { - input.focusWorkspaceTab(input.persistenceKey, tabId); + input.revealWorkspaceTab(input.persistenceKey, tabId); } } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ed1bbfdfcd..bcc2593611 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -786,10 +786,11 @@ function WorkspacePanelSurface({ children: ReactNode; tabIds: readonly string[]; }) { + const isCompact = useIsCompactFormFactor(); return ( - + {children} @@ -1138,6 +1139,7 @@ function WorkspaceScreenContent({ (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, ); const pendingOpenTarget = usePendingWorkspaceOpen({ + onOpened: revealWorkspaceTab, workspaceKey: persistenceKey, hasHydratedWorkspaces, workspaceExists: workspaceDescriptor !== null, @@ -1661,9 +1663,6 @@ function WorkspaceScreenContent({ if (!normalizedLocation) { return; } - if (isMobile) { - showMobileAgent(); - } if (!persistenceKey) { return; } @@ -1675,14 +1674,7 @@ function WorkspaceScreenContent({ navigateToTabId(tabId); } }, - [ - isMobile, - navigateToTabId, - openWorkspaceTabFocused, - revealWorkspaceChildTab, - persistenceKey, - showMobileAgent, - ], + [navigateToTabId, openWorkspaceTabFocused, revealWorkspaceChildTab, persistenceKey], ); const handleOpenPreferredAssistantFile = useCallback( @@ -3533,7 +3525,7 @@ function WorkspaceScreenContent({ composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId), }); - // Compact sheets use the shared workspace scope, above both center and Explorer. + // Compact sheets escape to the app overlay host, above both sidebars. const workspaceCenterColumn = isMobile ? ( { + const content = editing(cache(files(children))); + return ( + + + + {serverId ? ( + {content} + ) : ( + content + )} + + + + ); + }, + [editing, cache, files, active, serverId, navigation, route], + ); +} diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 0404ea34ed..16f3a23f0b 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -1,3 +1,5 @@ +import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; +import { usePanelStore } from "@/stores/panel-store"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useLocalSearchParams, usePathname } from "expo-router"; import { useEffect, useSyncExternalStore } from "react"; @@ -34,6 +36,12 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( function navigateDeps(): NavigateToWorkspaceDeps { return { + revealTab: revealWorkspaceTab, + revealWorkspace: () => { + if (useWorkspaceLayoutStore.getState().layoutMode === "compact") { + usePanelStore.getState().showMobileAgent(); + } + }, getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, getSessionAgents: (serverId) => useSessionStore.getState().sessions[serverId]?.agents.values() ?? [], diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index 055049baee..a3bb69821a 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -22,7 +22,14 @@ function createFakeDeps(overrides: Partial = {}) { const remembered: ActiveWorkspaceSelection[] = []; const openedTabs: RecordedTab[] = []; const deferredTabs: unknown[] = []; + const revealed: Array = []; const deps: NavigateToWorkspaceDeps = { + revealTab: (_key, tabId) => { + revealed.push(tabId); + }, + revealWorkspace: () => { + revealed.push(null); + }, deferOpenTab: (input) => { deferredTabs.push(input); return null; @@ -38,7 +45,7 @@ function createFakeDeps(overrides: Partial = {}) { navigateToRoute: (route) => navigations.push(route), ...overrides, }; - return { deps, navigations, remembered, openedTabs, deferredTabs }; + return { deps, navigations, remembered, openedTabs, deferredTabs, revealed }; } function createLastSelectionDeps( @@ -65,6 +72,24 @@ function createLastSelectionDeps( } describe("workspace navigation", () => { + it("reveals even a current workspace without agents", () => { + const { deps, revealed } = createFakeDeps(); + navigateToWorkspace({ serverId: "server", workspaceId: "workspace" }, deps); + expect(revealed).toEqual([null]); + }); + + it("reveals an explicit target after opening instead of forcing center", () => { + const workspace = { id: "workspace" } as WorkspaceDescriptor; + const { deps, revealed } = createFakeDeps({ + getSessionWorkspaces: () => new Map([[workspace.id, workspace]]), + }); + navigateToWorkspace( + { serverId: "server", workspaceId: "workspace", target: { kind: "agent", agentId: "root" } }, + deps, + ); + expect(revealed).toEqual(["root"]); + }); + it("reports when no last workspace is known", () => { const { deps } = createLastSelectionDeps(null); diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index f234b1c2a6..d856a9bb36 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -38,6 +38,8 @@ export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps { deferOpenTab: (input: OpenWorkspaceTabInput) => null; rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void; navigateToRoute: (route: string) => void; + revealTab: (workspaceKey: string, tabId: string | null) => void; + revealWorkspace: () => void; } export interface NavigateToLastWorkspaceDeps extends NavigateToWorkspaceDeps { @@ -93,8 +95,13 @@ export function navigateToWorkspace( workspaceId: input.workspaceId, }); const shouldDeferOpen = !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated(); + const openTab: NavigateToWorkspaceDeps["openTab"] = (request) => { + if (shouldDeferOpen) return deps.deferOpenTab(request); + const tabId = deps.openTab(request); + deps.revealTab(request.workspaceKey, tabId); + return tabId; + }; if (input.target) { - const openTab = shouldDeferOpen ? deps.deferOpenTab : deps.openTab; prepareWorkspaceTab({ ...input, target: input.target }, { openTab }); } else { const workspaceAgents = resolvedWorkspaceId @@ -104,11 +111,13 @@ export function navigateToWorkspace( : []; const attentionAgentId = pickAttentionAgent(workspaceAgents); if (attentionAgentId && resolvedWorkspaceId) { - deps.openTab({ + openTab({ workspaceKey: `${input.serverId}:${resolvedWorkspaceId}`, target: { kind: "agent", agentId: attentionAgentId }, intent: "reveal", }); + } else { + deps.revealWorkspace(); } } diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 60f96ae330..1c0b7a5037 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -2416,12 +2416,14 @@ interface PendingWorkspaceOpenReadiness { workspaceKey: string | null; hasHydratedWorkspaces: boolean; workspaceExists: boolean; + onOpened: (workspaceKey: string, tabId: string | null) => void; } export function usePendingWorkspaceOpen({ workspaceKey, hasHydratedWorkspaces, workspaceExists, + onOpened, }: PendingWorkspaceOpenReadiness): WorkspaceTabTarget | null { const workspaceReady = hasHydratedWorkspaces && workspaceExists; const pending = useWorkspaceLayoutStore((state) => @@ -2430,8 +2432,8 @@ export function usePendingWorkspaceOpen({ const layoutHydrated = useWorkspaceLayoutStoreHydrated(); useLayoutEffect(() => { if (workspaceKey && workspaceReady && layoutHydrated && pending) { - useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey); + onOpened(workspaceKey, useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey)); } - }, [workspaceKey, workspaceReady, layoutHydrated, pending]); + }, [workspaceKey, workspaceReady, layoutHydrated, pending, onOpened]); return pending ? pending.target : null; } diff --git a/packages/app/src/workspace-tabs/navigation.test.ts b/packages/app/src/workspace-tabs/navigation.test.ts index ab3d1539ab..c32fc91cf9 100644 --- a/packages/app/src/workspace-tabs/navigation.test.ts +++ b/packages/app/src/workspace-tabs/navigation.test.ts @@ -85,3 +85,26 @@ it("preserves dock visibility intent while compact targets are opened and reveal false, ); }); +it.each(["agent-list", "file-explorer"] as const)( + "preserves %s under a deferred file sheet and reveals deferred agents", + (panel) => { + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("compact"); + if (panel === "agent-list") usePanelStore.getState().showMobileAgentList(); + const before = usePanelStore.getState().mobilePanel; + store.deferOpenTab({ + workspaceKey, + target: { kind: "file", path: "pending.ts" }, + intent: "reveal", + }); + revealWorkspaceTab(workspaceKey, store.consumePendingOpen(workspaceKey)); + expect(usePanelStore.getState().mobilePanel).toEqual(before); + store.deferOpenTab({ + workspaceKey, + target: { kind: "agent", agentId: "root" }, + intent: "reveal", + }); + revealWorkspaceTab(workspaceKey, store.consumePendingOpen(workspaceKey)); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }, +); diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index fd12cd6189..2d7149442e 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -121,10 +121,32 @@ export default defineConfig({ // so it scans the native files and dies on imports react-native-web has no answer for. // Unbundled, the same imports go through the resolver below and land on the web files. optimizeDeps: { + // Expo's public navigation barrel also loads screens. Match Metro's web-first resolution + // while optimizing its CJS dependencies, including DebugContainer.web.js. + esbuildOptions: { + resolveExtensions: [ + ".web.js", + ".web.jsx", + ".web.ts", + ".web.tsx", + ".js", + ".jsx", + ".ts", + ".tsx", + ".json", + ], + }, // Bundle the CJS dependencies of the excluded gesture-handler package for the browser. include: [ "react/jsx-runtime", "react/compiler-runtime", + "expo-router/react-navigation", + "@testing-library/react", + "@tanstack/react-query", + "react-i18next", + "i18next", + "@marijn/find-cluster-break", + "tiny-invariant", "react-native-gesture-handler > hoist-non-react-statics", "react-native-gesture-handler > invariant", ], From f3b0199c13991320b5419a94400cb7b19132cac8 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:08:09 +0200 Subject: [PATCH 44/62] Open large Claude histories without whole-transcript duplication (#7431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): skip Claude subagent replay over a transcript size budget Loading a session's subagent transcripts all at once OOMs the daemon worker on large Claude sessions (#5820: a 395 MB parent plus 269 sidechain transcripts, 757 MB total, crash-loops the worker). Stat the sidechain files before reading any and leave them unread when the parent plus sidechain transcripts exceed a replay budget (256 MB by default, PASEO_CLAUDE_REPLAY_BUDGET_MB to override); the parent history replays unchanged, which is the same state as moving the subagent transcripts out of the way. Also parse the parent transcript once instead of twice while rebuilding sidechain ownership. * fix(server): accept fractional PASEO_CLAUDE_REPLAY_BUDGET_MB values parseInt read 0.5 as 0, so a fractional budget silently fell back to the 256 MB default — the opposite of what a small-host operator asked for. Parse the value as a float and pin the behavior with a regression test. * Stream Claude transcript replay without discarding subagent history Read JSONL incrementally, retain ownership facts instead of raw records, and replay workflow payloads through a chronological byte-location index. Preserve all subagent history and remove the input byte budget. Co-authored-by: ZouYusheng * fix(server): propagate Claude transcript replay read errors * fix(server): bound Claude replay output and index legacy history Apply the existing tool content limit before replay retains events, copying truncated prefixes so sliced strings do not pin the original output. Index each child once and own transcript snapshots through ingestion to preserve history across provider file deletion. Keep full-scale memory reproductions opt-in. * fix(server): replay Claude history without temporary transcript copies Keep the original descriptors open until synchronous replay finishes. Indexed passes survive unlink and rename without requiring a second copy of the session on temporary storage. Close every descriptor on success and failure. * fix(claude): replay large child histories without spread Streaming removes whole-transcript duplication, but folding many visible child events still passed the complete aggregate as arguments to Array.push. Large histories exceed the V8 argument limit, the loader catches a RangeError, and history remains empty. Append replay observations iteratively and cover 160,000 aggregate child events. Adapted from #7511, independently reproduced with the public resumeSession and streamHistory interfaces. Co-authored-by: Alin Jiang * test(claude): cover parent-only replay above the string limit Exercise the same parent-only resume path routinely with a small corpus, and use PASEO_TEST_LARGE_CLAUDE_REPLAY=1 for the 550 MiB string-limit reproduction. The eager loader returns empty history for this corpus; incremental replay preserves the visible parent event. --------- Co-authored-by: Yusheng Zou Co-authored-by: Mohamed Boudra Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Alin Jiang --- .../agent/agent-timeline-content.test.ts | 23 ++ .../server/agent/agent-timeline-content.ts | 13 +- .../claude/agent.history-load.test.ts | 50 ++- .../claude/agent.sub-agent-replay.test.ts | 264 ++++++++++++++ .../server/agent/providers/claude/agent.ts | 337 ++++-------------- .../providers/claude/replay-memory.fixture.ts | 202 +++++++++++ .../claude/subagents/replay-source.ts | 128 ++++--- .../subagents/workflow-replay-source.ts | 39 +- .../providers/claude/transcript-history.ts | 256 +++++++++++++ 9 files changed, 949 insertions(+), 363 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude/replay-memory.fixture.ts create mode 100644 packages/server/src/server/agent/providers/claude/transcript-history.ts diff --git a/packages/server/src/server/agent/agent-timeline-content.test.ts b/packages/server/src/server/agent/agent-timeline-content.test.ts index c40b712fad..1ccb78c67a 100644 --- a/packages/server/src/server/agent/agent-timeline-content.test.ts +++ b/packages/server/src/server/agent/agent-timeline-content.test.ts @@ -3,6 +3,29 @@ import { describe, expect, test } from "vitest"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; describe("agent timeline content", () => { + test.each([false, true])( + "preserves the exact UTF-16 prefix for oversized shell output (failed=%s)", + (failed) => { + const text = "x".repeat(64 * 1024 - 1) + "🙂"; + const item = limitAgentTimelineItemContent({ + type: "tool_call", + callId: "large-shell", + name: "Bash", + status: failed ? "failed" : "completed", + detail: { type: "shell", command: "echo large", output: text }, + error: failed ? { content: text } : null, + }); + expect(item).toEqual({ + type: "tool_call", + callId: "large-shell", + name: "Bash", + status: failed ? "failed" : "completed", + detail: { type: "shell", command: "echo large", output: text.slice(0, 64 * 1024) }, + error: failed ? { content: text.slice(0, 64 * 1024) } : null, + }); + }, + ); + test("marks truncated plain-text tool content within the content budget", () => { const oversizedInput = "x".repeat(64 * 1024 + 1); diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts index d9bb08c0bc..003239b283 100644 --- a/packages/server/src/server/agent/agent-timeline-content.ts +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -11,6 +11,12 @@ export function assertPluginTimelineDataSize(data: JsonValue): void { } } +/** A sliced string can keep the entire provider output alive. Copy only the retained UTF-16 + * units, including an unmatched surrogate at the existing character limit. */ +function retainTextPrefix(text: string, length: number): string { + return Buffer.from(text.slice(0, length), "utf16le").toString("utf16le"); +} + function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem { if ( item.type !== "tool_call" || @@ -28,7 +34,7 @@ function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem { ...item, error: { ...item.error, - content: item.error.content.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH), + content: retainTextPrefix(item.error.content, TOOL_CALL_CONTENT_MAX_LENGTH), }, }; } @@ -47,7 +53,8 @@ function limitPlainText(item: AgentTimelineItem): AgentTimelineItem { ...item, detail: { ...item.detail, - text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice, + text: + retainTextPrefix(item.detail.text, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice, }, }; } @@ -69,7 +76,7 @@ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTim ...item, detail: { ...item.detail, - output: item.detail.output.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH), + output: retainTextPrefix(item.detail.output, TOOL_CALL_CONTENT_MAX_LENGTH), }, }; } diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts index 50d9c56ab5..a8237d87d0 100644 --- a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import pino from "pino"; @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; +import { withClaudeReplayHistory } from "./transcript-history.js"; /** * A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline. @@ -91,6 +92,53 @@ describe("ClaudeAgentSession persisted history load", () => { ); }); + test("loads readable history when the temporary directory is unavailable", () => { + const file = transcriptPath(); + const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } }; + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(entry)); + const unavailable = path.join(tempRoot, "unavailable-tmp"); + for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable); + withClaudeReplayHistory(file, (history) => { + expect([...history.parentEntries]).toEqual([entry]); + }); + }); + + test.each(["parent", "child", "workflow"])( + "preserves indexed %s history when its transcript disappears between passes", + (target) => { + const parent = transcriptPath(); + const sessionDirectory = parent.slice(0, -".jsonl".length); + const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl"); + const workflow = path.join( + sessionDirectory, + "subagents", + "workflows", + "wf_test", + "agent-child.jsonl", + ); + const parentEntry = { + type: "assistant", + message: { content: [{ type: "text", text: "parent" }] }, + }; + const childEntry = { ...parentEntry, isSidechain: true, agentId: "child" }; + mkdirSync(path.dirname(workflow), { recursive: true }); + writeFileSync(parent, JSON.stringify(parentEntry)); + writeFileSync(child, JSON.stringify(childEntry)); + writeFileSync(workflow, JSON.stringify(childEntry)); + withClaudeReplayHistory(parent, (history) => { + rmSync({ parent, child, workflow }[target]); + // Ownership facts and visible payloads are separate passes over the same source. + for (let pass = 0; pass < 2; pass++) { + expect([...history.parentEntries]).toEqual([parentEntry]); + expect(history.subagents).toHaveLength(1); + expect([...history.subagents[0]!.entries]).toEqual([childEntry]); + expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]); + } + }); + }, + ); + // chmod does not remove read access on Windows, or for root. test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( "logs a warning with the error when Claude's project folders cannot be listed", diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 48ddab5973..8f2ba60907 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -1,6 +1,9 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { generateReplayCorpus, generateWorkflowReplayCorpus } from "./replay-memory.fixture.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; @@ -432,6 +435,267 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(upserts(await replayDescriptors())).toEqual([]); }); + test("replays several legacy children interleaved in the parent transcript", async () => { + const lines: string[] = []; + for (let index = 0; index < 8; index++) { + const agentId = `legacy-${index}`; + const toolUseId = `task-${index}`; + lines.push(parentEntry([{ type: "tool_use", id: toolUseId, name: "Task", input: {} }])); + lines.push( + JSON.stringify({ + type: "user", + message: { + content: [ + { type: "tool_result", tool_use_id: toolUseId, content: `agentId: ${agentId}\ndone` }, + ], + }, + }), + ); + lines.push(sidechainEntry({ agentId, stopReason: "end_turn" })); + } + writeParentSession(lines); + const events = await replayDescriptors(); + const histories = events + .map((event) => event.event) + .filter((event) => event.type === "timeline"); + expect(histories.map((event) => event.id).sort()).toEqual( + Array.from({ length: 8 }, (_, index) => `task-${index}`), + ); + expect(histories.map((event) => event.item)).toEqual( + Array.from({ length: 8 }, () => ({ type: "assistant_message", text: "summary of the docs" })), + ); + }); + + test("replays more child events than V8 permits in one spread call", async () => { + const childCount = 16; + const entriesPerChild = 10_000; + const children = Array.from({ length: childCount }, (_, index) => ({ + agentId: `large-child-${index}`, + toolUseId: `large-task-${index}`, + })); + const parentLines = children.flatMap(({ agentId, toolUseId }) => [ + parentEntry([{ type: "tool_use", id: toolUseId, name: "Task", input: {} }]), + JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: `agentId: ${agentId}\ndone`, + }, + ], + }, + }), + ]); + const subagentDir = writeParentSession(parentLines); + for (const { agentId, toolUseId } of children) { + writeSubagent({ + subagentDir, + agentId, + meta: JSON.stringify({ toolUseId }), + sidechainLines: Array.from({ length: entriesPerChild }, () => sidechainEntry({ agentId })), + }); + } + + const events = await replayDescriptors(); + const timelineCount = events.filter((event) => event.event.type === "timeline").length; + expect(timelineCount).toBe(childCount * entriesPerChild); + expect(upserts(events).filter((event) => event.status === "completed")).toHaveLength( + childCount, + ); + }, 120_000); + + test("limits retained child shell output to the timeline's existing content limit", async () => { + const output = "x".repeat(128 * 1024); + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + message: { + content: [ + { + type: "tool_use", + id: "bash-large", + name: "Bash", + input: { command: "echo large" }, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + message: { + content: [{ type: "tool_result", tool_use_id: "bash-large", content: output }], + }, + }), + ], + }); + const events = await replayDescriptors(); + const completed = events + .map((event) => event.event) + .filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "completed", + ); + expect(completed).toEqual([ + expect.objectContaining({ + type: "timeline", + id: TOOL_USE_ID, + item: expect.objectContaining({ + detail: expect.objectContaining({ type: "shell", output: output.slice(0, 64 * 1024) }), + }), + }), + ]); + }); + + test.each([ + { + label: "a parent transcript without sidechains", + mode: "children", + // Cross V8's whole-string limit only in the opt-in reproduction, keeping routine CI small. + generate: (root: string) => + generateReplayCorpus(root, { + parentMiB: process.env.PASEO_TEST_LARGE_CLAUDE_REPLAY === "1" ? 550 : 2, + childrenMiB: 0, + children: 0, + }), + expected: { parentItems: 1, completed: 0, histories: 0 }, + }, + { + label: "4 MiB parent and 20 sidechains", + mode: "children", + generate: (root: string) => + generateReplayCorpus(root, { parentMiB: 4, childrenMiB: 4, children: 20 }), + expected: { parentItems: 41, completed: 20, histories: 20 }, + }, + { + label: "five 2 MiB workflow sidechains", + mode: "workflow", + generate: (root: string) => generateWorkflowReplayCorpus(root, 2), + expected: { parentItems: 3, completed: 1, histories: 5 }, + }, + ])( + "opens $label with all history under a 256 MB heap", + ({ mode, generate, expected }) => { + const root = mkdtempSync(path.join(os.tmpdir(), "claude-replay-")); + try { + generate(root); + const worker = spawnSync( + process.execPath, + [ + "--max-old-space-size=256", + "--import", + "tsx", + fileURLToPath(new URL("./replay-memory.fixture.ts", import.meta.url)), + root, + mode, + ], + { encoding: "utf8", env: { ...process.env, NODE_NO_WARNINGS: "1" }, timeout: 120_000 }, + ); + expect({ status: worker.status, signal: worker.signal, stderr: worker.stderr }).toEqual({ + status: 0, + signal: null, + stderr: "", + }); + const result = JSON.parse(worker.stdout); + expect(result).toMatchObject(expected); + console.log("Claude replay memory:", result); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + 150_000, + ); + + test("replays UTF-8 across read chunks, corrupt rows, CRLF and an unterminated final row", async () => { + const text = "é🙂".repeat(30_000); + const historyDir = claudeProjectDirSync(cwd, { configDir }); + mkdirSync(historyDir, { recursive: true }); + writeFileSync( + path.join(historyDir, "replay-session.jsonl"), + parentEntry([{ type: "text", text }]) + + "\r\nnot json\r\n" + + parentEntry([{ type: "text", text: "final row" }]), + ); + const events = await replayEvents(); + expect(events.filter((event) => event.type === "timeline").map((event) => event.item)).toEqual([ + { type: "assistant_message", text }, + { type: "assistant_message", text: "final row" }, + ]); + }); + + test("converts workflow tools in timestamp order before correlating their results", async () => { + writeWorkflowSession("completed"); + const directory = path.join( + claudeProjectDirSync(cwd, { configDir }), + "replay-session", + "subagents", + "workflows", + WORKFLOW_RUN_ID, + ); + writeSubagent({ + subagentDir: directory, + agentId: "a-result", + sidechainLines: [ + JSON.stringify({ + type: "user", + timestamp: "2026-07-26T06:28:01.000Z", + message: { + content: [{ type: "tool_result", tool_use_id: "workflow-bash", content: "finished" }], + }, + }), + ], + }); + writeSubagent({ + subagentDir: directory, + agentId: "z-call", + sidechainLines: [ + JSON.stringify({ + type: "assistant", + timestamp: "2026-07-26T06:28:00.000Z", + message: { + content: [ + { + type: "tool_use", + id: "workflow-bash", + name: "Bash", + input: { command: "echo finished" }, + }, + ], + }, + }), + ], + }); + const events = await replayDescriptors(); + const calls = events + .map((event) => event.event) + .filter((event) => event.type === "timeline" && event.item.type === "tool_call"); + expect(calls).toEqual([ + expect.objectContaining({ + id: WORKFLOW_TOOL_USE_ID, + item: expect.objectContaining({ callId: "workflow-bash", name: "Bash", status: "running" }), + }), + expect.objectContaining({ + id: WORKFLOW_TOOL_USE_ID, + item: expect.objectContaining({ + callId: "workflow-bash", + name: "Bash", + status: "completed", + detail: expect.objectContaining({ type: "shell", output: "finished" }), + }), + }), + ]); + }); + test("does not accumulate internal workflow agents as replay-only running rows", async () => { const subagentRoot = writeParentSession([ parentEntry([ diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 681988a813..859e2d5f2e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -45,6 +45,7 @@ import { resolveClaudeDisabledThinkingForModel, } from "./model-manifest.js"; import { parsePartialJsonObject } from "./partial-json.js"; +import { withClaudeReplayHistory, type ClaudeReplayHistory } from "./transcript-history.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; import { readClaudeSubagentHandback } from "./subagent-handback.js"; import { ClaudeTaskState } from "./task-state.js"; @@ -52,17 +53,10 @@ import { ClaudeTaskProtocolSource, type ClaudeHookObservationInput, } from "./subagents/live-source.js"; -import { - observeReplaySubagents, - parseClaudeSubagentMeta, - type ClaudeReplayParentFacts, - type ClaudeSubagentMeta, -} from "./subagents/replay-source.js"; +import { observeReplaySubagents, type ClaudeReplayParentFacts } from "./subagents/replay-source.js"; import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js"; -import { - observeReplayWorkflows, - parseClaudeWorkflowRun, -} from "./subagents/workflow-replay-source.js"; +import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js"; +import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js"; import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js"; import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js"; import { @@ -5269,12 +5263,10 @@ class ClaudeAgentSession implements AgentSession { ); return; } - const content = fs.readFileSync(historyPath, "utf8"); - const replay = this.ingestPersistedSidechains( - content, - readClaudeSidechainHistory(historyPath), - ); - this.ingestPersistedHistory(content, replay); + withClaudeReplayHistory(historyPath, (history) => { + const replay = this.ingestPersistedSidechains(history); + this.ingestPersistedHistory(history.parentEntries, replay); + }); } catch (error) { this.logger.warn( { err: error, sessionId, historyPath }, @@ -5283,14 +5275,13 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedHistory(content: string, replay: ClaudeReplayOwnership): void { - if (!content) { - return; - } - + private ingestPersistedHistory( + entries: Iterable, + replay: ClaudeReplayOwnership, + ): void { const timeline: PersistedTimelineEntry[] = []; - for (const line of content.split(/\r?\n/)) { - this.ingestPersistedHistoryLine(line, timeline, replay); + for (const entry of entries) { + this.ingestPersistedHistoryEntry(entry, timeline, replay); } if (timeline.length > 0) { @@ -5299,46 +5290,26 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedSidechains( - parentContent: string, - sidechains: ClaudeSidechainHistory, - ): ClaudeReplayOwnership { - const parentEntries = parseClaudeHistoryRecords(parentContent).filter( - (entry) => entry.isSidechain !== true, - ); - const sidechainEntries = [parentContent, ...sidechains.contents] - .flatMap(parseClaudeHistoryRecords) - .filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string"); - const parentFacts = readClaudeReplayParentFacts(parentEntries); + private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership { + const parentFacts = readClaudeReplayParentFacts(history.parentEntries); // Replay produces the same observations the live task protocol produces, then folds them // with the same function, so identity and status are derived once for both paths. const subagentReplay = observeReplaySubagents({ - subagents: [...groupClaudeSidechainEntries(sidechainEntries)].map(([agentId, entries]) => ({ - agentId, - meta: sidechains.metaByAgentId.get(agentId) ?? null, - entries, - parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]), + subagents: history.subagents.map((subagent) => ({ + ...subagent, + parentFacts: readClaudeReplayParentFacts(subagent.entries), })), parent: parentFacts, - convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), + convertEntry: (entry) => this.convertHistoryEntry(entry), }); const observations = [ ...subagentReplay.observations, ...observeReplayWorkflows({ - workflows: sidechains.workflowContents - .map(parseClaudeWorkflowRun) - .filter((workflow) => workflow !== null), - parentEntries, - entriesByRunId: new Map( - [...sidechains.workflowSidechainContentsByRunId].map(([runId, contents]) => [ - runId, - contents - .flatMap(parseClaudeHistoryRecords) - .filter((entry) => entry.type !== "user" || isToolResultUserEntry(entry)), - ]), - ), - convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), + workflows: history.workflows, + parentEntries: history.parentEntries, + entriesByRunId: history.workflowEntriesByRunId, + convertEntry: (entry) => this.convertHistoryEntry(entry), }), ]; const restoredProviderSubagentIds = new Set( @@ -5353,41 +5324,22 @@ class ClaudeAgentSession implements AgentSession { }; if (observations.length === 0) return replay; - this.persistedProviderSubagentEvents.push( - ...foldSubagentObservations(observations).map( - (event): Extract => ({ - type: "provider_subagent", - provider: "claude", - event, - }), - ), - ); + for (const event of foldSubagentObservations(observations)) { + this.persistedProviderSubagentEvents.push({ + type: "provider_subagent", + provider: "claude", + event, + }); + } this.historyPending = true; return replay; } - private ingestPersistedHistoryLine( - line: string, + private ingestPersistedHistoryEntry( + entry: ClaudeHistoryEntry, timeline: PersistedTimelineEntry[], replay: ClaudeReplayOwnership, ): void { - const trimmed = line.trim(); - if (!trimmed) { - return; - } - - let entry: Record; - try { - const parsed: unknown = JSON.parse(trimmed); - const record = toObjectRecord(parsed); - if (!record) { - return; - } - entry = record; - } catch { - return; - } - if (entry.isSidechain) { return; } @@ -5436,12 +5388,12 @@ class ClaudeAgentSession implements AgentSession { } if (items.length > 0) { - timeline.push( - ...items.map((item) => ({ + for (const item of items) { + timeline.push({ item, timestamp: historyTimestamp ?? undefined, - })), - ); + }); + } } } @@ -5456,7 +5408,9 @@ class ClaudeAgentSession implements AgentSession { } private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] { - return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)); + return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map( + limitAgentTimelineItemContent, + ); } // Maps Claude content blocks into AgentTimelineItems. @@ -6176,21 +6130,6 @@ function normalizeHistoryBlocks(content: unknown): ClaudeContentChunk[] | null { return null; } -function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] { - const entries: ClaudeHistoryEntry[] = []; - for (const line of content.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const entry = toObjectRecord(JSON.parse(trimmed)); - if (entry) entries.push(entry); - } catch { - // Ignore individual corrupt history rows, matching the parent history replay behavior. - } - } - return entries; -} - /** The parent's card for a subagent, labeled with its type and task. */ function buildClaudeSubagentCardDetail( facts: ClaudeSubagentCardFacts, @@ -6223,187 +6162,49 @@ function labelReplayedSubagentCard( * source consumes. The agentId scrape is kept only as a fallback for sessions recorded before * Claude Code wrote the meta sidecar. */ -function readClaudeReplayParentFacts(parentEntries: ClaudeHistoryEntry[]): ClaudeReplayParentFacts { +function readClaudeReplayParentFacts( + entries: Iterable, +): ClaudeReplayParentFacts { const toolCalls = new Map(); - for (const [id, call] of readClaudeHistoricalSubagentToolCalls(parentEntries)) { - toolCalls.set(id, { - ...((call.name ?? call.subagentType) ? { title: call.name ?? call.subagentType } : {}), - ...(call.description ? { description: call.description } : {}), - }); - } - - // Read outcomes straight off the tool_result blocks rather than reusing the agentId scrape, - // so a subagent linked through its meta sidecar still gets a status when the scrape missed it. - const outcomesByToolCallId = new Map(); - for (const entry of parentEntries) { - const content = toObjectRecord(entry.message)?.content; - if (!Array.isArray(content)) continue; - for (const value of content) { - const block = toObjectRecord(value); - if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; - if (!toolCalls.has(block.tool_use_id)) continue; - outcomesByToolCallId.set(block.tool_use_id, { failed: block.is_error === true }); - } - } - - return { - toolCalls, - linksByAgentId: readClaudeHistoricalSubagentToolResults(parentEntries), - outcomesByToolCallId, - }; -} - -interface ClaudeSidechainHistory { - contents: string[]; - workflowContents: string[]; - workflowSidechainContentsByRunId: Map; - /** agentId -> sidecar metadata, when Claude Code wrote one next to the transcript. */ - metaByAgentId: Map; -} - -const CLAUDE_SUBAGENT_META_FILE = /^agent-(.+)\.meta\.json$/; - -function readClaudeSidechainHistory(historyPath: string): ClaudeSidechainHistory { - const sessionDirectory = path.join( - path.dirname(historyPath), - path.basename(historyPath, ".jsonl"), - ); - const sidechainDirectory = path.join(sessionDirectory, "subagents"); - const history: ClaudeSidechainHistory = { - contents: [], - workflowContents: [], - workflowSidechainContentsByRunId: new Map(), - metaByAgentId: new Map(), - }; - const workflowDirectory = path.join(sessionDirectory, "workflows"); - if (fs.existsSync(workflowDirectory)) { - for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith(".json")) continue; - try { - history.workflowContents.push( - fs.readFileSync(path.join(workflowDirectory, entry.name), "utf8"), - ); - } catch { - // A partial or unreadable run summary must not fail the rest of history ingestion. - } - } - } - if (!fs.existsSync(sidechainDirectory)) return history; - - const directories = [sidechainDirectory]; - while (directories.length > 0) { - const directory = directories.pop(); - if (!directory) continue; - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - directories.push(entryPath); - continue; - } - if (!entry.isFile()) continue; - if (entry.name.endsWith(".jsonl")) { - recordClaudeSidechainContents(history, sidechainDirectory, entryPath); - continue; - } - // The sidecar carries the Task tool_use id, which is the same id the live stream keys on. - // Reading it is what lets replay and live agree instead of each deriving its own link. - const metaMatch = CLAUDE_SUBAGENT_META_FILE.exec(entry.name); - if (!metaMatch?.[1]) continue; - try { - const meta = parseClaudeSubagentMeta(fs.readFileSync(entryPath, "utf8")); - if (meta) history.metaByAgentId.set(metaMatch[1], meta); - } catch { - // Undocumented internals: a missing or unreadable sidecar must never fail ingestion. - } - } - } - return history; -} - -function recordClaudeSidechainContents( - history: ClaudeSidechainHistory, - sidechainDirectory: string, - entryPath: string, -): void { - const contents = fs.readFileSync(entryPath, "utf8"); - const relativeParts = path.relative(sidechainDirectory, entryPath).split(path.sep); - const workflowRunId = - relativeParts[0] === "workflows" && relativeParts.length >= 3 ? relativeParts[1] : undefined; - if (!workflowRunId) { - history.contents.push(contents); - return; - } - - const workflowContents = history.workflowSidechainContentsByRunId.get(workflowRunId) ?? []; - workflowContents.push(contents); - history.workflowSidechainContentsByRunId.set(workflowRunId, workflowContents); -} - -interface ClaudeHistoricalSubagentToolCall { - name?: string; - subagentType?: string; - description?: string; -} - -function readClaudeHistoricalSubagentToolCalls( - entries: ClaudeHistoryEntry[], -): Map { - const toolCalls = new Map(); + const linksByAgentId = new Map(); + const outcomes = new Map(); for (const entry of entries) { const content = toObjectRecord(entry.message)?.content; if (!Array.isArray(content)) continue; for (const value of content) { const block = toObjectRecord(value); if ( - block?.type !== "tool_use" || - (block.name !== "Task" && block.name !== "Agent") || - typeof block.id !== "string" + block?.type === "tool_use" && + (block.name === "Task" || block.name === "Agent") && + typeof block.id === "string" ) { - continue; + const input = toObjectRecord(block.input); + const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type); + const description = readNonEmptyString(input?.description); + toolCalls.set(block.id, { + ...(title ? { title } : {}), + ...(description ? { description } : {}), + }); } - const input = toObjectRecord(block.input); - const name = readNonEmptyString(input?.name); - const subagentType = readNonEmptyString(input?.subagent_type); - const description = readNonEmptyString(input?.description); - toolCalls.set(block.id, { - ...(name ? { name } : {}), - ...(subagentType ? { subagentType } : {}), - ...(description ? { description } : {}), - }); - } - } - return toolCalls; -} - -function readClaudeHistoricalSubagentToolResults( - entries: ClaudeHistoryEntry[], -): Map { - const results = new Map(); - for (const entry of entries) { - const content = toObjectRecord(entry.message)?.content; - if (!Array.isArray(content)) continue; - for (const value of content) { - const block = toObjectRecord(value); if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; - const match = /agentId:\s*([\w-]+)/.exec(JSON.stringify(block.content)); - if (!match?.[1]) continue; - results.set(match[1], { toolCallId: block.tool_use_id, failed: block.is_error === true }); + outcomes.set(block.tool_use_id, { failed: block.is_error === true }); + const agentId = readLegacyClaudeSubagentId(block.content); + if (agentId) + linksByAgentId.set(agentId, { + toolCallId: block.tool_use_id, + failed: block.is_error === true, + }); } } - return results; + return { + toolCalls, + linksByAgentId, + outcomesByToolCallId: new Map([...outcomes].filter(([id]) => toolCalls.has(id))), + }; } -function groupClaudeSidechainEntries( - entries: ClaudeHistoryEntry[], -): Map { - const entriesByAgentId = new Map(); - for (const entry of entries) { - if (typeof entry.agentId !== "string") continue; - const grouped = entriesByAgentId.get(entry.agentId) ?? []; - grouped.push(entry); - entriesByAgentId.set(entry.agentId, grouped); - } - return entriesByAgentId; +function readLegacyClaudeSubagentId(content: unknown): string | undefined { + return /agentId:\s*([\w-]+)/.exec(JSON.stringify(content))?.[1]; } interface ClaudeHistoryEntry { diff --git a/packages/server/src/server/agent/providers/claude/replay-memory.fixture.ts b/packages/server/src/server/agent/providers/claude/replay-memory.fixture.ts new file mode 100644 index 0000000000..cab987e473 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/replay-memory.fixture.ts @@ -0,0 +1,202 @@ +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import pino from "pino"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +/** Full-scale reproduction is opt-in: run this file with ROOT children --generate. */ +export function generateReplayCorpus( + root: string, + size = { parentMiB: 395, childrenMiB: 362, children: 269 }, +): { cwd: string; configDir: string } { + const cwd = path.join(root, "repo"); + const configDir = path.join(root, "claude"); + mkdirSync(cwd, { recursive: true }); + const project = claudeProjectDirSync(cwd, { configDir }); + const subagents = path.join(project, "large-session", "subagents"); + mkdirSync(subagents, { recursive: true }); + const parent = path.join(project, "large-session.jsonl"); + const row = (value: unknown) => JSON.stringify(value) + "\n"; + // Large transcripts include records and context summaries that never become timeline items. + // Generate those incrementally: the fixture generator itself must not hold the corpus. + const padding = row({ type: "progress", data: "x".repeat(64 * 1024) }); + writeFileSync(parent, ""); + for (let i = 0; i < Math.ceil((size.parentMiB * 1024 * 1024) / padding.length); i++) { + appendFileSync(parent, padding); + } + appendFileSync(parent, row({ type: "user", message: { content: "Open the large session" } })); + for (let i = 0; i < size.children; i++) { + const agentId = `child-${i}`; + const id = `task-${i}`; + appendFileSync( + parent, + row({ + type: "assistant", + message: { + content: [{ type: "tool_use", id, name: "Task", input: { description: agentId } }], + }, + }), + ); + appendFileSync( + parent, + row({ + type: "user", + message: { content: [{ type: "tool_result", tool_use_id: id, content: "done" }] }, + }), + ); + const file = path.join(subagents, `agent-${agentId}.jsonl`); + writeFileSync(file, ""); + const summary = row({ + type: "user", + isCompactSummary: true, + isSidechain: true, + agentId, + message: { content: "x".repeat(64 * 1024) }, + }); + for ( + let j = 0; + j < Math.ceil((size.childrenMiB * 1024 * 1024) / size.children / summary.length); + j++ + ) + appendFileSync(file, summary); + appendFileSync( + file, + row({ + type: "assistant", + isSidechain: true, + agentId, + timestamp: "2026-07-26T06:28:00.000Z", + message: { + content: [{ type: "text", text: `History of ${agentId}` }], + stop_reason: "end_turn", + }, + }), + ); + writeFileSync( + path.join(subagents, `agent-${agentId}.meta.json`), + JSON.stringify({ toolUseId: id }), + ); + } + return { cwd, configDir }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const root = process.argv[2]; + if (!root) throw new Error("Usage: replay-memory.fixture.ts ROOT children|workflow [--generate]"); + if (process.argv.includes("--generate")) { + if (process.argv[3] === "workflow") generateWorkflowReplayCorpus(root); + else generateReplayCorpus(root); + } + process.env.CLAUDE_CONFIG_DIR = path.join(root, "claude"); + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + queryFactory: () => { + throw new Error("Replay must not query Claude"); + }, + resolveVersion: async () => "2.1.220", + }); + const session = await client.resumeSession( + { provider: "claude", sessionId: "large-session" }, + { cwd: path.join(root, "repo") }, + ); + let parentItems = 0; + const completed = new Set(); + const histories = new Set(); + for await (const event of session.streamHistory()) { + if (event.type === "timeline") parentItems++; + if ( + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.status === "completed" + ) + completed.add(event.event.id); + if ( + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "assistant_message" + ) + histories.add(event.event.item.text); + } + await session.close(); + console.log( + JSON.stringify({ + parentItems, + completed: completed.size, + histories: histories.size, + maxRSSKiB: process.resourceUsage().maxRSS, + }), + ); +} + +export function generateWorkflowReplayCorpus( + root: string, + childMiB = 320, +): { cwd: string; configDir: string } { + const cwd = path.join(root, "repo"); + const configDir = path.join(root, "claude"); + mkdirSync(cwd, { recursive: true }); + const project = claudeProjectDirSync(cwd, { configDir }); + const runId = "wf_large"; + const sessionDirectory = path.join(project, "large-session"); + const childrenDirectory = path.join(sessionDirectory, "subagents", "workflows", runId); + mkdirSync(childrenDirectory, { recursive: true }); + mkdirSync(path.join(sessionDirectory, "workflows"), { recursive: true }); + writeFileSync( + path.join(project, "large-session.jsonl"), + [ + { type: "user", message: { content: "Open the large session" } }, + { + type: "assistant", + message: { + content: [{ type: "tool_use", id: "workflow-task", name: "Workflow", input: {} }], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "workflow-task", + content: `Workflow launched in background.\nRun ID: ${runId}`, + }, + ], + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + writeFileSync( + path.join(sessionDirectory, "workflows", `${runId}.json`), + JSON.stringify({ runId, status: "completed", summary: "Large workflow" }), + ); + for (let i = 0; i < 5; i++) { + const file = path.join(childrenDirectory, `agent-child-${i}.jsonl`); + writeFileSync(file, ""); + const prompt = + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: `child-${i}`, + message: { content: "x".repeat(64 * 1024) }, + }) + "\n"; + for (let j = 0; j < Math.ceil((childMiB * 1024 * 1024) / prompt.length); j++) + appendFileSync(file, prompt); + appendFileSync( + file, + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: `child-${i}`, + timestamp: `2026-07-26T06:28:0${4 - i}.000Z`, + message: { + content: [{ type: "text", text: `History of child-${i}` }], + stop_reason: "end_turn", + }, + }) + "\n", + ); + } + return { cwd, configDir }; +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 372cbc01b2..23ba35c0fe 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -65,17 +65,12 @@ export interface ClaudeReplayEntry { * Effort is replay-only: the SDK's live assistant message does not carry it, and it can be * silently downgraded for the selected model, so any live value would be a guess. */ -function readRuntime(entries: readonly ClaudeReplayEntry[]): { model?: string; effort?: string } { - const runtime: { model?: string; effort?: string } = {}; - for (const entry of entries) { - if (entry.type !== "assistant") continue; - const effort = typeof entry.effort === "string" ? entry.effort.trim() : ""; - if (effort) runtime.effort = effort; - const rawModel = (entry.message as { model?: unknown } | undefined)?.model; - const model = resolveObservedClaudeModelId(typeof rawModel === "string" ? rawModel : undefined); - if (model) runtime.model = model; - } - return runtime; +function readRuntime(entry: ClaudeReplayEntry): { model?: string; effort?: string } { + if (entry.type !== "assistant") return {}; + const effort = typeof entry.effort === "string" ? entry.effort.trim() : ""; + const rawModel = entry.message?.model; + const model = resolveObservedClaudeModelId(typeof rawModel === "string" ? rawModel : undefined); + return { ...(effort ? { effort } : {}), ...(model ? { model } : {}) }; } /** @@ -126,22 +121,16 @@ function readTotalTokens(raw: unknown): number | undefined { * * The last assistant entry's summed usage matches Claude Code's live `total_tokens` definition. */ -function readUsage(entries: readonly ClaudeReplayEntry[]): ClaudeSubagentUsage | null { - let totalTokens: number | undefined; - - for (const entry of entries) { - if (entry.type !== "assistant") continue; - const observed = readTotalTokens(entry.message?.usage); - if (observed !== undefined) totalTokens = observed; - } - +function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null { + if (entry.type !== "assistant") return null; + const totalTokens = readTotalTokens(entry.message?.usage); return totalTokens === undefined ? null : { totalTokens }; } export interface ClaudeReplaySubagentInput { agentId: string; meta: ClaudeSubagentMeta | null; - entries: ClaudeReplayEntry[]; + entries: Iterable; /** Task declarations made by this subagent, used to attach its direct children. */ parentFacts?: ClaudeReplayParentFacts; } @@ -211,18 +200,6 @@ function resolveParentLink( return null; } -/** A final `end_turn` is the child's own completion signal when the parent result is absent. */ -function readChildTerminalStatus( - entries: readonly ClaudeReplayEntry[], -): ProviderSubagentStatus | null { - for (let index = entries.length - 1; index >= 0; index--) { - const entry = entries[index]; - if (entry?.type !== "assistant") continue; - return entry.message?.stop_reason === "end_turn" ? "completed" : null; - } - return null; -} - /** * The parent's tool input is the same identity source the live path reads; meta.json contributes * provider details only after that declaration has admitted the child. @@ -231,11 +208,11 @@ function declareSubagent( subagent: ClaudeReplaySubagentInput, link: ParentLink, toolCall: { title?: string; description?: string } | undefined, - parentSubagentId?: string, + parentSubagentId: string | undefined, + firstTimestamp: string | null, ): SubagentObservation { const title = toolCall?.title ?? subagent.meta?.agentType; const description = toolCall?.description ?? subagent.meta?.description; - const firstTimestamp = normalizeProviderReplayTimestamp(subagent.entries[0]?.timestamp); return { kind: "declared", id: link.id, @@ -248,12 +225,12 @@ function declareSubagent( } function observeSubtitle( - subagent: ClaudeReplaySubagentInput, link: ParentLink, title: string | undefined, + runtime: { model?: string; effort?: string }, + usage: ClaudeSubagentUsage | null, + timestamp: string | null, ): SubagentObservation | null { - const runtime = readRuntime(subagent.entries); - const usage = readUsage(subagent.entries); const hasDetails = runtime.model !== undefined || runtime.effort !== undefined || @@ -265,7 +242,6 @@ function observeSubtitle( ...(usage ? { usage } : {}), }); if (!subtitle) return null; - const timestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp); return { kind: "subtitle", id: link.id, @@ -278,35 +254,50 @@ function observeSubagent( subagent: ClaudeReplaySubagentInput, parent: ClaudeReplayParentFacts, convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[], + toolOwners: Map, parentSubagentId?: string, ): SubagentObservation[] { const link = resolveParentLink(subagent, parent); if (!link) return []; const toolCall = parent.toolCalls.get(link.toolCallId); + const timeline: SubagentObservation[] = []; + const runtime: { model?: string; effort?: string } = {}; + let usage: ClaudeSubagentUsage | null = null; + let firstTimestamp: string | null = null; + let lastTimestamp: string | null = null; + let seenEntry = false; + let childTerminalStatus: ProviderSubagentStatus | null = null; - const observations: SubagentObservation[] = [ - declareSubagent(subagent, link, toolCall, parentSubagentId), - ]; - const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType); - if (subtitle) observations.push(subtitle); - + // Consume each source once for presentation and timeline; keep no raw transcript records. for (const entry of subagent.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); + if (!seenEntry) firstTimestamp = timestamp; + seenEntry = true; + lastTimestamp = timestamp; + Object.assign(runtime, readRuntime(entry)); + usage = readUsage(entry) ?? usage; + if (entry.type === "assistant") { + childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null; + } + recordReplayToolOwners(toolOwners, entry, link.id); for (const item of convertEntry(entry)) { - observations.push({ - kind: "timeline", - id: link.id, - item, - ...(timestamp ? { timestamp } : {}), - }); + timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) }); } } - - // Prefer the parent's result because it carries failure. If that result is missing, the child's - // final end_turn still proves completion. Any other shape remains running. - const terminalStatus = link.status ?? readChildTerminalStatus(subagent.entries); + const observations = [ + declareSubagent(subagent, link, toolCall, parentSubagentId, firstTimestamp), + ]; + const subtitle = observeSubtitle( + link, + toolCall?.title ?? subagent.meta?.agentType, + runtime, + usage, + lastTimestamp, + ); + if (subtitle) observations.push(subtitle); + for (const observation of timeline) observations.push(observation); + const terminalStatus = link.status ?? childTerminalStatus; if (terminalStatus) { - const lastTimestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp); observations.push({ kind: "status", id: link.id, @@ -314,22 +305,18 @@ function observeSubagent( ...(lastTimestamp ? { timestamp: lastTimestamp } : {}), }); } - return observations; } function recordReplayToolOwners( owners: Map, - entries: readonly ClaudeReplayEntry[], + entry: ClaudeReplayEntry, subagentId: string, ): void { - for (const entry of entries) { - if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) continue; - for (const block of entry.message.content) { - if (block?.type === "tool_use" && typeof block.id === "string") { - owners.set(block.id, subagentId); - } - } + if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) return; + for (const block of entry.message.content) { + if (block?.type === "tool_use" && typeof block.id === "string") + owners.set(block.id, subagentId); } } @@ -359,8 +346,15 @@ export function observeReplaySubagents(input: { const { ownerId, parent, link } = resolved; // Only proven descendants may own notifications; ambient sidecars cannot claim them. - recordReplayToolOwners(toolOwners, subagent.entries, link.id); - observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId)); + for (const observation of observeSubagent( + subagent, + parent, + input.convertEntry, + toolOwners, + ownerId, + )) { + observations.push(observation); + } if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts); unresolved.splice(index, 1); madeProgress = true; diff --git a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts index ede48a5f48..1d7ff2b1a8 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts @@ -47,8 +47,9 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu */ export function observeReplayWorkflows(input: { workflows: readonly ClaudeWorkflowRun[]; - parentEntries: readonly ClaudeWorkflowParentEntry[]; - entriesByRunId?: ReadonlyMap; + parentEntries: Iterable; + /** Repeatable entries in chronological order, supplied by the transcript reader. */ + entriesByRunId?: ReadonlyMap>; convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; }): SubagentObservation[] { const toolCallIdByRunId = readWorkflowLinks(input.parentEntries); @@ -78,15 +79,15 @@ export function observeReplayWorkflows(input: { }); } - observations.push( - ...observeWorkflowTimeline({ - id, - result: workflow.result, - entries: input.entriesByRunId?.get(workflow.runId) ?? [], - convertEntry: input.convertEntry, - finishedAt, - }), - ); + for (const observation of observeWorkflowTimeline({ + id, + result: workflow.result, + entries: input.entriesByRunId?.get(workflow.runId) ?? [], + convertEntry: input.convertEntry, + finishedAt, + })) { + observations.push(observation); + } const subtitle = buildClaudeSubagentSubtitle({ title: "Workflow", @@ -117,15 +118,14 @@ export function observeReplayWorkflows(input: { function observeWorkflowTimeline(input: { id: string; result: unknown; - entries: readonly ClaudeReplayEntry[]; + entries: Iterable; convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; finishedAt?: string; }): SubagentObservation[] { const observations: SubagentObservation[] = []; const replayedAssistantText = new Set(); - const entries = [...input.entries].sort(compareReplayTimestamps); - for (const entry of entries) { + for (const entry of input.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); const items = input.convertEntry?.(entry) ?? []; for (const item of items) { @@ -150,16 +150,7 @@ function observeWorkflowTimeline(input: { return observations; } -function compareReplayTimestamps(a: ClaudeReplayEntry, b: ClaudeReplayEntry): number { - const aTimestamp = normalizeProviderReplayTimestamp(a.timestamp); - const bTimestamp = normalizeProviderReplayTimestamp(b.timestamp); - if (!aTimestamp && !bTimestamp) return 0; - if (!aTimestamp) return 1; - if (!bTimestamp) return -1; - return Date.parse(aTimestamp) - Date.parse(bTimestamp); -} - -function readWorkflowLinks(entries: readonly ClaudeWorkflowParentEntry[]): Map { +function readWorkflowLinks(entries: Iterable): Map { const workflowToolCallIds = new Set(); const toolCallIdByRunId = new Map(); diff --git a/packages/server/src/server/agent/providers/claude/transcript-history.ts b/packages/server/src/server/agent/providers/claude/transcript-history.ts new file mode 100644 index 0000000000..cba606b0e4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/transcript-history.ts @@ -0,0 +1,256 @@ +import fs from "node:fs"; +import path from "node:path"; +import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; +import { + parseClaudeSubagentMeta, + type ClaudeReplayEntry, + type ClaudeSubagentMeta, +} from "./subagents/replay-source.js"; +import { + parseClaudeWorkflowRun, + type ClaudeWorkflowRun, +} from "./subagents/workflow-replay-source.js"; + +interface TranscriptEntry extends ClaudeReplayEntry { + isSidechain?: unknown; +} + +interface RecordLocation { + file: string; + offset: number; + length: number; + timestamp: string | null; +} + +/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */ +function* locatedRecords( + file: string, + fd: number, +): Generator<{ entry: TranscriptEntry; location: RecordLocation }> { + const stat = fs.fstatSync(fd); + // Opening a directory succeeds on some platforms; it must never look like empty history. + if (stat.isDirectory()) { + throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), { + code: "EISDIR", + syscall: "read", + path: file, + }); + } + let remaining = stat.size; + let offset = 0; + let pieces: Buffer[] = []; + let length = 0; + while (remaining > 0) { + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining)); + const count = fs.readSync(fd, buffer, 0, buffer.length, null); + if (count === 0) break; + remaining -= count; + let start = 0; + let end: number; + while ((end = buffer.indexOf(10, start)) !== -1 && end < count) { + pieces.push(buffer.subarray(start, end)); + length += end - start; + const entry = parseRecord(Buffer.concat(pieces, length).toString("utf8")); + const location = { + file, + offset, + length, + timestamp: normalizeProviderReplayTimestamp(entry?.timestamp), + }; + offset += length + 1; + pieces = []; + length = 0; + if (entry) yield { entry, location }; + start = end + 1; + } + if (start < count) { + pieces.push(buffer.subarray(start, count)); + length += count - start; + } + } + const entry = parseRecord(Buffer.concat(pieces, length).toString("utf8")); + if (entry) + yield { + entry, + location: { + file, + offset, + length, + timestamp: normalizeProviderReplayTimestamp(entry.timestamp), + }, + }; +} + +/** Indexed sources parse only their own records using descriptors owned by this replay. */ +function indexedRecords( + locations: readonly RecordLocation[], + descriptors: ReadonlyMap, +): Iterable { + return { + *[Symbol.iterator]() { + for (const location of locations) { + const fd = descriptors.get(location.file)!; + const buffer = Buffer.allocUnsafe(location.length); + let read = 0; + while (read < buffer.length) { + const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read); + if (count === 0) + throw new Error(`Claude transcript was truncated during replay: ${location.file}`); + read += count; + } + const entry = parseRecord(buffer.toString("utf8")); + if (entry) yield entry; + } + }, + }; +} + +function isToolResult(entry: TranscriptEntry): boolean { + return ( + Array.isArray(entry.message?.content) && + entry.message.content.some((block) => block?.type === "tool_result") + ); +} + +function parseRecord(line: string): TranscriptEntry | null { + try { + const value: unknown = JSON.parse(line); + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as TranscriptEntry) + : null; + } catch { + return null; + } +} + +function readOptionalFile(file: string): string | null { + try { + return fs.readFileSync(file, "utf8"); + } catch { + return null; + } +} + +/** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded + * by the replay ownership resolver; workflow children retain their separate run ownership. */ +export interface ClaudeReplayHistory { + parentEntries: Iterable; + subagents: { + agentId: string; + meta: ClaudeSubagentMeta | null; + entries: Iterable; + }[]; + workflows: ClaudeWorkflowRun[]; + workflowEntriesByRunId: Map>; +} + +/** Own descriptors through every pass so unlink or rename cannot invalidate discovered history. + * Sources must be consumed before the callback returns. No transcript copies are needed. */ +export function withClaudeReplayHistory( + historyPath: string, + consume: (history: ClaudeReplayHistory) => T, +): T { + const descriptors = new Map(); + try { + return consume(indexHistory(historyPath, descriptors)); + } finally { + for (const fd of descriptors.values()) fs.closeSync(fd); + } +} + +function indexHistory(historyPath: string, descriptors: Map): ClaudeReplayHistory { + const sessionDirectory = path.join( + path.dirname(historyPath), + path.basename(historyPath, ".jsonl"), + ); + const sidechainDirectory = path.join(sessionDirectory, "subagents"); + const sidechainFiles = [historyPath]; + const workflowFiles = new Map(); + const metaByAgentId = new Map(); + const directories = [sidechainDirectory]; + while (directories.length) { + const directory = directories.pop()!; + if (!fs.existsSync(directory)) continue; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) { + directories.push(file); + continue; + } + if (!entry.isFile()) continue; + if (entry.name.endsWith(".jsonl")) { + const parts = path.relative(sidechainDirectory, file).split(path.sep); + if (parts[0] === "workflows" && parts.length >= 3) { + const runId = parts[1]!; + const files = workflowFiles.get(runId) ?? []; + files.push(file); + workflowFiles.set(runId, files); + } else sidechainFiles.push(file); + continue; + } + const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1]; + if (!agentId) continue; + const contents = readOptionalFile(file); + const meta = contents === null ? null : parseClaudeSubagentMeta(contents); + if (meta) metaByAgentId.set(agentId, meta); + } + } + const parentLocations: RecordLocation[] = []; + const locationsByAgentId = new Map(); + function scan( + file: string, + visit: (entry: TranscriptEntry, location: RecordLocation) => void, + ): void { + const fd = fs.openSync(file, "r"); + descriptors.set(file, fd); + for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location); + } + for (const file of sidechainFiles) { + scan(file, (entry, location) => { + if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location); + if (entry.isSidechain !== true || typeof entry.agentId !== "string") return; + const locations = locationsByAgentId.get(entry.agentId) ?? []; + locations.push(location); + locationsByAgentId.set(entry.agentId, locations); + }); + } + const workflowEntriesByRunId = new Map>(); + for (const [runId, files] of workflowFiles) { + const locations: RecordLocation[] = []; + for (const file of files) + scan(file, (entry, location) => { + if (entry.type !== "user" || isToolResult(entry)) locations.push(location); + }); + locations.sort((a, b) => { + if (!a.timestamp && !b.timestamp) return 0; + if (!a.timestamp) return 1; + if (!b.timestamp) return -1; + return Date.parse(a.timestamp) - Date.parse(b.timestamp); + }); + workflowEntriesByRunId.set(runId, indexedRecords(locations, descriptors)); + } + return { + parentEntries: indexedRecords(parentLocations, descriptors), + subagents: [...locationsByAgentId].map(([agentId, locations]) => ({ + agentId, + meta: metaByAgentId.get(agentId) ?? null, + entries: indexedRecords(locations, descriptors), + })), + workflows: readWorkflows(sessionDirectory), + workflowEntriesByRunId, + }; +} + +function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] { + const workflows: ClaudeWorkflowRun[] = []; + const workflowDirectory = path.join(sessionDirectory, "workflows"); + if (fs.existsSync(workflowDirectory)) { + for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const contents = readOptionalFile(path.join(workflowDirectory, entry.name)); + const workflow = contents === null ? null : parseClaudeWorkflowRun(contents); + if (workflow) workflows.push(workflow); + } + } + return workflows; +} From 59b01b33f1e58a4cfd7f55142d3ef51d95145784 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 19:00:15 +0700 Subject: [PATCH 45/62] fix(app): build iOS releases with the foldable-capable SDK (#7705) EAS auto selects Xcode 26.6 for Expo 57, leaving releases in phone compatibility mode despite scene support. Pin Xcode 27.1 for the native resizing opt-in. --- packages/app/eas.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app/eas.json b/packages/app/eas.json index 6ec7f248d3..719a9379e5 100644 --- a/packages/app/eas.json +++ b/packages/app/eas.json @@ -15,6 +15,9 @@ } }, "production": { + "ios": { + "image": "macos-tahoe-26.6-xcode-27.1" + }, "env": { "APP_VARIANT": "production" }, From d91d61c6ebd940d2d39a27056acb06f9e2067449 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 14:23:00 +0200 Subject: [PATCH 46/62] docs: update changelog for 0.13.0-beta.2 --- CHANGELOG.md | 36 ++++++++++++++++++++++++------------ CHANGELOG.zh-CN.md | 28 ++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d69f6cb492..78fd290dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.13.0-beta.1 - 2026-10-22 +## 0.13.0-beta.2 - 2026-10-23 Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. @@ -14,18 +14,25 @@ paseo host add 'relay://...' --label laptop ### Added -- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424)) +- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705)) - Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593)) - Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576)) - Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453)) +- Added pasting images and dropping files into terminals, so agent TUIs such as Claude Code and Codex can read them, including on remote hosts ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro)) ### Improved +- Changed sidebar workspaces to sort by Created, with Last prompt under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690)) +- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611)) - Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580)) -- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411)) - Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424)) - Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch)) +- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693)) +- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639)) - Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) - Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561)) - Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473)) @@ -33,28 +40,33 @@ paseo host add 'relay://...' --label laptop - Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8)) - Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8)) -### Deprecated - -- Deprecated plugin panel `context` and flat `agentId` props, and `addWorkspacePanel`'s `context` field (including the agent registration alias). Removal after 2027-04-23; use `params.agentId` and `addAgentPanel`. - -### Changed - -- Changed beta file panel props to `workspaceId`, read-only `params`, `revision`, `file`, and `tab` with persisted per-tab state. Removed beta-only `location`, `PluginFileLocation`, and `PluginFileTab`. - ### Plugins -- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366)) +- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614)) +- Added `useTabState` for per-tab panel state that survives reloads, and `addAgentPanel`; workspace, agent and file panels share the `workspaceId`, `params`, `revision` and `tab` props ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478)) - Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579)) - Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368)) - Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474)) +- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477)) +- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630)) - Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) ### Fixed - Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573)) +- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683)) +- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng)) +- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej)) +- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja)) +- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638)) +- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681)) +- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77)) +- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608)) +- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688)) - Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) - Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547)) - Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538)) diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index ac7c00cbe7..6c82382605 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,6 +1,6 @@ # 更新日志 -## 0.13.0-beta.1 - 2026-10-22 +## 0.13.0-beta.2 - 2026-10-23 折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。 @@ -14,18 +14,25 @@ paseo host add 'relay://...' --label laptop ### 新增 -- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424)) +- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705)) - 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593)) - 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576)) - 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453)) +- 新增在终端中粘贴图片和拖放文件,Claude Code、Codex 等 Agent TUI 可以读取它们,远程主机同样适用 ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro)) ### 改进 +- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”;工作区不再支持拖动排序 ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690)) +- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611)) - 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580)) -- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411)) - Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424)) - 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) +- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch)) +- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693)) +- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639)) - 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) - Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561)) - 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473)) @@ -35,18 +42,31 @@ paseo host add 'relay://...' --label laptop ### 插件 -- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366)) +- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614)) +- 新增 `useTabState`,用于保存每个标签页的面板状态并在重新加载后恢复,并新增 `addAgentPanel`;工作区、Agent 和文件面板共用 `workspaceId`、`params`、`revision` 和 `tab` 属性 ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478)) - 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579)) - 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368)) - 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474)) +- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477)) +- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630)) - 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) ### 修复 - 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573)) +- 修复了 daemon 重启后父 Agent 收不到 `notifyOnFinish` 子 Agent 通知的问题 ([#7683](https://github.com/getpaseo/paseo/pull/7683)) +- 修复了打开大型 Claude 会话时聊天为空或 daemon 崩溃的问题 ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng)) +- 修复了 Claude 历史记录较大时导入会话导致 daemon 崩溃的问题 ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej)) +- 修复了直连断开后主机停留在错误状态、不切换到已保存的 relay 连接的问题 ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae)) +- 修复了 iOS 上点击通知打开过期工作区,以及 Android 每次重新打开应用都会再次打开通知对应 Agent 的问题 ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja)) +- 修复了应用重新连接后侧边栏中的 PR 状态和检查消失的问题 ([#7638](https://github.com/getpaseo/paseo/pull/7638)) +- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681)) +- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77)) +- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608)) +- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688)) - 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) - 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547)) - 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538)) From 30d8cb044702fa873057ecc5c2676c7964a3da33 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Oct 2026 14:28:15 +0200 Subject: [PATCH 47/62] chore(release): cut 0.13.0-beta.2 --- package-lock.json | 56 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++-- packages/client/package.json | 6 +-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 10 ++--- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 ++--- packages/website/package.json | 2 +- plugins/package.json | 2 +- 14 files changed, 55 insertions(+), 55 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2f60b8cc1a..c93f050b23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -35695,7 +35695,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37961,12 +37961,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/server": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/server": "0.13.0-beta.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -38213,10 +38213,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/relay": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/relay": "0.13.0-beta.2", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -38228,7 +38228,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -38472,7 +38472,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -42124,7 +42124,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -42153,15 +42153,15 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -42171,8 +42171,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -42194,7 +42194,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -42221,7 +42221,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", @@ -42442,16 +42442,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/highlight": "0.13.0-beta.1", - "@getpaseo/plugin": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/relay": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/highlight": "0.13.0-beta.2", + "@getpaseo/plugin": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/relay": "0.13.0-beta.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -43372,7 +43372,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -44982,7 +44982,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 9a8a3322e5..583a88c406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index bc992b5d6c..b59ef19b73 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 2481184da6..8efbc81267 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "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.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/server": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/server": "0.13.0-beta.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 3f5b7c51a5..b0d7961ace 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/relay": "0.13.0-beta.1", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/relay": "0.13.0-beta.2", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index cbeffbb56d..8da5decfa4 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index b6392bafa1..5c265cc23c 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index f714608069..b7c478bce3 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index b6f1243c6c..17f9f76bd5 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -72,8 +72,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -83,8 +83,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index f089ca926b..75948ccf72 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index ea3d9d1111..8f5c495a83 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 046e979c86..0a7f9edbd4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -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.13.0-beta.1", - "@getpaseo/highlight": "0.13.0-beta.1", - "@getpaseo/plugin": "0.13.0-beta.1", - "@getpaseo/protocol": "0.13.0-beta.1", - "@getpaseo/relay": "0.13.0-beta.1", + "@getpaseo/client": "0.13.0-beta.2", + "@getpaseo/highlight": "0.13.0-beta.2", + "@getpaseo/plugin": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/relay": "0.13.0-beta.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 8f91671e25..1fb8b0087c 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "private": true, "type": "module", "scripts": { diff --git a/plugins/package.json b/plugins/package.json index f71101b0c6..34c57df528 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0-beta.1", + "version": "0.13.0-beta.2", "private": true, "type": "module", "scripts": { From 74dc764f0df1108f78e6ee3a8b5c876295ea5aa2 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:38:01 +0000 Subject: [PATCH 48/62] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 1a51631fc2..d345683e19 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU= +sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU= From fed19f3d29cbcd21ecd45834af81adc13d9d5a7f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:57:19 +0200 Subject: [PATCH 49/62] fix(app): identify Android DNS connection failures (#7701) Recognize explicit name-resolution failures before TLS keywords in hostnames, while preserving certificate-not-found handling and raw details. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../components/connection-failure-copy.test.ts | 18 ++++++++++++++++++ .../src/components/connection-failure-copy.ts | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/connection-failure-copy.test.ts b/packages/app/src/components/connection-failure-copy.test.ts index 21cfcd40e5..de78ba43eb 100644 --- a/packages/app/src/components/connection-failure-copy.test.ts +++ b/packages/app/src/components/connection-failure-copy.test.ts @@ -28,6 +28,23 @@ function failure(message: string) { } describe("direct connection failure copy", () => { + it.each([ + "paseo-qa-nonexistent.invalid", + "ssl-paseo-qa.invalid", + "tls-paseo-qa.invalid", + "certificate-paseo-qa.invalid", + ])( + "identifies Android DNS failures for %s without treating the hostname as TLS evidence", + (host) => { + const message = `Unable to resolve host "${host}": No address associated with hostname`; + expect(failure(message)).toEqual({ + title: "Failed: tcp://example.test:443?ssl=true", + detail: labels.hostNotFound, + raw: `transport error — ${message}`, + }); + }, + ); + it("identifies Android certificate validation errors without calling them DNS failures", () => { const message = "java.security.cert.CertPathValidatorException: Trust anchor for certification path not found."; @@ -43,6 +60,7 @@ describe("direct connection failure copy", () => { ["SSL handshake failed", labels.tlsError], ["TLS handshake failed", labels.tlsError], ["getaddrinfo ENOTFOUND example.test", labels.hostNotFound], + ["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound], ["Host not found", labels.hostNotFound], ["connect ECONNREFUSED", labels.refused], ["connection timed out", labels.timedOut], diff --git a/packages/app/src/components/connection-failure-copy.ts b/packages/app/src/components/connection-failure-copy.ts index 1491ed01d8..e1ad2f203c 100644 --- a/packages/app/src/components/connection-failure-copy.ts +++ b/packages/app/src/components/connection-failure-copy.ts @@ -83,6 +83,8 @@ export function buildConnectionFailureCopy(input: { rawLower.includes("err_connection_refused") ) { detail = labels.refused; + } else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) { + detail = labels.hostNotFound; } else if ( rawLower.includes("certificate") || rawLower.includes("certpathvalidator") || @@ -91,7 +93,7 @@ export function buildConnectionFailureCopy(input: { rawLower.includes("ssl") ) { detail = labels.tlsError; - } else if (rawLower.includes("enotfound") || rawLower.includes("not found")) { + } else if (rawLower.includes("not found")) { detail = labels.hostNotFound; } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) { detail = labels.hostUnreachable; From d6fa231894568e6ac9a11e8b2fb475343026eea6 Mon Sep 17 00:00:00 2001 From: Ni Xiao <87625310+nistream@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:05:54 +0800 Subject: [PATCH 50/62] fix(plugin): restore ACP history replayed during session load (#7717) ACP agents replay a loaded session's session/update notifications before session/load resolves, but AcpRuntime.open registered nativeSessionId only after the response, so the session filter dropped every replayed update and restored sessions opened with an empty timeline. Register the native id before the request, drain the notification lane once the response arrives so the replay completes before session.opened, and follow the open request's history contract: replayed updates rebuild the timeline under history "replay" and are dropped under history "skip". Replayed user_message_chunk updates now restore user messages through the chunk fallback scheme, so multi-chunk messages keep their boundaries; live user chunks stay dropped because admitPrompt already echoes the prompt. Co-authored-by: nistream --- .../src/server/acp-internal/connection.ts | 74 +++++-- packages/plugin/src/server/acp.test.ts | 184 ++++++++++++++++++ 2 files changed, 244 insertions(+), 14 deletions(-) diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index 05403594cf..b2fbee8e2c 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -374,13 +374,19 @@ class AcpRuntime { /** * Fallback ids for streamed chunks that carry no `messageId`, one per * chunk kind. Consecutive chunks of a kind continue the same timeline - * item; a chunk of the other kind, a tool call, a plan, a user message, - * or a turn boundary ends it (#4699). + * item; a chunk of another kind, a tool call, a plan, or a turn boundary + * ends it (#4699). */ private readonly fallbackChunkIds = new Map< - "agent_message_chunk" | "agent_thought_chunk", + "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk", string >(); + /** + * Active while a persisted session loads. ACP agents replay the loaded + * session's updates before session/load resolves; the value follows the + * open request's history contract. + */ + private replayWindow: "replay" | "skip" | null = null; private closing = false; private processFailed = false; private configTransaction = false; @@ -499,15 +505,23 @@ class AcpRuntime { }; let response: Pick; if (nativeSessionId) { - response = await this.call( - this.connection.loadSession({ - sessionId: nativeSessionId, - cwd: input.config.cwd, - mcpServers, - _meta: metadata, - }), - ); + // ACP agents replay the loaded session's updates before session/load resolves, + // so the native id must be registered first for them to pass the session filter. this.nativeSessionId = nativeSessionId; + this.replayWindow = input.history === "replay" ? "replay" : "skip"; + try { + response = await this.call( + this.connection.loadSession({ + sessionId: nativeSessionId, + cwd: input.config.cwd, + mcpServers, + _meta: metadata, + }), + ); + await this.drainNotifications(); + } finally { + this.replayWindow = null; + } } else { const newSession = await this.call( this.connection.newSession({ @@ -898,6 +912,14 @@ class AcpRuntime { private sessionUpdate(notification: SessionNotification): void { if (notification.sessionId !== this.nativeSessionId) return; + if (this.replayWindow === "skip") return; + if ( + this.replayWindow === "replay" && + notification.update.sessionUpdate === "user_message_chunk" + ) { + this.reduceUserMessageChunk(notification.update); + return; + } this.reduceUpdate(notification.update); } @@ -911,13 +933,14 @@ class AcpRuntime { this.fallbackChunkIds.clear(); return; } - // A chunk of one kind ends the other kind's item, whatever its content - // or id: reasoning, then text, then reasoning again are three items. + // A chunk of one kind ends every other kind's open item, whatever its + // content or id: reasoning, then text, then reasoning again are three items. this.fallbackChunkIds.delete( update.sessionUpdate === "agent_message_chunk" ? "agent_thought_chunk" : "agent_message_chunk", ); + this.fallbackChunkIds.delete("user_message_chunk"); if (update.content.type !== "text") return; const id = this.resolveChunkId(update.sessionUpdate, update.messageId); const text = `${this.messages.get(id) ?? ""}${update.content.text}`; @@ -945,8 +968,31 @@ class AcpRuntime { this.reduceStateUpdate(update); } + // Historical user messages arrive only as replayed chunks; a live prompt + // already echoes its own user message when it is admitted. + private reduceUserMessageChunk( + update: Extract, + ): void { + this.fallbackChunkIds.delete("agent_message_chunk"); + this.fallbackChunkIds.delete("agent_thought_chunk"); + if (update.content.type !== "text") return; + const id = this.resolveChunkId("user_message_chunk", update.messageId); + const text = `${this.messages.get(id) ?? ""}${update.content.text}`; + this.messages.set(id, text); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "user_message", + id, + text, + ...(update.messageId ? { messageId: update.messageId } : {}), + }, + }); + } + private resolveChunkId( - kind: "agent_message_chunk" | "agent_thought_chunk", + kind: "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk", messageId: string | null | undefined, ): string { if (messageId) { diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index 40c8019439..f4b429578f 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -1500,3 +1500,187 @@ describe("ACP streamed message boundaries", () => { }, ); }); + +function userChunk(text: string, messageId?: string): SessionUpdate { + return { + sessionUpdate: "user_message_chunk", + content: { type: "text", text }, + ...(messageId === undefined ? {} : { messageId }), + }; +} + +async function openReplaySession( + replay: SessionUpdate[], + history: "replay" | "skip", +): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> { + const harness = connectorHarness({ + capabilities: { loadSession: true }, + handleRequest(instance, request) { + if (request.method !== "session/load") return false; + for (const update of replay) { + instance.notify("session/update", { sessionId: "connector-session", update }); + } + instance.respond(request, { + sessionId: "connector-session", + modes: null, + configOptions: [], + }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "replay-acp", + label: "Replay ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "session.persistence"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + const input = openInput(); + input.persistence = { version: 1, data: { sessionId: "connector-session" } }; + input.history = history; + await connection.send(input); + await waitForEvent(events, (event) => event.type === "session.ready"); + return { connection, events }; +} + +function timelineProjection(events: ProviderEvent[]): Array> { + return events.flatMap((event) => { + if (event.type !== "timeline.item") return []; + const item = event.item; + if ( + item.type === "user_message" || + item.type === "assistant_message" || + item.type === "reasoning" + ) { + return [{ type: item.type, id: item.id, text: item.text }]; + } + if (item.type === "tool_call") return [{ type: item.type, id: item.id, status: item.status }]; + return [{ type: item.type, id: item.id }]; + }); +} + +describe("ACP session history replay", () => { + it("restores the timeline replayed before session/load resolves", async () => { + const { connection, events } = await openReplaySession( + [ + userChunk("What is the ", "user-1"), + userChunk("temperature?", "user-1"), + textChunk("Checking the sensor", "agent_thought_chunk"), + { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Read sensor", + kind: "read", + status: "in_progress", + }, + { sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: "completed" }, + textChunk("It is 25°C"), + ], + "replay", + ); + try { + expect(timelineProjection(events)).toEqual([ + { type: "user_message", id: "user-1", text: "What is the " }, + { type: "user_message", id: "user-1", text: "What is the temperature?" }, + { type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" }, + { type: "tool_call", id: "tool-1", status: "running" }, + { type: "tool_call", id: "tool-1", status: "completed" }, + { type: "assistant_message", id: "agent_message_chunk:2", text: "It is 25°C" }, + ]); + const lastReplayed = events.findLastIndex((event) => event.type === "timeline.item"); + const opened = events.findIndex((event) => event.type === "session.opened"); + expect(lastReplayed).toBeLessThan(opened); + } finally { + await connection.close(); + } + }); + + it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => { + const { connection, events } = await openReplaySession( + [ + userChunk("first "), + userChunk("question"), + textChunk("answer"), + userChunk("second question"), + ], + "replay", + ); + try { + expect(timelineProjection(events)).toEqual([ + { type: "user_message", id: "user_message_chunk:1", text: "first " }, + { type: "user_message", id: "user_message_chunk:1", text: "first question" }, + { type: "assistant_message", id: "agent_message_chunk:2", text: "answer" }, + { type: "user_message", id: "user_message_chunk:3", text: "second question" }, + ]); + } finally { + await connection.close(); + } + }); + + it("does not duplicate the live prompt echo when the agent echoes it as a user chunk", async () => { + const harness = connectorHarness({ + capabilities: { loadSession: true }, + promptUpdates: (promptText) => [ + { sessionUpdate: "user_message_chunk", content: { type: "text", text: promptText } }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Acknowledged" } }, + ], + }); + const registration = runAcpProvider({ + id: "echo-acp", + label: "Echo ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "session.persistence"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + const input = openInput(); + input.persistence = { version: 1, data: { sessionId: "connector-session" } }; + input.history = "replay"; + await connection.send(input); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "echo-1", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "hello" }] }, + }, + }); + try { + await waitForEvent( + events, + (event) => + event.type === "session.turn" && + event.turnId === "acp:echo-1" && + event.state === "completed", + ); + expect(timelineProjection(events)).toEqual([ + { type: "user_message", id: "echo-1", text: "hello" }, + { type: "assistant_message", id: "agent_message_chunk:1", text: "Acknowledged" }, + ]); + } finally { + await connection.close(); + } + }); + + it("drops replayed updates when the open request asks to skip history", async () => { + const { connection, events } = await openReplaySession( + [userChunk("What is in the README?", "user-1"), textChunk("It describes the project.")], + "skip", + ); + try { + expect(timelineProjection(events)).toEqual([]); + } finally { + await connection.close(); + } + }); +}); From ca894d49a5e22bd2f5e096fd56048f6bc8b4a85d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:01:02 +0200 Subject: [PATCH 51/62] fix(voice): keep prefetched sentences available for playback (#7756) Unread speech bodies lost their owning HTTP response while earlier sentences played. Garbage collection then cancelled those bodies and the playback manager skipped their audio. Consume each response inside the provider before returning its audio stream, matching the existing complete-clip playback. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: grueneisfabian <174708694+grueneisfabian@users.noreply.github.com> --- .../openai/test-utils/prefetched-playback.ts | 70 +++++++++++++++++++ .../providers/openai/tts-playback.test.ts | 21 ++++++ .../src/server/speech/providers/openai/tts.ts | 4 +- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/server/speech/providers/openai/test-utils/prefetched-playback.ts create mode 100644 packages/server/src/server/speech/providers/openai/tts-playback.test.ts diff --git a/packages/server/src/server/speech/providers/openai/test-utils/prefetched-playback.ts b/packages/server/src/server/speech/providers/openai/test-utils/prefetched-playback.ts new file mode 100644 index 0000000000..406d3056f1 --- /dev/null +++ b/packages/server/src/server/speech/providers/openai/test-utils/prefetched-playback.ts @@ -0,0 +1,70 @@ +import { createServer } from "node:http"; +import { setTimeout as delay } from "node:timers/promises"; +import pino from "pino"; +import { OpenAITTS } from "../tts.js"; +import { TTSManager } from "../../../../agent/tts-manager.js"; + +if (!global.gc) throw new Error("Run with --expose-gc"); + +const sentences = ["First sentence.", "Second sentence.", "Third sentence."]; +const audio = sentences.map((_, index) => Buffer.alloc(192_000, index + 1)); +const requests: string[] = []; +const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const { input } = JSON.parse(Buffer.concat(chunks).toString()) as { input: string }; + requests.push(input); + const body = audio[sentences.indexOf(input)]; + response.writeHead(200, { "Content-Type": "audio/pcm", "Content-Length": body.length }); + response.end(body); +}); + +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +if (!address || typeof address === "string") throw new Error("Missing server address"); +const logger = pino({ level: "silent" }); +const provider = new OpenAITTS( + { apiKey: "test", baseUrl: `http://127.0.0.1:${address.port}/v1` }, + logger, +); +const manager = new TTSManager("prefetched-playback", logger, provider); +const emitted: { bytes: number; value: number; format: string }[] = []; + +try { + await manager.generateAndWaitForPlayback( + sentences.join(" "), + (message) => { + if (message.type !== "audio_output") return; + const buffer = Buffer.from(message.payload.audio, "base64"); + emitted.push({ bytes: buffer.length, value: buffer[0], format: message.payload.format }); + if (emitted.length === 1) { + // Hold the playback acknowledgement while the other responses are prefetched. + void (async () => { + while (requests.length < sentences.length) await delay(10); + for (let cycle = 0; cycle < 20; cycle++) { + await delay(25); + global.gc!(); + } + manager.confirmAudioPlayed(message.payload.id); + })(); + } else { + setImmediate(() => manager.confirmAudioPlayed(message.payload.id)); + } + }, + new AbortController().signal, + true, + ); + console.log(JSON.stringify({ requests, emitted })); +} finally { + manager.cleanup(); + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} diff --git a/packages/server/src/server/speech/providers/openai/tts-playback.test.ts b/packages/server/src/server/speech/providers/openai/tts-playback.test.ts new file mode 100644 index 0000000000..b7b2224144 --- /dev/null +++ b/packages/server/src/server/speech/providers/openai/tts-playback.test.ts @@ -0,0 +1,21 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +test("prefetched speech survives garbage collection until its playback turn", () => { + const fixture = fileURLToPath(new URL("./test-utils/prefetched-playback.ts", import.meta.url)); + const output = execFileSync(process.execPath, ["--expose-gc", "--import", "tsx", fixture], { + encoding: "utf8", + timeout: 15_000, + }); + + const result = JSON.parse(output); + expect(result.requests.sort()).toEqual( + ["First sentence.", "Second sentence.", "Third sentence."].sort(), + ); + expect(result.emitted).toEqual([ + { bytes: 192_000, value: 1, format: "pcm" }, + { bytes: 192_000, value: 2, format: "pcm" }, + { bytes: 192_000, value: 3, format: "pcm" }, + ]); +}); diff --git a/packages/server/src/server/speech/providers/openai/tts.ts b/packages/server/src/server/speech/providers/openai/tts.ts index 31f7b5b263..49f43137ca 100644 --- a/packages/server/src/server/speech/providers/openai/tts.ts +++ b/packages/server/src/server/speech/providers/openai/tts.ts @@ -67,7 +67,9 @@ export class OpenAITTS implements TextToSpeechProvider { | "pcm", }); - const audioStream = response.body as unknown as Readable; + // Prefetched speech can wait through playback of an earlier sentence. Consume + // the response here so fetch cannot collect it and cancel its unread body. + const audioStream = Readable.from([Buffer.from(await response.arrayBuffer())]); const duration = Date.now() - startTime; this.logger.debug({ duration }, "Speech synthesis stream ready"); From 48d2c9ec36a026d4c8643c79fa8f2ec7b08fba7b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:54:08 +0200 Subject: [PATCH 52/62] Keep restored Claude background notifications from appearing twice (#7737) * fix(claude): restore each background task notification once Queued and delivered copies of one completion had different synthetic tool identities. Prefer their shared task ID so existing timeline projection folds them into one notification. * fix(claude): preserve separate notifications from one task A task can emit several Monitor events, including identical text. Assign replay identity per notification body and occurrence, pairing queue and delivery records before timeline projection instead of merging every event by task ID. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.request-journal.test.ts | 97 +++++++++++++++++++ .../claude/agent.sub-agent-replay.test.ts | 2 +- .../server/agent/providers/claude/agent.ts | 1 + .../task-notification-tool-call.test.ts | 13 +++ .../claude/task-notification-tool-call.ts | 69 +++++++++++-- .../providers/claude/transcript-history.ts | 30 +++--- 6 files changed, 192 insertions(+), 20 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts b/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts index 9be65da964..307365b5ac 100644 --- a/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.request-journal.test.ts @@ -7,6 +7,7 @@ import type { AgentTimelineItem } from "../../agent-sdk-types.js"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; +import { projectTimelineRows } from "../../timeline-projection.js"; describe("Claude session request journal replay", () => { let root: string; @@ -137,4 +138,100 @@ describe("Claude session request journal replay", () => { }), ]); }); + + test.each(["remove", "user"])( + "replays a background completion once across queue enqueue and %s records", + async (delivery) => { + const content = + "\nbg-1\ncompleted\nBackground command completed\n"; + const items = await replay([ + { type: "queue-operation", operation: "enqueue", content }, + delivery === "remove" + ? { type: "queue-operation", operation: "remove", content } + : { + type: "user", + uuid: "notification-delivery", + origin: { kind: "task-notification" }, + message: { role: "user", content }, + }, + ]); + const projected = projectTimelineRows({ + rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })), + mode: "projected", + }); + expect(projected.map((row) => row.item)).toEqual([ + expect.objectContaining({ + type: "tool_call", + name: "task_notification", + status: "completed", + metadata: expect.objectContaining({ taskId: "bg-1" }), + }), + ]); + }, + ); + test("restores each Monitor event and completion of one task once", async () => { + const notifications = ["1", "2", "3", "4"].map( + (event) => + `\nmonitor-1\nMonitor event\n${event}\n`, + ); + notifications.push( + "\nmonitor-1\ncompleted\nMonitor stream ended\n", + ); + const items = await replay( + notifications.flatMap((content, index) => [ + { type: "queue-operation", operation: "enqueue", content }, + { type: "user", uuid: `delivery-${index}`, message: { role: "user", content } }, + ]), + ); + const projected = projectTimelineRows({ + rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })), + mode: "projected", + }); + expect(projected.map(({ item }) => item.type === "tool_call" && item.detail)).toEqual( + notifications.map((text, index) => + expect.objectContaining({ + label: index === 4 ? "Monitor stream ended" : "Monitor event", + text, + }), + ), + ); + }); + test.each(["user", "remove", "system"])( + "preserves identical notifications from separate occurrences with %s deliveries", + async (delivery) => { + const content = + "\nmonitor-repeat\ncompleted\nMonitor event\n"; + const queued = [1, 2].map(() => ({ type: "queue-operation", operation: "enqueue", content })); + const delivered = [1, 2].map((index) => { + if (delivery === "user") { + return { type: "user", uuid: `repeat-${index}`, message: { role: "user", content } }; + } + if (delivery === "remove") { + return { type: "queue-operation", operation: "remove", content }; + } + return { + type: "system", + subtype: "task_notification", + uuid: `repeat-${index}`, + task_id: "monitor-repeat", + status: "completed", + summary: "Monitor event", + }; + }); + const items = await replay([ + ...queued, + ...delivered, + { type: "queue-operation", operation: "enqueue", content }, + ]); + const projected = projectTimelineRows({ + rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })), + mode: "projected", + }); + expect(projected.map(({ item }) => item.type === "tool_call" && item.detail?.label)).toEqual([ + "Monitor event", + "Monitor event", + "Monitor event", + ]); + }, + ); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 8f2ba60907..666e701649 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -834,7 +834,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => { .filter((item) => item.type === "tool_call" && item.name === "task_notification"), ).toEqual([ expect.objectContaining({ - callId: "task_notification_workflow-notification", + callId: expect.stringMatching(/^task_notification_[a-f0-9]+_1$/), status: "completed", }), ]); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 859e2d5f2e..a00f504f1a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -6313,6 +6313,7 @@ export function convertClaudeHistoryEntry( const userTaskNotificationItem = mapTaskNotificationUserContentToToolCall({ content, messageId: userMessageId, + notificationId: typeof entry.notificationId === "string" ? entry.notificationId : undefined, }); if (userTaskNotificationItem) { return [userTaskNotificationItem]; diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts index d538e14c29..18655a9d8d 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts @@ -97,4 +97,17 @@ describe("task-notification-tool-call", () => { expect(item).toBeNull(); }); + + it("keeps notifications without task IDs distinct by their message IDs", () => { + const items = ["message-1", "message-2"].map((messageId) => + mapTaskNotificationUserContentToToolCall({ + content: "completed", + messageId, + }), + ); + expect(items.map((item) => item?.callId)).toEqual([ + "task_notification_message-1", + "task_notification_message-2", + ]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts index 2db4beddb0..b269be70eb 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts @@ -12,6 +12,7 @@ const OptionalNonEmptyTrimmedStringSchema = z.preprocess( ); const TaskNotificationEnvelopeSchema = z.object({ + notificationId: z.string().optional(), messageId: z.string().nullable(), taskId: z.string().nullable(), toolUseId: z.string().nullable(), @@ -35,7 +36,9 @@ const TaskNotificationUserContentSchema = z.union([ const TaskNotificationHistoryRecordSchema = z .object({ + notificationId: z.string().optional(), type: z.string().optional(), + operation: z.string().optional(), subtype: z.string().optional(), uuid: z.string().optional(), message_id: z.string().optional(), @@ -52,6 +55,7 @@ const TaskNotificationHistoryRecordSchema = z const MapTaskNotificationUserContentToToolCallInputSchema = z.object({ content: z.unknown(), messageId: z.string().optional().nullable(), + notificationId: z.string().optional(), }); export type TaskNotificationEnvelope = z.infer; @@ -155,6 +159,7 @@ function parseTaskNotificationFromUserContent( } return TaskNotificationEnvelopeSchema.parse({ + notificationId: parsedInput.data.notificationId, messageId: toNonEmptyString(parsedInput.data.messageId), taskId: readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }), toolUseId: @@ -186,6 +191,7 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio const rawText = toNonEmptyString(systemRecord.content); return TaskNotificationEnvelopeSchema.parse({ + notificationId: systemRecord.notificationId, messageId: toNonEmptyString(systemRecord.uuid) ?? toNonEmptyString(systemRecord.message_id), taskId: toNonEmptyString(systemRecord.task_id) ?? @@ -212,25 +218,76 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio }); } +/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery + * are separate records of the same occurrence; each source counts repeated identical bodies + * independently so the second event is not merged with the first. */ +export function* assignTaskNotificationReplayIds(entries: Iterable): Generator { + const occurrences = new Map>(); + for (const entry of entries) { + const record = TaskNotificationHistoryRecordSchema.safeParse(entry); + if (!record.success) { + yield entry; + continue; + } + const data = record.data; + const envelope = + data.type === "user" + ? parseTaskNotificationFromUserContent({ content: data.message?.content }) + : parseTaskNotificationFromSystemRecord(data); + if (!envelope?.taskId || !data.type) { + yield entry; + continue; + } + const signature = taskNotificationReplaySignature(envelope); + const source = data.type === "queue-operation" ? `queue-${data.operation}` : data.type; + const counts = occurrences.get(signature) ?? new Map(); + const occurrence = (counts.get(source) ?? 0) + 1; + counts.set(source, occurrence); + occurrences.set(signature, counts); + yield { ...entry, notificationId: `task_notification_${signature}_${occurrence}` }; + } +} + +function taskNotificationReplaySignature(envelope: TaskNotificationEnvelope): string { + // Known fields are canonical across XML user/queue records and structured system records. + // Keep the remaining body too: Monitor events share a summary but carry different s. + const remainder = (envelope.rawText ?? "") + .replace(/<\/?task-notification>/gi, "") + .replace( + /<(task-id|tool-use-id|tool_use_id|status|summary|output-file|output_file)>[\s\S]*?<\/\1>/gi, + "", + ) + .trim(); + return createHash("sha1") + .update( + JSON.stringify([ + envelope.taskId, + envelope.toolUseId, + envelope.status, + envelope.summary, + envelope.outputFile, + remainder, + ]), + ) + .digest("hex"); +} + function normalizeTaskNotificationCallIdSegment(segment: string): string | null { const normalized = segment.trim().replace(/[^a-zA-Z0-9._:-]+/g, "_"); return normalized.length > 0 ? normalized : null; } function buildTaskNotificationCallId(envelope: TaskNotificationEnvelope): string { + if (envelope.notificationId) return envelope.notificationId; const messageSegment = envelope.messageId ? normalizeTaskNotificationCallIdSegment(envelope.messageId) : null; - if (messageSegment) { - return `task_notification_${messageSegment}`; - } + if (messageSegment) return `task_notification_${messageSegment}`; const taskSegment = envelope.taskId ? normalizeTaskNotificationCallIdSegment(envelope.taskId) : null; - if (taskSegment) { - return `task_notification_${taskSegment}`; - } + if (taskSegment) return `task_notification_${taskSegment}`; const seed = [envelope.status, envelope.summary, envelope.outputFile, envelope.rawText] diff --git a/packages/server/src/server/agent/providers/claude/transcript-history.ts b/packages/server/src/server/agent/providers/claude/transcript-history.ts index cba606b0e4..f8663f83d0 100644 --- a/packages/server/src/server/agent/providers/claude/transcript-history.ts +++ b/packages/server/src/server/agent/providers/claude/transcript-history.ts @@ -1,3 +1,4 @@ +import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js"; import fs from "node:fs"; import path from "node:path"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; @@ -88,21 +89,24 @@ function indexedRecords( ): Iterable { return { *[Symbol.iterator]() { - for (const location of locations) { - const fd = descriptors.get(location.file)!; - const buffer = Buffer.allocUnsafe(location.length); - let read = 0; - while (read < buffer.length) { - const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read); - if (count === 0) - throw new Error(`Claude transcript was truncated during replay: ${location.file}`); - read += count; - } - const entry = parseRecord(buffer.toString("utf8")); - if (entry) yield entry; - } + yield* assignTaskNotificationReplayIds(readEntries()); }, }; + function* readEntries(): Generator { + for (const location of locations) { + const fd = descriptors.get(location.file)!; + const buffer = Buffer.allocUnsafe(location.length); + let read = 0; + while (read < buffer.length) { + const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read); + if (count === 0) + throw new Error(`Claude transcript was truncated during replay: ${location.file}`); + read += count; + } + const entry = parseRecord(buffer.toString("utf8")); + if (entry) yield entry; + } + } } function isToolResult(entry: TranscriptEntry): boolean { From 632a7f885958f318ee9e0619b3af075d4798bf55 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Sat, 24 Oct 2026 07:05:57 +0800 Subject: [PATCH 53/62] fix(app): keep live workspace rows when a late creation snapshot arrives (#7758) A workspace.create.response carries the descriptor captured at provision time but is sent after the initial agent starts. A pin or title change made in between reaches the app first as a live update; the late response then replaced that row with the older snapshot, so a new pin vanished from the sidebar until the next directory update. Creation and progress snapshots now only seed rows the replica has not received; existing rows stay owned by directory updates. Archive rollback still restores a row that was removed. Closes #5447 Co-authored-by: Claude Opus 5.5 --- .../browser/new-workspace-live-pin.spec.ts | 100 ++++++++++++++++ .../directory-sync/workspace-replica.test.ts | 112 ++++++++++++++++++ .../directory-sync/workspace-replica.ts | 7 +- 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/browser/new-workspace-live-pin.spec.ts diff --git a/packages/app/e2e/browser/new-workspace-live-pin.spec.ts b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts new file mode 100644 index 0000000000..3ed0755fc9 --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-live-pin.spec.ts @@ -0,0 +1,100 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + delayBrowserAgentCreatedStatus, + openNewWorkspaceComposer, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +// The creation response carries the descriptor captured when the workspace was provisioned. With an +// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's +// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The +// late response must not put the old, unpinned row back. + +function pinnedRowTestId(workspaceId: string): string { + return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; +} + +async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise { + let createdId: string | undefined; + await expect + .poll( + async () => { + const { entries } = await seeded.client.fetchWorkspaces({ + filter: { projectId: seeded.projectId }, + }); + createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id; + return createdId; + }, + { timeout: 30_000 }, + ) + .toBeTruthy(); + return createdId!; +} + +// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts. +async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise> { + await page.evaluate((rowTestId) => { + const state = { missingFrames: 0, stopped: false }; + (window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state; + const sample = () => { + if (state.stopped) return; + const section = document.querySelector('[data-testid="sidebar-pinned-section"]'); + if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1; + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }, testId); + return () => + page.evaluate(() => { + const state = ( + window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } } + ).__pinnedRowWatch; + state.stopped = true; + return state.missingFrames; + }); +} + +test.describe("New workspace live pin", () => { + test("keeps a pin made before the creation response arrives", async ({ page }) => { + test.setTimeout(120_000); + const creation = await delayBrowserAgentCreatedStatus(page); + const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, seeded); + await submitNewWorkspacePrompt(page, "Pin me while I start"); + + await creation.waitForCreateRequest(); + // The response is held here, carrying the descriptor from before the pin. + await creation.waitForDelayedCreatedStatus(); + const workspaceId = await findCreatedWorkspaceId(seeded); + const rowTestId = pinnedRowTestId(workspaceId); + + await seeded.client.setWorkspacePinned(workspaceId, true); + const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId); + await expect(pinnedRow).toBeVisible({ timeout: 10_000 }); + + const stopWatching = await watchPinnedRow(page, rowTestId); + creation.release(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({ + timeout: 30_000, + }); + // Leave time for the released response to be merged and rendered. + await page.waitForTimeout(1_000); + + expect(await stopWatching()).toBe(0); + await expect(pinnedRow).toBeVisible(); + } finally { + creation.release(); + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index c9a674f3b3..792c005100 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -266,3 +266,115 @@ it("setup metadata from directory snapshots and transitions resets failure surfa expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(false); expect(useWorkspaceSetupStore.getState().snapshots).toEqual({}); }); + +it("keeps a live workspace update when an older creation snapshot arrives", () => { + const serverId = "workspace-create-after-pin"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const initial = workspace("new"); + const pinned = { + ...initial, + pinnedAt: "2026-09-26T00:00:00.000Z", + title: "Updated title", + }; + + try { + replica.applyDelta({ kind: "upsert", workspace: pinned }); + const accepted = useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id); + + expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(initial)])).toEqual([]); + expect(replica.snapshot().workspaces.get(initial.id)).toEqual( + normalizeWorkspaceDescriptor(pinned), + ); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toBe( + accepted, + ); + } finally { + store.clearSession(serverId); + } +}); + +it("seeds missing workspaces before live updates and in a mixed creation batch", () => { + const serverId = "workspace-create-before-pin"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const initial = normalizeWorkspaceDescriptor(workspace("new")); + const second = normalizeWorkspaceDescriptor(workspace("second")); + const pinned = { ...workspace(initial.id), pinnedAt: "2026-09-26T00:00:00.000Z" }; + + try { + expect(replica.acceptWorkspaces([initial])).toEqual([ + { kind: "workspace", type: "upsert", id: initial.id, value: initial }, + ]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual( + initial, + ); + + replica.applyDelta({ kind: "upsert", workspace: pinned }); + expect(replica.acceptWorkspaces([initial, second])).toEqual([ + { kind: "workspace", type: "upsert", id: second.id, value: second }, + ]); + expect([...useSessionStore.getState().sessions[serverId]!.workspaces.values()]).toEqual([ + normalizeWorkspaceDescriptor(pinned), + second, + ]); + } finally { + store.clearSession(serverId); + } +}); + +it("keeps a manual unpin when a stale pinned snapshot arrives", () => { + const serverId = "workspace-manual-unpin"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const pinned = { ...workspace("new"), pinnedAt: "2026-09-26T00:00:00.000Z" }; + const unpinned = { ...pinned, pinnedAt: null }; + + try { + replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)]); + replica.applyDelta({ kind: "upsert", workspace: unpinned }); + expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)])).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(pinned.id)).toEqual( + normalizeWorkspaceDescriptor(unpinned), + ); + } finally { + store.clearSession(serverId); + } +}); + +it("restores a missing workspace after an archive failure without replacing a live row", () => { + const serverId = "workspace-archive-rollback"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const initial = normalizeWorkspaceDescriptor(workspace("new")); + + try { + replica.acceptWorkspaces([initial]); + markWorkspaceArchivePending({ serverId, workspaceId: initial.id }); + replica.removeWorkspaceSnapshot(initial.id); + expect(replica.acceptWorkspaces([initial])).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(initial.id)).toBe(false); + + clearWorkspaceArchivePending({ serverId, workspaceId: initial.id }); + expect(replica.acceptWorkspaces([initial])).toEqual([ + { kind: "workspace", type: "upsert", id: initial.id, value: initial }, + ]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual( + initial, + ); + + const updated = { ...workspace(initial.id), title: "Updated during archive" }; + replica.applyDelta({ kind: "upsert", workspace: updated }); + expect(replica.acceptWorkspaces([initial])).toEqual([]); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual( + normalizeWorkspaceDescriptor(updated), + ); + } finally { + clearWorkspaceArchivePending({ serverId, workspaceId: initial.id }); + store.clearSession(serverId); + } +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index 2eb107ac4f..8f21ebd214 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -82,7 +82,12 @@ export class WorkspaceDirectoryReplica { } acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] { - return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace)); + // Creation responses and progress snapshots can arrive after live updates. + // They seed missing rows; the directory owns rows it has already received. + return workspaces.flatMap((workspace) => { + if (this.workspaces.has(workspace.id)) return []; + return this.upsertWorkspace(workspace); + }); } acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] { From 48623801a1bba8f1c259212d37f56a504812e42d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:01:19 +0200 Subject: [PATCH 54/62] fix(app): keep tablet sidebar controls above system navigation (#7768) The wide sidebar reserved only the top safe area, leaving its footer under Android navigation UI. Apply the existing bottom inset to the same interior and add a native geometry regression check. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../mobile/sidebar-system-insets/android.mjs | 40 +++++++++++++++++++ packages/app/src/components/left-sidebar.tsx | 7 +++- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 packages/app/e2e/mobile/sidebar-system-insets/android.mjs diff --git a/packages/app/e2e/mobile/sidebar-system-insets/android.mjs b/packages/app/e2e/mobile/sidebar-system-insets/android.mjs new file mode 100644 index 0000000000..cf7c45fbb0 --- /dev/null +++ b/packages/app/e2e/mobile/sidebar-system-insets/android.mjs @@ -0,0 +1,40 @@ +// Open a connected host with the wide sidebar visible, keyboard closed, and +// three-button navigation enabled on an Android tablet. Run against a fresh build: +// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; + +const [, , session, serial] = process.argv; +assert(session && serial, "Pass the agent-device session and adb serial"); +const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], { + encoding: "utf8", + timeout: 10_000, +}); +const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/); +assert(navBar, "A visible bottom navigation bar is required"); +assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first"); +const navigationTop = Number(navBar[1]); +const { data } = JSON.parse( + execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], { + encoding: "utf8", + timeout: 60_000, + }), +); +const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1]; +assert.equal( + focusedApp, + data.appBundleId, + "Snapshot must belong to the focused app on this device", +); +for (const id of [ + "sidebar-add-project", + "sidebar-hosts-trigger", + "sidebar-help", + "sidebar-settings", +]) { + const button = data.nodes.find((node) => node.identifier === id); + assert(button?.rect, `${id} must be visible above the system navigation bar`); + const bottom = button.rect.y + button.rect.height; + assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`); + console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`); +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 3f5d218150..dbc501b5c7 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -719,8 +719,11 @@ function DesktopSidebar({ [active, resizeAnimatedStyle], ); const desktopSidebarBorderStyle = useMemo( - () => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }], - [insetsTop], + () => [ + styles.desktopSidebarBorder, + { flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom }, + ], + [insetsTop, insets.bottom], ); const sidebarHeaderGroupStyle = useMemo( () => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome], From 686588c44be479ee3b5de04ed576ae0a97d3c915 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 24 Oct 2026 09:06:05 +0700 Subject: [PATCH 55/62] feat(app): restore manual workspace sorting (#7771) Keep manual workspace order separate from automatic sorting so surviving pre-beta arrangements and subsequent drags remain available across mode changes. Created stays the default; data already discarded by the beta cannot be reconstructed. --- .../app/e2e/browser/sidebar-reorder.spec.ts | 59 ++++++++- .../browser/sidebar-sort-old-daemon.spec.ts | 3 + .../src/components/sidebar-workspace-list.tsx | 115 ++++++++++++++++-- .../sidebar/display-preferences/menu.tsx | 39 ++++-- .../sidebar-workspaces-view-model.test.ts | 19 ++- .../hooks/sidebar-workspaces-view-model.ts | 42 ++++++- .../src/hooks/use-sidebar-workspaces-list.ts | 11 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../app/src/projects/workspace-structure.ts | 2 +- .../src/stores/session-store-hooks/index.ts | 6 +- .../session-store-hooks/selectors.test.ts | 24 ++++ .../stores/session-store-hooks/selectors.ts | 17 ++- .../src/stores/sidebar-order-store.test.ts | 45 ++++++- .../app/src/stores/sidebar-order-store.ts | 74 ++++++++++- .../app/src/stores/sidebar-view-store.test.ts | 4 + packages/app/src/stores/sidebar-view-store.ts | 2 +- 24 files changed, 435 insertions(+), 36 deletions(-) diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts index f6bf4d3f1b..03ffe9f7db 100644 --- a/packages/app/e2e/browser/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi } }); -async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") { +async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") { await page.getByLabel("Display preferences", { exact: true }).click(); await page.getByRole("menuitem", { name: /^Sort/ }).click(); await page.getByRole("menuitem", { name, exact: true }).click(); @@ -362,3 +362,60 @@ test("compact groups retain active overflow and reveal the current ordered rows" await fixture.seed.cleanup(); } }); + +async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) { + await page.evaluate( + ({ projectKey: scope, workspaceKeys: order }) => { + localStorage.setItem( + "sidebar-project-workspace-order", + JSON.stringify({ + version: 1, + state: { + projectOrder: [scope], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: { [scope]: order }, + }, + }), + ); + }, + { projectKey, workspaceKeys }, + ); + await reconnectSidebar(page); +} + +test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({ + page, +}) => { + const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" }); + try { + const second = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: "New manual workspace", + }); + if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed"); + const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`); + const ids = keys.map((key) => `sidebar-workspace-row-${key}`); + const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", ")); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + await selectWorkspaceSort(page, "Created"); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await selectWorkspaceSort(page, "Last prompt"); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow); + await reconnectSidebar(page); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow); + await selectWorkspaceSort(page, "Created"); + await reconnectSidebar(page); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + } finally { + await seed.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts index ba210131db..757b8908c2 100644 --- a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts +++ b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts @@ -72,6 +72,9 @@ async function expectSortingRequiresHostUpdate(page: Page) { ).toBeDisabled(); await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0); await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0); + await page.getByRole("menuitem", { name: "Manual", exact: true }).click(); + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual"); await page.keyboard.press("Escape"); } diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index cc248adeff..c3048e6d9a 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1550,6 +1550,11 @@ function ProjectBlock({ shortcutIndexByWorkspaceKey, onToggleCollapsed, onWorkspacePress, + parentGestureRef, + onWorkspaceReorder, + useNestable, + dragGestureHostActive, + manualSort, onWorktreeCreated, drag, isDragging, @@ -1571,6 +1576,11 @@ function ProjectBlock({ shortcutIndexByWorkspaceKey: Map; onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; + parentGestureRef?: MutableRefObject; + onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; + useNestable: boolean; + dragGestureHostActive?: boolean; + manualSort: boolean; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; @@ -1624,7 +1634,14 @@ function ProjectBlock({ }); const renderWorkspaceRow = useCallback( - (item: SidebarWorkspacePlacement) => { + ( + item: SidebarWorkspacePlacement, + input?: { + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + }, + ) => { return ( ); }, @@ -1658,6 +1678,29 @@ function ProjectBlock({ ], ); + const renderWorkspace = useCallback( + ({ + item, + drag: workspaceDrag, + isActive, + dragHandleProps: workspaceDragHandleProps, + }: DraggableRenderItemInfo) => { + return renderWorkspaceRow(item, { + drag: workspaceDrag, + isDragging: isActive, + dragHandleProps: workspaceDragHandleProps, + }); + }, + [renderWorkspaceRow], + ); + + const handleWorkspaceDragEnd = useCallback( + (workspaces: SidebarWorkspacePlacement[]) => { + onWorkspaceReorder(project.viewKey, workspaces); + }, + [onWorkspaceReorder, project.viewKey], + ); + const toast = useToast(); const { t } = useTranslation(); const [isRemovingProject, setIsRemovingProject] = useState(false); @@ -1723,12 +1766,29 @@ function ProjectBlock({ if (project.workspaces.length > 0) { projectChildren = ( <> - - {visibleWorkspaces.map(renderWorkspaceRow)} - + {manualSort ? ( + + ) : ( + + {visibleWorkspaces.map((item) => renderWorkspaceRow(item))} + + )} {canToggleWorkspaces ? ( state.togglePinnedCollapsed, ); + const manualSort = useSidebarViewStore((state) => state.workspaceSort === "manual"); + const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); + const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); @@ -2211,6 +2279,30 @@ function ProjectModeList({ [getProjectOrder, setProjectOrder], ); + const handleWorkspaceReorder = useCallback( + (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { + const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); + const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey); + if ( + !hasVisibleOrderChanged({ + currentOrder: currentWorkspaceOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }) + ) { + return; + } + + setWorkspaceOrder( + projectViewKey, + mergeWithRemainder({ + currentOrder: currentWorkspaceOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }), + ); + }, + [getWorkspaceOrder, setWorkspaceOrder], + ); + const handleWorktreeCreated = useCallback((workspaceId: string) => { setCreatingWorkspaceIds((current) => { const next = new Set(current); @@ -2259,6 +2351,11 @@ function ProjectModeList({ shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} onToggleCollapsed={onToggleProjectCollapsed} onWorkspacePress={onWorkspacePress} + manualSort={manualSort} + parentGestureRef={parentGestureRef} + onWorkspaceReorder={handleWorkspaceReorder} + useNestable={platformIsNative} + dragGestureHostActive={dragGestureHostActive} onWorktreeCreated={handleWorktreeCreated} drag={dragState.drag} isDragging={dragState.isDragging} @@ -2276,6 +2373,10 @@ function ProjectModeList({ collapsedProjectKeys, activeWorkspaceSelection, handleWorktreeCreated, + manualSort, + parentGestureRef, + dragGestureHostActive, + handleWorkspaceReorder, hostBadgeByServerId, supportsMultiplicityByServerId, supportsPinningByServerId, diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index 4fb04ebb67..d6483a5519 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -244,10 +244,8 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { definitions.push({ id: "sort", title: t("sidebar.display.sort.label"), - content: workspaceSortSupported ? ( - - ) : ( - {t("sidebar.display.sort.updateHost")} + content: ( + ), }); } @@ -325,7 +323,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { setWorkspaceSort("manual"), [setWorkspaceSort]); const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]); const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]); return ( <> - - {t("sidebar.display.sort.created")} - - - {t("sidebar.display.sort.lastPrompt")} + + {t("sidebar.display.sort.manual")} + {automaticSortSupported ? ( + <> + + {t("sidebar.display.sort.created")} + + + {t("sidebar.display.sort.lastPrompt")} + + + ) : ( + {t("sidebar.display.sort.updateHost")} + )} ); } diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 59648e237b..7aa85c1faf 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -617,7 +617,7 @@ describe("computeSidebarOrderUpdates", () => { persistedProjectOrder: ["stale-project"], }); - expect(updates).toEqual({ projectOrder: null }); + expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] }); }); it("appends unseen projects without changing saved project order", () => { @@ -979,3 +979,20 @@ it("retains unrelated projects and keyed row placements when one project reorder expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]); expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]); }); + +it("prepends newly discovered manual workspaces without dropping filtered saved keys", () => { + const projects = [sidebarProject({ projectKey: "p", workspaceKeys: ["new", "b", "a"] })]; + expect( + computeSidebarOrderUpdates({ + projects, + persistedProjectOrder: ["p"], + getWorkspaceOrder: () => ["srv:hidden", "srv:a", "srv:b"], + }), + ).toEqual({ + projectOrder: null, + workspaceOrders: [{ projectViewKey: "p", order: ["srv:new", "srv:hidden", "srv:a", "srv:b"] }], + }); + expect( + computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["p"] }).workspaceOrders, + ).toEqual([]); +}); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 3c4ce4e2da..da1086da45 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -535,21 +535,57 @@ export function appendMissingOrderKeys(input: { return [...input.currentOrder, ...missingKeys]; } +export function prependMissingOrderKeys(input: { + currentOrder: string[]; + visibleKeys: string[]; +}): string[] { + if (input.visibleKeys.length === 0) { + return input.currentOrder; + } + + const existingKeys = new Set(input.currentOrder); + const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); + if (missingKeys.length === 0) { + return input.currentOrder; + } + + return [...missingKeys, ...input.currentOrder]; +} + export interface SidebarOrderUpdates { projectOrder: string[] | null; + workspaceOrders: Array<{ projectViewKey: string; order: string[] }>; } export function computeSidebarOrderUpdates(input: { projects: SidebarProjectEntry[]; persistedProjectOrder: string[]; + getWorkspaceOrder?: (projectViewKey: string) => string[]; }): SidebarOrderUpdates { + if (input.projects.length === 0) { + return { projectOrder: null, workspaceOrders: [] }; + } + const nextProjectOrder = appendMissingOrderKeys({ currentOrder: input.persistedProjectOrder, visibleKeys: input.projects.map((project) => project.viewKey), }); - return { - projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder, - }; + const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; + + const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = []; + for (const project of input.projects) { + if (!input.getWorkspaceOrder) break; + const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey); + const nextWorkspaceOrder = prependMissingOrderKeys({ + currentOrder: persistedWorkspaceOrder, + visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), + }); + if (nextWorkspaceOrder !== persistedWorkspaceOrder) { + workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder }); + } + } + + return { projectOrder, workspaceOrders }; } export interface SidebarLoadingState { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 5665b07e11..378c4b4862 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -144,7 +144,10 @@ export function useSidebarWorkspacesList(options?: { const groupMode = useSidebarViewStore((state) => state.groupMode); const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes"); const workspaceSortSupported = [...sortSupport.values()].every(Boolean); - const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined; + const sort = + groupMode === "project" && (workspaceSort === "manual" || workspaceSortSupported) + ? workspaceSort + : undefined; const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort); const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []); @@ -166,12 +169,16 @@ export function useSidebarWorkspacesList(options?: { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder, + getWorkspaceOrder: sort === "manual" ? orderStore.getWorkspaceOrder : undefined, }); if (updates.projectOrder) { orderStore.setProjectOrder(updates.projectOrder); } - }, [persistedProjectOrder, projects]); + for (const update of updates.workspaceOrders) { + orderStore.setWorkspaceOrder(update.projectViewKey, update.order); + } + }, [persistedProjectOrder, projects, sort]); const refreshAll = useCallback(() => { if (!isActive) return; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 16779d95e1..c765f3b835 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1169,6 +1169,7 @@ export const ar: TranslationResources = { sort: { label: "الترتيب", created: "تاريخ الإنشاء", + manual: "يدوي", lastPrompt: "آخر طلب", updateHost: "حدّث المضيفين لترتيب مساحات العمل", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 6471b03f60..351fb33ac3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1177,6 +1177,7 @@ export const en = { sort: { label: "Sort", created: "Created", + manual: "Manual", lastPrompt: "Last prompt", updateHost: "Update hosts to sort workspaces", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 82ab9e6977..fd195ce599 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1205,6 +1205,7 @@ export const es: TranslationResources = { sort: { label: "Orden", created: "Creación", + manual: "Manual", lastPrompt: "Último prompt", updateHost: "Actualiza los hosts para ordenar los espacios de trabajo", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 44aae87628..975d6a3736 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1198,6 +1198,7 @@ export const fr: TranslationResources = { sort: { label: "Tri", created: "Création", + manual: "Manuel", lastPrompt: "Dernier prompt", updateHost: "Mettez les hôtes à jour pour trier les espaces de travail", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index baa248aa31..60a207e1e1 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1182,6 +1182,7 @@ export const ja: TranslationResources = { sort: { label: "並び順", created: "作成日時", + manual: "手動", lastPrompt: "最後のプロンプト", updateHost: "ワークスペースを並べ替えるにはホストを更新してください", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index a573ef1ffa..4773625327 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1176,6 +1176,7 @@ export const ko: TranslationResources = { sort: { label: "정렬", created: "생성일", + manual: "수동", lastPrompt: "마지막 프롬프트", updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 43c32d2914..766c6d98ed 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1195,6 +1195,7 @@ export const ptBR: TranslationResources = { sort: { label: "Ordenação", created: "Criação", + manual: "Manual", lastPrompt: "Último prompt", updateHost: "Atualize os hosts para ordenar os espaços de trabalho", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 586ad8595d..23f3d810ee 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1186,6 +1186,7 @@ export const ru: TranslationResources = { sort: { label: "Сортировка", created: "Дата создания", + manual: "Вручную", lastPrompt: "Последний запрос", updateHost: "Обновите хосты для сортировки рабочих пространств", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 08bb8491cd..43d8ceeef2 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1161,6 +1161,7 @@ export const zhCN: TranslationResources = { sort: { label: "排序", created: "创建时间", + manual: "手动", lastPrompt: "最近提示词", updateHost: "请更新主机以排序工作区", }, diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 92e6c68de4..0dac64b371 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -30,7 +30,7 @@ interface WorkspaceStructureSession { workspaces: Iterable; } -export type WorkspaceSort = "created" | "lastPrompt"; +export type WorkspaceSort = "manual" | "created" | "lastPrompt"; interface WorkspaceSortItem { workspaceName: string; diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 48c5594f21..3dba13bc80 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -117,13 +117,17 @@ export function useWorkspaceStructure( (state) => selectProjectOrder(state), workspaceEqualityFns.deep, ); + const workspaceOrderByProject = useSidebarOrderStore((state) => + workspaceSort === "manual" ? state.workspaceOrderByProject : undefined, + ); return useMemo( () => composeWorkspaceStructure({ projects, projectOrder, + workspaceOrderByProject, }), - [projectOrder, projects], + [projectOrder, projects, workspaceOrderByProject], ); } diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index a9c9d07772..95cc1ba498 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -640,3 +640,27 @@ it("a prompt changes only its project's structure and output preserves both proj expect(output[0]).toBe(after[0]); expect(output[1]).toBe(after[1]); }); + +it("applies manual order only when requested without mutating the automatic baseline", () => { + const project = { + viewKey: "p", + projectKey: "p", + projectName: "P", + projectKind: "git" as const, + iconWorkingDir: "/repo", + hosts: [], + workspaceKeys: ["h:new", "h:b", "h:a"], + }; + const input = { projects: [project], projectOrder: ["p"] }; + expect( + composeWorkspaceStructure({ + ...input, + workspaceOrderByProject: { p: ["h:filtered", "h:a", "h:b"] }, + }).projects[0].workspaceKeys, + ).toEqual(["h:new", "h:a", "h:b"]); + expect(composeWorkspaceStructure(input).projects[0].workspaceKeys).toEqual([ + "h:new", + "h:b", + "h:a", + ]); +}); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index 6c1100edd6..c131fa7a3a 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -254,6 +254,7 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] { export function composeWorkspaceStructure(input: { projects: WorkspaceStructureProject[]; projectOrder: readonly string[]; + workspaceOrderByProject?: Readonly>; }): WorkspaceStructure { if (input.projects.length === 0) { return EMPTY_WORKSPACE_STRUCTURE; @@ -265,7 +266,21 @@ export function composeWorkspaceStructure(input: { getKey: (project) => project.viewKey, }); - return { projects: orderedProjects }; + return { + // Keep the shared automatic projection immutable when applying a sidebar preference. + // eslint-disable-next-line oxc/no-map-spread + projects: orderedProjects.map((project) => { + const storedOrder = input.workspaceOrderByProject?.[project.viewKey]; + if (!storedOrder) return project; + const orderedProject = { ...project }; + orderedProject.workspaceKeys = applyStoredOrdering({ + items: project.workspaceKeys, + storedOrder, + getKey: (key) => key, + }); + return orderedProject; + }), + }; } export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] { diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts index b34fb02240..b288089742 100644 --- a/packages/app/src/stores/sidebar-order-store.test.ts +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -6,7 +6,7 @@ import { import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store"; describe("migrateSidebarOrderState", () => { - it("discards legacy workspace ordering while retaining project ordering", () => { + it("preserves legacy workspace ordering across hosts", () => { const migrated = migrateSidebarOrderState({ projectOrderByServerId: { "host-a": ["project-a"], @@ -21,6 +21,7 @@ describe("migrateSidebarOrderState", () => { expect(migrated).toEqual({ projectOrder: ["project-a"], pinnedWorkspaceOrder: [], + workspaceOrderByProject: { "project-a": ["host-a:main", "host-a:feature", "host-b:main"] }, }); }); @@ -88,14 +89,18 @@ describe("sidebar order keys that end in whitespace", () => { }); }); -it("preserves project and pinned order while discarding ordinary workspace order", () => { +it("preserves project, pinned and ordinary workspace order", () => { expect( migrateSidebarOrderState({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"], workspaceOrderByProject: { a: ["h:old", "h:new"] }, }), - ).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] }); + ).toEqual({ + projectOrder: ["b", "a"], + pinnedWorkspaceOrder: ["h:y", "h:x"], + workspaceOrderByProject: { a: ["h:old", "h:new"] }, + }); }); it("preserves whitespace in current project and pin identities during upgrade", () => { @@ -105,5 +110,37 @@ it("preserves whitespace in current project and pin identities during upgrade", pinnedWorkspaceOrder: ["host:workspace "], workspaceOrderByProject: {}, }), - ).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] }); + ).toEqual({ + projectOrder: ["project "], + pinnedWorkspaceOrder: ["host:workspace "], + workspaceOrderByProject: {}, + }); +}); + +it("retains a pre-beta manual order when upgrading", () => { + expect( + migrateSidebarOrderState({ + projectOrder: ["project"], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: { project: ["host:b", "host:a"] }, + }), + ).toEqual({ + projectOrder: ["project"], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: { project: ["host:b", "host:a"] }, + }); +}); + +it("starts with an empty manual order for beta users whose order was discarded", () => { + expect(migrateSidebarOrderState({ projectOrder: ["p"], pinnedWorkspaceOrder: ["h:a"] })).toEqual({ + projectOrder: ["p"], + pinnedWorkspaceOrder: ["h:a"], + workspaceOrderByProject: {}, + }); +}); +it("preserves spaces in saved workspace scopes and identities", () => { + expect( + migrateSidebarOrderState({ workspaceOrderByProject: { "project ": ["host:b ", "host:a "] } }) + .workspaceOrderByProject, + ).toEqual({ "project ": ["host:b ", "host:a "] }); }); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 54f27e714f..44e3887620 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -7,10 +7,13 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora interface SidebarOrderStoreState { projectOrder: string[]; pinnedWorkspaceOrder: string[]; + workspaceOrderByProject: Record; getProjectOrder: () => string[]; setProjectOrder: (keys: string[]) => void; getPinnedWorkspaceOrder: () => string[]; setPinnedWorkspaceOrder: (keys: string[]) => void; + getWorkspaceOrder: (projectViewKey: string) => string[]; + setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; } interface SidebarOrderPersistedState { @@ -25,12 +28,16 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); const SidebarOrderPersistedStateSchema = z.strictObject({ projectOrder: z.array(z.string()).optional(), pinnedWorkspaceOrder: z.array(z.string()).optional(), - // COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins. workspaceOrderByProject: StringArrayRecordSchema.optional(), projectOrderByServerId: StringArrayRecordSchema.optional(), workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), }); +interface SidebarWorkspaceOrderScope { + serverId: string; + projectViewKey: string; +} + // Trims each key. Only for persisted state read at migration time, where a // stray space is an artifact of an older format rather than part of the key. function normalizeKeys(keys: string[]): string[] { @@ -71,13 +78,42 @@ function dedupeKeys(keys: string[]): string[] { return deduped; } +function normalizeWorkspaceOrderByProject( + workspaceOrderByProject: Record | undefined, +): Record { + const normalized: Record = {}; + for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) { + const scope = projectViewKey; + if (!scope.trim()) continue; + normalized[scope] = dedupeKeys(order); + } + return normalized; +} + +function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null { + const separatorIndex = scopeKey.indexOf("::"); + if (separatorIndex < 0) return null; + const serverId = scopeKey.slice(0, separatorIndex).trim(); + const projectViewKey = scopeKey.slice(separatorIndex + 2).trim(); + if (!serverId || !projectViewKey) return null; + return { serverId, projectViewKey }; +} + +function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null { + const workspaceKey = rawWorkspaceKey.trim(); + if (!workspaceKey) return null; + const serverPrefix = `${serverId}:`; + return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`; +} + export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; pinnedWorkspaceOrder: string[]; + workspaceOrderByProject: Record; } { const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); if (!result.success) { - return { projectOrder: [], pinnedWorkspaceOrder: [] }; + return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} }; } const state: SidebarOrderPersistedState = result.data; @@ -91,9 +127,26 @@ export function migrateSidebarOrderState(persistedState: unknown): { } } + const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject); + for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) { + const scope = extractWorkspaceOrderScope(scopeKey); + if (!scope) continue; + const existing = workspaceOrderByProject[scope.projectViewKey] ?? []; + const merged = [...existing]; + const seen = new Set(merged); + for (const key of order) { + const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key); + if (!workspaceKey || seen.has(workspaceKey)) continue; + seen.add(workspaceKey); + merged.push(workspaceKey); + } + workspaceOrderByProject[scope.projectViewKey] = merged; + } + return { projectOrder, pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []), + workspaceOrderByProject, }; } @@ -102,6 +155,7 @@ export const useSidebarOrderStore = create()( (set, get) => ({ projectOrder: [], pinnedWorkspaceOrder: [], + workspaceOrderByProject: {}, getProjectOrder: () => get().projectOrder, setProjectOrder: (keys) => { set({ projectOrder: dedupeKeys(keys) }); @@ -110,6 +164,19 @@ export const useSidebarOrderStore = create()( setPinnedWorkspaceOrder: (keys) => { set({ pinnedWorkspaceOrder: dedupeKeys(keys) }); }, + getWorkspaceOrder: (projectViewKey) => { + if (!projectViewKey.trim()) return []; + return get().workspaceOrderByProject[projectViewKey] ?? []; + }, + setWorkspaceOrder: (projectViewKey, keys) => { + if (!projectViewKey.trim()) return; + set((state) => ({ + workspaceOrderByProject: { + ...state.workspaceOrderByProject, + [projectViewKey]: dedupeKeys(keys), + }, + })); + }, }), { name: "sidebar-project-workspace-order", @@ -117,8 +184,9 @@ export const useSidebarOrderStore = create()( partialize: (state) => ({ projectOrder: state.projectOrder, pinnedWorkspaceOrder: state.pinnedWorkspaceOrder, + workspaceOrderByProject: state.workspaceOrderByProject, }), - version: 2, + version: 3, migrate: migrateSidebarOrderState, }, ), diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts index a9e44e146b..4a277ae60d 100644 --- a/packages/app/src/stores/sidebar-view-store.test.ts +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -300,3 +300,7 @@ it("defaults to Created and persists the Last prompt choice across migration", ( migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }), ).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" }); }); + +it("retains the manual sorting preference on reload", () => { + expect(migrateSidebarViewState({ workspaceSort: "manual" }).workspaceSort).toBe("manual"); +}); diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 229e95f96b..1b53fdfe9d 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -91,7 +91,7 @@ const SidebarLabelFilterSchema = z.object({ labels: z.array(z.string()), }); const SidebarViewPersistedStateSchema = z.strictObject({ - workspaceSort: z.enum(["created", "lastPrompt"]).optional(), + workspaceSort: z.enum(["manual", "created", "lastPrompt"]).optional(), showBackground: z.boolean().optional(), groupMode: PersistedSidebarGroupModeSchema.optional(), hostFilters: z.array(z.string()).optional(), From f3d74b85759150bffc7a44ac40301d1cb280d66c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 24 Oct 2026 11:44:08 +0700 Subject: [PATCH 56/62] fix(plugins): restore deprecated useRevealedText compatibility (#7780) The 0.13 UI entry rename dropped the reveal hook from the 0.12 runtime. Keep it on the legacy entry until that entry is removed, with deprecated SDK typing and migration guidance. Closes #7577 Closes #7722 Co-authored-by: luke Co-authored-by: RoundRedDot Co-authored-by: Matt Cowger <1929548+mcowger@users.noreply.github.com> --- packages/app/src/plugins/evaluate.test.ts | 16 ++++++++++++ packages/app/src/plugins/evaluate.ts | 7 +++++- packages/plugin/src/boundaries.test.ts | 29 +++++++++++++++++----- packages/plugin/src/client/react-native.ts | 3 +++ public-docs/plugins/migration.md | 5 ++-- 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index c1f95b87fa..1aae11f63d 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { useRevealedText } from "@/hooks/use-revealed-text"; import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; const runtime = { @@ -705,6 +706,21 @@ describe("evaluatePluginClientBundle", () => { expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); }); + it("keeps the host reveal hook on the deprecated client/react-native entry", () => { + const plugin = evaluatePluginClientBundle( + "legacy-reveal", + bundle(` + const legacy = require("@getpaseo/plugin/client/react-native"); + const ui = require("@getpaseo/plugin/client/ui"); + if (typeof legacy.useRevealedText !== "function") throw new Error("Missing legacy reveal hook"); + if (legacy.StreamedText !== ui.StreamedText) throw new Error("Missing current UI exports"); + if ("useRevealedText" in ui) throw new Error("Legacy hook leaked into current UI"); + plugin.addScreen({ id: "reveal", title: "Reveal", Component: legacy.useRevealedText }); + `), + ); + expect(plugin.surfaces[0]?.Component).toBe(useRevealedText); + }); + it("keeps shared and client runtime exports separate", () => { expect(() => evaluatePluginClientBundle( diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 44bcbec57d..ea37209f57 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -2,6 +2,7 @@ import { requirePluginText } from "@getpaseo/plugin/client/host"; import type { createPluginHosts } from "./hosts"; import { openExternalUrl } from "@/utils/open-external-url"; import * as pluginUiRuntime from "./react-native/ui"; +import { useRevealedText } from "@/hooks/use-revealed-text"; import { useSettings } from "./settings/use-settings"; import * as pluginSharedRuntime from "@getpaseo/plugin"; import * as pluginClientRuntime from "@getpaseo/plugin/client"; @@ -114,6 +115,10 @@ export type PluginClientRuntime = Pick< | "addHeaderButton" > & { hosts: ReturnType }; +// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 +// The 0.12 entry exposed the reveal hook as well as UI components. +const pluginReactNativeCompatRuntime = { ...pluginUiRuntime, useRevealedText }; + export function runPluginClientBundle( id: string, bundle: string, @@ -503,7 +508,7 @@ export function runPluginClientBundle( }; const runtimeRequire = (name: string): unknown => { // COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 - if (name === "@getpaseo/plugin/client/react-native") return pluginUiRuntime; + if (name === "@getpaseo/plugin/client/react-native") return pluginReactNativeCompatRuntime; if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime; if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index e2547154bc..c9b54ecdb7 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -101,12 +101,29 @@ describe("plugin SDK import boundaries", () => { ); }); - it.each(compatibilityEntries)("%s only re-exports the UI entry", (entry) => { - const source = readFileSync( - path.resolve(sourceDirectory, "..", manifest.exports[entry].source), - "utf8", - ); - expect(source.replace(/\/\/[^\n]*|\/\*[^]*?\*\//g, "").trim()).toBe('export * from "./ui.js";'); + it.each(compatibilityEntries)("%s respects the client boundary", (entry) => { + expect( + boundaryViolations( + path.resolve(sourceDirectory, "..", manifest.exports[entry].source), + "client", + ), + ).toEqual([]); + }); + + it("typechecks a 0.12 reveal-hook consumer without exposing the hook on current UI", () => { + expect( + typeErrors(` + import { useRevealedText, Icon } from "@getpaseo/plugin/client/react-native"; + import { StreamedText } from "@getpaseo/plugin/client/ui"; + // @ts-expect-error the legacy hook is not part of the current UI API + import { useRevealedText as currentHook } from "@getpaseo/plugin/client/ui"; + import { Text } from "react-native"; + function LegacyRow({ text, phase }: { text: string; phase: "streaming" | "complete" }) { + const revealed: string = useRevealedText(text, phase); + return {revealed}; + } + `), + ).toEqual([]); }); it.each(Object.entries(entries))( diff --git a/packages/plugin/src/client/react-native.ts b/packages/plugin/src/client/react-native.ts index e1eb92f17b..13e8f88370 100644 --- a/packages/plugin/src/client/react-native.ts +++ b/packages/plugin/src/client/react-native.ts @@ -1,3 +1,6 @@ // COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 /** @deprecated Import Paseo UI from @getpaseo/plugin/client/ui. */ export * from "./ui.js"; + +/** @deprecated Render StreamedText from @getpaseo/plugin/client/ui. Removed after 2027-04-20 with this entry. */ +export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string; diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index f430dff92a..fdefda5971 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -176,8 +176,9 @@ its registration; that registration belongs in the client entry. ### UI imports in v0.13 -Use `/client/ui` for all Paseo UI components and UI hooks. The previous entry remains an -undocumented compatibility alias until 2027-04-20. +Use `/client/ui` for all Paseo UI components and UI hooks. The deprecated +`/client/react-native` entry retains `useRevealedText(text, phase): string` for 0.12 plugins +until removal after 2027-04-20. The hook is deprecated and is not exported from `/client/ui`. For paced timeline text, replace `useRevealedText(text, phase)` and the surrounding `Text` with `` from `/client/ui`. From c1b257b8f4bb389357a5006e1f217d7e383b40ff Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:55:07 +0200 Subject: [PATCH 57/62] fix(usage): show Cursor quota percentages without counting bonus spend as overage (#7778) * test: checkpoint usage helper before manual QA fixes * fix: show Cursor plan quotas and filter disabled usage providers * fix: display Cursor plan and capitalize Codex plan labels * fix: reconcile usage refreshes across account and provider changes * fix: keep usage cards in alphabetical order * docs: add usage fix QA evidence and provider contract * test: replace usage hook mocks with an injected client * fix: keep usage footer in alphabetical order (pair-review item 2) * refactor: remove usage provider enablement filtering --------- Co-authored-by: kb Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Stackie Jia --- .../server/usage-reported-pools.test.ts | 92 +++++++++++++++++++ plugins/cursor-usage-source/server/usage.ts | 28 +++++- 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 plugins/cursor-usage-source/server/usage-reported-pools.test.ts diff --git a/plugins/cursor-usage-source/server/usage-reported-pools.test.ts b/plugins/cursor-usage-source/server/usage-reported-pools.test.ts new file mode 100644 index 0000000000..04f672fcd3 --- /dev/null +++ b/plugins/cursor-usage-source/server/usage-reported-pools.test.ts @@ -0,0 +1,92 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { fetchUsage } from "./usage.js"; + +it("shows the reported Pro percentage pools without treating bonus spend as an overage", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-reported-pools-")); + const locator = join(directory, "auth.json"); + try { + await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" })); + const report = await fetchUsage({ store: "file", locator }, async () => + Response.json({ + billingCycleStart: "2026-09-25T07:10:08.000Z", + billingCycleEnd: "2026-10-25T07:10:08.000Z", + planUsage: { + totalSpend: 5717, + includedSpend: 2000, + bonusSpend: 3717, + limit: 2000, + autoPercentUsed: 9.157777777777778, + apiPercentUsed: 35.46666666666667, + totalPercentUsed: 11.54949494949495, + }, + spendLimitUsage: { individualLimit: 100, individualRemaining: 100, limitType: "user" }, + }), + ); + expect(report).toMatchObject({ + status: "available", + windows: [ + { id: "plan_usage", usedPct: 11.54949494949495 }, + { id: "cursor_usage", usedPct: 9.157777777777778 }, + { id: "third_party_usage", usedPct: 35.46666666666667 }, + ], + balances: [], + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it.each([ + ["Pro+", 42015, 7000, 34.583333333333336, 4.6818181818181825, 32.07251908396947], + ["Teams", 77145, 2000, 61.8352380952381, 61.09, 61.716], +] as const)( + "preserves the reported %s pools separately from bonus-inclusive dollars", + async (_plan, totalSpend, limit, autoPercentUsed, apiPercentUsed, totalPercentUsed) => { + const report = await reportFor({ + totalSpend, + limit, + autoPercentUsed, + apiPercentUsed, + totalPercentUsed, + }); + expect(report).toMatchObject({ + status: "available", + windows: [ + { id: "plan_usage", usedPct: totalPercentUsed, summary: true }, + { id: "cursor_usage", usedPct: autoPercentUsed }, + { id: "third_party_usage", usedPct: apiPercentUsed }, + ], + balances: [], + }); + }, +); + +it("retains a zero percentage without inventing absent breakdowns", async () => { + expect(await reportFor({ totalPercentUsed: "0", autoPercentUsed: null })).toMatchObject({ + status: "available", + windows: [{ id: "plan_usage", usedPct: 0, remainingPct: 100 }], + balances: [], + }); +}); + +it("keeps dollar-only reports when the response has no percentages", async () => { + expect(await reportFor({ totalSpend: 1500, remaining: 2500, limit: 4000 })).toMatchObject({ + status: "available", + windows: [], + balances: [{ id: "plan_usage", used: 15, remaining: 25, limit: 40, unit: "usd" }], + }); +}); + +async function reportFor(planUsage: Record) { + const directory = await mkdtemp(join(tmpdir(), "cursor-usage-pools-")); + const locator = join(directory, "auth.json"); + try { + await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" })); + return await fetchUsage({ store: "file", locator }, async () => Response.json({ planUsage })); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/plugins/cursor-usage-source/server/usage.ts b/plugins/cursor-usage-source/server/usage.ts index 2265dbb556..58d5b48ec2 100644 --- a/plugins/cursor-usage-source/server/usage.ts +++ b/plugins/cursor-usage-source/server/usage.ts @@ -9,10 +9,12 @@ import { toneFromUsedPct, usedPctOf, unavailable, + windowFromUsedPct, type UsageAccount, type UsageReport, type UsageBalance, type UsageScope, + type UsageWindow, } from "@getpaseo/plugin/server/usage"; const ApiNumberSchema = z.coerce.number().finite(); @@ -60,6 +62,9 @@ const CursorUsageResponseSchema = z.object({ bonusSpend: ApiNullableNumberSchema, remaining: ApiNullableNumberSchema, limit: ApiNullableNumberSchema, + totalPercentUsed: ApiNullableNumberSchema, + autoPercentUsed: ApiNullableNumberSchema, + apiPercentUsed: ApiNullableNumberSchema, }) .nullish(), billingCycleStart: CursorBillingCycleTimestampSchema, @@ -177,8 +182,29 @@ export async function fetchUsage( const resp = CursorUsageResponseSchema.parse(await res.json()); const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd); + const windows: UsageWindow[] = []; const balances: UsageBalance[] = []; if (resp.planUsage) { + // Total spend includes bonus credits; returned percentages measure quota usage. + for (const [id, label, usedPct] of [ + ["plan_usage", "Total", resp.planUsage.totalPercentUsed], + ["cursor_usage", "Cursor", resp.planUsage.autoPercentUsed], + ["third_party_usage", "Third Party", resp.planUsage.apiPercentUsed], + ] as const) { + if (usedPct === null) continue; + windows.push( + windowFromUsedPct({ + id, + label, + utilizationPct: usedPct, + resetsAt: billingCycleEnd, + summary: id === "plan_usage", + tone: toneFromUsedPct(usedPct), + }), + ); + } + } + if (resp.planUsage && windows.length === 0) { const totalSpend = centsToDollars(resp.planUsage.totalSpend); const remaining = centsToDollars(resp.planUsage.remaining); const limit = centsToDollars(resp.planUsage.limit); @@ -197,7 +223,7 @@ export async function fetchUsage( return { status: "available", planLabel: undefined, - windows: [], + windows, balances, details: [], }; From a54cc6dd11f73864fc78100a9587010274ba1e10 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 24 Oct 2026 12:36:10 +0700 Subject: [PATCH 58/62] chore(acp): refresh pinned provider packages (#7781) * chore(acp): refresh pinned provider packages Co-authored-by: Kim Co-authored-by: Louis Deconinck * test(app): capture the visible composer across retained tabs The settings regression sampler selected the first mounted composer even when it belonged to an inactive retained tab. Mount both tabs before capturing so the test consistently covers that case, and sample the visible composer. --------- Co-authored-by: Kim Co-authored-by: Louis Deconinck --- .../settings-toggle-tab-regression.spec.ts | 17 +++++- packages/app/src/data/acp-provider-catalog.ts | 58 +++++++++---------- .../hooks/use-acp-provider-catalog.test.ts | 4 +- 3 files changed, 47 insertions(+), 32 deletions(-) diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index 2c1fd0cc24..41da8ac3fa 100644 --- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -10,6 +10,16 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page await page.keyboard.press(`${modifier}+Comma`); } +async function openComposerTab(page: import("@playwright/test").Page, agentId: string) { + await page + .getByTestId(`workspace-tab-agent_${agentId}`) + .filter({ visible: true }) + .first() + .click(); + await expectAgentTabActive(page, agentId); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); +} + interface ComposerFrame { at: number; visible: boolean; @@ -27,7 +37,9 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page) const sample = () => { if (capture.stopped) return; - const root = document.querySelector('[data-testid="message-input-root"]'); + const root = Array.from( + document.querySelectorAll('[data-testid="message-input-root"]'), + ).find((candidate) => candidate.offsetParent !== null); const input = root?.querySelector("textarea"); if (root && input) { const style = getComputedStyle(input); @@ -178,6 +190,8 @@ test.describe("Settings toggle tab regression", () => { await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); await waitForTabBar(page); + await openComposerTab(page, firstAgent.id); + await openComposerTab(page, secondAgent.id); const input = page.getByRole("textbox", { name: "Message agent..." }); await input.fill("Keep this short draft in the composer"); const root = page.getByTestId("message-input-root").filter({ visible: true }).first(); @@ -194,6 +208,7 @@ test.describe("Settings toggle tab regression", () => { const frames = await stopComposerFrameCapture(page); const visibleFrames = frames.filter((frame) => frame.visible); + expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0); const tallestIndex = visibleFrames.reduce( (index, frame, candidateIndex) => frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index, diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index f91a9540f2..f0210f1402 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -38,10 +38,10 @@ const CATALOG_DATA = [ title: "Auggie CLI", description: "Augment Code's powerful software agent, backed by industry-leading context engine", - version: "0.33.0", + version: "0.36.0", iconId: "auggie", installLink: "https://www.augmentcode.com/", - command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"], + command: ["npx", "-y", "@augmentcode/auggie@0.36.0", "--acp"], env: { AUGMENT_DISABLE_AUTO_UPDATE: "1", }, @@ -60,10 +60,10 @@ const CATALOG_DATA = [ title: "Cline", description: "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", - version: "3.0.46", + version: "3.0.73", iconId: "cline", installLink: "https://cline.bot/cli", - command: ["npx", "-y", "cline@3.0.46", "--acp"], + command: ["npx", "-y", "cline@3.0.73", "--acp"], }, { id: "codebuddy-code", @@ -123,10 +123,10 @@ const CATALOG_DATA = [ id: "deepagents", title: "DeepAgents", description: "Batteries-included AI coding and general purpose agent powered by LangChain.", - version: "0.1.20", + version: "0.1.36", iconId: "deepagents", installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview", - command: ["npx", "-y", "deepagents-acp@0.1.20"], + command: ["npx", "-y", "deepagents-acp@0.1.36"], }, { id: "devin", @@ -141,29 +141,29 @@ const CATALOG_DATA = [ id: "dimcode", title: "DimCode", description: "A coding agent that puts leading models at your command.", - version: "0.2.36", + version: "0.5.21", iconId: "dimcode", installLink: "https://dimcode.dev/docs/acp.html", - command: ["npx", "-y", "dimcode@0.2.36", "acp"], + command: ["npx", "-y", "dimcode@0.5.21", "acp"], }, { id: "dirac", title: "Dirac", description: "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", - version: "0.4.22", + version: "0.5.23", iconId: "dirac", installLink: "https://dirac.run", - command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"], + command: ["npx", "-y", "dirac-cli@0.5.23", "--acp"], }, { id: "factory-droid", title: "Factory Droid", description: "Factory Droid - AI coding agent powered by Factory AI", - version: "0.179.0", + version: "0.249.0", iconId: "factory-droid", installLink: "https://factory.ai/product/cli", - command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"], + command: ["npx", "-y", "droid@0.249.0", "exec", "--output-format", "acp-daemon"], env: { DROID_DISABLE_AUTO_UPDATE: "true", FACTORY_DROID_AUTO_UPDATE_ENABLED: "false", @@ -174,19 +174,19 @@ const CATALOG_DATA = [ id: "fast-agent", title: "fast-agent", description: "Code and build agents with comprehensive multi-provider support", - version: "0.9.22", + version: "0.10.47", iconId: "fast-agent", installLink: "https://fast-agent.ai/acp/", - command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"], + command: ["uvx", "--from", "fast-agent-acp==0.10.47", "fast-agent-acp", "-x"], }, { id: "gemini", title: "Gemini CLI", description: "Google's official CLI for Gemini", - version: "0.52.0", + version: "0.65.0", iconId: "gemini", installLink: "https://geminicli.com", - command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"], + command: ["npx", "-y", "@google/gemini-cli@0.65.0", "--acp"], }, { id: "gjc", @@ -205,11 +205,11 @@ const CATALOG_DATA = [ id: "glm-acp-agent", title: "GLM Agent", description: - "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", - version: "1.3.0", + "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.3, glm-5.3-flash, glm-5-turbo, glm-4.7). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", + version: "1.15.0", iconId: "glm-acp-agent", installLink: "https://github.com/stefandevo/glm-acp-agent", - command: ["npx", "-y", "glm-acp-agent@1.3.0"], + command: ["npx", "-y", "glm-acp-agent@1.15.0"], }, { id: "goose", @@ -289,20 +289,20 @@ const CATALOG_DATA = [ id: "minimax-code", title: "MiniMax Code", description: "MiniMax's coding agent for the terminal", - version: "0.1.2", + version: "0.6.13", iconId: "minimax-code", installLink: "https://agent.minimax.io", - command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"], + command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"], }, { id: "minion-code", title: "Minion Code", description: "An enhanced AI code assistant built on the Minion framework with rich development tools", - version: "0.1.44", + version: "0.1.45", iconId: "minion-code", installLink: "https://github.com/femto/minion-code", - command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"], + command: ["uvx", "--from", "minion-code==0.1.45", "minion-code", "acp"], }, { id: "mistral-vibe", @@ -317,10 +317,10 @@ const CATALOG_DATA = [ id: "nova", title: "Nova", description: "Nova by Compass AI - a fully-fledged software engineer at your command", - version: "1.1.29", + version: "1.1.59", iconId: "nova", installLink: "https://www.compassap.ai/portfolio/nova.html", - command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"], + command: ["npx", "-y", "@compass-ai/nova@1.1.59", "acp"], }, { id: "poolside", @@ -335,19 +335,19 @@ const CATALOG_DATA = [ id: "qoder", title: "Qoder CLI", description: "AI coding assistant with agentic capabilities", - version: "1.1.4", + version: "1.1.78", iconId: "qoder", installLink: "https://qoder.com", - command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"], + command: ["npx", "-y", "@qoder-ai/qodercli@1.1.78", "--acp"], }, { id: "qwen-code", title: "Qwen Code", description: "Alibaba's Qwen coding assistant", - version: "0.20.1", + version: "0.26.0", iconId: "qwen-code", installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview", - command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"], + command: ["npx", "-y", "@qwen-code/qwen-code@0.26.0", "--acp", "--experimental-skills"], }, { id: "sigit", diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index c5fd59d53e..a30b27ce8f 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -61,8 +61,8 @@ describe("ACP provider catalog", () => { it("offers MiniMax Code through its pinned public ACP package", () => { expect(findProvider("minimax-code")).toMatchObject({ title: "MiniMax Code", - version: "0.1.2", - command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"], + version: "0.6.13", + command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"], }); expect(findProvider("minimax-code").iconSvg).toContain(" Date: Sat, 24 Oct 2026 07:58:40 +0200 Subject: [PATCH 59/62] docs: update changelog for 0.13.0 --- CHANGELOG.md | 15 +++++++++++---- CHANGELOG.zh-CN.md | 15 +++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fd290dfa..7d48d9c289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.13.0-beta.2 - 2026-10-23 +## 0.13.0 - 2026-10-24 Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. @@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop ### Improved -- Changed sidebar workspaces to sort by Created, with Last prompt under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771)) - Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635)) - Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690)) - Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611)) @@ -32,6 +32,7 @@ paseo host add 'relay://...' --label laptop - Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589)) - Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch)) - Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693)) +- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck)) - Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639)) - Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) - Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561)) @@ -48,10 +49,11 @@ paseo host add 'relay://...' --label laptop - Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579)) - Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368)) -- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474)) +- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger)) - Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477)) - Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630)) +- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream)) - Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) ### Fixed @@ -66,6 +68,11 @@ paseo host add 'relay://...' --label laptop - Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681)) - Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77)) - Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608)) +- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024)) +- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737)) +- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian)) +- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia)) +- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768)) - Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688)) - Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) - Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547)) @@ -73,7 +80,7 @@ paseo host add 'relay://...' --label laptop - Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph)) - Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589)) - Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589)) -- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429)) +- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701)) - Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362)) - Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy)) - Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365)) diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 6c82382605..09080be3bc 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,6 +1,6 @@ # 更新日志 -## 0.13.0-beta.2 - 2026-10-23 +## 0.13.0 - 2026-10-24 折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。 @@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop ### 改进 -- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”;工作区不再支持拖动排序 ([#7635](https://github.com/getpaseo/paseo/pull/7635)) +- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771)) - 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635)) - Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690)) - 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611)) @@ -32,6 +32,7 @@ paseo host add 'relay://...' --label laptop - 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) - 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch)) - 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693)) +- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck)) - 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639)) - 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu)) - Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561)) @@ -48,10 +49,11 @@ paseo host add 'relay://...' --label laptop - 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373)) - `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579)) - 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368)) -- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474)) +- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger)) - 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685)) - turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477)) - 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630)) +- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream)) - 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92)) ### 修复 @@ -66,6 +68,11 @@ paseo host add 'relay://...' --label laptop - 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681)) - 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77)) - 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608)) +- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024)) +- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737)) +- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian)) +- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia)) +- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768)) - 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688)) - 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan)) - 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547)) @@ -73,7 +80,7 @@ paseo host add 'relay://...' --label laptop - 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph)) - 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) - 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589)) -- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429)) +- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701)) - 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362)) - 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy)) - 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365)) From d34d17a9072149320ace35ec2a2e5c7dcd992cb3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 24 Oct 2026 08:02:33 +0200 Subject: [PATCH 60/62] chore(release): cut 0.13.0 --- .../android/en-US/changelogs/130001.txt | 15 +++++ .../android/en-US/changelogs/130002.txt | 15 +++++ .../android/en-US/changelogs/130003.txt | 15 +++++ .../android/en-US/changelogs/130004.txt | 15 +++++ package-lock.json | 56 +++++++++---------- package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 +-- packages/client/package.json | 6 +- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 10 ++-- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 ++-- packages/website/package.json | 2 +- plugins/package.json | 2 +- 18 files changed, 115 insertions(+), 55 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/130001.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/130002.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/130003.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/130004.txt diff --git a/fastlane/metadata/android/en-US/changelogs/130001.txt b/fastlane/metadata/android/en-US/changelogs/130001.txt new file mode 100644 index 0000000000..f28b4c4040 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130001.txt @@ -0,0 +1,15 @@ +Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. + +Connect your daemon to another machine, then ask an agent to delegate work there: + +`sh + +paseo host add 192.168.1.20:6767 --label studio + +paseo host add 'ssh://me@build-box' --label build-box + +paseo host add 'relay://...' --label laptop + +` + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/130002.txt b/fastlane/metadata/android/en-US/changelogs/130002.txt new file mode 100644 index 0000000000..f28b4c4040 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130002.txt @@ -0,0 +1,15 @@ +Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. + +Connect your daemon to another machine, then ask an agent to delegate work there: + +`sh + +paseo host add 192.168.1.20:6767 --label studio + +paseo host add 'ssh://me@build-box' --label build-box + +paseo host add 'relay://...' --label laptop + +` + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/130003.txt b/fastlane/metadata/android/en-US/changelogs/130003.txt new file mode 100644 index 0000000000..f28b4c4040 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130003.txt @@ -0,0 +1,15 @@ +Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. + +Connect your daemon to another machine, then ask an agent to delegate work there: + +`sh + +paseo host add 192.168.1.20:6767 --label studio + +paseo host add 'ssh://me@build-box' --label build-box + +paseo host add 'relay://...' --label laptop + +` + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/130004.txt b/fastlane/metadata/android/en-US/changelogs/130004.txt new file mode 100644 index 0000000000..f28b4c4040 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/130004.txt @@ -0,0 +1,15 @@ +Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. + +Connect your daemon to another machine, then ask an agent to delegate work there: + +`sh + +paseo host add 192.168.1.20:6767 --label studio + +paseo host add 'ssh://me@build-box' --label build-box + +paseo host add 'relay://...' --label laptop + +` + +Full notes: https://paseo.sh/changelog diff --git a/package-lock.json b/package-lock.json index c93f050b23..114a2b59fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.13.0-beta.2", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.13.0-beta.2", + "version": "0.13.0", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -35695,7 +35695,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37961,12 +37961,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/server": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/server": "0.13.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -38213,10 +38213,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/relay": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/relay": "0.13.0", "ws": "^8.20.0", "zod": "^4.4.3" }, @@ -38228,7 +38228,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.13.0-beta.2", + "version": "0.13.0", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -38472,7 +38472,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.0-beta.2", + "version": "0.13.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -42124,7 +42124,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -42153,15 +42153,15 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "i18next": "^26.3.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -42171,8 +42171,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -42194,7 +42194,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -42221,7 +42221,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", @@ -42442,16 +42442,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/highlight": "0.13.0-beta.2", - "@getpaseo/plugin": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/relay": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/highlight": "0.13.0", + "@getpaseo/plugin": "0.13.0", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/relay": "0.13.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -43372,7 +43372,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -44982,7 +44982,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0-beta.2", + "version": "0.13.0", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index 583a88c406..97a224ef4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.13.0-beta.2", + "version": "0.13.0", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index b59ef19b73..b0b806c7ab 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.13.0-beta.2", + "version": "0.13.0", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 8efbc81267..dd4ee4686d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.13.0-beta.2", + "version": "0.13.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.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/server": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/server": "0.13.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index b0d7961ace..64c559cdab 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/relay": "0.13.0-beta.2", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/relay": "0.13.0", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 8da5decfa4..b96b9f88e6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.13.0-beta.2", + "version": "0.13.0", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 5c265cc23c..b2da02d6e0 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index b7c478bce3..c1dd7c9381 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.13.0-beta.2", + "version": "0.13.0", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 17f9f76bd5..b6967d6fc1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -72,8 +72,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -83,8 +83,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/protocol": "0.13.0", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 75948ccf72..90ba994043 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 8f5c495a83..4f0aec8a03 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 0a7f9edbd4..dbba1c4aa8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.13.0-beta.2", + "version": "0.13.0", "description": "Paseo backend server", "files": [ "dist/server", @@ -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.13.0-beta.2", - "@getpaseo/highlight": "0.13.0-beta.2", - "@getpaseo/plugin": "0.13.0-beta.2", - "@getpaseo/protocol": "0.13.0-beta.2", - "@getpaseo/relay": "0.13.0-beta.2", + "@getpaseo/client": "0.13.0", + "@getpaseo/highlight": "0.13.0", + "@getpaseo/plugin": "0.13.0", + "@getpaseo/protocol": "0.13.0", + "@getpaseo/relay": "0.13.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 1fb8b0087c..852d40d0ab 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.13.0-beta.2", + "version": "0.13.0", "private": true, "type": "module", "scripts": { diff --git a/plugins/package.json b/plugins/package.json index 34c57df528..de80b5b54c 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0-beta.2", + "version": "0.13.0", "private": true, "type": "module", "scripts": { From e01a38d943025c82742f3389dcdc46b484870133 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:13:55 +0000 Subject: [PATCH 61/62] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index d345683e19..28e353268d 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU= +sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg= From b8bb335c563279be45544b1b20a1fa5b3e56d422 Mon Sep 17 00:00:00 2001 From: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:25:30 +0300 Subject: [PATCH 62/62] fix(app): close comboboxes when their retained panel becomes inactive (#7783) Close open comboboxes through their existing handler and remove their presentation before a retained chat can freeze its closing update. The compact regression came from unmounting the sheet while its visibility tracker survived in Combobox. Its non-closed phase blocked the next present(). Keep the tracker inside the sheet component so they unmount together, and preserve the desktop closed-state guard in Combobox. Cover draft and existing-chat model selectors and the compact tab switcher through Settings, Back, and reopening. The desktop regressions also verify Escape dismissal. --- .../e2e/browser/settings-navigation.spec.ts | 106 ++++++++++++++++++ packages/app/src/components/ui/combobox.tsx | 55 +++++---- 2 files changed, 138 insertions(+), 23 deletions(-) diff --git a/packages/app/e2e/browser/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts index bc8e1eedec..165bb168cd 100644 --- a/packages/app/e2e/browser/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -43,6 +43,8 @@ import { } from "../support/helpers/settings"; import { getServerId } from "../support/helpers/server-id"; import { expectAppRoute } from "../support/helpers/route-assertions"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; async function openWorkspace( page: import("@playwright/test").Page, @@ -53,6 +55,74 @@ async function openWorkspace( } test.describe("Settings sidebar navigation", () => { + test("opening Settings closes the composer model selector until it is opened again", async ({ + page, + withWorkspace, + }, testInfo) => { + const workspace = await withWorkspace({ prefix: "settings-model-selector-" }); + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + + const selector = page.getByTestId("combined-model-selector").filter({ visible: true }); + const popover = page.getByTestId("combobox-desktop-container"); + await selector.click(); + await expect(popover).toBeVisible(); + + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+,`); + await expect(page).toHaveURL(/\/settings\/general$/); + await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") }); + await expect(popover).toHaveCount(0); + + await clickSettingsBackToWorkspace(page); + await expect(selector).toBeVisible(); + await expect(popover).toHaveCount(0); + + await selector.click(); + await expect(popover).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(popover).toHaveCount(0); + }); + + test("opening Settings closes an existing chat's model selector until it is opened again", async ({ + page, + }, testInfo) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "settings-existing-model-", + title: "Settings model selector", + }); + try { + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + const selector = page.getByTestId("combined-model-selector").filter({ visible: true }); + const popover = page.getByTestId("combobox-desktop-container"); + await selector.click(); + await expect(popover).toBeVisible(); + + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+,`); + await expect(page).toHaveURL(/\/settings\/general$/); + await page.screenshot({ + path: testInfo.outputPath("settings-after-existing-model-selector.png"), + }); + await expect(popover).toHaveCount(0); + + await clickSettingsBackToWorkspace(page); + await expect(selector).toBeVisible(); + await expect(popover).toHaveCount(0); + + await selector.click(); + await expect(popover).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(popover).toHaveCount(0); + } finally { + await workspace.cleanup(); + } + }); + test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => { await gotoAppShell(page); await openSettings(page); @@ -147,6 +217,42 @@ test.describe("Settings sidebar navigation", () => { test.describe("Settings — compact master-detail", () => { test.use({ viewport: { width: 390, height: 844 } }); + test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "settings-tab-switcher-", + title: "Compact tab switcher", + }); + try { + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }); + const newAgent = page.getByTestId("workspace-tab-switcher-new-agent"); + await switcher.click(); + await expect(newAgent).toBeInViewport(); + + // Navigate without dismissing the sheet through its backdrop first. + await page + .getByTestId("menu-button") + .filter({ visible: true }) + .first() + .dispatchEvent("click"); + await page.getByTestId("sidebar-settings").first().dispatchEvent("click"); + await expectAppRoute(page, buildSettingsRoute()); + await expect(newAgent).toHaveCount(0); + + await closeCompactSettings(page); + await expect(switcher).toBeVisible(); + await expect(newAgent).toHaveCount(0); + await switcher.click(); + await expect(newAgent).toBeInViewport(); + } finally { + await workspace.cleanup(); + } + }); + test("opens compact app and host details and returns through the settings list", async ({ page, }) => { diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 7401f7aac3..84d76c1083 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { GestureHandlerRootView } from "react-native-gesture-handler"; @@ -910,13 +911,9 @@ function resolveInitialActiveIndex( return selectedIndex >= 0 ? selectedIndex : fallbackIndex; } -type BottomSheetVisibility = ReturnType; - interface MobileBodyProps { - bottomSheetRef: BottomSheetVisibility["sheetRef"]; + isOpen: boolean; snapPoints: string[]; - handleSheetChange: BottomSheetVisibility["handleSheetChange"]; - handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"]; handleIndicatorStyle: { backgroundColor: string }; titleColor: string; title: string; @@ -944,7 +941,22 @@ interface MobileBodyProps { safeAreaBottom: number; } -function MobileComboboxBody(props: MobileBodyProps): ReactElement { +function MobileComboboxBody(props: MobileBodyProps): ReactElement | null { + const active = useRetainedPanelActive(); + if (!active) return null; + // The visibility tracker must leave the tree with its sheet. + return ; +} + +function MobileComboboxSheet(props: MobileBodyProps): ReactElement { + const { + sheetRef: bottomSheetRef, + handleSheetChange, + handleSheetDismiss, + } = useIsolatedBottomSheetVisibility({ + visible: props.isOpen, + onClose: props.onClose, + }); const comboboxTitleStyle = useMemo( () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], @@ -979,13 +991,13 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { return ( { @@ -1190,6 +1203,9 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { [props.refs, setWebOverlayScope], ); + // Remove the portal before a retained chat can freeze its closing update. + if (!active) return null; + const overlay = ( { + if (!active && isOpen) handleClose(); + }, [active, isOpen, handleClose]); + useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey); const collisionPadding = useMemo(computeCollisionPadding, []); @@ -1413,16 +1434,6 @@ export function Combobox({ referenceWidth, }); - const { - sheetRef: bottomSheetRef, - handleSheetChange, - handleSheetDismiss, - } = useIsolatedBottomSheetVisibility({ - visible: isOpen, - isEnabled: isMobile, - onClose: handleClose, - }); - const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; const sanitizedSearchValue = searchQuery.trim(); const showCustomOption = useMemo( @@ -1572,10 +1583,8 @@ export function Combobox({ if (isMobile) { return (