Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 99bdf94e98 docs(plugins): explain saved configuration and launch-only MCP servers 2026-10-27 04:00:06 +01:00
Mohamed Boudra 837d96ad9a feat(plugins): contribute launch-only MCP servers at session open 2026-10-27 03:46:36 +01:00
Mohamed Boudra 10bc10c99f Let plugins give specific agents tools (#7938)
* feat(plugins): attach tools to individual agent sessions

* feat(plugins): reopen idle agent sessions after hook changes

Defer automatic plugin changes until the next idle prompt so running turns keep steering. Expose session reload separately from snapshot refresh in the SDK.

* docs(plugins): show how to attach tools to agents

* test(agent): update permission follow-up adapter
2026-10-27 09:23:50 +07:00
odoo-mvds 65ce4b590a fix(app): remove restored agent tabs from foreign workspaces (#7956)
A persisted explicit pin outlives archive recovery because its agent never joins the unrelated workspace directory. Drop positively foreign pins after hydration and let existing reconciliation remove only the misplaced view. Preserve unknown ownership and legitimate historical opens.

Adapts the repair proposed by kaicot in getpaseo/paseo#6901 to current main, with persisted-store and real-browser regression evidence. Refs getpaseo/paseo#6900.
2026-10-26 17:54:25 +00:00
paseo-bot[bot]andliyang 4f9d9d6de2 Keep ACP terminal output from stalling daemon requests (#7953)
* fix(acp): trim terminal output in linear time

* test(acp): reproduce terminal output stalls with a larger workload

The 128 KiB workload completes inside the regression deadline on faster
machines even with quadratic trimming. One MiB exceeds that deadline on
the old implementation while the bounded UTF-8 suffix implementation
finishes promptly.

Co-authored-by: liyang <liyang@msh.team>

---------

Co-authored-by: liyang <liyang@msh.team>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-26 17:04:42 +00:00
Mohamed Boudra 1f3266e2ba test(server): remove connected-host delivery timing assumptions (#7961)
CLI startup is not bounded by the recipient quiet window. Exercise the three delivery sources in separate windows and retain exact timeline and durable receipt assertions. Await permission-finish cleanup after dispatch acknowledgment using the integration cleanup budget.
2026-10-26 23:56:19 +07:00
Mohamed Boudra 28834a91ae fix(server): tolerate brief Windows atomic replace failures (#7955)
A receipt could remain pending after provider delivery when Windows rejected the completed-state rename with EPERM. Retry transient replace errors within a one-second budget while retaining the original file and cleanup semantics.
2026-10-26 23:13:25 +07:00
Simon Gurcke e3bbed3c73 fix(pi): queue prompts until manual compaction finishes (#7848)
* fix(pi): wait for manual compaction before sending prompts

* test(pi): simplify compaction regressions and stabilize live smoke
2026-10-26 14:02:44 +00:00
Mohamed Boudra 42d74c2546 fix(ci): retry transient Apple staple connection failures (#7933) 2026-10-26 19:20:17 +07:00
Mohamed Boudra 678fe308db chore(release): reconcile 0.13.2 metadata with main (#7931)
* docs(release): add 0.13.2 native patch notes

(cherry picked from commit 870c969fdb)

* chore(release): cut 0.13.2

(cherry picked from commit d2941157a8)

* chore(release): refresh Nix dependency hash for 0.13.2
2026-10-26 18:35:29 +07:00
Mohamed Boudra 287ca54749 fix(app): enlarge sidebar kebab tap targets on compact layouts (#7928)
On phones the project and workspace row kebabs were 24px and 18px boxes
with a 14px glyph, too small to hit reliably. Compact layouts now use a
32px box and the density-sized glyph (18px); desktop sizes are unchanged.
Negative margins keep the dots on the existing right rail and the rows at
their 44px compact height.
2026-10-26 18:12:56 +07:00
paseo-ai[bot] 902b0c7a16 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-26 08:55:29 +00:00
81 changed files with 3507 additions and 275 deletions
+28
View File
@@ -169,6 +169,34 @@ jobs:
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: | run: |
set -euo pipefail set -euo pipefail
export PASEO_REAL_XCRUN="$(command -v xcrun)"
stapler_bin="$(mktemp -d)"
trap 'rm -rf "$stapler_bin"' EXIT
cat > "$stapler_bin/xcrun" <<'STAPLER'
#!/bin/bash
if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then
exec "$PASEO_REAL_XCRUN" "$@"
fi
output="$(mktemp)"
trap 'rm -f "$output"' EXIT
for attempt in 1 2 3 4; do
if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then
cat "$output"
exit 0
else
status=$?
fi
cat "$output"
if [[ "$status" != 68 || "$attempt" == 4 ]] ||
! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then
exit "$status"
fi
echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2
sleep 30
done
STAPLER
chmod +x "$stapler_bin/xcrun"
export PATH="$stapler_bin:$PATH"
build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL") build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## 0.13.2 - 2026-10-26
### Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26 ## 0.13.1 - 2026-10-26
### Fixed ### Fixed
+9
View File
@@ -1,5 +1,14 @@
# 更新日志 # 更新日志
## 0.13.2 - 2026-10-26
### 修复
- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26 ## 0.13.1 - 2026-10-26
### 修复 ### 修复
+8 -1
View File
@@ -430,8 +430,15 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
Server entries register lifecycle observers with `server.on()` and request transforms with Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks) `server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
re-derived on each open and following the plugin's lifecycle. See
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
`plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks. eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
executable definitions stay in the plugin process.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-SKL7TMIuuV3imTIaQc66UDf3n6V3FB0nGSBucJ6rlA0= sha256-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE=
+28 -28
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.13.1", "version": "0.13.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.13.1", "version": "0.13.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
@@ -35695,7 +35695,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.13.1", "version": "0.13.2",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
@@ -37961,12 +37961,12 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.13.1", "version": "0.13.2",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.1", "@getpaseo/server": "0.13.2",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -38213,10 +38213,10 @@
}, },
"packages/client": { "packages/client": {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.13.1", "version": "0.13.2",
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.1", "@getpaseo/relay": "0.13.2",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -38228,7 +38228,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.13.1", "version": "0.13.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -38472,7 +38472,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.13.1", "version": "0.13.2",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
@@ -42091,7 +42091,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.13.1", "version": "0.13.2",
"dependencies": { "dependencies": {
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3", "@codemirror/legacy-modes": "^6.5.3",
@@ -42120,15 +42120,15 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.13.1", "version": "0.13.2",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^1.4.0", "@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.2",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.2.3", "react": "19.2.3",
@@ -42138,8 +42138,8 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.2",
"react": ">=19.1.0 <20", "react": ">=19.1.0 <20",
"react-native": ">=0.81.5", "react-native": ">=0.81.5",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -42161,7 +42161,7 @@
}, },
"packages/protocol": { "packages/protocol": {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.13.1", "version": "0.13.2",
"dependencies": { "dependencies": {
"semver": "^7.8.5", "semver": "^7.8.5",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -42188,7 +42188,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.13.1", "version": "0.13.2",
"dependencies": { "dependencies": {
"@noble/ciphers": "^2.4.0", "@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0", "@noble/curves": "^2.4.0",
@@ -42409,16 +42409,16 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.13.1", "version": "0.13.2",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.2",
"@getpaseo/highlight": "0.13.1", "@getpaseo/highlight": "0.13.2",
"@getpaseo/plugin": "0.13.1", "@getpaseo/plugin": "0.13.2",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.1", "@getpaseo/relay": "0.13.2",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
@@ -43339,7 +43339,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.13.1", "version": "0.13.2",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1", "@cloudflare/workers-types": "^4.20260317.1",
@@ -44949,7 +44949,7 @@
}, },
"plugins": { "plugins": {
"name": "@getpaseo/builtin-plugins", "name": "@getpaseo/builtin-plugins",
"version": "0.13.1", "version": "0.13.2",
"dependencies": { "dependencies": {
"@getpaseo/plugin": "*", "@getpaseo/plugin": "*",
"zod": "^4.4.3" "zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.13.1", "version": "0.13.2",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents", "description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [ "keywords": [
@@ -11,7 +11,10 @@ import {
expectWorkspaceTabVisible, expectWorkspaceTabVisible,
openWorkspaceWithAgents, openWorkspaceWithAgents,
} from "../support/helpers/archive-tab"; } from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout"; import {
expectAgentAbsentFromWorkspaceLayout,
seedForeignPinnedAgentTab,
} from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher"; import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer"; import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -105,6 +108,83 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
test.describe("Workspace navigation regression", () => { test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 }); test.describe.configure({ timeout: 240_000 });
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
page,
}, testInfo) => {
const serverId = getServerId();
const firstWorkspace = await seedWorkspace({
repoPrefix: "workspace-owner-",
title: "Original workspace",
});
const secondWorkspace = await seedWorkspace({
repoPrefix: "workspace-restored-",
title: "Restored workspace",
});
try {
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: "Original workspace agent",
});
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: "Restored workspace agent",
});
await gotoAppShell(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await seedForeignPinnedAgentTab(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await testInfo.attach("hydrated-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
contentType: "image/png",
});
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await testInfo.attach("restored-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
contentType: "image/png",
});
await switchWorkspaceViaSidebar({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
const agents = await firstWorkspace.client.fetchAgents();
expect(
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
).toBe(firstWorkspace.workspaceId);
expect(
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
).toBe(secondWorkspace.workspaceId);
await testInfo.attach("original-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
contentType: "image/png",
});
} finally {
await secondWorkspace.cleanup();
await firstWorkspace.cleanup();
}
});
test("keeps an offline notification on the agent route until the host connects", async ({ test("keeps an offline notification on the agent route until the host connects", async ({
page, page,
}) => { }) => {
@@ -1,12 +1,47 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { z } from "zod"; import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage"; import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
import { import {
buildWorkspaceTabPersistenceKey, buildWorkspaceTabPersistenceKey,
type WorkspaceTab, type WorkspaceTab,
} from "../../../src/workspace-tabs/model"; } from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id"; import { getServerId } from "./server-id";
export async function seedForeignPinnedAgentTab(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
.parse(JSON.parse(raw));
const key = buildWorkspaceTabPersistenceKey(input);
const layout = key ? stored.state.layoutByWorkspace[key] : null;
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
const opened = openTabInLayoutFocused({
layout,
target: { kind: "agent", agentId: input.agentId },
now: 1,
placement: { mode: "focused" },
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
});
if (!opened) throw new Error("Failed to seed foreign agent tab");
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
pinnedAgentIdsByWorkspace: {
...stored.state.pinnedAgentIdsByWorkspace,
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */ /** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> { export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state")); const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.13.1", "version": "0.13.2",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -30,7 +30,7 @@ import {
type ActiveWorkspaceSelection, type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityIconBox } from "@/styles/density"; import { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -463,12 +463,12 @@ const openInNewWindowLeadingIcon = (
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} /> <ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
); );
const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return ( return (
<ThemedMoreVertical <ThemedMoreVertical uniProps={hovered ? kebabTriggerIconHoveredProps : kebabTriggerIconProps} />
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
); );
} }
@@ -2705,8 +2705,12 @@ const styles = StyleSheet.create((theme) => ({
marginRight: -6, marginRight: -6,
}, },
projectKebabButton: { projectKebabButton: {
width: 24, // Larger on compact so the kebab is an easy tap target. The negative margins keep its
height: 24, // dots on the same rail as the 24px desktop box and the row at its 44px height.
width: { xs: 32, md: 24 },
height: { xs: 32, md: 24 },
marginRight: { xs: -4, md: 0 },
marginVertical: { xs: -4, md: 0 },
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -17,6 +17,7 @@ import { isWeb } from "@/constants/platform";
import { getForgePresentation, normalizeForge } from "@/git/forge"; import { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { import {
@@ -70,13 +71,11 @@ const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedCol
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />; const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />; const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
function renderTriggerIcon({ hovered }: { hovered?: boolean }) { function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
return ( return <ThemedMoreVertical uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />;
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
} }
export interface SidebarWorkspaceMenuProps { export interface SidebarWorkspaceMenuProps {
@@ -439,12 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
trigger: { trigger: {
padding: 2, // Larger on compact so the kebab is an easy tap target.
width: { xs: 32, md: 18 },
height: { xs: 32, md: 18 },
alignItems: "center",
justifyContent: "center",
borderRadius: 4, borderRadius: 4,
marginLeft: 2, marginLeft: 2,
// Center the compact box on the 20px title line it is anchored to.
marginTop: { xs: -6, md: 0 },
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but // MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
// pull the painted dots through that unused view-box space onto the trailing-content rail. // pull the painted dots through that unused view-box space onto the trailing-content rail.
marginRight: -7, marginRight: { xs: -14, md: -7 },
}, },
triggerHovered: { triggerHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -271,6 +271,7 @@ export interface WorkspaceTabSnapshot {
terminalsHydrated: boolean; terminalsHydrated: boolean;
activeAgentIds: Iterable<string>; activeAgentIds: Iterable<string>;
autoOpenAgentIds: Iterable<string>; autoOpenAgentIds: Iterable<string>;
foreignAgentIds?: Iterable<string>;
knownTerminalIds?: Iterable<string>; knownTerminalIds?: Iterable<string>;
standaloneTerminalIds: Iterable<string>; standaloneTerminalIds: Iterable<string>;
hasActivePendingTerminalCreate?: boolean; hasActivePendingTerminalCreate?: boolean;
@@ -2443,13 +2444,16 @@ export function reconcileWorkspaceTabs(
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
// An explicit open owns the tab until the agent joins the active directory. const foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []);
// From then on it follows the normal server archive lifecycle. Detail/cache // Explicit opens survive absent directory entries, but not known foreign ownership.
// hydration never decides whether the user's target is allowed to stay open. // Once an agent joins this workspace's active directory, normal archive rules apply.
const pinnedAgentIds = new Set( const pinnedAgentIds = new Set(
[...(state.pinnedAgentIds ?? [])].filter( [...(state.pinnedAgentIds ?? [])].filter((agentId) => {
(agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId), if (!snapshot.agentsHydrated) {
), return true;
}
return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId);
}),
); );
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
const knownTerminalIds = snapshot.knownTerminalIds const knownTerminalIds = snapshot.knownTerminalIds
@@ -1,6 +1,7 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { import {
buildWorkspaceTabSnapshot, buildWorkspaceTabSnapshot,
createWorkspaceAgentVisibilitySelector, createWorkspaceAgentVisibilitySelector,
@@ -63,6 +64,118 @@ function makeAgent(input: {
const WORKSPACE_ID = "ws-1"; const WORKSPACE_ID = "ws-1";
describe("workspace agent visibility", () => { describe("workspace agent visibility", () => {
it("removes a restored pinned tab from an unrelated workspace once its owner is known", async () => {
const values = new Map<string, string>();
const storage = {
getItem: async (key: string) => values.get(key) ?? null,
setItem: async (key: string, value: string) => {
values.set(key, value);
},
removeItem: async (key: string) => {
values.delete(key);
},
};
const original = createWorkspaceLayoutStore(undefined, storage);
await original.persist.rehydrate();
const agent = makeAgent({ id: "restored-agent", cwd: "/project-a", workspaceId: "ws-a" });
for (const workspaceId of ["ws-a", "ws-b"]) {
original.getState().openTab({
workspaceKey: `srv:${workspaceId}`,
target: { kind: "agent", agentId: agent.id },
intent: "reveal",
pin: true,
});
}
const restored = createWorkspaceLayoutStore(undefined, storage);
await restored.persist.rehydrate();
const sessionAgents = new Map([[agent.id, agent]]);
const beforeHydration = buildWorkspaceTabSnapshot({
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-b" }),
agentsHydrated: false,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingTerminalCreate: false,
hasActivePendingDraftCreate: false,
});
restored.getState().reconcileTabs("srv:ws-b", beforeHydration);
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toEqual(new Set([agent.id]));
for (const workspaceId of ["ws-a", "ws-b"]) {
restored.getState().reconcileTabs(
`srv:${workspaceId}`,
buildWorkspaceTabSnapshot({
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId }),
agentsHydrated: true,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingTerminalCreate: false,
hasActivePendingDraftCreate: false,
}),
);
}
expect(
restored
.getState()
.getWorkspaceTabs("srv:ws-a")
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.target),
).toEqual([{ kind: "agent", agentId: agent.id }]);
expect(
restored
.getState()
.getWorkspaceTabs("srv:ws-b")
.filter((tab) => tab.target.kind === "agent"),
).toEqual([]);
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined();
});
it("uses directory ownership before historical details when identifying foreign agents", () => {
const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID });
const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" });
const unknown = makeAgent({ id: "unknown", cwd: "/repo" });
const result = deriveWorkspaceAgentVisibility({
workspaceId: WORKSPACE_ID,
sessionAgents: new Map([
[own.id, own],
[foreign.id, foreign],
[unknown.id, unknown],
]),
agentDetails: new Map([
[own.id, { ...own, workspaceId: "ws-2" }],
[foreign.id, { ...foreign, workspaceId: WORKSPACE_ID }],
[unknown.id, { ...unknown, workspaceId: "ws-2" }],
]),
});
expect(result.activeAgentIds).toEqual(new Set([own.id]));
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
});
it("recognizes foreign historical ownership without making archived or unknown agents foreign", () => {
const archived = makeAgent({
id: "archived",
cwd: "/repo",
workspaceId: WORKSPACE_ID,
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
});
const foreign = { ...archived, id: "foreign", workspaceId: "ws-2" };
const unknown = { ...archived, id: "unknown", workspaceId: undefined };
const result = deriveWorkspaceAgentVisibility({
workspaceId: WORKSPACE_ID,
sessionAgents: new Map(),
agentDetails: new Map([
[archived.id, archived],
[foreign.id, foreign],
[unknown.id, unknown],
]),
});
expect(result.activeAgentIds).toEqual(new Set());
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
});
it("keeps subagents active while excluding them from auto-open", () => { it("keeps subagents active while excluding them from auto-open", () => {
const parent = makeAgent({ const parent = makeAgent({
id: "parent-agent", id: "parent-agent",
@@ -86,6 +199,7 @@ describe("workspace agent visibility", () => {
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
expect(result.foreignAgentIds).toEqual(new Set());
}); });
it("excludes archived subagents from active and auto-open", () => { it("excludes archived subagents from active and auto-open", () => {
@@ -295,6 +409,7 @@ describe("workspace agent visibility", () => {
}); });
expect(result.activeAgentIds).toEqual(new Set<string>()); expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"]));
}); });
it("excludes agents without a workspaceId", () => { it("excludes agents without a workspaceId", () => {
@@ -308,12 +423,14 @@ describe("workspace agent visibility", () => {
}); });
expect(result.activeAgentIds).toEqual(new Set<string>()); expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.foreignAgentIds).toEqual(new Set<string>());
}); });
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => { it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
const agentVisibility = { const agentVisibility = {
activeAgentIds: new Set(["active-agent"]), activeAgentIds: new Set(["active-agent"]),
autoOpenAgentIds: new Set(["root-agent"]), autoOpenAgentIds: new Set(["root-agent"]),
foreignAgentIds: new Set(["foreign-agent"]),
}; };
expect( expect(
@@ -331,6 +448,7 @@ describe("workspace agent visibility", () => {
terminalsHydrated: true, terminalsHydrated: true,
activeAgentIds: agentVisibility.activeAgentIds, activeAgentIds: agentVisibility.activeAgentIds,
autoOpenAgentIds: agentVisibility.autoOpenAgentIds, autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
foreignAgentIds: agentVisibility.foreignAgentIds,
knownTerminalIds: ["terminal-1", "script-terminal"], knownTerminalIds: ["terminal-1", "script-terminal"],
standaloneTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"],
hasActivePendingTerminalCreate: false, hasActivePendingTerminalCreate: false,
@@ -469,6 +587,24 @@ describe("workspace agent visibility", () => {
expect(after.activeAgentIds).toEqual(new Set([child.id])); expect(after.activeAgentIds).toEqual(new Set([child.id]));
expect(after.autoOpenAgentIds).toEqual(new Set([child.id])); expect(after.autoOpenAgentIds).toEqual(new Set([child.id]));
}); });
it("recomputes when only a previously unknown agent's foreign ownership becomes known", () => {
seedSession(new Map([[parent.id, parent]]));
const select = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
const before = select(useSessionStore.getState());
useSessionStore
.getState()
.setAgentDetails(SERVER_ID, new Map([[otherWorkspace.id, otherWorkspace]]));
const after = select(useSessionStore.getState());
expect(after).not.toBe(before);
expect(after.activeAgentIds).toEqual(before.activeAgentIds);
expect(after.autoOpenAgentIds).toEqual(before.autoOpenAgentIds);
expect(after.foreignAgentIds).toEqual(new Set([otherWorkspace.id]));
});
}); });
describe("workspaceAgentVisibilityEqual", () => { describe("workspaceAgentVisibilityEqual", () => {
@@ -476,10 +612,12 @@ describe("workspace agent visibility", () => {
const a = { const a = {
activeAgentIds: new Set(["a", "b"]), activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set(["c"]),
}; };
const b = { const b = {
activeAgentIds: new Set(["a", "b"]), activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set(["c"]),
}; };
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
}); });
@@ -488,10 +626,12 @@ describe("workspace agent visibility", () => {
const a = { const a = {
activeAgentIds: new Set(["a"]), activeAgentIds: new Set(["a"]),
autoOpenAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
}; };
const b = { const b = {
activeAgentIds: new Set(["b"]), activeAgentIds: new Set(["b"]),
autoOpenAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
}; };
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
}); });
@@ -500,10 +640,12 @@ describe("workspace agent visibility", () => {
const a = { const a = {
activeAgentIds: new Set(["a", "b"]), activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
}; };
const b = { const b = {
activeAgentIds: new Set(["a", "b"]), activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["b"]), autoOpenAgentIds: new Set(["b"]),
foreignAgentIds: new Set<string>(),
}; };
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
}); });
@@ -512,10 +654,12 @@ describe("workspace agent visibility", () => {
const a = { const a = {
activeAgentIds: new Set<string>(), activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(), autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
}; };
const b = { const b = {
activeAgentIds: new Set<string>(), activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(), autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
}; };
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
}); });
@@ -6,10 +6,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export interface WorkspaceAgentVisibility { export interface WorkspaceAgentVisibility {
activeAgentIds: Set<string>; activeAgentIds: Set<string>;
autoOpenAgentIds: Set<string>; autoOpenAgentIds: Set<string>;
} foreignAgentIds: Set<string>;
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
} }
export function deriveWorkspaceAgentVisibility(input: { export function deriveWorkspaceAgentVisibility(input: {
@@ -19,17 +16,23 @@ export function deriveWorkspaceAgentVisibility(input: {
}): WorkspaceAgentVisibility { }): WorkspaceAgentVisibility {
const { sessionAgents, agentDetails } = input; const { sessionAgents, agentDetails } = input;
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if ((!sessionAgents && !agentDetails) || !workspaceId) { if (!workspaceId) {
return { return {
activeAgentIds: new Set<string>(), activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(), autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
}; };
} }
const activeAgentIds = new Set<string>(); const activeAgentIds = new Set<string>();
const autoOpenAgentIds = new Set<string>(); const autoOpenAgentIds = new Set<string>();
const foreignAgentIds = new Set<string>();
for (const agent of sessionAgents?.values() ?? []) { for (const agent of sessionAgents?.values() ?? []) {
if (!agentBelongsToWorkspace(agent, workspaceId)) { const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (ownerId !== workspaceId) {
if (ownerId) {
foreignAgentIds.add(agent.id);
}
continue; continue;
} }
if (!agent.archivedAt) { if (!agent.archivedAt) {
@@ -42,7 +45,16 @@ export function deriveWorkspaceAgentVisibility(input: {
} }
} }
} }
return { activeAgentIds, autoOpenAgentIds }; for (const agent of agentDetails?.values() ?? []) {
if (sessionAgents?.has(agent.id)) {
continue;
}
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (ownerId && ownerId !== workspaceId) {
foreignAgentIds.add(agent.id);
}
}
return { activeAgentIds, autoOpenAgentIds, foreignAgentIds };
} }
interface AgentMapsSnapshot { interface AgentMapsSnapshot {
@@ -99,6 +111,7 @@ export function buildWorkspaceTabSnapshot(input: {
terminalsHydrated: input.terminalsHydrated, terminalsHydrated: input.terminalsHydrated,
activeAgentIds: input.agentVisibility.activeAgentIds, activeAgentIds: input.agentVisibility.activeAgentIds,
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds, autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
foreignAgentIds: input.agentVisibility.foreignAgentIds,
knownTerminalIds: input.knownTerminalIds, knownTerminalIds: input.knownTerminalIds,
standaloneTerminalIds: input.standaloneTerminalIds, standaloneTerminalIds: input.standaloneTerminalIds,
hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate, hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate,
@@ -113,7 +126,8 @@ export function workspaceAgentVisibilityEqual(
return ( return (
a === b || a === b ||
(setsEqual(a.activeAgentIds, b.activeAgentIds) && (setsEqual(a.activeAgentIds, b.activeAgentIds) &&
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds)) setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) &&
setsEqual(a.foreignAgentIds, b.foreignAgentIds))
); );
} }
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.13.1", "version": "0.13.2",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"bin": { "bin": {
"paseo": "bin/paseo" "paseo": "bin/paseo"
@@ -28,9 +28,9 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.1", "@getpaseo/server": "0.13.2",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.13.1", "version": "0.13.2",
"description": "Paseo client SDK package", "description": "Paseo client SDK package",
"files": [ "files": [
"dist", "dist",
@@ -47,8 +47,8 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.1", "@getpaseo/relay": "0.13.2",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
+63
View File
@@ -1770,3 +1770,66 @@ test("qualified parents preserve foreign identity without resolving an equal loc
await client.close(); await client.close();
expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" }); expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" });
}); });
test("agent reload reopens through the daemon RPC then returns the observed snapshot; refresh remains a read", async () => {
const { client, ws } = await connectClient();
try {
const agent = client.agents.ref("agent_sdk");
const snapshot = createAgent();
const reloaded = agent.reload({ requestId: "reload-sdk" });
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "refresh_agent_request",
agentId: "agent_sdk",
requestId: "reload-sdk",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "reload-sdk",
agentId: "agent_sdk",
timelineSize: 0,
},
}),
);
await expect
.poll(() => parseSentSessionMessage(ws.sent.at(-1)).type)
.toBe("fetch_agent_request");
const fetched = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: fetched.requestId,
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await reloaded).toEqual({ agent: snapshot, project: null });
expect(agent.current()).toEqual(snapshot);
const read = agent.refresh("snapshot-read");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_agent_request",
requestId: "snapshot-read",
});
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: "snapshot-read",
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await read).toEqual({ agent: snapshot, project: null });
} finally {
await client.close();
}
});
+11
View File
@@ -369,6 +369,11 @@ export interface PaseoAgentHandle {
readonly timeline: PaseoAgentTimelineHandle; readonly timeline: PaseoAgentTimelineHandle;
current(): PaseoAgent | null; current(): PaseoAgent | null;
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>; refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
/**
* Reopens the provider session with current configuration, rerunning session-open hooks.
* Interrupts a running turn and returns the refreshed snapshot.
*/
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>; send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>; respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */ /** Sends a prompt and resolves when that turn finishes or needs attention. */
@@ -981,6 +986,12 @@ function createAgentHandleFactory(
current = result?.agent ?? null; current = result?.agent ?? null;
return result; return result;
}, },
reload: async (options) => {
await daemonClient.refreshAgent(id, options?.requestId);
const result = await daemonClient.fetchAgent({ agentId: id });
current = result?.agent ?? null;
return result;
},
send: async (text, options) => { send: async (text, options) => {
await daemonClient.sendAgentMessage(id, text, options); await daemonClient.sendAgentMessage(id, text, options);
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.13.1", "version": "0.13.2",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh", "homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.13.1", "version": "0.13.2",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"keywords": [ "keywords": [
"ExpoTwoWayAudio", "ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.13.1", "version": "0.13.2",
"files": [ "files": [
"dist", "dist",
"!dist/**/*.map", "!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.13.1", "version": "0.13.2",
"description": "Paseo plugin SDK package", "description": "Paseo plugin SDK package",
"files": [ "files": [
"dist", "dist",
@@ -72,8 +72,8 @@
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.2",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.2.3", "react": "19.2.3",
@@ -83,8 +83,8 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.2",
"react": ">=19.1.0 <20", "react": ">=19.1.0 <20",
"react-native": ">=0.81.5", "react-native": ">=0.81.5",
"zod": "^4.4.3" "zod": "^4.4.3"
+61 -1
View File
@@ -92,7 +92,12 @@ function typeErrors(code: string): string[] {
} }
describe("plugin SDK import boundaries", () => { describe("plugin SDK import boundaries", () => {
it("publishes process capabilities without their Windows implementation helpers", () => { it("publishes process capabilities without their Windows implementation helpers", () => {
expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); expect(Object.keys(server).sort()).toEqual([
"defineAgentTool",
"execCommand",
"spawnProcess",
"terminateProcess",
]);
}); });
it("classifies every published entry", () => { it("classifies every published entry", () => {
@@ -209,3 +214,58 @@ describe("plugin panel SDK boundary", () => {
).toEqual([]); ).toEqual([]);
}); });
}); });
describe("plugin agent tools SDK boundary", () => {
it("lets a session hook attach typed tools without exposing their transport", () => {
expect(
typeErrors(`
import { z } from "zod";
import { defineAgentTool, type PluginServerContext, type AgentToolResult, type AgentToolContext, type AgentToolDefinition } from "@getpaseo/plugin/server";
const tool: AgentToolDefinition = defineAgentTool({
name: "create_issue",
description: "Create an issue",
input: z.object({ title: z.string(), count: z.string().transform(Number) }),
async execute(input, { agent, paseo, signal }) {
const count: number = input.count;
// @ts-expect-error input is inferred from the schema
input.missing;
return { text: agent.id + input.title, structuredContent: { count } };
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
const title: string | null = request.title;
// @ts-expect-error labels are read-only
request.labels.x = "changed";
if (request.labels.x !== "y") return;
// @ts-expect-error MCP servers use the same typed transport config as saved servers
request.mcpServers.invalid = { type: "http", url: 42 };
return { ...request, tools: [...request.tools, tool], mcpServers: {
...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" },
} };
});
return () => {};
}
`),
).toEqual([]);
});
});
describe("agent session reload SDK boundary", () => {
it("exposes reload separately from snapshot refresh", () => {
expect(
typeErrors(`
import { type PluginServerContext } from "@getpaseo/plugin/server";
import { type PaseoAgentRefetchResult } from "@getpaseo/client";
async function reload(server: PluginServerContext, paseo: import("@getpaseo/client").PaseoApi) {
const agent = paseo.agents.ref("chosen-agent");
const read: PaseoAgentRefetchResult | null = await agent.refresh("read-snapshot");
const reopened: PaseoAgentRefetchResult | null = await agent.reload({ requestId: "reload-session" });
await agent.reload();
// @ts-expect-error reload options can grow without a positional signature change
await agent.reload("reload-session");
}
`),
).toEqual([]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { PaseoApi } from "@getpaseo/client";
import type { z } from "zod";
import type { PluginHookAgent } from "./lifecycle.js";
export interface AgentToolContext {
agent: PluginHookAgent;
paseo: PaseoApi;
signal: AbortSignal;
}
export interface AgentToolResult {
text?: string;
structuredContent?: unknown;
isError?: boolean;
}
export interface AgentToolDefinition {
name: string;
title?: string;
description: string;
input: z.ZodType;
execute(input: unknown, context: AgentToolContext): AgentToolResult | Promise<AgentToolResult>;
}
export function defineAgentTool<Input extends z.ZodType>(tool: {
name: string;
title?: string;
description: string;
input: Input;
execute(
input: z.output<Input>,
context: AgentToolContext,
): AgentToolResult | Promise<AgentToolResult>;
}): AgentToolDefinition {
if (!/^[a-z][a-z0-9_]*$/.test(tool.name) || tool.name.length > 64) {
throw new Error(`Invalid agent tool name: ${tool.name}`);
}
return tool;
}
+3
View File
@@ -24,3 +24,6 @@ export type {
} from "./lifecycle.js"; } from "./lifecycle.js";
export { spawnProcess, execCommand, terminateProcess } from "./process.js"; export { spawnProcess, execCommand, terminateProcess } from "./process.js";
export { defineAgentTool } from "./agent-tools.js";
export type { AgentToolDefinition, AgentToolResult, AgentToolContext } from "./agent-tools.js";
+5
View File
@@ -1,3 +1,4 @@
import type { AgentToolDefinition } from "./agent-tools.js";
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
@@ -36,7 +37,11 @@ export interface PluginSessionOpenRequest {
cwd: string; cwd: string;
reason: "create" | "resume" | "refresh" | "import"; reason: "create" | "resume" | "refresh" | "import";
purpose: "interactive" | "history"; purpose: "interactive" | "history";
readonly title: string | null;
readonly labels: Readonly<Record<string, string>>;
tools: AgentToolDefinition[];
env: Record<string, string>; env: Record<string, string>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
} }
export type PluginTurnOutcome = export type PluginTurnOutcome =
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.13.1", "version": "0.13.2",
"description": "Paseo shared protocol schemas and wire types", "description": "Paseo shared protocol schemas and wire types",
"files": [ "files": [
"dist", "dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.13.1", "version": "0.13.2",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"files": [ "files": [
"dist", "dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.13.1", "version": "0.13.2",
"description": "Paseo backend server", "description": "Paseo backend server",
"files": [ "files": [
"dist/server", "dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.2",
"@getpaseo/highlight": "0.13.1", "@getpaseo/highlight": "0.13.2",
"@getpaseo/plugin": "0.13.1", "@getpaseo/plugin": "0.13.2",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.1", "@getpaseo/relay": "0.13.2",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
+182 -33
View File
@@ -1,3 +1,5 @@
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
import type { AgentToolResult } from "@getpaseo/plugin/server";
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js"; import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
import { ensureAgentLoaded } from "./agent-loading.js"; import { ensureAgentLoaded } from "./agent-loading.js";
import { projectTimelineRows } from "./timeline-projection.js"; import { projectTimelineRows } from "./timeline-projection.js";
@@ -83,7 +85,11 @@ import {
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js"; import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import {
PASEO_MCP_SERVER_NAME,
stripInternalPaseoMcpServer,
withRuntimePaseoMcpServer,
} from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js";
import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js"; import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js";
@@ -333,6 +339,7 @@ export interface AgentManagerOptions {
terminalManager?: TerminalManager | null; terminalManager?: TerminalManager | null;
mcpBaseUrl?: string; mcpBaseUrl?: string;
mcpAuthToken?: string; mcpAuthToken?: string;
pluginAgentTools?: PluginAgentToolsRuntime;
paseoToolsEnabled?: boolean; paseoToolsEnabled?: boolean;
paseoToolCatalogFactory?: PaseoToolCatalogFactory; paseoToolCatalogFactory?: PaseoToolCatalogFactory;
resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined; resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined;
@@ -556,18 +563,20 @@ interface AgentMetadataPatch {
const SYSTEM_ERROR_PREFIX = "[System Error]"; const SYSTEM_ERROR_PREFIX = "[System Error]";
function attachPersistenceCwd( function normalizePersistenceForConfig(
handle: AgentPersistenceHandle | null, handle: AgentPersistenceHandle | null,
cwd: string, config: AgentSessionConfig,
): AgentPersistenceHandle | null { ): AgentPersistenceHandle | null {
if (!handle) { if (!handle) {
return null; return null;
} }
const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {};
return { return {
...handle, ...handle,
metadata: { metadata: {
...handle.metadata, ...metadata,
cwd, cwd: config.cwd,
...(config.mcpServers ? { mcpServers: config.mcpServers } : {}),
}, },
}; };
} }
@@ -766,6 +775,10 @@ export class AgentManager {
private readonly agentStreamCoalescer: AgentStreamCoalescer; private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null; private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null; private readonly mcpAuthToken: string | null;
private sessionOpenGeneration = 0;
private readonly sessionOpenGenerations = new Map<string, number>();
private readonly attachedPluginTools = new Map<string, AttachedAgentTool[]>();
private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined;
private paseoToolsEnabled = true; private paseoToolsEnabled = true;
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null; private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
private readonly paseoToolPolicies = new Map<string, ProviderPaseoToolsPolicy | undefined>(); private readonly paseoToolPolicies = new Map<string, ProviderPaseoToolsPolicy | undefined>();
@@ -783,6 +796,7 @@ export class AgentManager {
constructor(options: AgentManagerOptions) { constructor(options: AgentManagerOptions) {
this.pluginLifecycle = options.pluginLifecycle; this.pluginLifecycle = options.pluginLifecycle;
this.pluginAgentTools = options.pluginAgentTools;
this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore(); this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore();
this.idFactory = options.idFactory ?? (() => randomUUID()); this.idFactory = options.idFactory ?? (() => randomUUID());
this.registry = options?.registry; this.registry = options?.registry;
@@ -887,6 +901,62 @@ export class AgentManager {
this.paseoToolCatalogFactory = factory; this.paseoToolCatalogFactory = factory;
} }
invalidatePluginSessions(): void {
this.sessionOpenGeneration++;
}
async reloadStalePluginSession(agentId: string): Promise<void> {
if (!this.needsPluginSessionReload(agentId)) return;
await this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, async () => {
if (!this.needsPluginSessionReload(agentId)) return;
await this.reloadAgentSessionInternal(agentId);
}),
);
}
private needsPluginSessionReload(agentId: string): boolean {
const agent = this.getAgent(agentId);
return (
agent !== null &&
agent.lifecycle !== "closed" &&
this.sessionOpenGenerations.get(agentId) !== this.sessionOpenGeneration &&
!this.hasInFlightRun(agentId)
);
}
getPluginTools(agentId: string): readonly AttachedAgentTool[] {
return (this.attachedPluginTools.get(agentId) ?? []).filter((tool) =>
this.pluginAgentTools?.isToolPluginAvailable(tool.pluginId, tool.pluginInstanceId),
);
}
async executePluginTool(
tool: AttachedAgentTool,
agentId: string,
input: unknown,
signal?: AbortSignal,
): Promise<AgentToolResult> {
const agent = this.getAgent(agentId);
if (!agent || agent.lifecycle === "closed") throw new Error(`Agent is not live: ${agentId}`);
if (!this.pluginAgentTools) throw new Error("Plugin tools are unavailable");
const record = await this.registry?.get(agentId);
return this.pluginAgentTools.callTool(
tool.pluginId,
tool.pluginInstanceId,
describeHookAgent({ ...agent, title: record?.title ?? agent.config.title }),
tool.name,
input,
signal,
);
}
private detachPluginTools(agentId: string): void {
this.attachedPluginTools.delete(agentId);
this.sessionOpenGenerations.delete(agentId);
this.pluginAgentTools?.detachTools(agentId);
}
getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined { getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined {
return this.paseoToolPolicies.get(agentId); return this.paseoToolPolicies.get(agentId);
} }
@@ -1312,12 +1382,18 @@ export class AgentManager {
storedConfig.cwd, storedConfig.cwd,
paseoToolPolicy, paseoToolPolicy,
options?.env, options?.env,
{ reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null }, {
reason: "create",
purpose: "interactive",
workspaceId: options.workspaceId ?? null,
title: options.initialTitle ?? storedConfig.title ?? null,
labels: options.labels ?? {},
},
); );
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options); const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
await this.requireExternalMcpSupport(session, storedConfig); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, { const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options.labels, labels: options.labels,
initialTitle: options.initialTitle, initialTitle: options.initialTitle,
@@ -1434,6 +1510,8 @@ export class AgentManager {
reason: "resume", reason: "resume",
purpose, purpose,
workspaceId: options?.workspaceId ?? null, workspaceId: options?.workspaceId ?? null,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
}, },
); );
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1443,7 +1521,7 @@ export class AgentManager {
launchContext, launchContext,
currentResumeOptions, currentResumeOptions,
); );
await this.requireExternalMcpSupport(session, storedConfig); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, { return this.registerSession(session, storedConfig, resolvedAgentId, {
...options, ...options,
persistence: handle, persistence: handle,
@@ -1491,7 +1569,12 @@ export class AgentManager {
storedConfig.cwd, storedConfig.cwd,
paseoToolPolicy, paseoToolPolicy,
undefined, undefined,
{ reason: "import", purpose: "interactive", workspaceId: input.workspaceId }, {
reason: "import",
purpose: "interactive",
workspaceId: input.workspaceId,
labels: input.labels ?? {},
},
); );
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const imported = await client.importSession( const imported = await client.importSession(
@@ -1501,6 +1584,7 @@ export class AgentManager {
}, },
{ config: providerLaunchConfig, storedConfig, launchContext }, { config: providerLaunchConfig, storedConfig, launchContext },
); );
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
let handedToRegistration = false; let handedToRegistration = false;
try { try {
const importedConfig = await this.normalizeConfig( const importedConfig = await this.normalizeConfig(
@@ -1586,14 +1670,20 @@ export class AgentManager {
storedConfig.cwd, storedConfig.cwd,
paseoToolPolicy, paseoToolPolicy,
undefined, undefined,
{ reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId }, {
reason: "refresh",
purpose: "interactive",
workspaceId: existing.workspaceId,
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
labels: existing.labels,
},
); );
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if ( if (
Object.keys(storedConfig.mcpServers ?? {}).length > 0 && Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
existing.session.capabilities.supportsMcpServers !== true existing.session.capabilities.supportsMcpServers !== true
) { ) {
throw new Error(`Provider '${provider}' does not support MCP servers`); throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
} }
let session: AgentSession | undefined; let session: AgentSession | undefined;
@@ -1612,7 +1702,7 @@ export class AgentManager {
session = handle session = handle
? await client.resumeSession(handle, providerLaunchConfig, launchContext) ? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext); : await client.createSession(providerLaunchConfig, launchContext);
await this.requireExternalMcpSupport(session, storedConfig); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
this.assertAcceptingAgentRegistrations(); this.assertAcceptingAgentRegistrations();
if (rehydrateFromDisk) { if (rehydrateFromDisk) {
@@ -1650,6 +1740,7 @@ export class AgentManager {
throw error; throw error;
} finally { } finally {
if (!handedToRegistration) { if (!handedToRegistration) {
this.detachPluginTools(agentId);
if (hadPreviousPaseoToolPolicy) { if (hadPreviousPaseoToolPolicy) {
this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy); this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy);
} else { } else {
@@ -1857,6 +1948,7 @@ export class AgentManager {
} catch (error) { } catch (error) {
persistError = error; persistError = error;
} }
this.detachPluginTools(agentId);
this.emitClosedAgent(closedAgent, { persist: false }); this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace( this.logger.trace(
{ {
@@ -2836,7 +2928,10 @@ export class AgentManager {
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId } ? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
: null); : null);
if (persistenceHandle) { if (persistenceHandle) {
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd); mutableAgent.persistence = normalizePersistenceForConfig(
persistenceHandle,
mutableAgent.config,
);
} }
this.logger.trace( this.logger.trace(
{ {
@@ -3780,16 +3875,23 @@ export class AgentManager {
private async requireExternalMcpSupport( private async requireExternalMcpSupport(
session: AgentSession, session: AgentSession,
storedConfig: AgentSessionConfig, launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
): Promise<void> { ): Promise<void> {
if ( if (
Object.keys(storedConfig.mcpServers ?? {}).length === 0 || Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
session.capabilities.supportsMcpServers === true session.capabilities.supportsMcpServers === true
) { ) {
return; return;
} }
await this.closeUnregisteredSession(session); await this.closeUnregisteredSession(session);
throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`); throw this.unsupportedMcpError(launchConfig, launchContext);
}
private unsupportedMcpError(config: AgentSessionConfig, context: AgentLaunchContext): Error {
const owners = [...new Set(Object.values(context.mcpServerOwners ?? {}))];
const source = owners.length ? ` (plugin ${owners.join(", ")})` : "";
return new Error(`Provider '${config.provider}' does not support MCP servers${source}`);
} }
private async initializeAgentTimelineForRegister(params: { private async initializeAgentTimelineForRegister(params: {
@@ -3875,9 +3977,9 @@ export class AgentManager {
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(), foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
finalizedForegroundTurnIds: new Set<string>(), finalizedForegroundTurnIds: new Set<string>(),
unsubscribeSession: null, unsubscribeSession: null,
persistence: attachPersistenceCwd( persistence: normalizePersistenceForConfig(
options?.persistence ?? session.describePersistence(), options?.persistence ?? session.describePersistence(),
config.cwd, config,
), ),
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows, historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
lastUserMessageAt: options?.lastUserMessageAt ?? null, lastUserMessageAt: options?.lastUserMessageAt ?? null,
@@ -3940,6 +4042,7 @@ export class AgentManager {
private discardRetainedAgentState(agentId: string): void { private discardRetainedAgentState(agentId: string): void {
this.timelineStore.delete(agentId); this.timelineStore.delete(agentId);
this.paseoToolPolicies.delete(agentId); this.paseoToolPolicies.delete(agentId);
this.detachPluginTools(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) { for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event }); this.dispatch({ type: "provider_subagent", event });
} }
@@ -4180,9 +4283,9 @@ export class AgentManager {
newInfo.modeId !== agent.runtimeInfo?.modeId; newInfo.modeId !== agent.runtimeInfo?.modeId;
agent.runtimeInfo = newInfo; agent.runtimeInfo = newInfo;
if (!agent.persistence && newInfo.sessionId) { if (!agent.persistence && newInfo.sessionId) {
agent.persistence = attachPersistenceCwd( agent.persistence = normalizePersistenceForConfig(
{ provider: agent.provider, sessionId: newInfo.sessionId }, { provider: agent.provider, sessionId: newInfo.sessionId },
agent.cwd, agent.config,
); );
} }
// Emit state if runtimeInfo changed so clients get the updated model // Emit state if runtimeInfo changed so clients get the updated model
@@ -4540,9 +4643,9 @@ export class AgentManager {
case "model_changed": case "model_changed":
agent.runtimeInfo = event.runtimeInfo; agent.runtimeInfo = event.runtimeInfo;
if (!agent.persistence && event.runtimeInfo.sessionId) { if (!agent.persistence && event.runtimeInfo.sessionId) {
agent.persistence = attachPersistenceCwd( agent.persistence = normalizePersistenceForConfig(
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId }, { provider: agent.provider, sessionId: event.runtimeInfo.sessionId },
agent.cwd, agent.config,
); );
} }
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId); this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
@@ -4616,7 +4719,7 @@ export class AgentManager {
private refreshSessionPersistence(agent: ActiveManagedAgent): void { private refreshSessionPersistence(agent: ActiveManagedAgent): void {
const handle = agent.session.describePersistence(); const handle = agent.session.describePersistence();
if (handle) { if (handle) {
agent.persistence = attachPersistenceCwd(handle, agent.cwd); agent.persistence = normalizePersistenceForConfig(handle, agent.config);
} }
} }
@@ -5426,10 +5529,17 @@ export class AgentManager {
reason: PluginSessionOpenRequest["reason"]; reason: PluginSessionOpenRequest["reason"];
purpose: PluginSessionOpenRequest["purpose"]; purpose: PluginSessionOpenRequest["purpose"];
workspaceId?: string | null; workspaceId?: string | null;
title?: string | null;
labels?: Record<string, string>;
}, },
): Promise<AgentLaunchContext> { ): Promise<AgentLaunchContext> {
// A plugin changing during this open must still make the resulting session stale.
const generation = this.sessionOpenGeneration;
this.detachPluginTools(agentId);
let mcpServers: AgentSessionConfig["mcpServers"];
let mcpServerOwners: Record<string, string> | undefined;
if (this.pluginLifecycle) { if (this.pluginLifecycle) {
const request: PluginSessionOpenRequest = { const request = {
agentId, agentId,
provider: client.provider, provider: client.provider,
cwd, cwd,
@@ -5437,29 +5547,49 @@ export class AgentManager {
reason: opening?.reason ?? "resume", reason: opening?.reason ?? "resume",
purpose: opening?.purpose ?? "interactive", purpose: opening?.purpose ?? "interactive",
env: { ...env }, env: { ...env },
title: opening?.title ?? null,
labels: opening?.labels ?? {},
tools: [],
mcpServers: {},
}; };
const transformed = await this.pluginLifecycle.before("agent.session_open", request); const transformed = await this.pluginLifecycle.before("agent.session_open", request);
env = transformed.env; env = transformed.env;
if (request.purpose === "interactive") {
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
mcpServers = transformed.mcpServers;
mcpServerOwners = transformed.mcpServerOwners;
}
} }
const context: AgentLaunchContext = { const context: AgentLaunchContext = {
agentId, agentId,
mcpServers,
mcpServerOwners,
env: { env: {
...env, ...env,
PASEO_AGENT_ID: agentId, PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd, PASEO_AGENT_CWD: cwd,
}, },
}; };
if ( const useNativeTools =
this.paseoToolsEnabled && this.paseoToolsEnabled &&
isPaseoToolPolicyEnabled(paseoToolPolicy) && isPaseoToolPolicyEnabled(paseoToolPolicy) &&
client.capabilities.supportsNativePaseoTools && client.capabilities.supportsNativePaseoTools;
this.paseoToolCatalogFactory if (
this.paseoToolCatalogFactory &&
(useNativeTools || this.getPluginTools(agentId).length > 0)
) { ) {
context.paseoTools = await this.paseoToolCatalogFactory({ try {
callerAgentId: agentId, const catalog = await this.paseoToolCatalogFactory({
paseoToolPolicy, callerAgentId: agentId,
}); paseoToolPolicy,
});
if (useNativeTools) context.paseoTools = catalog;
} catch (error) {
this.detachPluginTools(agentId);
throw error;
}
} }
this.sessionOpenGenerations.set(agentId, generation);
return context; return context;
} }
@@ -5467,7 +5597,26 @@ export class AgentManager {
launchConfig: AgentSessionConfig, launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext, launchContext: AgentLaunchContext,
): AgentSessionConfig { ): AgentSessionConfig {
return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig; const savedServerNames = new Set(
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}),
);
for (const name of Object.keys(launchContext.mcpServers ?? {})) {
const owner = launchContext.mcpServerOwners?.[name];
if (name === PASEO_MCP_SERVER_NAME || savedServerNames.has(name)) {
const existingOwner = savedServerNames.has(name)
? "agent saved configuration"
: "Paseo internal MCP server";
throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`);
}
}
const merged =
Object.keys(launchContext.mcpServers ?? {}).length > 0
? {
...launchConfig,
mcpServers: { ...launchConfig.mcpServers, ...launchContext.mcpServers },
}
: launchConfig;
return launchContext.paseoTools ? stripInternalPaseoMcpServer(merged) : merged;
} }
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> { private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
@@ -15,6 +15,7 @@ export type AgentRunController = Pick<
| "getAgent" | "getAgent"
| "tryRunOutOfBand" | "tryRunOutOfBand"
| "hasInFlightRun" | "hasInFlightRun"
| "reloadStalePluginSession"
| "replaceAgentRun" | "replaceAgentRun"
| "steerOrReplaceActiveTurn" | "steerOrReplaceActiveTurn"
| "streamAgent" | "streamAgent"
@@ -115,6 +116,7 @@ export async function startAgentRun(
return { disposition: "out_of_band" }; return { disposition: "out_of_band" };
} }
try { try {
await agentManager.reloadStalePluginSession(agentId);
return await startAgentRunInner(agentManager, agentId, prompt, logger, options); return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
} catch (error) { } catch (error) {
if (!isStaleProviderSessionError(error)) throw error; if (!isStaleProviderSessionError(error)) throw error;
@@ -616,6 +616,9 @@ export interface AgentSessionConfig {
} }
export interface AgentLaunchContext { export interface AgentLaunchContext {
/** Launch-only plugin MCP servers and their owners; never persisted. */
mcpServers?: Record<string, McpServerConfig>;
mcpServerOwners?: Record<string, string>;
agentId?: string; agentId?: string;
env?: Record<string, string>; env?: Record<string, string>;
/** /**
@@ -69,6 +69,7 @@ test("session create forwards clientMessageId to the initial prompt run options"
getAgent: vi.fn(() => snapshot), getAgent: vi.fn(() => snapshot),
tryRunOutOfBand: vi.fn(() => false), tryRunOutOfBand: vi.fn(() => false),
hasInFlightRun: vi.fn(() => false), hasInFlightRun: vi.fn(() => false),
reloadStalePluginSession: async () => {},
streamAgent, streamAgent,
waitForAgentRunStart: vi.fn(async () => undefined), waitForAgentRunStart: vi.fn(async () => undefined),
} as unknown as Parameters<typeof createAgentCommand>[0]["agentManager"], } as unknown as Parameters<typeof createAgentCommand>[0]["agentManager"],
@@ -270,6 +270,8 @@ function buildAgentManagerSpies() {
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
appendTimelineItem: vi.fn().mockResolvedValue(undefined), appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined), emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
getPluginTools: () => [],
reloadStalePluginSession: async () => {},
hasInFlightRun: vi.fn().mockReturnValue(false), hasInFlightRun: vi.fn().mockReturnValue(false),
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }), steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
tryRunOutOfBand: vi.fn().mockReturnValue(false), tryRunOutOfBand: vi.fn().mockReturnValue(false),
@@ -617,6 +619,10 @@ class FakeBrowserToolsBroker {
} }
class BoundaryAgentManagerFake { class BoundaryAgentManagerFake {
public getPluginTools(): [] {
return [];
}
private readonly agent = createManagedAgent({ private readonly agent = createManagedAgent({
id: "agent-1", id: "agent-1",
cwd: REPO_CWD, cwd: REPO_CWD,
@@ -40,6 +40,8 @@ class FakePermissionAgentManager {
return undefined; return undefined;
} }
async reloadStalePluginSession(): Promise<void> {}
hasInFlightRun(): boolean { hasInFlightRun(): boolean {
return this.hasRunInFlight; return this.hasRunInFlight;
} }
@@ -1321,3 +1321,28 @@ test("plugin without a runtime safety capability uses baseline collection withou
await rm(home, { recursive: true, force: true }); await rm(home, { recursive: true, force: true });
} }
}); });
test("plugin provider import launches with runtime MCP servers and returns only saved configuration", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const storedConfig = { provider: harness.registration.id, cwd: "/project" };
const company = { type: "http", url: "https://tools.example.com/mcp" } as const;
try {
const imported = await client.importSession!(
{ providerHandleId: 'plugin:{"version":1,"data":{"token":"root"}}', cwd: "/project" },
{ config: { ...storedConfig, mcpServers: { company } }, storedConfig },
);
expect(harness.inputs).toContainEqual(
expect.objectContaining({
type: "session.open",
config: expect.objectContaining({ mcpServers: { company } }),
}),
);
expect(imported.config).toEqual(storedConfig);
await imported.session.close();
} finally {
await registry.shutdown();
}
});
@@ -1052,7 +1052,7 @@ class PluginAgentClient implements AgentClient {
if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`); if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`);
return { return {
session, session,
config: { ...context.config, provider: this.provider, cwd: input.cwd }, config: { ...context.storedConfig, provider: this.provider, cwd: input.cwd },
persistence, persistence,
timeline: session.timelineHistory(), timeline: session.timelineHistory(),
providerSubagentEvents: session.subagentHistory(), providerSubagentEvents: session.subagentHistory(),
@@ -811,6 +811,46 @@ describe("ACPAgentSession terminal tools", () => {
truncated: false, truncated: false,
}); });
}); });
test("trims large terminal output without rescanning the retained suffix", async () => {
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: ["-e", 'process.stdout.write("x".repeat(1024 * 1024))'],
cwd: process.cwd(),
outputByteLimit: 1024,
});
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).resolves.toEqual({
output: "x".repeat(1024),
truncated: true,
exitStatus: { exitCode: 0, signal: null },
});
}, 1000);
test("trims terminal output at a UTF-8 character boundary", async () => {
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: ["-e", 'process.stdout.write("😀ab")'],
cwd: process.cwd(),
outputByteLimit: 3,
});
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).resolves.toEqual({
output: "ab",
truncated: true,
exitStatus: { exitCode: 0, signal: null },
});
});
}); });
describe("mapACPUsage", () => { describe("mapACPUsage", () => {
@@ -4093,10 +4093,16 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void {
if (!limit) { if (!limit) {
return; return;
} }
while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) { const output = Buffer.from(entry.output, "utf8");
entry.output = entry.output.slice(1); if (output.length <= limit) {
entry.truncated = true; return;
} }
let start = output.length - limit;
while (start < output.length && (output[start] & 0xc0) === 0x80) {
start += 1;
}
entry.output = output.subarray(start).toString("utf8");
entry.truncated = true;
} }
function readRecord(value: unknown): Record<string, unknown> | null { function readRecord(value: unknown): Record<string, unknown> | null {
@@ -3063,6 +3063,81 @@ describe("PiRpcAgentClient", () => {
]); ]);
}); });
test.each([
{ outcome: "successful", error: null },
{ outcome: "failed", error: new Error("summarizer failed") },
])("waits for $outcome manual compaction before sending a new prompt", async ({ error }) => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
fakeSession.compactError = error;
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
const { turnId } = await session.startTurn("continue after compaction");
await flushTurnScheduling();
expect(fakeSession.compactRequests).toEqual([{}]);
expect(fakeSession.prompts).toEqual([]);
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.prompts).toEqual([{ message: "continue after compaction", imageCount: 0 }]);
fakeSession.finishTurn();
await events.nextTurnCompletion();
expect(events.turnLifecycleEvents()).toContainEqual({ type: "turn_completed", turnId });
expect(events.eventTypes()).not.toContain("turn_failed");
});
test.each(["interrupt", "close"] as const)(
"does not send a prompt waiting on compaction after %s",
async (action) => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
await session.startTurn("do not send this prompt");
await session[action]();
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.prompts).toEqual([]);
},
);
test("does not send a prompt when compaction finishes during interruption", async () => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const clearQueueFinished = Promise.withResolvers<void>();
fakeSession.clearQueueCompletion = clearQueueFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
const { turnId } = await session.startTurn("do not send this prompt");
const interruption = session.interrupt();
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.controlRequests).toEqual(["clear_queue"]);
expect(fakeSession.prompts).toEqual([]);
clearQueueFinished.resolve();
await interruption;
expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_canceled", turnId }]);
expect(fakeSession.prompts).toEqual([]);
});
test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => { test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => {
const { pi, session } = await createSession(); const { pi, session } = await createSession();
const fakeSession = pi.latestSession(); const fakeSession = pi.latestSession();
@@ -1189,6 +1189,7 @@ export class PiRpcAgentSession implements AgentSession {
private readonly contextUserEntries: PiCapturedEntry[] = []; private readonly contextUserEntries: PiCapturedEntry[] = [];
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>(); private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>(); private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
private compactionPromise: Promise<void> | null = null;
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
private outOfBandCompactionStarted = false; private outOfBandCompactionStarted = false;
private outOfBandCompactionCompleted = false; private outOfBandCompactionCompleted = false;
@@ -1296,6 +1297,15 @@ export class PiRpcAgentSession implements AgentSession {
void (async () => { void (async () => {
try { try {
if (this.compactionPromise) {
// Pi RPC rejects prompts until the manual compaction request settles.
await this.compactionPromise.catch(() => undefined);
const turnCanceled =
this.closed || this.activeTurnId !== turnId || this.interruptingTurn?.turnId === turnId;
if (turnCanceled) {
return;
}
}
const ack = await this.runtimeSession.prompt(payload.text, payload.images); const ack = await this.runtimeSession.prompt(payload.text, payload.images);
this.activePromptRequestId = ack.requestId ?? null; this.activePromptRequestId = ack.requestId ?? null;
const correlatedResult = ack.requestId const correlatedResult = ack.requestId
@@ -1804,7 +1814,8 @@ export class PiRpcAgentSession implements AgentSession {
this.outOfBandCompactionStarted = false; this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false; this.outOfBandCompactionCompleted = false;
try { try {
await this.runtimeSession.compact(customInstructions); this.compactionPromise = this.runtimeSession.compact(customInstructions);
await this.compactionPromise;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if ( if (
@@ -1830,6 +1841,7 @@ export class PiRpcAgentSession implements AgentSession {
}, },
}); });
} finally { } finally {
this.compactionPromise = null;
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) { if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
this.outOfBandCompactionEmit = null; this.outOfBandCompactionEmit = null;
this.outOfBandCompactionStarted = false; this.outOfBandCompactionStarted = false;
@@ -108,6 +108,7 @@ export class FakePiSession implements PiRuntimeSession {
steerError: Error | null = null; steerError: Error | null = null;
readonly controlRequests: string[] = []; readonly controlRequests: string[] = [];
clearQueueError: Error | null = null; clearQueueError: Error | null = null;
clearQueueCompletion: Promise<void> | null = null;
readonly compactRequests: Array<{ customInstructions?: string }> = []; readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = []; readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = []; readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
@@ -140,6 +141,7 @@ export class FakePiSession implements PiRuntimeSession {
subagentSubscriptionError: Error | null = null; subagentSubscriptionError: Error | null = null;
setSessionNameError: Error | null = null; setSessionNameError: Error | null = null;
compactError: Error | null = null; compactError: Error | null = null;
compactCompletion: Promise<void> | null = null;
emitCompactEnd = true; emitCompactEnd = true;
getStateError: Error | null = null; getStateError: Error | null = null;
getSessionStatsError: Error | null = null; getSessionStatsError: Error | null = null;
@@ -233,6 +235,7 @@ export class FakePiSession implements PiRuntimeSession {
async clearQueue(): Promise<void> { async clearQueue(): Promise<void> {
this.controlRequests.push("clear_queue"); this.controlRequests.push("clear_queue");
await this.clearQueueCompletion;
if (this.clearQueueError) { if (this.clearQueueError) {
throw this.clearQueueError; throw this.clearQueueError;
} }
@@ -241,6 +244,7 @@ export class FakePiSession implements PiRuntimeSession {
async compact(customInstructions?: string): Promise<void> { async compact(customInstructions?: string): Promise<void> {
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions }); this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
this.emit({ type: "compaction_start", reason: "manual" }); this.emit({ type: "compaction_start", reason: "manual" });
await this.compactCompletion;
if (this.emitCompactEnd) { if (this.emitCompactEnd) {
this.emit({ type: "compaction_end", reason: "manual" }); this.emit({ type: "compaction_end", reason: "manual" });
} }
@@ -1,6 +1,6 @@
import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js"; import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js";
const PASEO_MCP_SERVER_NAME = "paseo"; export const PASEO_MCP_SERVER_NAME = "paseo";
const PASEO_MCP_PATHNAME = "/mcp/agents"; const PASEO_MCP_PATHNAME = "/mcp/agents";
export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig { export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig {
@@ -1,3 +1,4 @@
import { registerPluginTools } from "./plugin-tools.js";
import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js"; import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js";
import type { AgentMessageDelivery } from "../agent-messages/index.js"; import type { AgentMessageDelivery } from "../agent-messages/index.js";
import { import {
@@ -704,6 +705,7 @@ function createToolCatalog(
} }
const localHostOperations = new Map<string, HostOperation>(); const localHostOperations = new Map<string, HostOperation>();
const tools = new Map<string, PaseoToolDefinition>(); const tools = new Map<string, PaseoToolDefinition>();
const builtinNames = new Set<string>();
const hostTools = new Map([ const hostTools = new Map([
["create_agent", "createAgent"], ["create_agent", "createAgent"],
["create_workspace", "createWorkspace"], ["create_workspace", "createWorkspace"],
@@ -738,6 +740,7 @@ function createToolCatalog(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries.
handler: (input: any, context: PaseoToolExecutionContext) => Promise<PaseoToolResult>, handler: (input: any, context: PaseoToolExecutionContext) => Promise<PaseoToolResult>,
) => { ) => {
builtinNames.add(name);
if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) { if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) {
return; return;
} }
@@ -3272,6 +3275,14 @@ function createToolCatalog(
}, },
); );
registerPluginTools({
agentManager,
callerAgentId,
scope,
tools,
builtinNames,
policy: options.paseoToolPolicy,
});
return toCatalog(); return toCatalog();
} }
@@ -0,0 +1,40 @@
import { z } from "zod";
import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
import type { AgentManager } from "../agent-manager.js";
import { isPaseoToolEnabled } from "../paseo-tool-policy.js";
import type { PaseoToolDefinition } from "./types.js";
import { addModelVisibleStructuredContent } from "./paseo-tool-serialization.js";
export function registerPluginTools(options: {
agentManager: AgentManager;
callerAgentId?: string;
scope: "agent" | "top-level";
tools: Map<string, PaseoToolDefinition>;
builtinNames: Set<string>;
policy?: ProviderPaseoToolsPolicy;
}): void {
const { agentManager, callerAgentId, scope, tools, builtinNames, policy } = options;
if (scope !== "agent" || !callerAgentId) return;
for (const tool of agentManager.getPluginTools(callerAgentId)) {
if (builtinNames.has(tool.name)) {
throw new Error(
`Agent tool ${tool.name} from plugin ${tool.pluginId} collides with Paseo tool ${tool.name}`,
);
}
if (!isPaseoToolEnabled(policy, tool.name)) continue;
tools.set(tool.name, {
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: z.fromJSONSchema(tool.inputSchema),
async handler(input, { signal }) {
const result = await agentManager.executePluginTool(tool, callerAgentId, input, signal);
return addModelVisibleStructuredContent({
content: result.text === undefined ? [] : [{ type: "text", text: result.text }],
structuredContent: result.structuredContent,
isError: result.isError,
});
},
});
}
}
@@ -0,0 +1,147 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it } from "vitest";
import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js";
let directory: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-"));
});
afterEach(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it.each(["EPERM", "EACCES", "EBUSY"])(
"commits a completed receipt after transient Windows %s replace failures",
async (code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
let failures = 3;
let elapsed = 0;
await writeJsonFileAtomic(
file,
{ state: "completed" },
{
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
elapsed += milliseconds;
},
rename: async (source, destination) => {
if (failures-- > 0) {
expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({
state: "pending",
});
expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" });
throw Object.assign(new Error("destination held open"), { code });
}
await fs.rename(source, destination);
},
},
);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
expect(elapsed).toBeGreaterThan(0);
},
);
it.each(["EPERM", "EACCES", "EBUSY"])(
"preserves the previous file and removes the temp file when Windows %s persists",
async (code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
const error = Object.assign(new Error("destination held open"), { code });
const waits: number[] = [];
let elapsed = 0;
const attempts: number[] = [];
await expect(
writeJsonFileAtomic(
file,
{ state: "completed" },
{
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
waits.push(milliseconds);
elapsed += milliseconds;
},
rename: async () => {
attempts.push(elapsed);
throw error;
},
},
),
).rejects.toBe(error);
expect(elapsed).toBe(1000);
expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]);
expect(Math.max(...waits)).toBe(100);
expect(attempts.every((time) => time < 1000)).toBe(true);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
},
);
it.each([
["linux", "EPERM"],
["darwin", "EACCES"],
["linux", "EBUSY"],
["win32", "ENOENT"],
["win32", "ENOSPC"],
["win32", "EXDEV"],
] as const)("does not retry %s %s failures", async (platform, code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
const error = Object.assign(new Error("rename failed"), { code });
let attempts = 0;
const waits: number[] = [];
await expect(
writeFileAtomic(file, "replacement", {
platform,
now: () => 0,
sleep: async (milliseconds) => {
waits.push(milliseconds);
},
rename: async () => {
attempts++;
throw error;
},
}),
).rejects.toBe(error);
expect(attempts).toBe(1);
expect(waits).toEqual([]);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
});
it("does not retry after a rename consumes the Windows retry budget", async () => {
const file = path.join(directory, "receipt.json");
const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" });
let elapsed = 0;
const waits: number[] = [];
await expect(
writeFileAtomic(file, "replacement", {
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
waits.push(milliseconds);
},
rename: async () => {
elapsed += 1000;
throw error;
},
}),
).rejects.toBe(error);
expect(waits).toEqual([]);
expect(await fs.readdir(directory)).toEqual([]);
});
it("creates parent directories and replaces binary and JSON files through the default runtime", async () => {
const file = path.join(directory, "nested", "file");
const bytes = Buffer.from([0, 255, 128, 1]);
await writeFileAtomic(file, bytes);
expect(await fs.readFile(file)).toEqual(bytes);
await writeJsonFileAtomic(file, { state: "completed" });
expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}');
expect(await fs.readdir(path.dirname(file))).toEqual(["file"]);
});
+54 -3
View File
@@ -1,10 +1,26 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
interface AtomicFileRuntime {
platform: NodeJS.Platform;
rename: (source: string, destination: string) => Promise<void>;
now: () => number;
sleep: (milliseconds: number) => Promise<void>;
}
const runtime: AtomicFileRuntime = {
platform: process.platform,
rename: fs.rename,
now: () => performance.now(),
sleep,
};
export async function writeFileAtomic( export async function writeFileAtomic(
filePath: string, filePath: string,
data: string | NodeJS.ArrayBufferView, data: string | NodeJS.ArrayBufferView,
operations: AtomicFileRuntime = runtime,
): Promise<void> { ): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = path.join( const tempPath = path.join(
@@ -13,13 +29,48 @@ export async function writeFileAtomic(
); );
try { try {
await fs.writeFile(tempPath, data, "utf8"); await fs.writeFile(tempPath, data, "utf8");
await fs.rename(tempPath, filePath); await replaceFile(tempPath, filePath, operations);
} catch (error) { } catch (error) {
await fs.rm(tempPath, { force: true }); await fs.rm(tempPath, { force: true });
throw error; throw error;
} }
} }
export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> { async function replaceFile(
await writeFileAtomic(filePath, JSON.stringify(value, null, 2)); source: string,
destination: string,
operations: AtomicFileRuntime,
): Promise<void> {
// Windows readers/scanners can briefly deny replacement. Yield with backoff,
// but keep interactive persistence failures bounded to a one-second retry budget.
const deadline = operations.now() + 1000;
let delay = 10;
for (;;) {
try {
await operations.rename(source, destination);
return;
} catch (error) {
if (
operations.platform !== "win32" ||
!(error instanceof Error) ||
!("code" in error) ||
!["EPERM", "EACCES", "EBUSY"].includes(String(error.code))
) {
throw error;
}
const remaining = deadline - operations.now();
if (remaining <= 0) throw error;
await operations.sleep(Math.min(delay, remaining));
if (operations.now() >= deadline) throw error;
delay = Math.min(delay * 2, 100);
}
}
}
export async function writeJsonFileAtomic(
filePath: string,
value: unknown,
operations: AtomicFileRuntime = runtime,
): Promise<void> {
await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations);
} }
+7
View File
@@ -995,6 +995,7 @@ export async function createPaseoDaemon(
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
const agentManager = new AgentManager({ const agentManager = new AgentManager({
pluginLifecycle: pluginRuntime, pluginLifecycle: pluginRuntime,
pluginAgentTools: pluginRuntime,
clients: initialAgentManagerState.clients, clients: initialAgentManagerState.clients,
providerDefinitions: initialAgentManagerState.providerDefinitions, providerDefinitions: initialAgentManagerState.providerDefinitions,
registry: agentStorage, registry: agentStorage,
@@ -1015,6 +1016,10 @@ export async function createPaseoDaemon(
const unsubscribePluginProviders = const unsubscribePluginProviders =
pluginRuntime.subscribeProviderRegistrations(syncPluginProviders); pluginRuntime.subscribeProviderRegistrations(syncPluginProviders);
const unsubscribePluginSessionChanges = pluginRuntime.subscribeSessionOpenChanges(() => {
agentManager.invalidatePluginSessions();
});
const detachAgentStoragePersistence = attachAgentStoragePersistence( const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger, logger,
agentManager, agentManager,
@@ -1914,6 +1919,7 @@ export async function createPaseoDaemon(
localCredential = null; localCredential = null;
await deleteLocalCredential(config.paseoHome); await deleteLocalCredential(config.paseoHome);
unsubscribePluginProviders(); unsubscribePluginProviders();
unsubscribePluginSessionChanges();
await pluginRuntime.stopAllPlugins().catch(() => undefined); await pluginRuntime.stopAllPlugins().catch(() => undefined);
await serviceProxy.stopStandalone().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined);
await agentProviderRuntime.shutdown().catch(() => undefined); await agentProviderRuntime.shutdown().catch(() => undefined);
@@ -1933,6 +1939,7 @@ export async function createPaseoDaemon(
// that is still open. Plugins themselves are stopped once every session // that is still open. Plugins themselves are stopped once every session
// they serve has been closed, further down. // they serve has been closed, further down.
unsubscribePluginProviders(); unsubscribePluginProviders();
unsubscribePluginSessionChanges();
await hubRelationships.stop(); await hubRelationships.stop();
workspaceReconciliation.dispose(); workspaceReconciliation.dispose();
scriptHealthMonitor.stop(); scriptHealthMonitor.stop();
@@ -1544,7 +1544,7 @@ test(
); );
test( test(
"remote agent reports, local CLI sends and automatic completion share the recipient batch", "remote agent reports, local CLI sends and automatic completion reach the same recipient",
{ timeout: 30000 }, { timeout: 30000 },
async () => { async () => {
const parentPrompts: string[] = []; const parentPrompts: string[] = [];
@@ -1633,7 +1633,10 @@ test(
}); });
expect(report.isError).not.toBe(true); expect(report.isError).not.toBe(true);
expect(report.structuredContent).toMatchObject({ deliveryStatus: "queued", serverId: aId }); expect(report.structuredContent).toMatchObject({ deliveryStatus: "queued", serverId: aId });
expect(parentPrompts).toEqual([]); // A CLI process can take longer than the two-second quiet window to start.
// Exercise each delivery across windows; delivery.test.ts owns batching with a clock.
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1);
expect(parentPrompts[0]).toContain("REMOTE_REPORT &lt;ready&gt;");
const cli = await promisify(execFile)( const cli = await promisify(execFile)(
process.execPath, process.execPath,
@@ -1656,7 +1659,8 @@ test(
}, },
); );
expect(JSON.parse(cli.stdout)).toMatchObject({ status: "queued" }); expect(JSON.parse(cli.stdout)).toMatchObject({ status: "queued" });
expect(parentPrompts).toEqual([]); await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2);
expect(parentPrompts[1]).toContain("LOCAL_CLI_REPORT");
const receiptStates = async () => { const receiptStates = async () => {
const directory = join(a.paseoHome, "agent-requests"); const directory = join(a.paseoHome, "agent-requests");
const receipts = await Promise.all( const receipts = await Promise.all(
@@ -1671,13 +1675,10 @@ test(
.map((receipt) => receipt.state) .map((receipt) => receipt.state)
.sort(); .sort();
}; };
expect(await receiptStates()).toEqual(["pending", "pending"]);
finishChild.resolve(); finishChild.resolve();
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(3);
expect(parentPrompts[0]).toContain("REMOTE_REPORT &lt;ready&gt;"); expect(parentPrompts[2]).toContain('event="finished"');
expect(parentPrompts[0]).toContain("LOCAL_CLI_REPORT"); expect(parentPrompts[2]).toContain(`source-agent-id="${child.id}"`);
expect(parentPrompts[0]).toContain('event="finished"');
expect(parentPrompts[0]).toContain(`source-agent-id="${child.id}"`);
await expect await expect
.poll(receiptStates, { .poll(receiptStates, {
timeout: 10000, timeout: 10000,
@@ -1870,6 +1871,7 @@ test(
}); });
const a = await daemon({ const a = await daemon({
mcpEnabled: true, mcpEnabled: true,
logger: completionLogger(),
agentClients: createTestAgentClients({ agentClients: createTestAgentClients({
onStartTurn: (prompt) => { onStartTurn: (prompt) => {
if (String(prompt).includes('kind="agent-notification"')) if (String(prompt).includes('kind="agent-notification"'))
@@ -1931,8 +1933,14 @@ test(
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2);
expect(parentPrompts[1]).toContain('event="finished"'); expect(parentPrompts[1]).toContain('event="finished"');
await expect await expect
.poll(async () => // Provider start precedes dispatch acknowledgment and the durable removal commit.
JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), .poll(
async () =>
JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")),
{
timeout: 10000,
message: "Parent accepted completion; waiting for durable intent removal",
},
) )
.toEqual([]); .toEqual([]);
}, },
@@ -219,7 +219,7 @@ test(
); );
test( test(
"real Pi daemon executes manual compact out-of-band instead of prompt text", "real Pi daemon accepts a prompt after manual compact",
async () => { async () => {
const cwd = tmpCwd("pi-manual-compact-"); const cwd = tmpCwd("pi-manual-compact-");
@@ -234,20 +234,24 @@ test(
cwd, cwd,
title: "pi-manual-compact", title: "pi-manual-compact",
provider: "pi", provider: "pi",
model: PI_COMPACTION_TEST_MODEL, model: PI_REAL_TEST_MODEL,
}); });
await client.sendMessage(agent.id, "Reply exactly: compact-ready"); await client.sendMessage(agent.id, "Reply exactly: compact-ready");
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status).toBe("idle"); expect(finish.status, JSON.stringify(await fetchCanonicalTimeline(client, agent.id))).toBe(
"idle",
);
await client.sendMessage(agent.id, "/compact summarize this e2e run"); await client.sendMessage(agent.id, "/compact summarize this e2e run");
await waitForTimelineItem( await waitForTimelineItem(
client, client,
agent.id, agent.id,
(item) => item.type === "compaction" && item.status === "completed", (item) => item.type === "compaction" && item.status === "completed",
); );
await client.sendMessage(agent.id, "Reply exactly: after-compaction");
const followUpFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(followUpFinish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, agent.id); const items = await fetchCanonicalTimeline(client, agent.id);
expect(items).toEqual( expect(items).toEqual(
@@ -263,9 +267,12 @@ test(
}), }),
]), ]),
); );
expect( expect(items).toContainEqual(
items.some((item) => item.type === "user_message" && item.text.includes("/compact")), expect.objectContaining({
).toBe(false); type: "assistant_message",
text: "after-compaction",
}),
);
expect( expect(
items items
.filter((item) => item.type === "assistant_message") .filter((item) => item.type === "assistant_message")
@@ -4,17 +4,29 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js"; import { DaemonClient } from "../test-utils/daemon-client.js";
import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
test("the configuration example adds MCP servers and overrides Codex options while preserving other configuration", async () => { test("the configuration example saves Codex options and injects launch-only MCP servers while preserving other configuration", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-")); const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-"));
const launches: AgentSessionConfig[] = [];
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
daemonVersion: "0.8.0", daemonVersion: "0.13.2",
agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) }, agentClients: {
codex: createTestAgentClient("codex", {
supportsMcpServers: true,
beforeCreateSession: async (config) => {
launches.push(config);
},
}),
},
mcpEnabled: false, mcpEnabled: false,
}); });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.13.2",
});
try { try {
await client.connect(); await client.connect();
await client.fetchAgents({ subscribe: {} }); await client.fetchAgents({ subscribe: {} });
@@ -44,9 +56,15 @@ test("the configuration example adds MCP servers and overrides Codex options whi
}, },
mcpServers: { mcpServers: {
existing: { type: "http", url: "https://existing.example.com/mcp" }, existing: { type: "http", url: "https://existing.example.com/mcp" },
company: { type: "http", url: "https://tools.example.com/mcp" },
}, },
}); });
expect(daemon.daemon.agentManager.getAgent(agent.id)!.config.mcpServers).toEqual({
existing: { type: "http", url: "https://existing.example.com/mcp" },
});
expect(launches[0].mcpServers).toEqual({
existing: { type: "http", url: "https://existing.example.com/mcp" },
company: { type: "http", url: "https://tools.example.com/mcp" },
});
await client.archiveAgent(agent.id); await client.archiveAgent(agent.id);
} finally { } finally {
await client.close(); await client.close();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
import { expect, test } from "vitest";
import { z } from "zod";
import { defineAgentTool } from "@getpaseo/plugin/server";
import { serializeAgentTool } from "./agent-tools.js";
import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js";
test("agent tool schemas survive the process boundary and catalog round trip", () => {
const input = z
.object({
title: z.string().min(1).describe("Issue title"),
state: z.enum(["open", "closed"]),
tags: z.array(z.string()).optional(),
nested: z.object({ enabled: z.boolean() }),
})
.describe("Create an issue");
const tool = defineAgentTool({
name: "create_issue",
description: "Create",
input,
execute: () => ({ text: "done" }),
});
const metadata = JSON.parse(JSON.stringify(serializeAgentTool(tool)));
const restored = z.fromJSONSchema(metadata.inputSchema);
expect(
serializePaseoToolInputParameters({
...metadata,
inputSchema: restored,
handler: async () => ({ content: [] }),
}),
).toEqual({
...metadata.inputSchema,
additionalProperties: {},
properties: {
...metadata.inputSchema.properties,
nested: { ...metadata.inputSchema.properties.nested, additionalProperties: {} },
},
});
expect(restored.parse({ title: "Bug", state: "open", nested: { enabled: true } })).toEqual({
title: "Bug",
state: "open",
nested: { enabled: true },
});
expect(
restored.safeParse({ title: "", state: "unknown", nested: { enabled: true } }).success,
).toBe(false);
});
@@ -0,0 +1,108 @@
import { z } from "zod";
import type {
AgentToolDefinition,
AgentToolResult,
PluginHookContext,
PluginSessionOpenRequest,
PluginHookAgent,
} from "@getpaseo/plugin/server";
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
export const agentToolMetadataSchema = z
.object({
name: z
.string()
.regex(/^[a-z][a-z0-9_]*$/)
.max(64),
title: z.string().optional(),
description: z.string(),
inputSchema: z.record(z.string(), z.unknown()),
pluginId: z.string().optional(),
pluginInstanceId: z.string().optional(),
})
.strict();
export type AgentToolMetadata = z.infer<typeof agentToolMetadataSchema>;
export type AttachedAgentTool = AgentToolMetadata & { pluginId: string; pluginInstanceId: string };
export type SessionOpenWireRequest = Omit<PluginSessionOpenRequest, "tools"> & {
tools: AgentToolMetadata[];
};
export interface PluginAgentToolsRuntime {
isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean;
detachTools(agentId: string): void;
callTool(
pluginId: string,
pluginInstanceId: string,
agent: PluginHookAgent,
name: string,
input: unknown,
signal?: AbortSignal,
): Promise<AgentToolResult>;
}
export function serializeAgentTool(tool: AgentToolDefinition): AgentToolMetadata {
return agentToolMetadataSchema.parse({
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: toJsonSchemaCompat(tool.input, { strictUnions: true, pipeStrategy: "input" }),
});
}
// This store lives only in the plugin process. Definitions and parsed values never cross IPC.
export class PluginAgentTools {
private readonly agents = new Map<string, AgentToolDefinition[]>();
private readonly calls = new Map<string, { agentId: string; controller: AbortController }>();
attach(agentId: string, tools: AgentToolDefinition[]): AgentToolMetadata[] {
const names = new Set<string>();
const metadata = tools.map((tool) => {
const serialized = serializeAgentTool(tool);
if (names.has(tool.name)) throw new Error(`Duplicate agent tool: ${tool.name}`);
names.add(tool.name);
return serialized;
});
this.agents.set(agentId, tools);
return metadata;
}
detach(agentId: string): void {
this.agents.delete(agentId);
for (const call of this.calls.values()) {
if (call.agentId === agentId) call.controller.abort();
}
}
cancel(requestId: string): void {
this.calls.get(requestId)?.controller.abort();
}
close(): void {
for (const call of this.calls.values()) call.controller.abort();
this.agents.clear();
}
async call(
requestId: string,
agent: PluginHookAgent,
name: string,
input: unknown,
paseo: PluginHookContext["paseo"],
): Promise<AgentToolResult> {
const controller = new AbortController();
this.calls.set(requestId, { agentId: agent.id, controller });
try {
const tool = this.agents.get(agent.id)?.find((definition) => definition.name === name);
if (!tool) throw new Error(`Agent tool not attached: ${name}`);
const parsed = await tool.input.parseAsync(input);
controller.signal.throwIfAborted();
const result = await tool.execute(parsed, { agent, paseo, signal: controller.signal });
controller.signal.throwIfAborted();
return result;
} catch (error) {
return { isError: true, text: error instanceof Error ? error.message : String(error) };
} finally {
this.calls.delete(requestId);
}
}
}
@@ -1,3 +1,4 @@
import type { PluginAgentToolsRuntime } from "./agent-tools.js";
import type { PluginLifecycle } from "./lifecycle/index.js"; import type { PluginLifecycle } from "./lifecycle/index.js";
import path from "node:path"; import path from "node:path";
import { stat, rm } from "node:fs/promises"; import { stat, rm } from "node:fs/promises";
@@ -37,6 +38,10 @@ import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js";
const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS); const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS);
interface PluginRuntimePort { interface PluginRuntimePort {
subscribeSessionOpenChanges?: PluginRuntime["subscribeSessionOpenChanges"];
isToolPluginAvailable?: PluginRuntime["isToolPluginAvailable"];
callTool?: PluginRuntime["callTool"];
detachTools?: PluginRuntime["detachTools"];
emit?: PluginLifecycle["emit"]; emit?: PluginLifecycle["emit"];
drainEvents?: PluginRuntime["drainEvents"]; drainEvents?: PluginRuntime["drainEvents"];
before?: PluginLifecycle["before"]; before?: PluginLifecycle["before"];
@@ -131,6 +136,34 @@ export class PluginService {
this.runtime.emit?.(name, event); this.runtime.emit?.(name, event);
}; };
subscribeSessionOpenChanges(listener: () => void): () => void {
return this.runtime.subscribeSessionOpenChanges?.(listener) ?? (() => {});
}
readonly isToolPluginAvailable: PluginAgentToolsRuntime["isToolPluginAvailable"] = (
pluginId,
pluginInstanceId,
) => {
return this.runtime.isToolPluginAvailable?.(pluginId, pluginInstanceId) ?? false;
};
readonly detachTools: PluginAgentToolsRuntime["detachTools"] = (agentId) => {
this.runtime.detachTools?.(agentId);
};
readonly callTool: PluginAgentToolsRuntime["callTool"] = async (
pluginId,
pluginInstanceId,
agent,
name,
input,
signal,
) => {
if (!this.isToolPluginAvailable(pluginId, pluginInstanceId) || !this.runtime.callTool)
return { isError: true, text: `Plugin is not available: ${pluginId}` };
return this.runtime.callTool(pluginId, pluginInstanceId, agent, name, input, signal);
};
async drainEvents(): Promise<void> { async drainEvents(): Promise<void> {
await this.runtime.drainEvents?.(); await this.runtime.drainEvents?.();
} }
@@ -93,8 +93,41 @@ test("session-open hooks reject changes to session identity instead of silently
reason: "resume", reason: "resume",
purpose: "interactive", purpose: "interactive",
env: {}, env: {},
title: null,
labels: {},
tools: [],
mcpServers: {},
}, },
paseo, paseo,
), ),
).rejects.toThrow("agent.session_open hooks can only change env"); ).rejects.toThrow("agent.session_open hooks can only change env");
}); });
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
"session-open targeting metadata is read-only: %j",
async (patch) => {
const hooks = new PluginHookHandlers(() => {});
hooks.before("agent.session_open", ({ request }) => ({ ...request, ...patch }));
await expect(
hooks.invoke(
"operation",
"before",
"agent.session_open",
{
agentId: "agent",
workspaceId: null,
provider: "codex",
cwd: "/project",
title: "Original",
labels: {},
tools: [],
mcpServers: {},
env: {},
reason: "create",
purpose: "interactive",
},
paseo,
),
).rejects.toThrow("agent.session_open hooks can only change env, tools, and mcpServers");
},
);
@@ -1,3 +1,11 @@
import { isDeepStrictEqual } from "node:util";
import {
serializeAgentTool,
agentToolMetadataSchema,
type SessionOpenWireRequest,
type PluginAgentTools,
} from "../agent-tools.js";
import type { AgentToolDefinition } from "@getpaseo/plugin/server";
import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js"; import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js";
import { z } from "zod"; import { z } from "zod";
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages"; import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
@@ -15,6 +23,15 @@ import type {
import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages"; import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages";
import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js";
export type PluginWireBeforeRequests = Omit<PluginBeforeRequests, "agent.session_open"> & {
"agent.session_open": SessionOpenWireRequest;
};
/** Daemon-only results; ownership never crosses the plugin process boundary. */
export type PluginBeforeResults = PluginWireBeforeRequests & {
"agent.session_open": SessionOpenWireRequest & { mcpServerOwners?: Record<string, string> };
};
export const lifecycleEventNames = [ export const lifecycleEventNames = [
"agent.created", "agent.created",
"agent.closed", "agent.closed",
@@ -38,28 +55,45 @@ const beforeSchemas = {
cwd: z.string(), cwd: z.string(),
reason: z.enum(["create", "resume", "refresh", "import"]), reason: z.enum(["create", "resume", "refresh", "import"]),
purpose: z.enum(["interactive", "history"]), purpose: z.enum(["interactive", "history"]),
title: z.string().nullable(),
labels: z.record(z.string(), z.string()),
tools: z.array(agentToolMetadataSchema),
env: z.record(z.string(), z.string()), env: z.record(z.string(), z.string()),
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
}) })
.strict(), .strict(),
"workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(),
}; };
const sessionOpenLocalResultSchema = beforeSchemas["agent.session_open"].extend({
tools: z.array(
z.custom<AgentToolDefinition>(
(tool) =>
typeof tool === "object" &&
tool !== null &&
"input" in tool &&
"execute" in tool &&
typeof tool.execute === "function",
),
),
});
export interface PluginLifecycle { export interface PluginLifecycle {
emit<Name extends keyof PluginLifecycleEvents>( emit<Name extends keyof PluginLifecycleEvents>(
name: Name, name: Name,
event: PluginLifecycleEvents[Name], event: PluginLifecycleEvents[Name],
): void; ): void;
before<Name extends keyof PluginBeforeRequests>( before<Name extends keyof PluginWireBeforeRequests>(
name: Name, name: Name,
request: PluginBeforeRequests[Name], request: PluginWireBeforeRequests[Name],
): Promise<PluginBeforeRequests[Name]>; ): Promise<PluginBeforeResults[Name]>;
} }
export function validateBeforeRequest<Name extends keyof PluginBeforeRequests>( export function validateBeforeRequest<Name extends keyof PluginWireBeforeRequests>(
name: Name, name: Name,
value: unknown, value: unknown,
): PluginBeforeRequests[Name] { ): PluginWireBeforeRequests[Name] {
return beforeSchemas[name].parse(value) as PluginBeforeRequests[Name]; return beforeSchemas[name].parse(value) as PluginWireBeforeRequests[Name];
} }
export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): PluginHookWorkspace { export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): PluginHookWorkspace {
@@ -129,11 +163,11 @@ export function publishAgentStream(
} }
} }
export function validateBeforeResult<Name extends keyof PluginBeforeRequests>( export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
name: Name, name: Name,
input: PluginBeforeRequests[Name], input: PluginWireBeforeRequests[Name],
output: unknown, output: unknown,
): PluginBeforeRequests[Name] { ): PluginWireBeforeRequests[Name] {
const result = validateBeforeRequest(name, output); const result = validateBeforeRequest(name, output);
if (name === "agent.session_open") { if (name === "agent.session_open") {
const previous = beforeSchemas["agent.session_open"].parse(input); const previous = beforeSchemas["agent.session_open"].parse(input);
@@ -144,9 +178,11 @@ export function validateBeforeResult<Name extends keyof PluginBeforeRequests>(
previous.provider !== next.provider || previous.provider !== next.provider ||
previous.cwd !== next.cwd || previous.cwd !== next.cwd ||
previous.reason !== next.reason || previous.reason !== next.reason ||
previous.purpose !== next.purpose previous.purpose !== next.purpose ||
previous.title !== next.title ||
!isDeepStrictEqual(previous.labels, next.labels)
) { ) {
throw new Error("agent.session_open hooks can only change env"); throw new Error("agent.session_open hooks can only change env, tools, and mcpServers");
} }
} }
if (name === "agent.create") { if (name === "agent.create") {
@@ -168,7 +204,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
private readonly changed: () => void; private readonly changed: () => void;
constructor(changed: () => void) { private readonly agentTools: PluginAgentTools | undefined;
constructor(changed: () => void, agentTools?: PluginAgentTools) {
this.agentTools = agentTools;
this.changed = changed; this.changed = changed;
} }
@@ -209,6 +248,9 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
throw new Error(`Unknown before hook: ${name}`); throw new Error(`Unknown before hook: ${name}`);
} }
const hookName = name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests; const hookName = name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests;
if (hookName === "agent.session_open") {
return await this.openSession(input, { paseo, signal: controller.signal });
}
let request = validateBeforeRequest(hookName, input); let request = validateBeforeRequest(hookName, input);
for (const handler of this.transforms.get(name) ?? []) { for (const handler of this.transforms.get(name) ?? []) {
controller.signal.throwIfAborted(); controller.signal.throwIfAborted();
@@ -216,9 +258,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
{ request: structuredClone(request) }, { request: structuredClone(request) },
{ paseo, signal: controller.signal }, { paseo, signal: controller.signal },
); );
if (result !== undefined) { if (result !== undefined) request = validateBeforeResult(hookName, request, result);
request = validateBeforeResult(hookName, request, result);
}
} }
return request; return request;
} }
@@ -236,6 +276,33 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
} }
} }
private async openSession(
input: unknown,
context: PluginHookContext,
): Promise<SessionOpenWireRequest> {
let request = validateBeforeRequest("agent.session_open", input);
this.agentTools?.detach(request.agentId);
let tools: AgentToolDefinition[] = [];
for (const handler of this.transforms.get("agent.session_open") ?? []) {
context.signal.throwIfAborted();
const result = await handler(
{ request: { ...structuredClone(request), tools: [...tools] } },
context,
);
if (result === undefined) continue;
const local = sessionOpenLocalResultSchema.parse(result);
const metadata = local.tools.map(serializeAgentTool);
request = validateBeforeResult("agent.session_open", request, { ...local, tools: metadata });
tools = local.tools;
}
context.signal.throwIfAborted();
if (request.purpose === "history") {
request.tools = [];
request.mcpServers = {};
} else this.agentTools?.attach(request.agentId, tools);
return request;
}
cancel(id: string): void { cancel(id: string): void {
this.active.get(id)?.abort(); this.active.get(id)?.abort();
} }
@@ -1,3 +1,4 @@
import type { PluginHookAgent } from "@getpaseo/plugin/server";
import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage"; import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage";
import type { import type {
ProviderConnectRequest, ProviderConnectRequest,
@@ -31,6 +32,9 @@ export interface PluginUsageSourceMetadata {
} }
export type PluginProcessRequest = export type PluginProcessRequest =
| { type: "tool.call"; requestId: string; agent: PluginHookAgent; name: string; input: unknown }
| { type: "tool.cancel"; requestId: string }
| { type: "tools.detach"; agentId: string }
| { | {
type: "provider.status"; type: "provider.status";
requestId: string; requestId: string;
@@ -141,6 +145,26 @@ const frameFields = {
export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.discriminatedUnion( export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.discriminatedUnion(
"type", "type",
[ [
z
.object({
type: z.literal("tool.call"),
requestId: z.string(),
agent: z
.object({
id: z.string(),
workspaceId: z.string().nullable(),
parentAgentId: z.string().nullable(),
provider: z.string(),
cwd: z.string(),
title: z.string().nullable(),
})
.strict(),
name: z.string(),
input: z.unknown(),
})
.strict(),
z.object({ type: z.literal("tool.cancel"), requestId: z.string() }).strict(),
z.object({ type: z.literal("tools.detach"), agentId: z.string() }).strict(),
z z
.object({ .object({
type: z.literal("provider.status"), type: z.literal("provider.status"),
@@ -1,3 +1,4 @@
import { PluginAgentTools } from "./agent-tools.js";
import { PluginHookHandlers } from "./lifecycle/index.js"; import { PluginHookHandlers } from "./lifecycle/index.js";
import { evaluateBundle } from "./bundle-evaluator.js"; import { evaluateBundle } from "./bundle-evaluator.js";
import { import {
@@ -59,9 +60,10 @@ export function createPluginWorker(options: {
handler: RpcHandler; handler: RpcHandler;
} }
const agentTools = new PluginAgentTools();
const hooks = new PluginHookHandlers(() => { const hooks = new PluginHookHandlers(() => {
send({ type: "hooks.changed", hooks: hooks.catalog() }); send({ type: "hooks.changed", hooks: hooks.catalog() });
}); }, agentTools);
const handlers = new Map<string, RegisteredRpc>(); const handlers = new Map<string, RegisteredRpc>();
const providers = new Map<string, ProviderRegistration>(); const providers = new Map<string, ProviderRegistration>();
const usageSources = new Map<string, UsageSourceRegistration>(); const usageSources = new Map<string, UsageSourceRegistration>();
@@ -322,6 +324,7 @@ export function createPluginWorker(options: {
?.dispose() ?.dispose()
.catch((error) => console.error("Plugin API cleanup failed", error)); .catch((error) => console.error("Plugin API cleanup failed", error));
hooks.close(); hooks.close();
agentTools.close();
for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; for (const pending of pendingProviderConnections.values()) pending.tombstoned = true;
const currentCleanup = cleanup; const currentCleanup = cleanup;
cleanup = null; cleanup = null;
@@ -381,6 +384,42 @@ export function createPluginWorker(options: {
} }
} }
function handleToolMessage(
message: PluginProcessRequest,
): message is Extract<
PluginProcessRequest,
{ type: "tool.call" | "tool.cancel" | "tools.detach" }
> {
if (message.type === "tools.detach") {
agentTools.detach(message.agentId);
return true;
}
if (message.type === "tool.cancel") {
agentTools.cancel(message.requestId);
return true;
}
if (message.type === "tool.call") {
if (!paseo) {
send({ type: "error", requestId: message.requestId, error: "Plugin API unavailable" });
return true;
}
void agentTools
.call(message.requestId, message.agent, message.name, message.input, paseo)
.then((output) =>
send({
type: "result",
requestId: message.requestId,
output: jsonTransportValue(output),
}),
)
.catch((error) =>
send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
return true;
}
return false;
}
function handleMessage(rawMessage: unknown): void { function handleMessage(rawMessage: unknown): void {
const parsed = PluginProcessRequestSchema.safeParse(rawMessage); const parsed = PluginProcessRequestSchema.safeParse(rawMessage);
if (!parsed.success) { if (!parsed.success) {
@@ -422,6 +461,7 @@ export function createPluginWorker(options: {
rejectWhileStopping(message); rejectWhileStopping(message);
return; return;
} }
if (handleToolMessage(message)) return;
if (message.type === "provider.status") { if (message.type === "provider.status") {
void (async () => { void (async () => {
const provider = providers.get(message.providerId); const provider = providers.get(message.providerId);
+158 -12
View File
@@ -1,10 +1,16 @@
import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js";
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
import type { UsageScope } from "@getpaseo/plugin/server/usage"; import type { UsageScope } from "@getpaseo/plugin/server/usage";
import { import {
ProviderStatusSchema, ProviderStatusSchema,
type ProviderStatus, type ProviderStatus,
type ProviderStatusRequest, type ProviderStatusRequest,
} from "@getpaseo/plugin/server/provider"; } from "@getpaseo/plugin/server/provider";
import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; import type {
PluginLifecycleEvents,
PluginHookAgent,
AgentToolResult,
} from "@getpaseo/plugin/server";
import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js";
import { fork } from "node:child_process"; import { fork } from "node:child_process";
import { stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
@@ -40,6 +46,7 @@ import { evaluateBundle } from "./bundle-evaluator.js";
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
const REQUEST_TIMEOUT_MS = 30_000; const REQUEST_TIMEOUT_MS = 30_000;
const TOOL_CALL_TIMEOUT_MS = 10 * 60_000;
const MAX_LOG_ENTRIES = 500; const MAX_LOG_ENTRIES = 500;
const MAX_LOG_BYTES = 256 * 1024; const MAX_LOG_BYTES = 256 * 1024;
const MAX_LOG_LINE_BYTES = 16 * 1024; const MAX_LOG_LINE_BYTES = 16 * 1024;
@@ -69,6 +76,7 @@ interface PendingInvocation {
interface LoadedPlugin { interface LoadedPlugin {
id: string; id: string;
instanceId: string;
requirements: PluginRequirements | undefined; requirements: PluginRequirements | undefined;
clientBundle: string; clientBundle: string;
methods: ReadonlySet<string>; methods: ReadonlySet<string>;
@@ -302,6 +310,7 @@ export class PluginRuntime {
private readonly spawnChild: () => PluginChild; private readonly spawnChild: () => PluginChild;
private sessionHost: PluginPaseoSessionHost | null; private sessionHost: PluginPaseoSessionHost | null;
private readonly listeners = new Set<(pluginId: string, error?: string) => void>(); private readonly listeners = new Set<(pluginId: string, error?: string) => void>();
private readonly sessionOpenListeners = new Set<() => void>();
constructor( constructor(
logger: pino.Logger, logger: pino.Logger,
@@ -324,6 +333,16 @@ export class PluginRuntime {
return () => this.listeners.delete(listener); return () => this.listeners.delete(listener);
} }
subscribeSessionOpenChanges(listener: () => void): () => void {
this.sessionOpenListeners.add(listener);
return () => this.sessionOpenListeners.delete(listener);
}
private notifySessionOpenChange(loaded: LoadedPlugin): void {
if (!loaded.hooks.before.includes("agent.session_open")) return;
for (const listener of this.sessionOpenListeners) listener();
}
async startPlugin( async startPlugin(
pluginId: string, pluginId: string,
configuredPath: string, configuredPath: string,
@@ -340,6 +359,7 @@ export class PluginRuntime {
throw new Error(`Plugin start cancelled: ${pluginId}`); throw new Error(`Plugin start cancelled: ${pluginId}`);
} }
this.plugins.set(pluginId, loaded); this.plugins.set(pluginId, loaded);
this.notifySessionOpenChange(loaded);
this.appendLog(pluginId, "stdout", "[paseo] Plugin ready"); this.appendLog(pluginId, "stdout", "[paseo] Plugin ready");
} }
@@ -363,6 +383,7 @@ export class PluginRuntime {
clientBundle: bundles.clientBundle ?? "", clientBundle: bundles.clientBundle ?? "",
}); });
this.plugins.set(input.id, loaded); this.plugins.set(input.id, loaded);
this.notifySessionOpenChange(loaded);
this.appendLog(input.id, "stdout", "[paseo] Plugin ready"); this.appendLog(input.id, "stdout", "[paseo] Plugin ready");
} }
@@ -378,6 +399,7 @@ export class PluginRuntime {
const loaded = this.plugins.get(pluginId); const loaded = this.plugins.get(pluginId);
if (!loaded) return false; if (!loaded) return false;
this.plugins.delete(pluginId); this.plugins.delete(pluginId);
this.notifySessionOpenChange(loaded);
this.rejectPending(loaded, `Plugin stopped: ${pluginId}`); this.rejectPending(loaded, `Plugin stopped: ${pluginId}`);
await this.stopPlugin(loaded); await this.stopPlugin(loaded);
return true; return true;
@@ -546,14 +568,17 @@ export class PluginRuntime {
await Promise.all(this.pendingEvents); await Promise.all(this.pendingEvents);
} }
async before<Name extends keyof PluginBeforeRequests>( async before<Name extends keyof PluginWireBeforeRequests>(
name: Name, name: Name,
input: PluginBeforeRequests[Name], input: PluginWireBeforeRequests[Name],
): Promise<PluginBeforeRequests[Name]> { ): Promise<PluginBeforeResults[Name]> {
let request = validateBeforeRequest(name, input); let request = validateBeforeRequest(name, input);
const plugins = [...this.plugins.values()].sort((left, right) => { const plugins = [...this.plugins.values()].sort((left, right) => {
return left.id.localeCompare(right.id); return left.id.localeCompare(right.id);
}); });
const attached: AttachedAgentTool[] = [];
const mcpServers: SessionOpenWireRequest["mcpServers"] = {};
const mcpServerOwners: Record<string, string> = {};
for (const loaded of plugins) { for (const loaded of plugins) {
if (!loaded.hooks.before.includes(name)) { if (!loaded.hooks.before.includes(name)) {
continue; continue;
@@ -564,15 +589,31 @@ export class PluginRuntime {
requestId: randomUUID(), requestId: randomUUID(),
kind: "before", kind: "before",
name, name,
input: request, input:
name === "agent.session_open" ? { ...request, tools: [], mcpServers: {} } : request,
}); });
request = validateBeforeResult(name, request, output); request = validateBeforeResult(name, request, output);
if (name === "agent.session_open") {
const opened = request as SessionOpenWireRequest;
this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers);
this.collectAgentTools(attached, loaded, opened.tools);
}
} catch (error) { } catch (error) {
if (name === "agent.session_open")
this.detachTools((request as SessionOpenWireRequest).agentId);
throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, { throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, {
cause: error, cause: error,
}); });
} }
} }
if (name === "agent.session_open") {
return {
...request,
tools: attached,
mcpServers,
mcpServerOwners,
} as PluginBeforeResults[Name];
}
return request; return request;
} }
@@ -594,25 +635,119 @@ export class PluginRuntime {
return output; return output;
} }
private collectMcpServers(
servers: SessionOpenWireRequest["mcpServers"],
owners: Record<string, string>,
loaded: LoadedPlugin,
contributions: SessionOpenWireRequest["mcpServers"],
): void {
for (const [name, server] of Object.entries(contributions)) {
if (Object.hasOwn(servers, name)) {
throw new Error(
`MCP server ${name} from plugin ${loaded.id} collides with plugin ${owners[name]}`,
);
}
servers[name] = server;
owners[name] = loaded.id;
}
}
private collectAgentTools(
attached: AttachedAgentTool[],
loaded: LoadedPlugin,
tools: SessionOpenWireRequest["tools"],
): void {
for (const tool of tools) {
const collision = attached.find((entry) => entry.name === tool.name);
if (collision)
throw new Error(
`Agent tool ${tool.name} from plugin ${loaded.id} collides with plugin ${collision.pluginId}`,
);
attached.push({ ...tool, pluginId: loaded.id, pluginInstanceId: loaded.instanceId });
}
}
isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean {
return this.plugins.get(pluginId)?.instanceId === pluginInstanceId;
}
detachTools(agentId: string): void {
for (const loaded of this.plugins.values()) {
if (loaded.child) void send(loaded.child, { type: "tools.detach", agentId }).catch(() => {});
}
}
async callTool(
pluginId: string,
pluginInstanceId: string,
agent: PluginHookAgent,
name: string,
input: unknown,
signal?: AbortSignal,
): Promise<AgentToolResult> {
const loaded = this.plugins.get(pluginId);
if (!loaded || loaded.instanceId !== pluginInstanceId)
return { isError: true, text: `Plugin is not available: ${pluginId}` };
const requestId = randomUUID();
try {
return (await this.request(
loaded,
{ type: "tool.call", requestId, agent, name, input },
signal,
)) as AgentToolResult;
} catch (error) {
if (signal?.aborted) throw error;
return { isError: true, text: describeError(error) };
}
}
private request( private request(
loaded: LoadedPlugin, loaded: LoadedPlugin,
message: Extract<PluginProcessRequest, { requestId: string }>, message: Extract<PluginProcessRequest, { requestId: string }>,
signal?: AbortSignal,
): Promise<unknown> { ): Promise<unknown> {
const child = loaded.child; const child = loaded.child;
const pluginId = loaded.id; const pluginId = loaded.id;
if (!child) throw new Error(`Plugin ${pluginId} has no server entry`); if (!child) throw new Error(`Plugin ${pluginId} has no server entry`);
const requestId = message.requestId; const requestId = message.requestId;
signal?.throwIfAborted();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const cancel = () => {
clearTimeout(timeout);
loaded.pending.delete(requestId); loaded.pending.delete(requestId);
if (message.type === "hook") { if (message.type === "tool.call")
void send(child, { type: "hook.cancel", requestId }).catch(() => {}); void send(child, { type: "tool.cancel", requestId }).catch(() => {});
} signal?.removeEventListener("abort", cancel);
reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); reject(signal?.reason ?? new Error("Tool call canceled"));
}, REQUEST_TIMEOUT_MS); };
loaded.pending.set(requestId, { resolve, reject, timeout }); const timeout = setTimeout(
() => {
loaded.pending.delete(requestId);
signal?.removeEventListener("abort", cancel);
if (message.type === "tool.call")
void send(child, { type: "tool.cancel", requestId }).catch(() => {});
if (message.type === "hook") {
void send(child, { type: "hook.cancel", requestId }).catch(() => {});
}
reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`));
},
message.type === "tool.call" ? TOOL_CALL_TIMEOUT_MS : REQUEST_TIMEOUT_MS,
);
signal?.addEventListener("abort", cancel, { once: true });
loaded.pending.set(requestId, {
resolve: (value) => {
signal?.removeEventListener("abort", cancel);
resolve(value);
},
reject: (error) => {
signal?.removeEventListener("abort", cancel);
reject(error);
},
timeout,
});
void send(child, message).catch((error) => { void send(child, message).catch((error) => {
clearTimeout(timeout); clearTimeout(timeout);
signal?.removeEventListener("abort", cancel);
loaded.pending.delete(requestId); loaded.pending.delete(requestId);
reject(error); reject(error);
}); });
@@ -623,6 +758,7 @@ export class PluginRuntime {
const loaded = [...this.plugins.values()]; const loaded = [...this.plugins.values()];
this.plugins.clear(); this.plugins.clear();
for (const plugin of loaded) { for (const plugin of loaded) {
this.notifySessionOpenChange(plugin);
this.rejectPending(plugin, `Plugin stopped: ${plugin.id}`); this.rejectPending(plugin, `Plugin stopped: ${plugin.id}`);
} }
await Promise.all(loaded.map((plugin) => this.stopPlugin(plugin))); await Promise.all(loaded.map((plugin) => this.stopPlugin(plugin)));
@@ -641,6 +777,7 @@ export class PluginRuntime {
if (!serverBundle) { if (!serverBundle) {
return { return {
id: pluginId, id: pluginId,
instanceId: randomUUID(),
clientBundle: bundles.clientBundle ?? "", clientBundle: bundles.clientBundle ?? "",
requirements: manifest.requirements, requirements: manifest.requirements,
methods: new Set(), methods: new Set(),
@@ -769,6 +906,7 @@ export class PluginRuntime {
} }
loaded = { loaded = {
id: pluginId, id: pluginId,
instanceId: randomUUID(),
clientBundle, clientBundle,
requirements, requirements,
methods: new Set(ready.methods), methods: new Set(ready.methods),
@@ -852,7 +990,14 @@ export class PluginRuntime {
private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void {
if (message.type === "hooks.changed") { if (message.type === "hooks.changed") {
const hadSessionOpen = loaded.hooks.before.includes("agent.session_open");
loaded.hooks = message.hooks; loaded.hooks = message.hooks;
if (
this.plugins.get(loaded.id) === loaded &&
hadSessionOpen !== loaded.hooks.before.includes("agent.session_open")
) {
for (const listener of this.sessionOpenListeners) listener();
}
return; return;
} }
if (message.type === "settings.changed") { if (message.type === "settings.changed") {
@@ -1166,6 +1311,7 @@ export class PluginRuntime {
const wasPublished = this.plugins.get(loaded.id) === loaded; const wasPublished = this.plugins.get(loaded.id) === loaded;
if (wasPublished) { if (wasPublished) {
this.plugins.delete(loaded.id); this.plugins.delete(loaded.id);
this.notifySessionOpenChange(loaded);
} }
this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`); this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`);
for (const state of loaded.providerConnections.values()) { for (const state of loaded.providerConnections.values()) {
@@ -226,6 +226,7 @@ type ScheduleAgentManager = Pick<
AgentRunController, AgentRunController,
| "getAgent" | "getAgent"
| "reloadAgentSession" | "reloadAgentSession"
| "reloadStalePluginSession"
| "tryRunOutOfBand" | "tryRunOutOfBand"
| "hasInFlightRun" | "hasInFlightRun"
| "replaceAgentRun" | "replaceAgentRun"
@@ -60,6 +60,8 @@ interface FakeAgentSessionOptions {
closeSession?: () => Promise<void>; closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
historyEvents?: readonly AgentStreamEvent[]; historyEvents?: readonly AgentStreamEvent[];
} }
@@ -68,6 +70,8 @@ export interface TestAgentClientOptions {
closeSession?: () => Promise<void>; closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
supportsMcpServers?: boolean; supportsMcpServers?: boolean;
historyEvents?: readonly AgentStreamEvent[]; historyEvents?: readonly AgentStreamEvent[];
} }
@@ -340,6 +344,8 @@ class FakeAgentSession implements AgentSession {
private nextTurnOrdinal = 0; private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null; private activeForegroundTurnId: string | null = null;
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
private readonly onInterrupt: (() => void) | undefined;
private readonly closeSession: (() => Promise<void>) | undefined; private readonly closeSession: (() => Promise<void>) | undefined;
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined; private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
@@ -354,6 +360,8 @@ class FakeAgentSession implements AgentSession {
this.config = options.config; this.config = options.config;
this.id = options.sessionId ?? randomUUID(); this.id = options.sessionId ?? randomUUID();
this.memoryMarker = options.memoryMarker ?? null; this.memoryMarker = options.memoryMarker ?? null;
this.steerActiveTurn = options.steerActiveTurn;
this.onInterrupt = options.onInterrupt;
this.closeSession = options.closeSession; this.closeSession = options.closeSession;
this.onStartTurn = options.onStartTurn; this.onStartTurn = options.onStartTurn;
this.beforeTurnComplete = options.beforeTurnComplete; this.beforeTurnComplete = options.beforeTurnComplete;
@@ -896,6 +904,7 @@ class FakeAgentSession implements AgentSession {
} }
async interrupt(): Promise<void> { async interrupt(): Promise<void> {
this.onInterrupt?.();
this.interruptSignal.resolve(); this.interruptSignal.resolve();
} }
@@ -1234,6 +1243,8 @@ class FakeAgentClient implements AgentClient {
closeSession: this.options.closeSession, closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn, onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete, beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
historyEvents: this.options.historyEvents, historyEvents: this.options.historyEvents,
}); });
} }
@@ -1261,6 +1272,8 @@ class FakeAgentClient implements AgentClient {
closeSession: this.options.closeSession, closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn, onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete, beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
historyEvents: this.options.historyEvents, historyEvents: this.options.historyEvents,
}); });
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.13.1", "version": "0.13.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+18 -11
View File
@@ -1,14 +1,19 @@
# Agent configuration # Agent configuration
Changes new Codex agents through `server.before("agent.create", ...)`: Sets saved Codex options through `server.before("agent.create", ...)`:
- Sets `config.providerOptions.sandbox_mode` to `workspace-write`. - Sets `config.providerOptions.sandbox_mode` to `workspace-write`.
- Sets `config.providerOptions.approval_policy` to `on-request`. - Sets `config.providerOptions.approval_policy` to `on-request`.
- Adds the `company` server to `config.mcpServers`. - Preserves other configuration and provider options.
- Preserves other configuration, provider options, MCP servers, and environment overrides.
Adds the `company` MCP server to Codex launches through
`server.before("agent.session_open", ...)`. The server is never saved in the agent's configuration;
existing saved MCP servers are preserved. Existing agents receive the server through the
[session reopen behavior](../../public-docs/plugins/reference.md#attach-tools-to-agents).
Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using
this example. The URL is a placeholder. The `company` entry replaces an existing server with that name. this example. A `company` name collision with a saved server or another
plugin fails the session open and names both sources.
Install on a daemon with plugins enabled: Install on a daemon with plugins enabled:
@@ -16,11 +21,13 @@ Install on a daemon with plugins enabled:
paseo plugin install /absolute/path/to/plugin-examples/agent-configuration paseo plugin install /absolute/path/to/plugin-examples/agent-configuration
``` ```
The callback receives the request after earlier plugins have transformed it. The nested spreads Each session-open callback receives only this plugin's MCP servers. The nested spreads preserve
preserve their settings; the explicit values replace only the named fields. Paseo validates and saves entries added by this plugin's earlier callbacks. The create callback receives configuration after
the resulting configuration before starting the agent. Existing agents are unchanged. earlier plugins have transformed it; its explicit Codex options replace the named fields in the
saved configuration of new agents and remain after this plugin is disabled.
See the [lifecycle reference](../../public-docs/plugins/reference.md#lifecycle-hooks) for ordering See [Inject MCP servers](../../public-docs/plugins/reference.md#inject-mcp-servers) for choosing
and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and between saved configuration and plugin-owned servers. [Lifecycle actions](../lifecycle-actions)
environment injection. Its provider-switch callback clears provider options when changing Codex to demonstrates provider switching and environment injection. Its provider-switch callback clears
Claude, so use these examples separately when trying the Codex-specific settings. provider options when changing Codex to Claude, so use these examples separately when trying the
Codex-specific settings.
@@ -15,12 +15,22 @@ export default function contribute(server: PluginServerContext) {
sandbox_mode: "workspace-write", sandbox_mode: "workspace-write",
approval_policy: "on-request", approval_policy: "on-request",
}, },
mcpServers: { },
...request.config.mcpServers, };
company: { });
type: "http",
url: "https://tools.example.com/mcp", server.before("agent.session_open", ({ request }) => {
}, if (request.provider !== "codex") {
return;
}
return {
...request,
mcpServers: {
...request.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
}, },
}, },
}; };
@@ -1,6 +1,6 @@
{ {
"id": "agent-configuration", "id": "agent-configuration",
"requirements": { "requirements": {
"paseo": ">=0.8.0" "paseo": ">=0.13.2"
} }
} }
+33
View File
@@ -0,0 +1,33 @@
# Agent tools
Attaches `append_workspace_note` to agents whose `notes/enabled` label is `"true"`.
Calling the tool with `{ "note": "Run the targeted tests before committing." }` appends that
line to `AGENT_NOTES.md` in the calling agent's working directory and returns the file path.
It creates the file if it does not exist.
Install on a daemon with plugins enabled:
```bash
paseo plugin install /absolute/path/to/plugin-examples/agent-tools
```
Create a labeled agent through the SDK:
```ts
const agent = await paseo.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/projects/shop",
labels: { "notes/enabled": "true" },
});
```
The session-open callback also runs when an agent resumes or reloads. After changing an existing
agent's labels, call `paseo.agents.ref(id).reload()` to apply the selection immediately. This
interrupts a running turn. `refresh()` reads the agent snapshot without reopening its session.
From the repository root, `npm run typecheck` includes this example. Outside this repository,
use `paseo plugin init` to create the package and TypeScript setup, then copy `index.server.ts`
and the manifest into that project.
See [Attach tools to agents](../../public-docs/plugins/reference.md#attach-tools-to-agents)
for targeting, execution, provider gates, collisions, and plugin reload behavior.
@@ -0,0 +1,26 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server";
const appendWorkspaceNote = defineAgentTool({
name: "append_workspace_note",
description: "Append a note to AGENT_NOTES.md in this agent's working directory.",
input: z.object({ note: z.string().min(1).describe("Note to append") }),
async execute({ note }, { agent, signal }) {
const notePath = path.join(agent.cwd, "AGENT_NOTES.md");
await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal });
return {
text: `Saved note to ${notePath}`,
structuredContent: { path: notePath },
};
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
if (request.labels["notes/enabled"] !== "true") return;
return { ...request, tools: [...request.tools, appendWorkspaceNote] };
});
return () => {};
}
@@ -0,0 +1,4 @@
{
"id": "agent-tools",
"requirements": { "paseo": ">=0.13.2" }
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/builtin-plugins", "name": "@getpaseo/builtin-plugins",
"version": "0.13.1", "version": "0.13.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+179 -27
View File
@@ -653,9 +653,14 @@ export default function contribute(server: PluginServerContext) {
Hooks run on the daemon while the plugin is enabled, even with no app connected. Hooks run on the daemon while the plugin is enabled, even with no app connected.
### Change configuration and inject an MCP server `agent.create` edits the agent's saved configuration. Changes to `config` are persisted and remain
after the plugin is disabled or removed. `agent.session_open` contributes launch-only `env`, `tools`,
and `mcpServers`, re-derived on every create, resume, refresh, and import, following the plugin's
lifecycle without being saved in the agent record.
Add this callback inside `contribute(server)`. Replace the placeholder URL with your MCP endpoint. ### Change saved configuration
Add this callback inside `contribute(server)` to set the saved options for new Codex agents:
```ts ```ts
server.before("agent.create", ({ request }) => { server.before("agent.create", ({ request }) => {
@@ -672,27 +677,50 @@ server.before("agent.create", ({ request }) => {
sandbox_mode: "workspace-write", sandbox_mode: "workspace-write",
approval_policy: "on-request", approval_policy: "on-request",
}, },
mcpServers: {
...request.config.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
},
},
}, },
}; };
}); });
``` ```
| Input | Result | | Input | Result |
| ------------------------------------------- | ----------------------------- | | ------------------------------------------- | ----------------------- |
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | | `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
| `providerOptions.web_search: "disabled"` | Preserved by the spread | | `providerOptions.web_search: "disabled"` | Preserved by the spread |
| Existing `mcpServers.search` | Preserved by the spread |
| Existing `mcpServers.company` | Replaced with the entry above |
The selected provider validates `providerOptions` and must support the configured MCP servers. The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options
Explicit Codex sandbox and approval options override its mode presets. override its mode presets.
### Inject MCP servers
Use `agent.create.config.mcpServers` when the server should become part of the agent's saved
configuration. For a plugin-owned server, add this callback inside `contribute(server)`, replacing
the placeholder URL with your MCP endpoint:
```ts
server.before("agent.session_open", ({ request }) => {
if (request.provider !== "codex") return;
return {
...request,
mcpServers: {
...request.mcpServers,
company: { type: "http", url: "https://tools.example.com/mcp" },
},
};
});
```
`request.mcpServers` uses the same server configurations as `AgentSessionConfig.mcpServers`.
The map starts empty for each plugin and accumulates across that plugin's own callbacks. It never
contains the agent's saved servers or another plugin's servers. Replace the map to add, replace,
or remove this plugin's entries; returning `undefined` preserves it. Paseo merges the plugins'
final maps in plugin-ID order.
A name colliding with a saved server, Paseo's internal `paseo` server, or another plugin's server
fails the session open and names the server and both sources. A provider without MCP support fails
with `does not support MCP servers`, naming the plugin. Opens with `purpose: "history"` ignore
these servers. Install, disable, and reload changes reach existing agents through the
[session reopen behavior](#attach-tools-to-agents).
### Inject environment variables on every session opening ### Inject environment variables on every session opening
@@ -708,8 +736,122 @@ server.before("agent.session_open", ({ request }) => {
}); });
``` ```
This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an Session-open overrides are re-derived on every open. `agent.create.env` applies only to the
`agent.create` callback instead. initial launch and is not saved in the agent's configuration.
### Attach tools to agents
Define a tool in `index.server.ts` and attach it in `before("agent.session_open")`.
This example gives `append_workspace_note` only to agents with the label `notes/enabled: "true"`:
```ts
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server";
const appendWorkspaceNote = defineAgentTool({
name: "append_workspace_note",
description: "Append a note to AGENT_NOTES.md in this agent's working directory.",
input: z.object({ note: z.string().min(1).describe("Note to append") }),
async execute({ note }, { agent, signal }) {
const notePath = path.join(agent.cwd, "AGENT_NOTES.md");
await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal });
return {
text: `Saved note to ${notePath}`,
structuredContent: { path: notePath },
};
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
if (request.labels["notes/enabled"] !== "true") return;
return { ...request, tools: [...request.tools, appendWorkspaceNote] };
});
return () => {};
}
```
Set the label when creating an agent with the SDK:
```ts
const agent = await paseo.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/projects/shop",
labels: { "notes/enabled": "true" },
});
```
Select agents using any of the session-open request fields:
| Select by | Condition in the callback |
| ---------------- | -------------------------------------------- |
| Label | `request.labels["notes/enabled"] === "true"` |
| Provider | `request.provider === "codex"` |
| Workspace | `request.workspaceId === "workspace-456"` |
| Individual agent | `request.agentId === chosenAgentId` |
`request.tools` starts empty for each plugin. Each callback sees the tools returned by earlier
callbacks in that plugin; replace the array to add or remove tools. Returning `undefined` preserves
the request. Tools are ignored for `purpose: "history"`.
Import `defineAgentTool`, `AgentToolDefinition`, `AgentToolContext`, and `AgentToolResult` from
`@getpaseo/plugin/server`. The helper infers the validated input from your Zod schema:
```ts
declare function defineAgentTool<Input extends z.ZodType>(tool: {
name: string;
title?: string;
description: string;
input: Input;
execute(
input: z.output<Input>,
context: AgentToolContext,
): AgentToolResult | Promise<AgentToolResult>;
}): AgentToolDefinition;
interface AgentToolResult {
text?: string;
structuredContent?: unknown;
isError?: boolean;
}
```
| Execution context | Value |
| ----------------- | --------------------------------------------------------------------------------------------------- |
| `agent` | Calling agent as a `PluginHookAgent`, including `id`, `workspaceId`, `provider`, `cwd`, and `title` |
| `paseo` | SDK connected to this daemon |
| `signal` | Abort signal for this tool call; pass it to file operations or external requests |
`execute` runs in the plugin process. Closures and the original Zod schema stay there; only tool
metadata and call inputs/results cross the process boundary. Paseo validates input before calling
`execute`. Invalid input returns schema issues to the agent. A thrown error becomes
`{ isError: true, text: message }` and the plugin keeps running. Canceling the call or closing its
transport aborts `signal` and discards the result. Tool calls have a 10 minute ceiling, separate
from the 30 second hook timeout. Closing or reopening an agent session drops its old definitions.
Return model-visible text in `text`. `structuredContent` must be JSON-serializable; when `text` is
omitted, Paseo formats that value as text for the model. Set `isError: true` to report a tool error.
Paseo delivers attached tools through its agent tool transport, using MCP or native registration
as the provider supports. The same definition and callback work with either. Attached tools obey
the [same gates as Paseo tools](/docs/mcp#limit-paseo-tools-by-provider). Only that agent sees them.
Names must match `/^[a-z][a-z0-9_]*$/` and be at most 64 characters. A name colliding with a Paseo
tool or another plugin's tool on the same agent fails the session open and names both owners.
A duplicate name within one plugin also fails the session open. Agents the callback does not
target are unaffected.
| Change | When the agent receives it |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Start, stop, or reload a plugin declaring `agent.session_open` | Open sessions become stale. Each reopens before its next idle prompt; a running turn continues without interruption. The next prompt after that reopen reuses the session. |
| Change which agents the callback targets | Run `paseo plugin reload agent-tools` after editing this example, then send the agent a prompt when it is idle. |
| Apply immediately | Call `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`. `requestId` is optional. This reopens the provider session, reruns hooks with `reason: "refresh"`, and interrupts a running turn. |
| Read the current snapshot | Call `await paseo.agents.ref(id).refresh()`. This reads state without reopening the session. |
If an automatic reopen fails, the triggering prompt fails with that error. See the
[complete agent-tools example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools).
### Choose workspace isolation ### Choose workspace isolation
@@ -878,11 +1020,11 @@ type PluginTurnOutcome =
### Before hooks ### Before hooks
| Name | Request fields | Editable | | Name | Request fields | Editable |
| -------------------- | ----------------------------------------------------------------------- | --------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- |
| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | | `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | Only `env` | | `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools`, `mcpServers` | Launch-only `env`, `tools`, `mcpServers` |
| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | | `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request |
**`agent.create.config`** uses `AgentSessionConfig`: **`agent.create.config`** uses `AgentSessionConfig`:
@@ -903,6 +1045,10 @@ type PluginTurnOutcome =
"workspaceId": "workspace-456", "workspaceId": "workspace-456",
"provider": "codex", "provider": "codex",
"cwd": "/projects/shop", "cwd": "/projects/shop",
"title": "Workspace notes",
"labels": { "notes/enabled": "true" },
"tools": [],
"mcpServers": {},
"reason": "resume", "reason": "resume",
"purpose": "interactive", "purpose": "interactive",
"env": { "COMPANY_ENV": "development" } "env": { "COMPANY_ENV": "development" }
@@ -912,6 +1058,10 @@ type PluginTurnOutcome =
| Field | Values | | Field | Values |
| ------------- | ----------------------------------------------------------------------------------------------------------------------- | | ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | String or `null` | | `workspaceId` | String or `null` |
| `title` | Read-only string or `null`; creation title on create, stored title on resume. |
| `labels` | Read-only string map; creation labels on create, stored labels on resume. |
| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). |
| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). |
| `reason` | `create`, `resume`, `refresh`, `import` | | `reason` | `create`, `resume`, `refresh`, `import` |
| `purpose` | `interactive`, `history` | | `purpose` | `interactive`, `history` |
| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | | `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. |
@@ -923,7 +1073,7 @@ Creation request
→ agent.create hooks (plugin-ID order; registration order within each plugin) → agent.create hooks (plugin-ID order; registration order within each plugin)
→ resolve defaults and validate provider configuration → resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt → derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.session_open hooks (same ordering; env only) → agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD → set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration → open provider session and save agent configuration
``` ```
@@ -935,8 +1085,9 @@ Creation request
| `undefined` | Unchanged request | | `undefined` | Unchanged request |
| Throws or returns invalid data | Operation fails; later callbacks do not run | | Throws or returns invalid data | Operation fails; later callbacks do not run |
No automatic deep merge. Later callbacks can overwrite earlier values. Agent configuration is There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers`
saved; environment overrides are not persisted with it. accumulate only across callbacks within the same plugin. Returning a replacement map or array
replaces that scope's prior value.
### Context and cleanup ### Context and cleanup
@@ -960,6 +1111,7 @@ saved; environment overrides are not persisted with it.
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All eleven hooks; JSON logs with environment values redacted | | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All eleven hooks; JSON logs with environment values redacted |
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection | | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection |
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options | | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options |
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process |
Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`.
+171 -27
View File
@@ -529,9 +529,12 @@ export default function contribute(server: PluginServerContext) {
只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。 只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。
### 修改配置并注入 MCP 服务器 {#change-configuration-and-inject-an-mcp-server} `agent.create` 修改 Agent 的已保存配置。对 `config` 的修改会被持久化,在插件停用或移除后仍然保留。
`agent.session_open` 提供仅用于启动的 `env`、`tools` 和 `mcpServers`;每次创建、恢复、刷新和导入时都会重新计算,随插件的生命周期变化,不会写入 Agent 记录。
在 `contribute(server)` 中添加这个回调。把占位 URL 替换为你的 MCP 端点。 ### 修改已保存的配置 {#change-saved-configuration}
在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项:
```ts ```ts
server.before("agent.create", ({ request }) => { server.before("agent.create", ({ request }) => {
@@ -548,13 +551,6 @@ server.before("agent.create", ({ request }) => {
sandbox_mode: "workspace-write", sandbox_mode: "workspace-write",
approval_policy: "on-request", approval_policy: "on-request",
}, },
mcpServers: {
...request.config.mcpServers,
company: {
type: "http",
url: "https://tools.example.com/mcp",
},
},
}, },
}; };
}); });
@@ -564,10 +560,37 @@ server.before("agent.create", ({ request }) => {
| ------------------------------------------- | ------------------- | | ------------------------------------------- | ------------------- |
| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | | `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` |
| `providerOptions.web_search: "disabled"` | 由展开运算保留 | | `providerOptions.web_search: "disabled"` | 由展开运算保留 |
| 已有的 `mcpServers.search` | 由展开运算保留 |
| 已有的 `mcpServers.company` | 被替换为上面的条目 |
所选的 Provider 会校验 `providerOptions`,并且必须支持配置的 MCP 服务器。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 所选的 Provider 会校验 `providerOptions`。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。
### 注入 MCP 服务器 {#inject-mcp-servers}
需要将服务器保存为 Agent 配置的一部分时,使用 `agent.create.config.mcpServers`。
插件拥有的服务器使用下面的回调,在 `contribute(server)` 中添加,并把占位 URL 替换为你的 MCP 端点:
```ts
server.before("agent.session_open", ({ request }) => {
if (request.provider !== "codex") return;
return {
...request,
mcpServers: {
...request.mcpServers,
company: { type: "http", url: "https://tools.example.com/mcp" },
},
};
});
```
`request.mcpServers` 使用与 `AgentSessionConfig.mcpServers` 相同的服务器配置。
每个插件的映射从空开始,只在该插件自己的回调之间累积,不包含 Agent 已保存的服务器或其他插件的服务器。
替换映射即可添加、替换或移除本插件的条目;返回 `undefined` 会保持不变。
Paseo 按插件 ID 顺序合并各插件的最终映射。
名称与已保存的服务器、Paseo 内部的 `paseo` 服务器或其他插件的服务器冲突时,会话打开失败,错误会指出服务器名称及双方来源。
不支持 MCP 的 Provider 会报 `does not support MCP servers` 错误,并指出插件名称。
`purpose: "history"` 的会话打开会忽略这些服务器。安装、停用和重新加载插件后,现有 Agent
通过[会话重新打开行为](#attach-tools-to-agents)获取变化。
### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening} ### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening}
@@ -583,7 +606,118 @@ server.before("agent.session_open", ({ request }) => {
}); });
``` ```
它会在创建、恢复、刷新和导入时运行。如果只想在创建时注入,请改为在 `agent.create` 回调中设置 `env`。 会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.env` 只作用于首次启动,不会写入 Agent 的已保存配置。
### 为 Agent 附加工具 {#attach-tools-to-agents}
在 `index.server.ts` 中定义工具,并在 `before("agent.session_open")` 中附加它。
此示例只向带有 `notes/enabled: "true"` 标签的 Agent 提供 `append_workspace_note`:
```ts
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server";
const appendWorkspaceNote = defineAgentTool({
name: "append_workspace_note",
description: "Append a note to AGENT_NOTES.md in this agent's working directory.",
input: z.object({ note: z.string().min(1).describe("Note to append") }),
async execute({ note }, { agent, signal }) {
const notePath = path.join(agent.cwd, "AGENT_NOTES.md");
await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal });
return {
text: `Saved note to ${notePath}`,
structuredContent: { path: notePath },
};
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
if (request.labels["notes/enabled"] !== "true") return;
return { ...request, tools: [...request.tools, appendWorkspaceNote] };
});
return () => {};
}
```
通过 SDK 创建 Agent 时设置标签:
```ts
const agent = await paseo.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/projects/shop",
labels: { "notes/enabled": "true" },
});
```
使用会话打开请求中的字段选择 Agent:
| 选择依据 | 回调中的条件 |
| ---------- | -------------------------------------------- |
| 标签 | `request.labels["notes/enabled"] === "true"` |
| Provider | `request.provider === "codex"` |
| 工作区 | `request.workspaceId === "workspace-456"` |
| 单个 Agent | `request.agentId === chosenAgentId` |
每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具;
替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。
从 `@getpaseo/plugin/server` 导入 `defineAgentTool`、`AgentToolDefinition`、`AgentToolContext`
和 `AgentToolResult`。辅助函数会根据 Zod schema 推断验证后的输入类型:
```ts
declare function defineAgentTool<Input extends z.ZodType>(tool: {
name: string;
title?: string;
description: string;
input: Input;
execute(
input: z.output<Input>,
context: AgentToolContext,
): AgentToolResult | Promise<AgentToolResult>;
}): AgentToolDefinition;
interface AgentToolResult {
text?: string;
structuredContent?: unknown;
isError?: boolean;
}
```
| 执行 context | 值 |
| ------------ | --------------------------------------------------------------------------------------------------- |
| `agent` | 发起调用的 Agent,以 `PluginHookAgent` 表示,包括 `id`、`workspaceId`、`provider`、`cwd` 和 `title` |
| `paseo` | 已连接到此 daemon 的 SDK |
| `signal` | 此次工具调用的中止信号;请把它传给文件操作或外部请求 |
`execute` 在插件进程中运行。闭包和原始 Zod schema 保留在该进程中;只有工具元数据和调用的
输入、结果会跨进程传递。Paseo 在调用 `execute` 前验证输入。无效输入会将 schema 问题返回给
Agent。抛出的错误会转换为 `{ isError: true, text: message }`,插件继续运行。取消调用或关闭
其传输连接会中止 `signal` 并丢弃结果。工具调用最长运行 10 分钟,与钩子的 30 秒超时分开。
关闭或重新打开 Agent 会话会移除旧定义。
在 `text` 中返回模型可见的文本。`structuredContent` 必须可序列化为 JSON;省略 `text` 时,
Paseo 会将该值格式化为供模型读取的文本。设置 `isError: true` 可报告工具错误。
Paseo 通过 Agent 工具传输提供附加的工具,按 Provider 的支持情况使用 MCP 或原生注册。
同一份定义和回调适用于这两种方式。附加的工具遵循[与 Paseo 工具相同的限制](/docs/mcp#limit-paseo-tools-by-provider)。
只有该 Agent 能看到这些工具。
名称必须匹配 `/^[a-z][a-z0-9_]*$/`,且不超过 64 个字符。如果名称与同一 Agent 的 Paseo
工具或另一插件的工具冲突,会话打开会失败,并指出双方。一个插件内的重复名称也会导致
会话打开失败。回调未选中的 Agent 不受影响。
| 变更 | Agent 何时收到变更 |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 启动、停止或重新加载声明了 `agent.session_open` 的插件 | 已打开的会话会标记为过期。每个会话在下一次空闲提示词前重新打开;正在运行的轮次会继续,不会被中断。重新打开后的下一条提示词复用该会话。 |
| 修改回调选择的 Agent | 编辑此示例后运行 `paseo plugin reload agent-tools`,然后在 Agent 空闲时发送提示词。 |
| 立即应用 | 调用 `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`。`requestId` 可选。这会重新打开 Provider 会话,以 `reason: "refresh"` 再次运行钩子,并中断正在运行的轮次。 |
| 读取当前快照 | 调用 `await paseo.agents.ref(id).refresh()`。这只读取状态,不会重新打开会话。 |
如果自动重新打开失败,触发它的提示词会返回该错误。参阅
[完整 agent-tools 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools)。
### 选择工作区隔离方式 {#choose-workspace-isolation} ### 选择工作区隔离方式 {#choose-workspace-isolation}
@@ -741,11 +875,11 @@ type PluginTurnOutcome =
### before 钩子 {#before-hooks} ### before 钩子 {#before-hooks}
| 名称 | 请求字段 | 可编辑 | | 名称 | 请求字段 | 可编辑 |
| -------------------- | ----------------------------------------------------------------------- | ----------------------------------- | | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | | `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | 仅 `env` | | `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools`, `mcpServers` | 仅用于启动的 `env`、`tools`、`mcpServers` |
| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | | `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 |
**`agent.create.config`** 使用 `AgentSessionConfig`: **`agent.create.config`** 使用 `AgentSessionConfig`:
@@ -766,18 +900,26 @@ type PluginTurnOutcome =
"workspaceId": "workspace-456", "workspaceId": "workspace-456",
"provider": "codex", "provider": "codex",
"cwd": "/projects/shop", "cwd": "/projects/shop",
"title": "Workspace notes",
"labels": { "notes/enabled": "true" },
"tools": [],
"mcpServers": {},
"reason": "resume", "reason": "resume",
"purpose": "interactive", "purpose": "interactive",
"env": { "COMPANY_ENV": "development" } "env": { "COMPANY_ENV": "development" }
} }
``` ```
| 字段 | 取值 | | 字段 | 取值 |
| ------------- | ------------------------------------------------------------------------------------ | | ------------- | -------------------------------------------------------------------------------------------------------- |
| `workspaceId` | 字符串或 `null` | | `workspaceId` | 字符串或 `null` |
| `reason` | `create`、`resume`、`refresh`、`import` | | `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 |
| `purpose` | `interactive`、`history` | | `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 |
| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | | `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 |
| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 |
| `reason` | `create`、`resume`、`refresh`、`import` |
| `purpose` | `interactive`、`history` |
| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 |
### 顺序与返回值 {#ordering-and-returned-values} ### 顺序与返回值 {#ordering-and-returned-values}
@@ -786,7 +928,7 @@ Creation request
→ agent.create hooks (plugin-ID order; registration order within each plugin) → agent.create hooks (plugin-ID order; registration order within each plugin)
→ resolve defaults and validate provider configuration → resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt → derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.session_open hooks (same ordering; env only) → agent.session_open hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD → set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration → open provider session and save agent configuration
``` ```
@@ -798,7 +940,8 @@ Creation request
| `undefined` | 未改变的请求 | | `undefined` | 未改变的请求 |
| 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 | | 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 |
没有自动的深度合并。后面的回调可以覆盖前面的值。Agent 配置会被保存;环境变量覆盖项不会随之持久化。 没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。
返回替换的映射或数组会替换该范围内之前的值。
### context 与清理 {#context-and-cleanup} ### context 与清理 {#context-and-cleanup}
@@ -822,6 +965,7 @@ Creation request
| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十一个钩子;隐去环境变量值的 JSON 日志 | | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十一个钩子;隐去环境变量值的 JSON 日志 |
| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree | | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree |
| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 | | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 |
| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 |
用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。 用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。
@@ -1069,7 +1213,7 @@ export function IssueActions({ theme }: PluginSurfaceProps) {
```tsx ```tsx
import { Markdown } from "@getpaseo/plugin/client/ui"; import { Markdown } from "@getpaseo/plugin/client/ui";
<Markdown>{"## 审查\n\n检查**通过**。"}</Markdown>; <Markdown>{"## Review\n\nThe checks **passed**."}</Markdown>;
``` ```
`children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。 `children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。
+112 -1
View File
@@ -1,5 +1,8 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs"; import { readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { relative as relativePath } from "node:path"; import { relative as relativePath } from "node:path";
import test from "node:test"; import test from "node:test";
import { runInNewContext } from "node:vm"; import { runInNewContext } from "node:vm";
@@ -530,3 +533,111 @@ test("Windows units and integration run independently with complete routed cover
assert.equal(selected(integrationCondition, outputs, true), false); assert.equal(selected(integrationCondition, outputs, true), false);
} }
}); });
test("macOS release retries only transient stapling failures without rebuilding the signed app", async (t) => {
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
const wrapper = workflow
.match(/<<'STAPLER'\n([\s\S]*?)\n STAPLER/)[1]
.replace(/^ /gm, "");
const networkError =
'Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."';
const cases = [
{
name: "success",
outcomes: [0],
output: "stapled",
command: "stapler",
expected: 0,
attempts: 1,
},
{
name: "transient then success",
outcomes: [68, 0],
output: networkError,
command: "stapler",
expected: 0,
attempts: 2,
},
{
name: "exhausted network retries",
outcomes: [68, 68, 68, 68, 0],
output: networkError,
command: "stapler",
expected: 68,
attempts: 4,
},
{
name: "non-network staple error",
outcomes: [68, 0],
output: "ticket not found",
command: "stapler",
expected: 68,
attempts: 1,
},
{
name: "different exit code",
outcomes: [1, 0],
output: networkError,
command: "stapler",
expected: 1,
attempts: 1,
},
{
name: "notary submission is not retried",
outcomes: [68, 0],
output: networkError,
command: "notarytool",
expected: 68,
attempts: 1,
},
];
for (const scenario of cases) {
await t.test(scenario.name, () => {
const directory = mkdtempSync(join(tmpdir(), "stapler-retry-"));
try {
const statePath = join(directory, "state.json");
const realXcrun = join(directory, "real-xcrun");
const wrapperPath = join(directory, "xcrun");
writeFileSync(wrapperPath, wrapper, { mode: 0o755 });
writeFileSync(join(directory, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
writeFileSync(
statePath,
JSON.stringify({ outcomes: scenario.outcomes, output: scenario.output, calls: [] }),
);
writeFileSync(
realXcrun,
`#!/usr/bin/env node
const fs = require("node:fs");
const state = JSON.parse(fs.readFileSync(process.env.STAPLER_STATE, "utf8"));
state.calls.push(process.argv.slice(2));
const code = state.outcomes.shift();
fs.writeFileSync(process.env.STAPLER_STATE, JSON.stringify(state));
console.log(state.output);
process.exit(code);
`,
{ mode: 0o755 },
);
const args = [scenario.command, "staple", "-v", "Paseo signed.app"];
const result = spawnSync("bash", [wrapperPath, ...args], {
encoding: "utf8",
env: {
...process.env,
PASEO_REAL_XCRUN: realXcrun,
STAPLER_STATE: statePath,
PATH: `${directory}:${process.env.PATH}`,
},
timeout: 5000,
});
assert.equal(result.status, scenario.expected, result.stdout + result.stderr);
const state = JSON.parse(readFileSync(statePath, "utf8"));
assert.deepEqual(
state.calls,
Array.from({ length: scenario.attempts }, () => args),
);
assert.match(result.stdout, /stapled|network connection was lost|ticket not found/);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
}
});
+51 -21
View File
@@ -27,28 +27,29 @@ public docs; this skill indexes the references and examples.
Pick the contribution that matches the request. Each row names the registration, when it fits, and where the full contract lives. Most plugins combine several: a slash command that calls an RPC, which appends a timeline row, which a renderer draws. Pick the contribution that matches the request. Each row names the registration, when it fits, and where the full contract lives. Most plugins combine several: a slash command that calls an RPC, which appends a timeline row, which a renderer draws.
| Contribution | Registration | Use it when | Reference | | Contribution | Registration | Use it when | Reference |
| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items | | Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items |
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | | Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` | | File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` |
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | | Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands | | Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands |
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | | Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | | Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | | Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | | Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | | Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | | Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) | | Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) |
| Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) | | Creation and launch hooks | `server.before` | `agent.create` edits saved config; `agent.session_open` contributes launch-only `env`, `tools`, `mcpServers` on every open; `workspace.create` chooses isolation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) |
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | | Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
| Lifecycle task | Example | | Lifecycle task | Example |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | | Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) |
| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | | Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) |
| Inject MCP servers and change Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | | Inject launch-only MCP servers and save Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) |
| Attach a tool to selected agents | [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) |
## Create the project ## Create the project
@@ -377,6 +378,35 @@ Inputs and outputs are validated on both sides. Backend handlers receive the sam
Use TanStack Query for async request state, caching, and mutations. Use TanStack Query for async request state, caching, and mutations.
### Contribute tools and MCP servers at session open
Use `before("agent.session_open")` for per-agent tools and plugin-owned MCP servers. Add this
callback inside `contribute(server)`:
```ts
server.before("agent.session_open", ({ request }) => {
if (request.provider !== "codex") return;
return {
...request,
mcpServers: {
...request.mcpServers,
company: { type: "http", url: "https://tools.example.com/mcp" },
},
};
});
```
`tools` and `mcpServers` start empty for each plugin and accumulate across its own callbacks.
They are launch-only. Use `agent.create` to edit configuration that should be saved with the agent.
See [Attach tools to agents](https://paseo.sh/docs/plugins/reference.md#attach-tools-to-agents)
for `defineAgentTool` and targeting, and
[Inject MCP servers](https://paseo.sh/docs/plugins/reference.md#inject-mcp-servers) for server configuration and collisions.
Starting, stopping, or reloading a plugin with a session-open hook marks open agent sessions stale.
Each reopens before its next idle prompt without interrupting a running turn.
`await paseo.agents.ref(id).reload()` applies changes immediately and interrupts a running turn;
`refresh()` only reads the current snapshot.
### Debug daemon-side behavior ### Debug daemon-side behavior
Backend contributions can use normal Node logging. `console.log()` writes to the plugin's stdout; Backend contributions can use normal Node logging. `console.log()` writes to the plugin's stdout;