mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bdf94e98 | ||
|
|
837d96ad9a | ||
|
|
10bc10c99f | ||
|
|
65ce4b590a | ||
|
|
4f9d9d6de2 | ||
|
|
1f3266e2ba | ||
|
|
28834a91ae | ||
|
|
e3bbed3c73 | ||
|
|
42d74c2546 | ||
|
|
678fe308db | ||
|
|
287ca54749 | ||
|
|
902b0c7a16 |
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -1 +1 @@
|
|||||||
sha256-SKL7TMIuuV3imTIaQc66UDf3n6V3FB0nGSBucJ6rlA0=
|
sha256-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE=
|
||||||
|
|||||||
Generated
+28
-28
@@ -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
@@ -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,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))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,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,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,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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <ready>");
|
||||||
|
|
||||||
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 <ready>");
|
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);
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user