mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Let plugins customize agent creation and observe lifecycle events (#4435)
* feat(plugins): expose agent and workspace lifecycle hooks * test(plugins): verify lifecycle examples with version requirements * fix(plugins): publish archive hooks for closed agents * test(plugins): remove diagnostic catch from lifecycle test * refactor(plugins): define hook handlers in lifecycle module
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# Agent configuration
|
||||
|
||||
Changes new Codex agents through `server.before("agent.create", ...)`:
|
||||
|
||||
- Sets `config.providerOptions.sandbox_mode` to `workspace-write`.
|
||||
- Sets `config.providerOptions.approval_policy` to `on-request`.
|
||||
- Adds the `company` server to `config.mcpServers`.
|
||||
- Preserves other configuration, provider options, MCP servers, and environment overrides.
|
||||
|
||||
Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using
|
||||
this example. The URL is a placeholder. The `company` entry replaces an existing server with that name.
|
||||
|
||||
Install on a daemon with plugins enabled:
|
||||
|
||||
```bash
|
||||
paseo plugin install /absolute/path/to/plugin-examples/agent-configuration
|
||||
```
|
||||
|
||||
The callback receives the request after earlier plugins have transformed it. The nested spreads
|
||||
preserve their settings; the explicit values replace only the named fields. Paseo validates and saves
|
||||
the resulting configuration before starting the agent. Existing agents are unchanged.
|
||||
|
||||
See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for ordering
|
||||
and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and
|
||||
environment injection. Its provider-switch callback clears provider options when changing Codex to
|
||||
Claude, so use these examples separately when trying the Codex-specific settings.
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.before("agent.create", ({ request }) => {
|
||||
if (request.config.provider !== "codex") {
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
...request,
|
||||
config: {
|
||||
...request.config,
|
||||
providerOptions: {
|
||||
...request.config.providerOptions,
|
||||
sandbox_mode: "workspace-write",
|
||||
approval_policy: "on-request",
|
||||
},
|
||||
mcpServers: {
|
||||
...request.config.mcpServers,
|
||||
company: {
|
||||
type: "http",
|
||||
url: "https://tools.example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "agent-configuration",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Lifecycle actions
|
||||
|
||||
Small callbacks using the existing SDK:
|
||||
|
||||
- Send `Try again.` when the latest output contains `out of credits`.
|
||||
- Decline a shell request matching `rm -rf`; approve exactly `git status`; leave other requests alone.
|
||||
- Inject `PASEO_HOOK_CREATE_EXAMPLE=created` on creation and `PASEO_HOOK_OPEN_EXAMPLE=opened` on each session opening.
|
||||
- Change requested Codex agents to Claude Haiku before creation.
|
||||
- Create a worktree when a directory creation request is titled `Isolated lifecycle example`.
|
||||
|
||||
This is a demonstration plugin: installing it enables all these behaviors on its host. Install it
|
||||
alongside `lifecycle-logger` on a test daemon with plugins enabled:
|
||||
|
||||
```bash
|
||||
paseo plugin install /absolute/path/to/plugin-examples/lifecycle-actions
|
||||
paseo plugin install /absolute/path/to/plugin-examples/lifecycle-logger
|
||||
paseo plugin logs lifecycle-logger
|
||||
```
|
||||
|
||||
Copy the callbacks you need into your own plugin. The follow-up example sends a new message; it does
|
||||
not replay attachments or undo previous tool effects. If the provider keeps returning the matching
|
||||
phrase, it keeps sending follow-ups. Add limits or delay in your plugin when your workflow needs them.
|
||||
The command regex illustrates a policy; it does not parse shell syntax or cover every deletion command.
|
||||
|
||||
`server/inspect.ts` joins provider text chunks and excludes earlier turns and the current user prompt
|
||||
from the phrase match. The failing-first regression test records the split text observed with a real
|
||||
Claude agent.
|
||||
|
||||
See [agent configuration](../agent-configuration) for a focused example that injects MCP servers and
|
||||
changes Codex sandbox and approval options while preserving other settings.
|
||||
|
||||
See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for the API.
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { latestOutputText, shellCommand } from "./server/inspect";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.before("workspace.create", ({ request }) => {
|
||||
if (request.title === "Isolated lifecycle example" && request.source.kind === "directory") {
|
||||
return {
|
||||
...request,
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: request.source.path,
|
||||
action: "branch-off",
|
||||
branchName: "lifecycle-example",
|
||||
},
|
||||
};
|
||||
}
|
||||
return request;
|
||||
});
|
||||
|
||||
server.on("agent.turn_ended", async (event, context) => {
|
||||
if (event.outcome.kind === "canceled") {
|
||||
return;
|
||||
}
|
||||
const text = latestOutputText(event.timeline);
|
||||
if (/out of credits/i.test(text)) {
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
console.log("Sending a follow-up after out of credits", event.agent.id);
|
||||
await agent.send("Try again.");
|
||||
}
|
||||
});
|
||||
|
||||
server.on("agent.permission_requested", async (event, context) => {
|
||||
const command = shellCommand(event.request);
|
||||
if (command === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
if (/\brm\s+-rf\b/.test(command)) {
|
||||
await agent.respondToPermission({
|
||||
requestId: event.request.id,
|
||||
response: {
|
||||
behavior: "deny",
|
||||
message: "Recursive deletion is blocked by the example plugin.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.trim() === "git status") {
|
||||
await agent.respondToPermission({
|
||||
requestId: event.request.id,
|
||||
response: { behavior: "allow" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.before("agent.create", ({ request }) => {
|
||||
return {
|
||||
...request,
|
||||
env: {
|
||||
...request.env,
|
||||
PASEO_HOOK_CREATE_EXAMPLE: "created",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
server.before("agent.create", ({ request }) => {
|
||||
if (request.config.provider === "codex") {
|
||||
return {
|
||||
...request,
|
||||
config: {
|
||||
...request.config,
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
modeId: "default",
|
||||
providerOptions: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
return request;
|
||||
});
|
||||
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
return {
|
||||
...request,
|
||||
env: {
|
||||
...request.env,
|
||||
PASEO_HOOK_OPEN_EXAMPLE: "opened",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "lifecycle-actions",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { latestOutputText } from "./inspect";
|
||||
|
||||
test("matches provider text split across timeline chunks without matching old turns or user prompts", () => {
|
||||
const output = latestOutputText([
|
||||
{ type: "user_message", text: "An older request" },
|
||||
{ type: "assistant_message", text: "old out of credits" },
|
||||
{ type: "user_message", text: "The user mentions out of credits" },
|
||||
{ type: "assistant_message", text: "out", messageId: "reply" },
|
||||
{ type: "assistant_message", text: " of credits", messageId: "reply" },
|
||||
]);
|
||||
expect(output).toBe("out of credits");
|
||||
expect(
|
||||
latestOutputText([
|
||||
{ type: "assistant_message", text: "out of credits" },
|
||||
{ type: "user_message", text: "Try again." },
|
||||
{ type: "assistant_message", text: "Done" },
|
||||
]),
|
||||
).toBe("Done");
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AgentPermissionRequest, AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export function latestOutputText(timeline: readonly AgentTimelineItem[]): string {
|
||||
let output = "";
|
||||
for (const item of timeline) {
|
||||
if (item.type === "user_message") {
|
||||
output = "";
|
||||
} else if ("text" in item && typeof item.text === "string") {
|
||||
output += item.text;
|
||||
} else {
|
||||
output += JSON.stringify(item) + "\n";
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function shellCommand(request: AgentPermissionRequest): string | null {
|
||||
if (!request.input) {
|
||||
return null;
|
||||
}
|
||||
if (typeof request.input.command === "string") {
|
||||
return request.input.command;
|
||||
}
|
||||
if (typeof request.input.cmd === "string") {
|
||||
return request.input.cmd;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Lifecycle logger
|
||||
|
||||
Registers all eleven server lifecycle hooks and logs their data as JSON. It observes agent creation,
|
||||
session opening, turns, permissions, archive, and workspace creation/archive. Before hooks return
|
||||
the request unchanged. Environment values are redacted; prompts and timeline content are logged.
|
||||
|
||||
Install it on a daemon where plugins are enabled:
|
||||
|
||||
```bash
|
||||
paseo plugin install /absolute/path/to/plugin-examples/lifecycle-logger
|
||||
paseo plugin logs lifecycle-logger
|
||||
```
|
||||
|
||||
The same entries appear in the target daemon's `daemon.log`. The plugin log viewer retains a bounded
|
||||
tail; use the daemon log for longer captures. Log lines are capped at 16 KiB, so a large timeline can
|
||||
be truncated.
|
||||
|
||||
See the [lifecycle reference](../../public-docs/plugins/v0.8/reference.md#lifecycle-hooks) for callback
|
||||
shapes and delivery rules. The real-provider test in
|
||||
`packages/server/src/server/plugins/lifecycle.real.e2e.test.ts` runs both examples on an isolated daemon
|
||||
and saves evidence under `.dev/lifecycle-proof/`.
|
||||
|
||||
Run it from `packages/server` on Linux with Claude configured:
|
||||
|
||||
```bash
|
||||
npx vitest run src/server/plugins/lifecycle.real.e2e.test.ts --bail=1
|
||||
```
|
||||
|
||||
The test uses real provider calls and a temporary daemon home. Its failure check uses Linux `/proc`
|
||||
to identify and terminate only its own Claude subprocess. The credits check asks the real agent to
|
||||
emit the matching phrase; it does not exhaust an account's credits.
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { logHook } from "./server/log";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.before("workspace.create", ({ request }) => {
|
||||
logHook("before workspace.create", { request });
|
||||
return request;
|
||||
});
|
||||
|
||||
server.on("workspace.created", (event) => {
|
||||
logHook("workspace.created", event);
|
||||
});
|
||||
|
||||
server.on("workspace.archived", (event) => {
|
||||
logHook("workspace.archived", event);
|
||||
});
|
||||
|
||||
server.before("agent.create", ({ request }) => {
|
||||
logHook("before agent.create", { request });
|
||||
return request;
|
||||
});
|
||||
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
logHook("before agent.session_open", { request });
|
||||
return request;
|
||||
});
|
||||
|
||||
server.on("agent.created", (event) => {
|
||||
logHook("agent.created", event);
|
||||
});
|
||||
|
||||
server.on("agent.turn_started", (event) => {
|
||||
logHook("agent.turn_started", event);
|
||||
});
|
||||
|
||||
server.on("agent.turn_ended", (event) => {
|
||||
logHook("agent.turn_ended", event);
|
||||
});
|
||||
|
||||
server.on("agent.permission_requested", (event) => {
|
||||
logHook("agent.permission_requested", event);
|
||||
});
|
||||
|
||||
server.on("agent.permission_resolved", (event) => {
|
||||
logHook("agent.permission_resolved", event);
|
||||
});
|
||||
|
||||
server.on("agent.archived", (event) => {
|
||||
logHook("agent.archived", event);
|
||||
});
|
||||
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "lifecycle-logger",
|
||||
"requirements": {
|
||||
"paseo": ">=0.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function logHook(hook: string, data: unknown): void {
|
||||
const entry = { hook, timestamp: new Date().toISOString(), data };
|
||||
const line = JSON.stringify(entry, (key, value: unknown) => {
|
||||
if (key === "env" && value !== null && typeof value === "object") {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const name of Object.keys(value)) {
|
||||
redacted[name] = "[redacted]";
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
console.log(line);
|
||||
}
|
||||
Reference in New Issue
Block a user