Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 573ae731f7 test(agent): update permission follow-up adapter 2026-10-26 15:18:05 +01:00
Mohamed Boudra 065893bd5b docs(plugins): show how to attach tools to agents 2026-10-26 14:23:31 +01:00
Mohamed Boudra 052004a97d feat(plugins): reopen idle agent sessions after hook changes
Defer automatic plugin changes until the next idle prompt so running turns keep steering. Expose session reload separately from snapshot refresh in the SDK.
2026-10-26 14:03:30 +01:00
Mohamed Boudra f5e311ffd8 feat(plugins): attach tools to individual agent sessions 2026-10-26 14:01:06 +01:00
43 changed files with 2060 additions and 987 deletions
-28
View File
@@ -169,34 +169,6 @@ jobs:
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: |
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+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
+3
View File
@@ -432,6 +432,9 @@ Server entries register lifecycle observers with `server.on()` and request trans
`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
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
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
+63
View File
@@ -1770,3 +1770,66 @@ test("qualified parents preserve foreign identity without resolving an equal loc
await client.close();
expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" });
});
test("agent reload reopens through the daemon RPC then returns the observed snapshot; refresh remains a read", async () => {
const { client, ws } = await connectClient();
try {
const agent = client.agents.ref("agent_sdk");
const snapshot = createAgent();
const reloaded = agent.reload({ requestId: "reload-sdk" });
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "refresh_agent_request",
agentId: "agent_sdk",
requestId: "reload-sdk",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "reload-sdk",
agentId: "agent_sdk",
timelineSize: 0,
},
}),
);
await expect
.poll(() => parseSentSessionMessage(ws.sent.at(-1)).type)
.toBe("fetch_agent_request");
const fetched = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: fetched.requestId,
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await reloaded).toEqual({ agent: snapshot, project: null });
expect(agent.current()).toEqual(snapshot);
const read = agent.refresh("snapshot-read");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_agent_request",
requestId: "snapshot-read",
});
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: "snapshot-read",
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await read).toEqual({ agent: snapshot, project: null });
} finally {
await client.close();
}
});
+11
View File
@@ -369,6 +369,11 @@ export interface PaseoAgentHandle {
readonly timeline: PaseoAgentTimelineHandle;
current(): PaseoAgent | 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>;
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */
@@ -981,6 +986,12 @@ function createAgentHandleFactory(
current = result?.agent ?? null;
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) => {
await daemonClient.sendAgentMessage(id, text, options);
},
+57 -1
View File
@@ -92,7 +92,12 @@ function typeErrors(code: string): string[] {
}
describe("plugin SDK import boundaries", () => {
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", () => {
@@ -209,3 +214,54 @@ describe("plugin panel SDK boundary", () => {
).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;
return { ...request, tools: [...request.tools, tool] };
});
return () => {};
}
`),
).toEqual([]);
});
});
describe("agent session reload SDK boundary", () => {
it("exposes reload separately from snapshot refresh", () => {
expect(
typeErrors(`
import { type PluginServerContext } from "@getpaseo/plugin/server";
import { type PaseoAgentRefetchResult } from "@getpaseo/client";
async function reload(server: PluginServerContext, paseo: import("@getpaseo/client").PaseoApi) {
const agent = paseo.agents.ref("chosen-agent");
const read: PaseoAgentRefetchResult | null = await agent.refresh("read-snapshot");
const reopened: PaseoAgentRefetchResult | null = await agent.reload({ requestId: "reload-session" });
await agent.reload();
// @ts-expect-error reload options can grow without a positional signature change
await agent.reload("reload-session");
}
`),
).toEqual([]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { PaseoApi } from "@getpaseo/client";
import type { z } from "zod";
import type { PluginHookAgent } from "./lifecycle.js";
export interface AgentToolContext {
agent: PluginHookAgent;
paseo: PaseoApi;
signal: AbortSignal;
}
export interface AgentToolResult {
text?: string;
structuredContent?: unknown;
isError?: boolean;
}
export interface AgentToolDefinition {
name: string;
title?: string;
description: string;
input: z.ZodType;
execute(input: unknown, context: AgentToolContext): AgentToolResult | Promise<AgentToolResult>;
}
export function defineAgentTool<Input extends z.ZodType>(tool: {
name: string;
title?: string;
description: string;
input: Input;
execute(
input: z.output<Input>,
context: AgentToolContext,
): AgentToolResult | Promise<AgentToolResult>;
}): AgentToolDefinition {
if (!/^[a-z][a-z0-9_]*$/.test(tool.name) || tool.name.length > 64) {
throw new Error(`Invalid agent tool name: ${tool.name}`);
}
return tool;
}
+3
View File
@@ -24,3 +24,6 @@ export type {
} from "./lifecycle.js";
export { spawnProcess, execCommand, terminateProcess } from "./process.js";
export { defineAgentTool } from "./agent-tools.js";
export type { AgentToolDefinition, AgentToolResult, AgentToolContext } from "./agent-tools.js";
+4
View File
@@ -1,3 +1,4 @@
import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
@@ -36,6 +37,9 @@ export interface PluginSessionOpenRequest {
cwd: string;
reason: "create" | "resume" | "refresh" | "import";
purpose: "interactive" | "history";
readonly title: string | null;
readonly labels: Readonly<Record<string, string>>;
tools: AgentToolDefinition[];
env: Record<string, string>;
}
+116 -11
View File
@@ -1,3 +1,5 @@
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
import type { AgentToolResult } from "@getpaseo/plugin/server";
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { projectTimelineRows } from "./timeline-projection.js";
@@ -333,6 +335,7 @@ export interface AgentManagerOptions {
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
mcpAuthToken?: string;
pluginAgentTools?: PluginAgentToolsRuntime;
paseoToolsEnabled?: boolean;
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined;
@@ -766,6 +769,10 @@ export class AgentManager {
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: 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 paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
private readonly paseoToolPolicies = new Map<string, ProviderPaseoToolsPolicy | undefined>();
@@ -783,6 +790,7 @@ export class AgentManager {
constructor(options: AgentManagerOptions) {
this.pluginLifecycle = options.pluginLifecycle;
this.pluginAgentTools = options.pluginAgentTools;
this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore();
this.idFactory = options.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
@@ -887,6 +895,62 @@ export class AgentManager {
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 {
return this.paseoToolPolicies.get(agentId);
}
@@ -1312,7 +1376,13 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
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 createOptions = this.buildCreateSessionOptions(options);
@@ -1434,6 +1504,8 @@ export class AgentManager {
reason: "resume",
purpose,
workspaceId: options?.workspaceId ?? null,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1491,7 +1563,12 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
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 imported = await client.importSession(
@@ -1586,7 +1663,13 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
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);
if (
@@ -1650,6 +1733,7 @@ export class AgentManager {
throw error;
} finally {
if (!handedToRegistration) {
this.detachPluginTools(agentId);
if (hadPreviousPaseoToolPolicy) {
this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy);
} else {
@@ -1857,6 +1941,7 @@ export class AgentManager {
} catch (error) {
persistError = error;
}
this.detachPluginTools(agentId);
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace(
{
@@ -3940,6 +4025,7 @@ export class AgentManager {
private discardRetainedAgentState(agentId: string): void {
this.timelineStore.delete(agentId);
this.paseoToolPolicies.delete(agentId);
this.detachPluginTools(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
@@ -5426,10 +5512,15 @@ export class AgentManager {
reason: PluginSessionOpenRequest["reason"];
purpose: PluginSessionOpenRequest["purpose"];
workspaceId?: string | null;
title?: string | null;
labels?: Record<string, string>;
},
): Promise<AgentLaunchContext> {
// A plugin changing during this open must still make the resulting session stale.
const generation = this.sessionOpenGeneration;
this.detachPluginTools(agentId);
if (this.pluginLifecycle) {
const request: PluginSessionOpenRequest = {
const request = {
agentId,
provider: client.provider,
cwd,
@@ -5437,9 +5528,14 @@ export class AgentManager {
reason: opening?.reason ?? "resume",
purpose: opening?.purpose ?? "interactive",
env: { ...env },
title: opening?.title ?? null,
labels: opening?.labels ?? {},
tools: [],
};
const transformed = await this.pluginLifecycle.before("agent.session_open", request);
env = transformed.env;
if (request.purpose === "interactive")
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
}
const context: AgentLaunchContext = {
agentId,
@@ -5449,17 +5545,26 @@ export class AgentManager {
PASEO_AGENT_CWD: cwd,
},
};
if (
const useNativeTools =
this.paseoToolsEnabled &&
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
client.capabilities.supportsNativePaseoTools &&
this.paseoToolCatalogFactory
client.capabilities.supportsNativePaseoTools;
if (
this.paseoToolCatalogFactory &&
(useNativeTools || this.getPluginTools(agentId).length > 0)
) {
context.paseoTools = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
try {
const catalog = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
if (useNativeTools) context.paseoTools = catalog;
} catch (error) {
this.detachPluginTools(agentId);
throw error;
}
}
this.sessionOpenGenerations.set(agentId, generation);
return context;
}
@@ -15,6 +15,7 @@ export type AgentRunController = Pick<
| "getAgent"
| "tryRunOutOfBand"
| "hasInFlightRun"
| "reloadStalePluginSession"
| "replaceAgentRun"
| "steerOrReplaceActiveTurn"
| "streamAgent"
@@ -115,6 +116,7 @@ export async function startAgentRun(
return { disposition: "out_of_band" };
}
try {
await agentManager.reloadStalePluginSession(agentId);
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
} catch (error) {
if (!isStaleProviderSessionError(error)) throw error;
@@ -69,6 +69,7 @@ test("session create forwards clientMessageId to the initial prompt run options"
getAgent: vi.fn(() => snapshot),
tryRunOutOfBand: vi.fn(() => false),
hasInFlightRun: vi.fn(() => false),
reloadStalePluginSession: async () => {},
streamAgent,
waitForAgentRunStart: vi.fn(async () => undefined),
} as unknown as Parameters<typeof createAgentCommand>[0]["agentManager"],
@@ -270,6 +270,8 @@ function buildAgentManagerSpies() {
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
getPluginTools: () => [],
reloadStalePluginSession: async () => {},
hasInFlightRun: vi.fn().mockReturnValue(false),
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
tryRunOutOfBand: vi.fn().mockReturnValue(false),
@@ -617,6 +619,10 @@ class FakeBrowserToolsBroker {
}
class BoundaryAgentManagerFake {
public getPluginTools(): [] {
return [];
}
private readonly agent = createManagedAgent({
id: "agent-1",
cwd: REPO_CWD,
@@ -40,6 +40,8 @@ class FakePermissionAgentManager {
return undefined;
}
async reloadStalePluginSession(): Promise<void> {}
hasInFlightRun(): boolean {
return this.hasRunInFlight;
}
@@ -3063,81 +3063,6 @@ 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 () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
@@ -1189,7 +1189,6 @@ export class PiRpcAgentSession implements AgentSession {
private readonly contextUserEntries: PiCapturedEntry[] = [];
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
private compactionPromise: Promise<void> | null = null;
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
private outOfBandCompactionStarted = false;
private outOfBandCompactionCompleted = false;
@@ -1297,15 +1296,6 @@ export class PiRpcAgentSession implements AgentSession {
void (async () => {
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);
this.activePromptRequestId = ack.requestId ?? null;
const correlatedResult = ack.requestId
@@ -1814,8 +1804,7 @@ export class PiRpcAgentSession implements AgentSession {
this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false;
try {
this.compactionPromise = this.runtimeSession.compact(customInstructions);
await this.compactionPromise;
await this.runtimeSession.compact(customInstructions);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
@@ -1841,7 +1830,6 @@ export class PiRpcAgentSession implements AgentSession {
},
});
} finally {
this.compactionPromise = null;
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
this.outOfBandCompactionEmit = null;
this.outOfBandCompactionStarted = false;
@@ -108,7 +108,6 @@ export class FakePiSession implements PiRuntimeSession {
steerError: Error | null = null;
readonly controlRequests: string[] = [];
clearQueueError: Error | null = null;
clearQueueCompletion: Promise<void> | null = null;
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
@@ -141,7 +140,6 @@ export class FakePiSession implements PiRuntimeSession {
subagentSubscriptionError: Error | null = null;
setSessionNameError: Error | null = null;
compactError: Error | null = null;
compactCompletion: Promise<void> | null = null;
emitCompactEnd = true;
getStateError: Error | null = null;
getSessionStatsError: Error | null = null;
@@ -235,7 +233,6 @@ export class FakePiSession implements PiRuntimeSession {
async clearQueue(): Promise<void> {
this.controlRequests.push("clear_queue");
await this.clearQueueCompletion;
if (this.clearQueueError) {
throw this.clearQueueError;
}
@@ -244,7 +241,6 @@ export class FakePiSession implements PiRuntimeSession {
async compact(customInstructions?: string): Promise<void> {
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
this.emit({ type: "compaction_start", reason: "manual" });
await this.compactCompletion;
if (this.emitCompactEnd) {
this.emit({ type: "compaction_end", reason: "manual" });
}
@@ -1,3 +1,4 @@
import { registerPluginTools } from "./plugin-tools.js";
import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js";
import type { AgentMessageDelivery } from "../agent-messages/index.js";
import {
@@ -704,6 +705,7 @@ function createToolCatalog(
}
const localHostOperations = new Map<string, HostOperation>();
const tools = new Map<string, PaseoToolDefinition>();
const builtinNames = new Set<string>();
const hostTools = new Map([
["create_agent", "createAgent"],
["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.
handler: (input: any, context: PaseoToolExecutionContext) => Promise<PaseoToolResult>,
) => {
builtinNames.add(name);
if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) {
return;
}
@@ -3272,6 +3275,14 @@ function createToolCatalog(
},
);
registerPluginTools({
agentManager,
callerAgentId,
scope,
tools,
builtinNames,
policy: options.paseoToolPolicy,
});
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,
});
},
});
}
}
+7
View File
@@ -995,6 +995,7 @@ export async function createPaseoDaemon(
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
const agentManager = new AgentManager({
pluginLifecycle: pluginRuntime,
pluginAgentTools: pluginRuntime,
clients: initialAgentManagerState.clients,
providerDefinitions: initialAgentManagerState.providerDefinitions,
registry: agentStorage,
@@ -1015,6 +1016,10 @@ export async function createPaseoDaemon(
const unsubscribePluginProviders =
pluginRuntime.subscribeProviderRegistrations(syncPluginProviders);
const unsubscribePluginSessionChanges = pluginRuntime.subscribeSessionOpenChanges(() => {
agentManager.invalidatePluginSessions();
});
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
agentManager,
@@ -1914,6 +1919,7 @@ export async function createPaseoDaemon(
localCredential = null;
await deleteLocalCredential(config.paseoHome);
unsubscribePluginProviders();
unsubscribePluginSessionChanges();
await pluginRuntime.stopAllPlugins().catch(() => undefined);
await serviceProxy.stopStandalone().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
// they serve has been closed, further down.
unsubscribePluginProviders();
unsubscribePluginSessionChanges();
await hubRelationships.stop();
workspaceReconciliation.dispose();
scriptHealthMonitor.stop();
@@ -219,7 +219,7 @@ test(
);
test(
"real Pi daemon accepts a prompt after manual compact",
"real Pi daemon executes manual compact out-of-band instead of prompt text",
async () => {
const cwd = tmpCwd("pi-manual-compact-");
@@ -234,24 +234,20 @@ test(
cwd,
title: "pi-manual-compact",
provider: "pi",
model: PI_REAL_TEST_MODEL,
model: PI_COMPACTION_TEST_MODEL,
});
await client.sendMessage(agent.id, "Reply exactly: compact-ready");
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(finish.status, JSON.stringify(await fetchCanonicalTimeline(client, agent.id))).toBe(
"idle",
);
expect(finish.status).toBe("idle");
await client.sendMessage(agent.id, "/compact summarize this e2e run");
await waitForTimelineItem(
client,
agent.id,
(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);
expect(items).toEqual(
@@ -267,12 +263,9 @@ test(
}),
]),
);
expect(items).toContainEqual(
expect.objectContaining({
type: "assistant_message",
text: "after-compaction",
}),
);
expect(
items.some((item) => item.type === "user_message" && item.text.includes("/compact")),
).toBe(false);
expect(
items
.filter((item) => item.type === "assistant_message")
@@ -0,0 +1,752 @@
import { fileURLToPath } from "node:url";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import {
createTestAgentClient,
type TestAgentClientOptions,
} from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js";
import type {
AgentClient,
AgentLaunchContext,
AgentSessionConfig,
AgentPromptInput,
} from "../agent/agent-sdk-types.js";
function recordingProvider(native: boolean, callbacks: TestAgentClientOptions = {}) {
const launches: { config: AgentSessionConfig; context?: AgentLaunchContext }[] = [];
const turns: { prompt: AgentPromptInput; launchCount: number }[] = [];
const steers: AgentPromptInput[] = [];
const events = { interrupts: 0 };
const inner = createTestAgentClient("codex", {
...callbacks,
supportsMcpServers: true,
onStartTurn: (prompt, config) => {
turns.push({ prompt, launchCount: launches.length });
callbacks.onStartTurn?.(prompt, config);
},
steerActiveTurn: async (prompt) => {
steers.push(prompt);
return { status: "accepted" };
},
onInterrupt: () => {
events.interrupts++;
callbacks.onInterrupt?.();
},
});
const provider: AgentClient = {
provider: inner.provider,
capabilities: { ...inner.capabilities, supportsNativePaseoTools: native },
createSession: async (config, context, options) => {
launches.push({ config, context });
return inner.createSession(config, context, options);
},
resumeSession: async (handle, config, context, options) => {
launches.push({ config: config as AgentSessionConfig, context });
return inner.resumeSession(handle, config, context, options);
},
isAvailable: () => inner.isAvailable(),
fetchCatalog: (options) => inner.fetchCatalog(options),
};
return { provider, launches, turns, steers, events };
}
const pluginSource = `
import { z } from "zod";
import { defineAgentTool } from "@getpaseo/plugin/server";
let calls = 0;
const tool = defineAgentTool({
name: "plugin_echo", description: "Echo for the chosen agent",
input: z.object({ text: z.string().min(1).refine(value => value !== "forbidden", "Forbidden by plugin").describe("Message") }),
async execute(input, { agent, paseo, signal }) {
calls++;
if (input.text === "throw") throw new Error("handler failed");
return { text: JSON.stringify({ agent, input, calls, sdk: typeof paseo.agents.ref, aborted: signal.aborted }) };
},
});
export default function contribute(server) {
server.before("agent.session_open", ({ request }) => {
if (request.labels.x !== "y") return;
return { ...request, env: { ...request.env, OPEN_INFO: JSON.stringify({ title: request.title, labels: request.labels, reason: request.reason }) }, tools: [...request.tools, tool] };
});
return () => {};
}
`;
async function setup(
native = false,
source = pluginSource,
policy?: { enabled?: boolean; disabledTools?: string[] },
providerOptions?: TestAgentClientOptions,
) {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-tools-"));
const recorder = recordingProvider(native, providerOptions);
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.13.2",
agentClients: { codex: recorder.provider },
mcpEnabled: true,
providerOverrides: { codex: { paseoTools: policy } },
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.13.2",
});
const mcps: Client[] = [];
async function install(id: string, code: string) {
const pluginDirectory = path.join(directory, id);
await mkdir(pluginDirectory);
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id, requirements: { paseo: ">=0.13.2" } }),
);
await writeFile(path.join(pluginDirectory, "index.server.ts"), code);
await client.installDirectoryPlugin(pluginDirectory);
}
await client.connect();
await client.fetchAgents({ subscribe: {} });
await client.patchDaemonConfig({ pluginsEnabled: true });
try {
await install("tools-test", source);
} catch (error) {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
throw error;
}
return {
...recorder,
daemon,
client,
directory,
install,
async create(labels: Record<string, string> = {}) {
return daemon.daemon.agentManager.createAgent(
{ provider: "codex", cwd: directory },
undefined,
{ labels, initialTitle: "Chosen title" },
);
},
async mcp(agentId?: string) {
const server = recorder.launches.at(-1)!.config.mcpServers?.paseo;
let url: string;
if (agentId) url = `http://127.0.0.1:${daemon.port}/mcp/agents?callerAgentId=${agentId}`;
else {
if (server?.type !== "http") throw new Error("Expected HTTP server");
url = server.url;
}
const mcp = new Client({ name: "plugin-tools-test", version: "1" });
mcps.push(mcp);
await mcp.connect(new StreamableHTTPClientTransport(new URL(url)));
return mcp;
},
async close() {
await Promise.all(mcps.map((mcp) => mcp.close()));
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
},
};
}
test("session hooks attach tools only to matching agents over MCP, with validated input and caller context", async () => {
const h = await setup();
try {
const a = await h.create({ x: "y" });
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO)).toEqual({
title: "Chosen title",
labels: { x: "y" },
reason: "create",
});
const mcpA = await h.mcp();
const listed = (await mcpA.listTools()).tools.find((tool) => tool.name === "plugin_echo");
expect(listed).toMatchObject({
name: "plugin_echo",
inputSchema: {
type: "object",
properties: { text: { type: "string", minLength: 1, description: "Message" } },
required: ["text"],
},
});
const b = await h.create();
const mcpB = await h.mcp();
expect((await mcpB.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo");
const invalid = await mcpA.callTool({ name: "plugin_echo", arguments: { text: "" } });
expect(invalid.isError).toBe(true);
expect(JSON.stringify(invalid)).toContain("text");
expect(
await mcpA.callTool({ name: "plugin_echo", arguments: { text: "forbidden" } }),
).toMatchObject({
isError: true,
content: [{ type: "text", text: expect.stringContaining("Forbidden by plugin") }],
});
const result = await mcpA.callTool({ name: "plugin_echo", arguments: { text: "hello" } });
expect(result.isError).not.toBe(true);
expect(JSON.parse((result.content as { text: string }[])[0].text)).toMatchObject({
agent: { id: a.id, cwd: h.directory, title: "Chosen title" },
input: { text: "hello" },
calls: 1,
sdk: "function",
aborted: false,
});
expect(a.id).not.toBe(b.id);
expect(
await mcpA.callTool({ name: "plugin_echo", arguments: { text: "throw" } }),
).toMatchObject({ isError: true, content: [{ type: "text", text: "handler failed" }] });
expect(
(await mcpA.callTool({ name: "plugin_echo", arguments: { text: "still alive" } })).isError,
).not.toBe(true);
} finally {
await h.close();
}
}, 60_000);
test("native providers get the same attached schema and execution", async () => {
const h = await setup(true);
try {
const a = await h.create({ x: "y" });
const catalog = h.launches.at(-1)!.context!.paseoTools!;
expect(catalog.getTool("plugin_echo")?.description).toBe("Echo for the chosen agent");
const mcp = await h.mcp(a.id);
const advertised = (await mcp.listTools()).tools.find((tool) => tool.name === "plugin_echo")!;
expect(serializePaseoToolInputParameters(catalog.getTool("plugin_echo")!)).toEqual(
advertised.inputSchema,
);
const result = await catalog.executeTool("plugin_echo", { text: "native" });
expect(JSON.parse(result.content[0].text!)).toMatchObject({
agent: { id: a.id },
input: { text: "native" },
});
} finally {
await h.close();
}
}, 60_000);
test("resume uses the stored title and labels, and close removes the attachment", async () => {
const h = await setup(true);
try {
const a = await h.create({ x: "y" });
const catalog = h.launches.at(-1)!.context!.paseoTools!;
const handle = a.persistence!;
await h.daemon.daemon.agentManager.setTitle(a.id, "Stored title");
await h.daemon.daemon.agentManager.closeAgent(a.id);
expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]);
await expect(catalog.executeTool("plugin_echo", { text: "closed" })).rejects.toThrow(
"Agent is not live",
);
await h.daemon.daemon.agentManager.resumeAgentFromPersistence(handle, a.config, a.id);
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO)).toEqual({
title: "Stored title",
labels: { x: "y" },
reason: "resume",
});
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe(
"plugin_echo",
);
} finally {
await h.close();
}
}, 60_000);
test.each([
{
name: "Paseo",
source: pluginSource.replace('name: "plugin_echo"', 'name: "list_agents"'),
other: false,
message: /plugin tools-test.*Paseo tool list_agents/,
},
{
name: "another plugin",
source: pluginSource,
other: true,
message: /plugin tools-test.*plugin other-tools/,
},
{
name: "the same plugin",
source: pluginSource.replace("tools: [...request.tools, tool]", "tools: [tool, tool]"),
other: false,
message: /Duplicate agent tool: plugin_echo/,
},
])(
"collisions with $name fail only targeted session opens",
async ({ source, other, message }) => {
const h = await setup(false, source);
try {
if (other) await h.install("other-tools", pluginSource);
await expect(h.create({ x: "y" })).rejects.toThrow(message);
expect(h.launches).toHaveLength(0);
await h.create();
expect(h.launches).toHaveLength(1);
expect((await (await h.mcp()).listTools()).tools.map((tool) => tool.name)).not.toContain(
"plugin_echo",
);
} finally {
await h.close();
}
},
60_000,
);
test("each plugin owns its tool array and callbacks can remove earlier attachments", async () => {
const source = pluginSource.replace(
" return () => {};",
`
server.before("agent.session_open", ({ request }) => {
if (request.labels.x === "y" && request.tools[0]?.name !== "plugin_echo") throw new Error("Lost plugin-local definitions");
return { ...request, tools: [] };
});
return () => {};`,
);
const h = await setup(false, source);
try {
await h.install(
"other-tools",
pluginSource
.replace('name: "plugin_echo"', 'name: "other_echo"')
.replace(
' if (request.labels.x !== "y") return;',
' if (request.tools.length !== 0) throw new Error("Saw another plugin tools");\n if (request.labels.x !== "y") return;',
),
);
await h.create({ x: "y" });
const names = (await (await h.mcp()).listTools()).tools.map((tool) => tool.name);
expect(names).toContain("other_echo");
expect(names).not.toContain("plugin_echo");
} finally {
await h.close();
}
}, 60_000);
test("disabled plugins remove tools from existing and new MCP catalogs", async () => {
const h = await setup();
try {
await h.create({ x: "y" });
const mcp = await h.mcp();
expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo");
await h.client.patchDaemonConfig({ pluginsEnabled: false });
expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo");
await h.create({ x: "y" });
expect((await (await h.mcp()).listTools()).tools.map((tool) => tool.name)).not.toContain(
"plugin_echo",
);
} finally {
await h.close();
}
}, 60_000);
test.each([false, true])(
"provider tools disabled gates plugin tools (native=%s)",
async (native) => {
const h = await setup(native, pluginSource, { enabled: false });
try {
await h.create({ x: "y" });
expect(h.launches.at(-1)!.context!.paseoTools).toBeUndefined();
expect(h.launches.at(-1)!.config.mcpServers?.paseo).toBeUndefined();
} finally {
await h.close();
}
},
60_000,
);
test("disabledTools filters plugin tool names", async () => {
const h = await setup(true, pluginSource, { disabledTools: ["plugin_echo"] });
try {
await h.create({ x: "y" });
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")).toBeUndefined();
} finally {
await h.close();
}
}, 60_000);
const waitingSource = pluginSource
.replace("import { z }", 'import { writeFile } from "node:fs/promises";\nimport { z }')
.replace(
" calls++;",
`
calls++;
if (input.text === "wait") {
await writeFile(agent.cwd + "/started", agent.id);
await new Promise(resolve => signal.addEventListener("abort", resolve, { once: true }));
await writeFile(agent.cwd + "/aborted", agent.id);
return { text: "discard this result" };
}
`,
);
test("caller cancellation aborts the plugin handler and discards its result", async () => {
const h = await setup(true, waitingSource);
try {
const a = await h.create({ x: "y" });
const catalog = h.launches.at(-1)!.context!.paseoTools!;
const controller = new AbortController();
const call = catalog.executeTool(
"plugin_echo",
{ text: "wait" },
{ signal: controller.signal },
);
const rejected = expect(call).rejects.toThrow("Canceled by caller");
await expect.poll(() => readFile(path.join(h.directory, "started"), "utf8")).toBe(a.id);
controller.abort(new Error("Canceled by caller"));
await rejected;
await expect.poll(() => readFile(path.join(h.directory, "aborted"), "utf8")).toBe(a.id);
} finally {
await h.close();
}
}, 60_000);
test("a plugin stopping mid-call returns a tool error", async () => {
const h = await setup(true, waitingSource);
try {
const a = await h.create({ x: "y" });
const call = h.launches
.at(-1)!
.context!.paseoTools!.executeTool("plugin_echo", { text: "wait" });
await expect.poll(() => readFile(path.join(h.directory, "started"), "utf8")).toBe(a.id);
await h.client.patchDaemonConfig({ pluginsEnabled: false });
expect(await call).toMatchObject({
isError: true,
content: [{ type: "text", text: expect.stringContaining("Plugin stopped") }],
});
} finally {
await h.close();
}
}, 60_000);
test("history sessions ignore attached tools", async () => {
const h = await setup(true);
try {
const a = await h.create({ x: "y" });
const handle = a.persistence!;
await h.client.archiveAgent(a.id);
await h.daemon.daemon.agentManager.resumeAgentFromPersistence(handle, a.config, a.id);
expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]);
expect(h.launches.at(-1)!.context!.paseoTools?.getTool("plugin_echo")).toBeUndefined();
} finally {
await h.close();
}
}, 60_000);
test("disabled plugins attach no tools to a new native session", async () => {
const h = await setup(true);
try {
await h.client.patchDaemonConfig({ pluginsEnabled: false });
await h.create({ x: "y" });
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")).toBeUndefined();
} finally {
await h.close();
}
}, 60_000);
test("structured tool results get model-visible text when text is omitted", async () => {
const h = await setup(
true,
pluginSource.replace(
" calls++;",
' return { structuredContent: { status: "created" } };',
),
);
try {
await h.create({ x: "y" });
expect(
await h.launches
.at(-1)!
.context!.paseoTools!.executeTool("plugin_echo", { text: "structured" }),
).toEqual({
content: [{ type: "text", text: JSON.stringify({ status: "created" }, null, 2) }],
structuredContent: { status: "created" },
isError: undefined,
});
} finally {
await h.close();
}
}, 60_000);
test("reloading a plugin retires its old attachments before an agent reopens", async () => {
const h = await setup(true);
try {
const a = await h.create({ x: "y" });
const mcp = await h.mcp(a.id);
const oldCatalog = h.launches.at(-1)!.context!.paseoTools!;
expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo");
await h.client.reloadPlugin("tools-test");
expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo");
expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]);
expect(await oldCatalog.executeTool("plugin_echo", { text: "retired" })).toMatchObject({
isError: true,
content: [{ type: "text", text: expect.stringContaining("not available") }],
});
} finally {
await h.close();
}
}, 60_000);
test("built-in plugins attach and execute tools while configured plugins are disabled", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-builtin-tools-"));
const directory = path.join(root, "builtin-tools");
await mkdir(directory);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.13.2" } }),
);
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
const recorder = recordingProvider(true);
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.13.2",
agentClients: { codex: recorder.provider },
pluginsEnabled: false,
builtinPlugins: new BuiltinPluginLoader(root, ["builtin-tools"]),
});
try {
const a = await daemon.daemon.agentManager.createAgent(
{ provider: "codex", cwd: root },
undefined,
{ labels: { x: "y" } },
);
const catalog = recorder.launches.at(-1)!.context!.paseoTools!;
expect(catalog.getTool("plugin_echo")?.name).toBe("plugin_echo");
const result = await catalog.executeTool("plugin_echo", { text: "builtin" });
expect(result.isError).not.toBe(true);
expect(JSON.parse(result.content[0].text!)).toMatchObject({
agent: { id: a.id },
input: { text: "builtin" },
});
} finally {
await daemon.close();
await rm(root, { recursive: true, force: true });
}
}, 60_000);
test("a session hook missing tools reports a schema issue", async () => {
const h = await setup(
false,
pluginSource.replace("tools: [...request.tools, tool]", "tools: undefined"),
);
try {
await expect(h.create({ x: "y" })).rejects.toThrow('"tools"');
} finally {
await h.close();
}
}, 60_000);
async function sendAndFinish(h: Awaited<ReturnType<typeof setup>>, agentId: string, text: string) {
const previous = h.turns.length;
await h.client.sendMessage(agentId, text, { activeTurnBehavior: "steer" });
await expect.poll(() => h.turns.length).toBe(previous + 1);
await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(agentId)).toBe(false);
}
test("an idle agent reopens once before its next prompt after a session-hook plugin reload", async () => {
const h = await setup();
try {
const b = await h.create();
const mcp = await h.mcp();
expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo");
await writeFile(
path.join(h.directory, "tools-test", "index.server.ts"),
pluginSource.replace(' if (request.labels.x !== "y") return;', ""),
);
await h.client.reloadPlugin("tools-test");
expect(h.launches).toHaveLength(1);
await sendAndFinish(h, b.id, "first prompt");
expect(h.launches).toHaveLength(2);
expect(h.turns.map((turn) => turn.launchCount)).toEqual([2]);
expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo");
await sendAndFinish(h, b.id, "second prompt");
expect(h.launches).toHaveLength(2);
expect(h.turns.map((turn) => turn.launchCount)).toEqual([2, 2]);
expect(h.events.interrupts).toBe(0);
} finally {
await h.close();
}
}, 60_000);
test("a busy agent is steered after plugin reload and reopens only at its next idle prompt", async () => {
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
const h = await setup(true, pluginSource, undefined, { beforeTurnComplete: async () => blocked });
try {
const a = await h.create({ x: "y" });
await h.client.sendMessage(a.id, "busy prompt", { activeTurnBehavior: "steer" });
await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(a.id)).toBe(true);
await expect
.poll(() => h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId)
.not.toBeNull();
const turnId = h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId;
await h.client.reloadPlugin("tools-test");
await h.client.sendMessage(a.id, "steering prompt", { activeTurnBehavior: "steer" });
expect(h.steers).toHaveLength(1);
expect(h.launches).toHaveLength(1);
expect(h.events.interrupts).toBe(0);
expect(h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId).toBe(turnId);
release();
await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(a.id)).toBe(false);
await sendAndFinish(h, a.id, "idle prompt");
expect(h.launches).toHaveLength(2);
expect(h.events.interrupts).toBe(0);
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe(
"plugin_echo",
);
await sendAndFinish(h, a.id, "stable prompt");
expect(h.launches).toHaveLength(2);
} finally {
release();
await h.close();
}
}, 60_000);
const reloadRpcSource = pluginSource
.replace("import { z }", 'import { defineRpc } from "@getpaseo/plugin";\nimport { z }')
.replace(
" return () => {};",
`
server.handle(defineRpc({ name: "reload_agent", input: z.object({ id: z.string() }), output: z.object({ id: z.string() }) }), async ({ id }, { paseo }) => {
const agent = paseo.agents.ref(id);
await agent.refresh();
await agent.reload();
return { id: agent.current().id };
});
return () => {};
`,
);
test("a plugin can reload an agent through the SDK and rerun session hooks with reason refresh", async () => {
const h = await setup(true, reloadRpcSource);
try {
const a = await h.create({ x: "y" });
expect(h.launches).toHaveLength(1);
expect(await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id })).toEqual({
id: a.id,
});
expect(h.launches).toHaveLength(2);
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh");
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe(
"plugin_echo",
);
} finally {
await h.close();
}
}, 60_000);
test("starting and stopping a session-hook plugin each causes one idle reopen", async () => {
const h = await setup(true);
try {
const a = await h.create({ x: "y" });
await h.install(
"added-tools",
pluginSource.replace('name: "plugin_echo"', 'name: "added_echo"'),
);
await sendAndFinish(h, a.id, "after start");
expect(h.launches).toHaveLength(2);
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("added_echo")?.name).toBe("added_echo");
await h.client.disablePlugin("added-tools");
await sendAndFinish(h, a.id, "after stop");
expect(h.launches).toHaveLength(3);
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("added_echo")).toBeUndefined();
await sendAndFinish(h, a.id, "stable after stop");
expect(h.launches).toHaveLength(3);
expect(h.events.interrupts).toBe(0);
} finally {
await h.close();
}
}, 60_000);
test("plugins without a session-open hook do not make agent sessions stale", async () => {
const h = await setup(true);
try {
const a = await h.create({ x: "y" });
await h.install(
"no-session-hook",
"export default function contribute(server) { server.on('agent.created', () => {}); return () => {}; }",
);
await sendAndFinish(h, a.id, "after unrelated start");
expect(h.launches).toHaveLength(1);
await h.client.reloadPlugin("no-session-hook");
await sendAndFinish(h, a.id, "after unrelated reload");
expect(h.launches).toHaveLength(1);
await h.client.disablePlugin("no-session-hook");
await sendAndFinish(h, a.id, "after unrelated stop");
expect(h.launches).toHaveLength(1);
} finally {
await h.close();
}
}, 60_000);
test("explicit SDK reload interrupts a running turn before reopening its session", async () => {
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
const h = await setup(true, reloadRpcSource, undefined, {
beforeTurnComplete: async () => blocked,
onInterrupt: () => release(),
});
try {
const a = await h.create({ x: "y" });
await h.client.sendMessage(a.id, "running before explicit reload", {
activeTurnBehavior: "steer",
});
await expect
.poll(() => h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId)
.not.toBeNull();
expect(await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id })).toEqual({
id: a.id,
});
expect(h.events.interrupts).toBe(1);
expect(h.launches).toHaveLength(2);
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh");
} finally {
release();
await h.close();
}
}, 60_000);
test("the agent-tools example appends notes only for labeled agents", async () => {
const h = await setup();
try {
await h.client.installDirectoryPlugin(
fileURLToPath(new URL("../../../../../plugin-examples/agent-tools", import.meta.url)),
);
const a = await h.create({ "notes/enabled": "true" });
const mcpA = await h.mcp(a.id);
expect((await mcpA.listTools()).tools.map((tool) => tool.name)).toContain(
"append_workspace_note",
);
expect(
await mcpA.callTool({
name: "append_workspace_note",
arguments: { note: "Run the targeted tests before committing." },
}),
).toMatchObject({
content: [
{ type: "text", text: `Saved note to ${path.join(h.directory, "AGENT_NOTES.md")}` },
],
structuredContent: { path: path.join(h.directory, "AGENT_NOTES.md") },
});
expect(await readFile(path.join(h.directory, "AGENT_NOTES.md"), "utf8")).toBe(
"Run the targeted tests before committing.\n",
);
await mcpA.callTool({
name: "append_workspace_note",
arguments: { note: "Keep existing notes." },
});
expect(await readFile(path.join(h.directory, "AGENT_NOTES.md"), "utf8")).toBe(
"Run the targeted tests before committing.\nKeep existing notes.\n",
);
await h.create();
const mcpB = await h.mcp();
expect((await mcpB.listTools()).tools.map((tool) => tool.name)).not.toContain(
"append_workspace_note",
);
} finally {
await h.close();
}
}, 60_000);
@@ -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 path from "node:path";
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);
interface PluginRuntimePort {
subscribeSessionOpenChanges?: PluginRuntime["subscribeSessionOpenChanges"];
isToolPluginAvailable?: PluginRuntime["isToolPluginAvailable"];
callTool?: PluginRuntime["callTool"];
detachTools?: PluginRuntime["detachTools"];
emit?: PluginLifecycle["emit"];
drainEvents?: PluginRuntime["drainEvents"];
before?: PluginLifecycle["before"];
@@ -131,6 +136,34 @@ export class PluginService {
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> {
await this.runtime.drainEvents?.();
}
@@ -93,8 +93,39 @@ test("session-open hooks reject changes to session identity instead of silently
reason: "resume",
purpose: "interactive",
env: {},
title: null,
labels: {},
tools: [],
},
paseo,
),
).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: [],
env: {},
reason: "create",
purpose: "interactive",
},
paseo,
),
).rejects.toThrow("agent.session_open hooks can only change env and tools");
},
);
@@ -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 { z } from "zod";
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
@@ -15,6 +23,10 @@ import type {
import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages";
import type { PersistedWorkspaceRecord } from "../../workspace-registry.js";
export type PluginWireBeforeRequests = Omit<PluginBeforeRequests, "agent.session_open"> & {
"agent.session_open": SessionOpenWireRequest;
};
export const lifecycleEventNames = [
"agent.created",
"agent.closed",
@@ -38,28 +50,44 @@ const beforeSchemas = {
cwd: z.string(),
reason: z.enum(["create", "resume", "refresh", "import"]),
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()),
})
.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 {
emit<Name extends keyof PluginLifecycleEvents>(
name: Name,
event: PluginLifecycleEvents[Name],
): void;
before<Name extends keyof PluginBeforeRequests>(
before<Name extends keyof PluginWireBeforeRequests>(
name: Name,
request: PluginBeforeRequests[Name],
): Promise<PluginBeforeRequests[Name]>;
request: PluginWireBeforeRequests[Name],
): Promise<PluginWireBeforeRequests[Name]>;
}
export function validateBeforeRequest<Name extends keyof PluginBeforeRequests>(
export function validateBeforeRequest<Name extends keyof PluginWireBeforeRequests>(
name: Name,
value: unknown,
): PluginBeforeRequests[Name] {
return beforeSchemas[name].parse(value) as PluginBeforeRequests[Name];
): PluginWireBeforeRequests[Name] {
return beforeSchemas[name].parse(value) as PluginWireBeforeRequests[Name];
}
export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): PluginHookWorkspace {
@@ -129,11 +157,11 @@ export function publishAgentStream(
}
}
export function validateBeforeResult<Name extends keyof PluginBeforeRequests>(
export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
name: Name,
input: PluginBeforeRequests[Name],
input: PluginWireBeforeRequests[Name],
output: unknown,
): PluginBeforeRequests[Name] {
): PluginWireBeforeRequests[Name] {
const result = validateBeforeRequest(name, output);
if (name === "agent.session_open") {
const previous = beforeSchemas["agent.session_open"].parse(input);
@@ -144,9 +172,11 @@ export function validateBeforeResult<Name extends keyof PluginBeforeRequests>(
previous.provider !== next.provider ||
previous.cwd !== next.cwd ||
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 and tools");
}
}
if (name === "agent.create") {
@@ -168,7 +198,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
private readonly changed: () => void;
constructor(changed: () => void) {
private readonly agentTools: PluginAgentTools | undefined;
constructor(changed: () => void, agentTools?: PluginAgentTools) {
this.agentTools = agentTools;
this.changed = changed;
}
@@ -209,6 +242,9 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
throw new Error(`Unknown before hook: ${name}`);
}
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);
for (const handler of this.transforms.get(name) ?? []) {
controller.signal.throwIfAborted();
@@ -216,9 +252,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
{ request: structuredClone(request) },
{ paseo, signal: controller.signal },
);
if (result !== undefined) {
request = validateBeforeResult(hookName, request, result);
}
if (result !== undefined) request = validateBeforeResult(hookName, request, result);
}
return request;
}
@@ -236,6 +270,31 @@ 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 = [];
else this.agentTools?.attach(request.agentId, tools);
return request;
}
cancel(id: string): void {
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 type {
ProviderConnectRequest,
@@ -31,6 +32,9 @@ export interface PluginUsageSourceMetadata {
}
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";
requestId: string;
@@ -141,6 +145,26 @@ const frameFields = {
export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.discriminatedUnion(
"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
.object({
type: z.literal("provider.status"),
@@ -1,3 +1,4 @@
import { PluginAgentTools } from "./agent-tools.js";
import { PluginHookHandlers } from "./lifecycle/index.js";
import { evaluateBundle } from "./bundle-evaluator.js";
import {
@@ -59,9 +60,10 @@ export function createPluginWorker(options: {
handler: RpcHandler;
}
const agentTools = new PluginAgentTools();
const hooks = new PluginHookHandlers(() => {
send({ type: "hooks.changed", hooks: hooks.catalog() });
});
}, agentTools);
const handlers = new Map<string, RegisteredRpc>();
const providers = new Map<string, ProviderRegistration>();
const usageSources = new Map<string, UsageSourceRegistration>();
@@ -322,6 +324,7 @@ export function createPluginWorker(options: {
?.dispose()
.catch((error) => console.error("Plugin API cleanup failed", error));
hooks.close();
agentTools.close();
for (const pending of pendingProviderConnections.values()) pending.tombstoned = true;
const currentCleanup = cleanup;
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 {
const parsed = PluginProcessRequestSchema.safeParse(rawMessage);
if (!parsed.success) {
@@ -422,6 +461,7 @@ export function createPluginWorker(options: {
rejectWhileStopping(message);
return;
}
if (handleToolMessage(message)) return;
if (message.type === "provider.status") {
void (async () => {
const provider = providers.get(message.providerId);
+129 -12
View File
@@ -1,10 +1,16 @@
import type { PluginWireBeforeRequests } from "./lifecycle/index.js";
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
import type { UsageScope } from "@getpaseo/plugin/server/usage";
import {
ProviderStatusSchema,
type ProviderStatus,
type ProviderStatusRequest,
} 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 { fork } from "node:child_process";
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 SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
const REQUEST_TIMEOUT_MS = 30_000;
const TOOL_CALL_TIMEOUT_MS = 10 * 60_000;
const MAX_LOG_ENTRIES = 500;
const MAX_LOG_BYTES = 256 * 1024;
const MAX_LOG_LINE_BYTES = 16 * 1024;
@@ -69,6 +76,7 @@ interface PendingInvocation {
interface LoadedPlugin {
id: string;
instanceId: string;
requirements: PluginRequirements | undefined;
clientBundle: string;
methods: ReadonlySet<string>;
@@ -302,6 +310,7 @@ export class PluginRuntime {
private readonly spawnChild: () => PluginChild;
private sessionHost: PluginPaseoSessionHost | null;
private readonly listeners = new Set<(pluginId: string, error?: string) => void>();
private readonly sessionOpenListeners = new Set<() => void>();
constructor(
logger: pino.Logger,
@@ -324,6 +333,16 @@ export class PluginRuntime {
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(
pluginId: string,
configuredPath: string,
@@ -340,6 +359,7 @@ export class PluginRuntime {
throw new Error(`Plugin start cancelled: ${pluginId}`);
}
this.plugins.set(pluginId, loaded);
this.notifySessionOpenChange(loaded);
this.appendLog(pluginId, "stdout", "[paseo] Plugin ready");
}
@@ -363,6 +383,7 @@ export class PluginRuntime {
clientBundle: bundles.clientBundle ?? "",
});
this.plugins.set(input.id, loaded);
this.notifySessionOpenChange(loaded);
this.appendLog(input.id, "stdout", "[paseo] Plugin ready");
}
@@ -378,6 +399,7 @@ export class PluginRuntime {
const loaded = this.plugins.get(pluginId);
if (!loaded) return false;
this.plugins.delete(pluginId);
this.notifySessionOpenChange(loaded);
this.rejectPending(loaded, `Plugin stopped: ${pluginId}`);
await this.stopPlugin(loaded);
return true;
@@ -546,14 +568,15 @@ export class PluginRuntime {
await Promise.all(this.pendingEvents);
}
async before<Name extends keyof PluginBeforeRequests>(
async before<Name extends keyof PluginWireBeforeRequests>(
name: Name,
input: PluginBeforeRequests[Name],
): Promise<PluginBeforeRequests[Name]> {
input: PluginWireBeforeRequests[Name],
): Promise<PluginWireBeforeRequests[Name]> {
let request = validateBeforeRequest(name, input);
const plugins = [...this.plugins.values()].sort((left, right) => {
return left.id.localeCompare(right.id);
});
const attached: AttachedAgentTool[] = [];
for (const loaded of plugins) {
if (!loaded.hooks.before.includes(name)) {
continue;
@@ -564,15 +587,21 @@ export class PluginRuntime {
requestId: randomUUID(),
kind: "before",
name,
input: request,
input: name === "agent.session_open" ? { ...request, tools: [] } : request,
});
request = validateBeforeResult(name, request, output);
if (name === "agent.session_open") {
this.collectAgentTools(attached, loaded, (request as SessionOpenWireRequest).tools);
}
} catch (error) {
if (name === "agent.session_open")
this.detachTools((request as SessionOpenWireRequest).agentId);
throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, {
cause: error,
});
}
}
if (name === "agent.session_open") (request as SessionOpenWireRequest).tools = attached;
return request;
}
@@ -594,25 +623,102 @@ export class PluginRuntime {
return output;
}
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(
loaded: LoadedPlugin,
message: Extract<PluginProcessRequest, { requestId: string }>,
signal?: AbortSignal,
): Promise<unknown> {
const child = loaded.child;
const pluginId = loaded.id;
if (!child) throw new Error(`Plugin ${pluginId} has no server entry`);
const requestId = message.requestId;
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
const cancel = () => {
clearTimeout(timeout);
loaded.pending.delete(requestId);
if (message.type === "hook") {
void send(child, { type: "hook.cancel", requestId }).catch(() => {});
}
reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`));
}, REQUEST_TIMEOUT_MS);
loaded.pending.set(requestId, { resolve, reject, timeout });
if (message.type === "tool.call")
void send(child, { type: "tool.cancel", requestId }).catch(() => {});
signal?.removeEventListener("abort", cancel);
reject(signal?.reason ?? new Error("Tool call canceled"));
};
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) => {
clearTimeout(timeout);
signal?.removeEventListener("abort", cancel);
loaded.pending.delete(requestId);
reject(error);
});
@@ -623,6 +729,7 @@ export class PluginRuntime {
const loaded = [...this.plugins.values()];
this.plugins.clear();
for (const plugin of loaded) {
this.notifySessionOpenChange(plugin);
this.rejectPending(plugin, `Plugin stopped: ${plugin.id}`);
}
await Promise.all(loaded.map((plugin) => this.stopPlugin(plugin)));
@@ -641,6 +748,7 @@ export class PluginRuntime {
if (!serverBundle) {
return {
id: pluginId,
instanceId: randomUUID(),
clientBundle: bundles.clientBundle ?? "",
requirements: manifest.requirements,
methods: new Set(),
@@ -769,6 +877,7 @@ export class PluginRuntime {
}
loaded = {
id: pluginId,
instanceId: randomUUID(),
clientBundle,
requirements,
methods: new Set(ready.methods),
@@ -852,7 +961,14 @@ export class PluginRuntime {
private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void {
if (message.type === "hooks.changed") {
const hadSessionOpen = loaded.hooks.before.includes("agent.session_open");
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;
}
if (message.type === "settings.changed") {
@@ -1166,6 +1282,7 @@ export class PluginRuntime {
const wasPublished = this.plugins.get(loaded.id) === loaded;
if (wasPublished) {
this.plugins.delete(loaded.id);
this.notifySessionOpenChange(loaded);
}
this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`);
for (const state of loaded.providerConnections.values()) {
@@ -226,6 +226,7 @@ type ScheduleAgentManager = Pick<
AgentRunController,
| "getAgent"
| "reloadAgentSession"
| "reloadStalePluginSession"
| "tryRunOutOfBand"
| "hasInFlightRun"
| "replaceAgentRun"
@@ -60,6 +60,8 @@ interface FakeAgentSessionOptions {
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
historyEvents?: readonly AgentStreamEvent[];
}
@@ -68,6 +70,8 @@ export interface TestAgentClientOptions {
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
supportsMcpServers?: boolean;
historyEvents?: readonly AgentStreamEvent[];
}
@@ -340,6 +344,8 @@ class FakeAgentSession implements AgentSession {
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
private readonly onInterrupt: (() => void) | undefined;
private readonly closeSession: (() => Promise<void>) | undefined;
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
@@ -354,6 +360,8 @@ class FakeAgentSession implements AgentSession {
this.config = options.config;
this.id = options.sessionId ?? randomUUID();
this.memoryMarker = options.memoryMarker ?? null;
this.steerActiveTurn = options.steerActiveTurn;
this.onInterrupt = options.onInterrupt;
this.closeSession = options.closeSession;
this.onStartTurn = options.onStartTurn;
this.beforeTurnComplete = options.beforeTurnComplete;
@@ -896,6 +904,7 @@ class FakeAgentSession implements AgentSession {
}
async interrupt(): Promise<void> {
this.onInterrupt?.();
this.interruptSignal.resolve();
}
@@ -1234,6 +1243,8 @@ class FakeAgentClient implements AgentClient {
closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
historyEvents: this.options.historyEvents,
});
}
@@ -1261,6 +1272,8 @@ class FakeAgentClient implements AgentClient {
closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
historyEvents: this.options.historyEvents,
});
}
@@ -358,7 +358,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry {
get: async () => null,
update: async () => null,
upsert: async () => {},
archive: async () => false,
archive: async () => {},
remove: async () => {},
};
}
@@ -495,10 +495,6 @@ describe("observed workspace placement", () => {
expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String));
expect(observed.workspaceBatches).toEqual([["workspace-one"]]);
const archived = await observed.placement("workspace-one");
await observed.advanceBy(RESCAN_INTERVAL_MS);
expect(await observed.placement("workspace-one")).toEqual(archived);
expect(observed.workspaceBatches).toEqual([["workspace-one"]]);
observed.dispose();
});
@@ -31,14 +31,6 @@ function canonicalLocalProjectKey(rootPath: string): string {
}
function createTestRegistries() {
let archiveGate: {
started: ReturnType<typeof deferred>;
release: ReturnType<typeof deferred>;
} | null = null;
const holdNextArchive = () => {
archiveGate = { started: deferred(), release: deferred() };
return archiveGate;
};
const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>();
@@ -63,13 +55,6 @@ function createTestRegistries() {
projects.set(record.projectId, record);
return record;
},
update: async (id, updater) => {
const current = projects.get(id);
if (!current) return null;
const updated = updater(current);
projects.set(id, updated);
return updated;
},
upsert: async (record: PersistedProjectRecord) => {
projects.set(record.projectId, record);
},
@@ -100,23 +85,17 @@ function createTestRegistries() {
workspaces.set(record.workspaceId, record);
},
archive: async (id: string, archivedAt: string) => {
const gate = archiveGate;
archiveGate = null;
if (gate) {
gate.started.resolve();
await gate.release.promise;
}
const existing = workspaces.get(id);
if (!existing || existing.archivedAt) return false;
workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
return true;
if (existing) {
workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
}
},
remove: async (id: string) => {
workspaces.delete(id);
},
};
return { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive };
return { projects, workspaces, projectRegistry, workspaceRegistry };
}
function createTestLogger() {
@@ -138,7 +117,6 @@ interface CapturedLogRecord {
function createCapturingLogger() {
const infoRecords: CapturedLogRecord[] = [];
const warnRecords: CapturedLogRecord[] = [];
const logger = {
child: () => logger,
trace: () => undefined,
@@ -146,12 +124,10 @@ function createCapturingLogger() {
info: (payload: unknown, message?: string) => {
infoRecords.push({ payload, message: message ?? "" });
},
warn: (payload: unknown, message?: string) => {
warnRecords.push({ payload, message: message ?? "" });
},
warn: () => undefined,
error: () => undefined,
};
return { logger: logger as unknown as pino.Logger, infoRecords, warnRecords };
return { logger: logger as unknown as pino.Logger, infoRecords };
}
function createWorkspaceGitServiceStub(
@@ -265,419 +241,6 @@ describe("WorkspaceReconciliationService", () => {
tempDirs.length = 0;
});
test("does not emit a duplicate archive when user archival commits before reconciliation's archive", async () => {
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-archive-commit-")));
tempDirs.push(rootPath);
const { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive } =
createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath,
projectKey: canonicalLocalProjectKey(rootPath),
kind: "non_git",
displayName: "project",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
"w1",
createPersistedWorkspaceRecord({
workspaceId: "w1",
projectId: "p1",
cwd: path.join(rootPath, "missing"),
kind: "directory",
displayName: "workspace",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const callbacks: string[] = [];
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
onWorkspaceArchived: (id) => {
callbacks.push(id);
},
});
const gate = holdNextArchive();
try {
const scan = service.runOnce();
await gate.started.promise;
expect(await workspaceRegistry.archive("w1", timestamp)).toBe(true);
gate.release.resolve();
expect((await scan).changesApplied).toEqual([]);
expect(callbacks).toEqual([]);
expect(workspaces.get("w1")?.archivedAt).toBe(timestamp);
} finally {
gate.release.resolve();
service.dispose();
}
});
test("preserves paths while two inspections saturate capacity, logs once, and recovers after they drain", async () => {
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-saturated-")));
tempDirs.push(rootPath);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
const { logger, warnRecords, infoRecords } = createCapturingLogger();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath,
projectKey: canonicalLocalProjectKey(rootPath),
kind: "non_git",
displayName: "project",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const ids = ["hung1", "hung2", "healthy"];
for (const workspaceId of ids) {
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: "p1",
cwd: path.join(rootPath, workspaceId),
kind: "directory",
displayName: workspaceId,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
}
const release = deferred();
const calls: string[] = [];
let recovered = false;
let ticks = 0;
const probe = setInterval(() => ticks++, 1);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger,
inspectionTimeoutMs: 30,
inspectDirectory: async (targetPath) => {
calls.push(targetPath);
if (!recovered && path.basename(targetPath).startsWith("hung")) await release.promise;
return "directory";
},
});
try {
const result = await service.runOnce();
expect(ticks).toBeGreaterThan(0);
expect(result.durationMs).toBeLessThan(1000);
expect(result.changesApplied).toEqual([]);
expect(calls).toEqual(ids.slice(0, 2).map((id) => path.join(rootPath, id)));
expect([...workspaces.values()].map((workspace) => workspace.archivedAt)).toEqual([
null,
null,
null,
]);
expect(warnRecords.filter((record) => record.message.includes("saturated"))).toEqual([
{
message: "Workspace directory inspection saturated; preserving uninspected workspaces",
payload: { paths: ids.slice(0, 2).map((id) => path.join(rootPath, id)) },
},
]);
recovered = true;
release.resolve();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(infoRecords).toEqual([
{
message:
"Timed-out workspace directory inspections drained; reconciliation capacity restored",
payload: {},
},
]);
calls.length = 0;
await service.runOnce();
expect(calls).toEqual([...ids.map((id) => path.join(rootPath, id)), rootPath]);
expect(warnRecords.filter((record) => record.message.includes("saturated"))).toHaveLength(1);
} finally {
release.resolve();
clearInterval(probe);
service.dispose();
}
});
test("inspects every healthy path when one hung inspection times out during a longer scan", async () => {
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-long-hung-")));
tempDirs.push(rootPath);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath,
projectKey: canonicalLocalProjectKey(rootPath),
kind: "non_git",
displayName: "project",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const healthyIds = Array.from({ length: 12 }, (_, i) => `healthy${i}`);
for (const workspaceId of ["hung", ...healthyIds]) {
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: "p1",
cwd: path.join(rootPath, workspaceId),
kind: "directory",
displayName: workspaceId,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
}
const calls: string[] = [];
let outstanding = 0;
let maximumOutstanding = 0;
const { logger, warnRecords } = createCapturingLogger();
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger,
inspectionTimeoutMs: 100,
inspectDirectory: async (targetPath) => {
calls.push(targetPath);
maximumOutstanding = Math.max(maximumOutstanding, ++outstanding);
if (path.basename(targetPath) === "hung") return new Promise(() => {});
await new Promise<void>((resolve) => setTimeout(resolve, 20));
outstanding--;
return "directory";
},
});
try {
const result = await service.runOnce();
expect(result.durationMs).toBeGreaterThan(100);
expect(calls).toEqual(
["hung", ...healthyIds].map((id) => path.join(rootPath, id)).concat(rootPath),
);
expect(maximumOutstanding).toBe(2);
expect(outstanding).toBe(1);
expect(workspaces.get("hung")?.archivedAt).toBeNull();
expect(result.changesApplied).toEqual([]);
expect(warnRecords.filter((record) => record.message.includes("saturated"))).toEqual([]);
} finally {
service.dispose();
}
});
test("times out a hung inspection without archiving it or blocking healthy paths, and retains its IO slot", async () => {
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-hung-")));
tempDirs.push(rootPath);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath,
projectKey: canonicalLocalProjectKey(rootPath),
kind: "non_git",
displayName: "project",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
for (const workspaceId of ["hung", "healthy", "healthy2", "healthy3"]) {
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: "p1",
cwd: path.join(rootPath, workspaceId),
kind: "directory",
displayName: workspaceId,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
}
const calls: string[] = [];
let outstanding = 0;
let maximumOutstanding = 0;
const healthyInspected = deferred();
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
inspectionTimeoutMs: 30,
inspectDirectory: async (targetPath) => {
calls.push(targetPath);
maximumOutstanding = Math.max(maximumOutstanding, ++outstanding);
if (path.basename(targetPath) === "hung") return new Promise(() => {});
healthyInspected.resolve();
await new Promise<void>((resolve) => setImmediate(resolve));
outstanding--;
return "directory";
},
});
try {
const scan = service.runOnce();
await Promise.race([
healthyInspected.promise,
scan.then(() => {
throw new Error("Reconciliation did not use its directory inspection capability");
}),
]);
expect(outstanding).toBeLessThanOrEqual(2);
expect(workspaces.get("hung")?.archivedAt).toBeNull();
const result = await scan;
expect(result.changesApplied).toEqual([]);
await service.runOnce();
expect(calls.filter((cwd) => path.basename(cwd) === "hung")).toHaveLength(1);
expect(calls.filter((cwd) => path.basename(cwd) === "healthy3")).toHaveLength(2);
expect(maximumOutstanding).toBe(2);
expect([...workspaces.values()].map((workspace) => workspace.archivedAt)).toEqual([
null,
null,
null,
null,
]);
} finally {
service.dispose();
}
});
test.each(["missing", "directory"] as const)(
"does not process or archive a workspace archived during %s inspection",
async (state) => {
const rootPath = realpathSync(
mkdtempSync(path.join(tmpdir(), "reconcile-inspection-archive-")),
);
tempDirs.push(rootPath);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath,
projectKey: canonicalLocalProjectKey(rootPath),
kind: "non_git",
displayName: "project",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const cwd = path.join(rootPath, "missing");
workspaces.set(
"w1",
createPersistedWorkspaceRecord({
workspaceId: "w1",
projectId: "p1",
cwd,
kind: "directory",
displayName: "workspace",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const started = deferred();
const release = deferred();
const archivedCallbacks: string[] = [];
const checkouts = new TestCheckouts();
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
workspaceGitService: checkouts,
onWorkspaceArchived: (id) => {
archivedCallbacks.push(id);
},
inspectDirectory: async (targetPath) => {
if (targetPath === cwd) {
started.resolve();
await release.promise;
return state;
}
return "directory";
},
});
try {
const scan = service.runOnce();
await Promise.race([
started.promise,
scan.then(() => {
throw new Error("Reconciliation did not use its directory inspection capability");
}),
]);
await workspaceRegistry.archive("w1", "2026-01-01T00:00:00.000Z");
const archived = workspaces.get("w1");
release.resolve();
const result = await scan;
expect(workspaces.get("w1")).toEqual(archived);
expect(archivedCallbacks).toEqual([]);
expect(checkouts.reads).toEqual([rootPath]);
expect(result.changesApplied).toEqual([]);
} finally {
release.resolve();
service.dispose();
}
},
);
test("keeps the event loop responsive while inspecting 20000 real workspace paths", async () => {
const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-many-")));
tempDirs.push(rootPath);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath,
projectKey: canonicalLocalProjectKey(rootPath),
kind: "non_git",
displayName: "project",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
for (let i = 0; i < 20000; i++) {
const workspaceId = `w${i}`;
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: "p1",
cwd: path.join(rootPath, workspaceId),
kind: "directory",
displayName: workspaceId,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
}
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
});
let last = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - last);
last = now;
}, 1);
let result;
try {
result = await service.runOnce();
await new Promise<void>((resolve) => setTimeout(resolve, 1));
} finally {
clearInterval(probe);
service.dispose();
}
expect(longestGap).toBeLessThan(20);
expect(result.changesApplied).toHaveLength(20000);
expect([...workspaces.values()].every((workspace) => workspace.archivedAt !== null)).toBe(true);
}, 30000);
test("preserves workspace archival that lands during boot reconciliation", async () => {
const workspaceRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-archive-race-")));
tempDirs.push(workspaceRoot);
@@ -1,6 +1,4 @@
import { watch as watchPath } from "node:fs";
import { stat } from "node:fs/promises";
import { setImmediate as yieldToLoop } from "node:timers/promises";
import { statSync, watch as watchPath } from "node:fs";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import type pino from "pino";
import type {
@@ -21,8 +19,6 @@ import { deriveProjectKey } from "./project-key.js";
const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000;
const DEFAULT_DEBOUNCE_MS = 100;
const INSPECTION_CONCURRENCY = 2;
const DEFAULT_INSPECTION_TIMEOUT_MS = 5000;
export type ProjectUpdate =
| { kind: "upsert"; project: PersistedProjectRecord }
@@ -99,8 +95,6 @@ export interface WorkspaceReconciliationServiceOptions {
clock?: ReconciliationClock;
rescanIntervalMs?: number;
debounceMs?: number;
inspectDirectory?: (targetPath: string) => Promise<DirectoryState>;
inspectionTimeoutMs?: number;
}
interface ProjectReconciliationInput {
@@ -116,17 +110,9 @@ interface CachedCheckoutRead {
checkout: Promise<ProjectCheckoutLitePayload>;
}
export type DirectoryState = "directory" | "missing" | "unreadable";
type DirectoryState = "directory" | "missing" | "unreadable";
export class WorkspaceReconciliationService {
// Timed-out fs operations retain their slot until the OS returns. Never use
// the deadline to admit more work into libuv's shared threadpool.
private readonly timedOutPaths = new Set<string>();
private inspectionSaturated = false;
private readonly inspectionWaiters = new Set<() => void>();
private readonly pendingInspections = new Map<string, Promise<DirectoryState>>();
private readonly directoryInspection: (targetPath: string) => Promise<DirectoryState>;
private readonly inspectionTimeoutMs: number;
private readonly serverId: string | undefined;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
@@ -150,10 +136,6 @@ export class WorkspaceReconciliationService {
private reconcileQueuedMode: "metadata" | "full" | null = null;
constructor(options: WorkspaceReconciliationServiceOptions) {
this.directoryInspection =
options.inspectDirectory ??
(async (targetPath) => ((await stat(targetPath)).isDirectory() ? "directory" : "missing"));
this.inspectionTimeoutMs = options.inspectionTimeoutMs ?? DEFAULT_INSPECTION_TIMEOUT_MS;
this.serverId = options.serverId;
this.projectRegistry = options.projectRegistry;
this.workspaceRegistry = options.workspaceRegistry;
@@ -198,7 +180,6 @@ export class WorkspaceReconciliationService {
dispose(): void {
this.disposed = true;
this.wakeInspectionWaiters();
this.unsubscribeRegistry?.();
this.unsubscribeRegistry = null;
if (this.rescanTimer) this.clock.clearInterval(this.rescanTimer);
@@ -216,23 +197,16 @@ export class WorkspaceReconciliationService {
this.workspaceRegistry.list(),
]);
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
const workspaceStates = await mapBounded(
workspaces.filter((w) => !w.archivedAt),
async (workspace) => ({ workspace, state: await this.inspectDirectory(workspace.cwd) }),
);
for (const { workspace, state } of workspaceStates) {
if (state !== "directory") continue;
if ((await this.workspaceRegistry.get(workspace.workspaceId))?.archivedAt !== null) continue;
for (const workspace of workspaces) {
if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue;
const siblings = workspacesByProject.get(workspace.projectId) ?? [];
siblings.push(workspace);
workspacesByProject.set(workspace.projectId, siblings);
}
const projectStates = await mapBounded(
projects.filter((p) => !p.archivedAt),
async (project) => ({ project, state: await this.inspectDirectory(project.rootPath) }),
);
await this.reconcileGitMetadataForProjects(
projectStates.filter(({ state }) => state === "directory").map(({ project }) => project),
projects.filter(
(project) => !project.archivedAt && this.inspectDirectory(project.rootPath) === "directory",
),
workspacesByProject,
changes,
);
@@ -248,30 +222,23 @@ export class WorkspaceReconciliationService {
const allWorkspaces = await this.workspaceRegistry.list();
const activeProjects = allProjects.filter((p) => !p.archivedAt);
const activeProjectsById = new Map(
activeProjects.map((project) => [project.projectId, project]),
);
const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt);
const workspaceDirectoryStates = await mapBounded(activeWorkspaces, async (workspace) => ({
const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({
workspace,
state: await this.inspectDirectory(workspace.cwd),
}));
// Read project roots after workspaces: losing a volume can withhold an
// archive, never turn a workspace's absence into proof of deletion.
const projectStates = await mapBounded(activeProjects, async (project) => ({
project,
state: await this.inspectDirectory(project.rootPath),
state: this.inspectDirectory(workspace.cwd),
}));
// Project roots are read after the workspace directories, so a volume that
// goes away mid-pass leaves its project unreachable rather than its workspaces
// alone. The skew can only withhold an archive, never produce one.
const reachableProjectIds = new Set(
projectStates
.filter(({ state }) => state === "directory")
.map(({ project }) => project.projectId),
activeProjects
.filter((project) => this.inspectDirectory(project.rootPath) === "directory")
.map((project) => project.projectId),
);
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
for (const { workspace, state } of workspaceDirectoryStates) {
if (state !== "directory") continue;
if ((await this.workspaceRegistry.get(workspace.workspaceId))?.archivedAt !== null) continue;
const list = workspacesByProject.get(workspace.projectId) ?? [];
list.push(workspace);
workspacesByProject.set(workspace.projectId, list);
@@ -288,29 +255,26 @@ export class WorkspaceReconciliationService {
state === "missing" && reachableProjectIds.has(workspace.projectId),
)
.map(({ workspace }) => workspace);
await mapBounded(missingWorkspaces, async (workspace) => {
const current = await this.workspaceRegistry.get(workspace.workspaceId);
const project = await this.projectRegistry.get(workspace.projectId);
if (
!current ||
current.archivedAt ||
current.cwd !== workspace.cwd ||
current.projectId !== workspace.projectId ||
!project ||
project.archivedAt ||
project.rootPath !== activeProjectsById.get(project.projectId)?.rootPath
)
return;
const timestamp = new Date().toISOString();
if (!(await this.workspaceRegistry.archive(workspace.workspaceId, timestamp))) return;
await this.onWorkspaceArchived?.(workspace.workspaceId);
changes.push({
kind: "workspace_archived",
workspaceId: workspace.workspaceId,
directory: workspace.cwd,
reason: "directory_missing",
});
});
await Promise.all(
missingWorkspaces.map(async (workspace) => {
const timestamp = new Date().toISOString();
await this.workspaceRegistry.archive(workspace.workspaceId, timestamp);
await this.onWorkspaceArchived?.(workspace.workspaceId);
changes.push({
kind: "workspace_archived",
workspaceId: workspace.workspaceId,
directory: workspace.cwd,
reason: "directory_missing",
});
// Update the in-memory list for the project orphan check below
const siblings = workspacesByProject.get(workspace.projectId);
if (siblings) {
const updated = siblings.filter((w) => w.workspaceId !== workspace.workspaceId);
workspacesByProject.set(workspace.projectId, updated);
}
}),
);
// 2. Reconcile mutable git metadata without changing identity or membership.
// Projects persist until explicitly removed, even when they currently have
@@ -387,17 +351,13 @@ export class WorkspaceReconciliationService {
}
private async reconcileProject(input: ProjectReconciliationInput): Promise<void> {
const { siblings, currentGit, readCheckout, changes } = input;
const { project, siblings, currentGit, readCheckout, changes } = input;
const workspaceCheckouts = await Promise.all(
siblings.map(async (workspace) => {
const current = await this.workspaceRegistry.get(workspace.workspaceId);
if (!current || current.cwd !== workspace.cwd || current.projectId !== workspace.projectId)
return null;
return { workspace: current, checkout: await readCheckout(current.cwd) };
}),
siblings.map(async (workspace) => ({
workspace,
checkout: await readCheckout(workspace.cwd),
})),
);
const project = await this.projectRegistry.get(input.project.projectId);
if (!project || project.archivedAt || project.rootPath !== input.project.rootPath) return;
const projectUpdates: Partial<Pick<PersistedProjectRecord, "kind" | "projectKey">> = {};
const mappedKind = deriveProjectKind(currentGit);
const projectKey = deriveProjectKey({
@@ -417,47 +377,40 @@ export class WorkspaceReconciliationService {
if (Object.keys(projectUpdates).length > 0) {
const timestamp = new Date().toISOString();
let applied = false;
await this.projectRegistry.update(project.projectId, (current) => {
if (current.archivedAt || current.rootPath !== project.rootPath) return current;
applied = true;
return { ...current, ...projectUpdates, updatedAt: timestamp };
await this.projectRegistry.upsert({
...project,
...projectUpdates,
updatedAt: timestamp,
});
changes.push({
kind: "project_updated",
projectId: project.projectId,
directory: project.rootPath,
fields: projectUpdates,
});
if (applied)
changes.push({
kind: "project_updated",
projectId: project.projectId,
directory: project.rootPath,
fields: projectUpdates,
});
}
await Promise.all(
workspaceCheckouts.map(async (entry) => {
if (!entry) return;
const { workspace, checkout: wsGit } = entry;
workspaceCheckouts.map(async ({ workspace, checkout: wsGit }) => {
const timestamp = new Date().toISOString();
if (!reconcileWorkspacePlacement({ workspace, checkout: wsGit, updatedAt: timestamp }))
return;
let fields: Partial<MutableWorkspacePlacement> | null = null;
await this.workspaceRegistry.update(workspace.workspaceId, (current) => {
if (current.cwd !== workspace.cwd || current.projectId !== workspace.projectId)
return current;
const update = reconcileWorkspacePlacement({
workspace: current,
checkout: wsGit,
updatedAt: timestamp,
});
if (!update) return current;
fields = update.fields;
return { ...current, ...fields, updatedAt: timestamp };
const update = reconcileWorkspacePlacement({
workspace,
checkout: wsGit,
updatedAt: timestamp,
});
if (!fields) return;
if (!update) return;
const updated = await this.workspaceRegistry.update(workspace.workspaceId, (current) => ({
...current,
...update.fields,
updatedAt: timestamp,
}));
if (!updated) return;
changes.push({
kind: "workspace_updated",
workspaceId: workspace.workspaceId,
directory: workspace.cwd,
fields,
fields: update.fields,
});
}),
);
@@ -584,74 +537,16 @@ export class WorkspaceReconciliationService {
return this.workspaceGitService.getCheckout(cwd);
}
private wakeInspectionWaiters(): void {
for (const wake of this.inspectionWaiters) wake();
this.inspectionWaiters.clear();
}
private async inspectDirectory(targetPath: string): Promise<DirectoryState> {
// A healthy in-flight inspection owns capacity temporarily. Wait for it;
// only unresolved operations that have all timed out justify skipping paths.
while (
!this.disposed &&
!this.pendingInspections.has(targetPath) &&
this.pendingInspections.size >= INSPECTION_CONCURRENCY
) {
if (this.timedOutPaths.size === INSPECTION_CONCURRENCY) return "unreadable";
await new Promise<void>((resolve) => this.inspectionWaiters.add(resolve));
}
if (this.disposed) return "unreadable";
let pending = this.pendingInspections.get(targetPath);
if (!pending) {
pending = Promise.resolve()
.then(() => this.directoryInspection(targetPath))
.catch((error) => {
if (isMissingPathError(error)) return "missing" as const;
this.logger.warn(
{ err: error, targetPath },
"Skipped workspace reconciliation after directory inspection failed",
);
return "unreadable" as const;
})
.finally(() => {
this.pendingInspections.delete(targetPath);
this.timedOutPaths.delete(targetPath);
this.wakeInspectionWaiters();
if (this.inspectionSaturated && this.timedOutPaths.size === 0) {
this.inspectionSaturated = false;
this.logger.info(
{},
"Timed-out workspace directory inspections drained; reconciliation capacity restored",
);
}
});
this.pendingInspections.set(targetPath, pending);
}
let timer: ReturnType<typeof setTimeout> | undefined;
private inspectDirectory(targetPath: string): DirectoryState {
try {
return await Promise.race([
pending,
new Promise<DirectoryState>((resolve) => {
timer = setTimeout(() => {
this.timedOutPaths.add(targetPath);
this.wakeInspectionWaiters();
if (!this.inspectionSaturated && this.timedOutPaths.size === INSPECTION_CONCURRENCY) {
this.inspectionSaturated = true;
this.logger.warn(
{ paths: [...this.timedOutPaths] },
"Workspace directory inspection saturated; preserving uninspected workspaces",
);
}
this.logger.warn(
{ targetPath },
"Skipped workspace reconciliation after directory inspection timed out",
);
resolve("unreadable");
}, this.inspectionTimeoutMs);
}),
]);
} finally {
clearTimeout(timer);
return statSync(targetPath).isDirectory() ? "directory" : "missing";
} catch (error) {
if (isMissingPathError(error)) return "missing";
this.logger.warn(
{ err: error, targetPath },
"Skipped workspace reconciliation after directory inspection failed",
);
return "unreadable";
}
}
}
@@ -660,21 +555,3 @@ function isMissingPathError(error: unknown): boolean {
if (typeof error !== "object" || error === null || !("code" in error)) return false;
return error.code === "ENOENT" || error.code === "ENOTDIR";
}
// Two workers bound admission; yielding also bounds passes whose adapters settle
// immediately (including unavailable paths after both IO slots time out).
async function mapBounded<T, R>(items: T[], visit: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = [];
let next = 0;
await Promise.all(
Array.from({ length: Math.min(INSPECTION_CONCURRENCY, items.length) }, async () => {
let count = 0;
while (next < items.length) {
const index = next++;
results[index] = await visit(items[index]!);
if (++count % 32 === 0) await yieldToLoop();
}
}),
);
return results;
}
@@ -474,7 +474,7 @@ describe("workspace registries", () => {
expect(await workspaceRegistry.list()).toEqual([]);
});
test("preserves the first workspace archive and reports whether a queued archive applied", async () => {
test("refreshes workspace archive timestamps when an archive is repeated", async () => {
await workspaceRegistry.initialize();
await workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
@@ -488,24 +488,12 @@ describe("workspace registries", () => {
}),
);
const events: WorkspaceMutation[] = [];
workspaceRegistry.subscribeToMutations((event) => {
events.push(event);
});
expect(
await Promise.all([
workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z"),
workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z"),
]),
).toEqual([true, false]);
expect(events.map((event) => event.kind)).toEqual(["archive"]);
const reloaded = new FileBackedWorkspaceRegistry(
path.join(tmpDir, "projects", "workspaces.json"),
logger,
);
expect(await reloaded.get("workspace-one")).toMatchObject({
archivedAt: "2026-03-02T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
await workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z");
await workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z");
expect(await workspaceRegistry.get("workspace-one")).toMatchObject({
archivedAt: "2026-03-03T00:00:00.000Z",
updatedAt: "2026-03-03T00:00:00.000Z",
});
});
@@ -167,7 +167,7 @@ export interface WorkspaceRegistry {
workspaceId: string,
archivedAt: string,
context?: WorkspaceArchiveContext,
): Promise<boolean>;
): Promise<void>;
remove(workspaceId: string): Promise<void>;
/** Central lifecycle seam for daemon-global workspace observers. */
subscribeToMutations?(
@@ -247,6 +247,20 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
});
}
async archive(id: string, archivedAt: string): Promise<void> {
await this.archiveIfPresent(id, archivedAt);
}
protected async archiveIfPresent(id: string, archivedAt: string): Promise<TRecord | null> {
return this.mutateCache((records) => {
const existing = records.get(id);
if (!existing) return null;
const next = this.schema.parse({ ...existing, updatedAt: archivedAt, archivedAt });
records.set(id, next);
return next;
});
}
protected async archiveIfActive(id: string, archivedAt: string): Promise<TRecord | null> {
return this.mutateCache((records) => {
const existing = records.get(id);
@@ -471,7 +485,7 @@ export class FileBackedProjectRegistry
return project;
}
async archive(projectId: string, archivedAt: string): Promise<void> {
override async archive(projectId: string, archivedAt: string): Promise<void> {
const project = await this.archiveIfActive(projectId, archivedAt);
if (!project) return;
await this.notifyMutation({ kind: "archive", projectId, project });
@@ -551,28 +565,21 @@ export class FileBackedWorkspaceRegistry
});
}
async archive(
override async archive(
workspaceId: string,
archivedAt: string,
context?: WorkspaceArchiveContext,
): Promise<boolean> {
const [workspace] = await this.mutateMany((records) => {
const existing = records.get(workspaceId);
if (!existing || existing.archivedAt) return [];
return [
{
...existing,
updatedAt: archivedAt,
archivedAt,
...(context?.autoArchivedChangeRequestUrl
? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl }
: {}),
},
];
});
if (!workspace) return false;
): Promise<void> {
const workspace = await super.update(workspaceId, (existing) => ({
...existing,
updatedAt: archivedAt,
archivedAt,
...(context?.autoArchivedChangeRequestUrl
? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl }
: {}),
}));
if (!workspace) return;
await this.notifyMutation({ kind: "archive", workspaceId, workspace });
return true;
}
override async remove(workspaceId: string): Promise<void> {
+33
View File
@@ -0,0 +1,33 @@
# Agent tools
Attaches `append_workspace_note` to agents whose `notes/enabled` label is `"true"`.
Calling the tool with `{ "note": "Run the targeted tests before committing." }` appends that
line to `AGENT_NOTES.md` in the calling agent's working directory and returns the file path.
It creates the file if it does not exist.
Install on a daemon with plugins enabled:
```bash
paseo plugin install /absolute/path/to/plugin-examples/agent-tools
```
Create a labeled agent through the SDK:
```ts
const agent = await paseo.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/projects/shop",
labels: { "notes/enabled": "true" },
});
```
The session-open callback also runs when an agent resumes or reloads. After changing an existing
agent's labels, call `paseo.agents.ref(id).reload()` to apply the selection immediately. This
interrupts a running turn. `refresh()` reads the agent snapshot without reopening its session.
From the repository root, `npm run typecheck` includes this example. Outside this repository,
use `paseo plugin init` to create the package and TypeScript setup, then copy `index.server.ts`
and the manifest into that project.
See [Attach tools to agents](../../public-docs/plugins/reference.md#attach-tools-to-agents)
for targeting, execution, provider gates, collisions, and plugin reload behavior.
@@ -0,0 +1,26 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server";
const appendWorkspaceNote = defineAgentTool({
name: "append_workspace_note",
description: "Append a note to AGENT_NOTES.md in this agent's working directory.",
input: z.object({ note: z.string().min(1).describe("Note to append") }),
async execute({ note }, { agent, signal }) {
const notePath = path.join(agent.cwd, "AGENT_NOTES.md");
await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal });
return {
text: `Saved note to ${notePath}`,
structuredContent: { path: notePath },
};
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
if (request.labels["notes/enabled"] !== "true") return;
return { ...request, tools: [...request.tools, appendWorkspaceNote] };
});
return () => {};
}
@@ -0,0 +1,4 @@
{
"id": "agent-tools",
"requirements": { "paseo": ">=0.13.2" }
}
+127 -6
View File
@@ -711,6 +711,120 @@ server.before("agent.session_open", ({ request }) => {
This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an
`agent.create` callback instead.
### 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
```ts
@@ -878,11 +992,11 @@ type PluginTurnOutcome =
### Before hooks
| Name | Request fields | Editable |
| -------------------- | ----------------------------------------------------------------------- | --------------------------------------- |
| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | Only `env` |
| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request |
| Name | Request fields | Editable |
| -------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, read-only `title` and `labels`, `reason`, `purpose`, `env`, `tools` | `env` and `tools` |
| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request |
**`agent.create.config`** uses `AgentSessionConfig`:
@@ -903,6 +1017,9 @@ type PluginTurnOutcome =
"workspaceId": "workspace-456",
"provider": "codex",
"cwd": "/projects/shop",
"title": "Workspace notes",
"labels": { "notes/enabled": "true" },
"tools": [],
"reason": "resume",
"purpose": "interactive",
"env": { "COMPANY_ENV": "development" }
@@ -912,6 +1029,9 @@ type PluginTurnOutcome =
| Field | Values |
| ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `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). |
| `reason` | `create`, `resume`, `refresh`, `import` |
| `purpose` | `interactive`, `history` |
| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. |
@@ -923,7 +1043,7 @@ Creation request
→ agent.create hooks (plugin-ID order; registration order within each plugin)
→ resolve defaults and validate provider configuration
→ 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)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration
```
@@ -960,6 +1080,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-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-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`.
+130 -12
View File
@@ -585,6 +585,117 @@ server.before("agent.session_open", ({ request }) => {
它会在创建、恢复、刷新和导入时运行。如果只想在创建时注入,请改为在 `agent.create` 回调中设置 `env`。
### 为 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}
```ts
@@ -741,11 +852,11 @@ type PluginTurnOutcome =
### before 钩子 {#before-hooks}
| 名称 | 请求字段 | 可编辑 |
| -------------------- | ----------------------------------------------------------------------- | ----------------------------------- |
| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | 仅 `env` |
| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 |
| 名称 | 请求字段 | 可编辑 |
| -------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------- |
| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` |
| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`,只读的 `title` 和 `labels`,`reason`, `purpose`, `env`, `tools` | `env` 和 `tools` |
| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 |
**`agent.create.config`** 使用 `AgentSessionConfig`:
@@ -766,18 +877,24 @@ type PluginTurnOutcome =
"workspaceId": "workspace-456",
"provider": "codex",
"cwd": "/projects/shop",
"title": "Workspace notes",
"labels": { "notes/enabled": "true" },
"tools": [],
"reason": "resume",
"purpose": "interactive",
"env": { "COMPANY_ENV": "development" }
}
```
| 字段 | 取值 |
| ------------- | ------------------------------------------------------------------------------------ |
| `workspaceId` | 字符串或 `null` |
| `reason` | `create`、`resume`、`refresh`、`import` |
| `purpose` | `interactive`、`history` |
| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 |
| 字段 | 取值 |
| ------------- | -------------------------------------------------------------------------------------------------------- |
| `workspaceId` | 字符串或 `null` |
| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 |
| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 |
| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 |
| `reason` | `create`、`resume`、`refresh`、`import` |
| `purpose` | `interactive`、`history` |
| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 |
### 顺序与返回值 {#ordering-and-returned-values}
@@ -786,7 +903,7 @@ Creation request
→ agent.create hooks (plugin-ID order; registration order within each plugin)
→ resolve defaults and validate provider configuration
→ 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)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration
```
@@ -822,6 +939,7 @@ Creation request
| [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 |
| [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 的输出。
+1 -112
View File
@@ -1,8 +1,5 @@
import assert from "node:assert/strict";
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 { readFileSync, readdirSync } from "node:fs";
import { relative as relativePath } from "node:path";
import test from "node:test";
import { runInNewContext } from "node:vm";
@@ -533,111 +530,3 @@ test("Windows units and integration run independently with complete routed cover
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 });
}
});
}
});