Give plugins an agent's background work count and a background flag on hooks (#9362)

* feat(plugins): give plugins the agent's sidebar indicator and background flag

Plugin UI re-derived agent state from status and attention fields and could
not see background work, so it showed agents as done while a shell they left
running was still live. Agent snapshots now carry the sidebar's own bucket as
indicator/indicatorLabel, computed by the same function the sidebar uses so
the two cannot drift.

Lifecycle hooks and the agent.open.before request now carry `background`, so a
hook can skip a hidden helper agent without a lookup. A hook cannot change it;
an omitted value means unchanged, like metadata.

`finished` attention still means the turn finished; background work is the
indicator's concern.

* fix(plugins): keep session-open hooks that omit background typechecking

A hook that returns its own object instead of spreading the request has no
background field; the result type now treats it as optional, like metadata,
matching the runtime where an omitted value means unchanged.

* refactor(plugins): give agent snapshots the background work count instead of an indicator

* fix(plugins): keep legacy session-open hooks that omit background typechecking
This commit is contained in:
Mohamed Boudra
2026-11-11 15:10:13 +07:00
committed by GitHub
parent 88b46c6781
commit a886bfbcdd
24 changed files with 283 additions and 28 deletions
@@ -1,7 +1,8 @@
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import {
createPluginAgentSnapshot,
createPluginWorkspaceSnapshot,
@@ -109,6 +110,26 @@ describe("sidebar entity snapshots", () => {
).toBe(after);
});
it("carries the background work count the host publishes for the agent", () => {
// Round-trip through the wire payload, the way the app receives each agent update.
const published = (backgroundWork: Agent["backgroundWork"]) =>
createPluginAgentSnapshot(
normalizeAgentSnapshot({ ...projectAgentSnapshot(agent), backgroundWork }, agent.serverId),
workspace.id,
).backgroundWork;
expect(published(undefined)).toEqual({ count: 0 });
expect(published({ count: 2 })).toEqual({ count: 2 });
expect(published({ count: 0 })).toEqual({ count: 0 });
expect(Object.isFrozen(published({ count: 1 }))).toBe(true);
});
it("marks a background agent", () => {
expect(createPluginAgentSnapshot(agent, workspace.id).background).toBe(false);
expect(createPluginAgentSnapshot({ ...agent, background: true }, workspace.id).background).toBe(
true,
);
});
it("reads projects without requiring a workspace", () => {
const project = {
projectId: "empty",
@@ -86,6 +86,8 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
workspaceId,
provider: agent.provider,
status: agent.status,
backgroundWork: Object.freeze({ count: agent.backgroundWork?.count ?? 0 }),
background: agent.background ?? false,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastActivityAt: agent.lastActivityAt.toISOString(),
@@ -51,6 +51,8 @@ const agent: PluginAgentSnapshot = {
workspaceId: workspace.id,
provider: "codex",
status: "running",
backgroundWork: { count: 0 },
background: false,
createdAt: "2026-08-16T10:00:00.000Z",
updatedAt: "2026-08-16T10:00:00.000Z",
lastActivityAt: "2026-08-16T10:00:00.000Z",
+17
View File
@@ -370,6 +370,17 @@ describe("plugin agent tools SDK boundary", () => {
...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" },
} };
});
server.addHook("agent.open.before", async ({ request }) => {
const background: boolean = request.background;
if (background) return;
// A hook written before background and metadata returns its own object without them.
return {
agentId: request.agentId, workspaceId: request.workspaceId, provider: request.provider,
cwd: request.cwd, reason: request.reason, purpose: request.purpose, title: request.title,
labels: request.labels, tools: request.tools, env: request.env,
mcpServers: request.mcpServers,
};
});
return () => {};
}
`),
@@ -429,6 +440,12 @@ describe("lifecycle hook SDK types", () => {
// @ts-expect-error A prepare handler must return the whole request or undefined.
server.addHook("agent.create.before", async () => ({ invalid: true }));
server.before("agent.session_open", ({ request }) => request);
server.before("agent.session_open", ({ request }) => ({
agentId: request.agentId, workspaceId: request.workspaceId, provider: request.provider,
cwd: request.cwd, reason: request.reason, purpose: request.purpose, title: request.title,
labels: request.labels, metadata: request.metadata, tools: request.tools, env: request.env,
mcpServers: request.mcpServers,
}));
server.on("agent.turn_ended", () => {});
return () => {};
}
+4
View File
@@ -61,6 +61,10 @@ export interface PluginAgentSnapshot {
readonly workspaceId: string;
readonly provider: string;
readonly status: "initializing" | "idle" | "running" | "error" | "closed";
/** Shells and monitors the agent left running after its turn. 0 when none are live. */
readonly backgroundWork: { readonly count: number };
/** A helper hidden from tabs, lists and attention. Fixed at creation. */
readonly background: boolean;
readonly createdAt: string;
readonly updatedAt: string;
readonly lastActivityAt: string;
+11 -2
View File
@@ -71,6 +71,8 @@ export interface PluginHookAgent {
provider: string;
cwd: string;
title: string | null;
/** A helper hidden from tabs, lists and attention. Fixed at creation. */
readonly background: boolean;
/** Data plugins stored on the agent. Paseo never reads a key. */
metadata: Record<string, JsonValue>;
}
@@ -83,6 +85,8 @@ export interface PluginSessionOpenRequest {
reason: "create" | "resume" | "refresh" | "import";
purpose: "interactive" | "history";
readonly title: string | null;
/** A helper hidden from tabs, lists and attention. Fixed at creation. */
readonly background: boolean;
readonly labels: Readonly<Record<string, string>>;
readonly metadata: Readonly<Record<string, JsonValue>>;
tools: AgentToolDefinition[];
@@ -134,7 +138,9 @@ export interface PluginHookRequests {
/** What a hook may return. An omitted `metadata` stays as it was. */
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
// Read-only fields added after the first SDK stay optional in a result; omitted means unchanged.
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata" | "background"> & {
readonly background?: boolean;
metadata?: Readonly<Record<string, JsonValue>>;
};
};
@@ -184,7 +190,7 @@ export interface PluginLifecycleRegistration {
handler: (
input: { request: PluginBeforeRequests[Name] },
context: PluginHookContext,
) => PluginBeforeRequests[Name] | void | Promise<PluginBeforeRequests[Name] | void>,
) => PluginBeforeResults[Name] | void | Promise<PluginBeforeResults[Name] | void>,
): () => void;
}
@@ -229,6 +235,9 @@ export type PluginLifecycleEvents = {
export type PluginBeforeRequests = {
[Name in keyof typeof legacyRequests]: PluginHookRequests[(typeof legacyRequests)[Name]];
};
type PluginBeforeResults = {
[Name in keyof typeof legacyRequests]: PluginHookRequestResults[(typeof legacyRequests)[Name]];
};
/** @internal SDK adapter for the daemon's hook registry. */
export function createLifecycleRegistration(
@@ -13225,6 +13225,7 @@ test.each([
provider: "codex",
cwd: workdir,
title: null,
background: false,
metadata: {},
},
turnId: "hook-turn",
@@ -1566,6 +1566,7 @@ export class AgentManager {
purpose: "interactive",
workspaceId: options.workspaceId ?? null,
title: options.initialTitle ?? storedConfig.title ?? null,
background: options.background,
labels: options.labels ?? {},
metadata: options.metadata ?? {},
},
@@ -1700,6 +1701,7 @@ export class AgentManager {
purpose,
workspaceId,
title: record?.title ?? mergedConfig.title ?? null,
background: options?.background,
...agentEntityData(record ?? options),
},
);
@@ -1881,6 +1883,7 @@ export class AgentManager {
purpose: "interactive",
workspaceId: existing.workspaceId,
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
background: existing.background,
labels: existing.labels,
metadata: existing.metadata,
},
@@ -6101,6 +6104,7 @@ export class AgentManager {
purpose: PluginSessionOpenRequest["purpose"];
workspaceId?: string | null;
title?: string | null;
background?: boolean;
labels?: Record<string, string>;
metadata?: AgentMetadata;
},
@@ -6120,6 +6124,7 @@ export class AgentManager {
purpose: opening?.purpose ?? "interactive",
env: { ...env },
title: opening?.title ?? null,
background: opening?.background === true,
...agentEntityData(opening),
tools: [],
mcpServers: {},
@@ -323,6 +323,88 @@ export default function contribute(server) {
}
}, 60_000);
test("hooks tell a background agent from a normal one", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-background-hooks-"));
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: "background-hooks",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
export default function contribute(server) {
const log = (hook, agentId, background) =>
console.log(JSON.stringify({ hook, agentId, background }));
server.addHook("agent.open.before", async ({ request }) => {
log("agent.open.before", request.agentId, request.background);
return { ...request, background: request.background };
});
for (const hook of ["agent.create.after", "agent.turn.before", "agent.turn.after", "agent.archive.after"]) {
server.addHook(hook, async ({ agent }) => log(hook, agent.id, agent.background));
}
return () => {};
}
`,
);
await client.connect();
await client.fetchAgents({ subscribe: {} });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
const workspace = await client.createWorkspace({
source: { kind: "directory", path: directory },
});
const workspaceId = workspace.workspace!.id;
const helper = await client.createAgent({
provider: "claude",
cwd: directory,
workspaceId,
background: true,
});
const visible = await client.createAgent({ provider: "claude", cwd: directory, workspaceId });
expect(helper.background).toBe(true);
for (const agent of [helper, visible]) {
await client.sendMessage(agent.id, "hello");
await client.waitForFinish(agent.id, 10_000);
await client.archiveAgent(agent.id);
}
const hooks = [
"agent.open.before",
"agent.create.after",
"agent.turn.before",
"agent.turn.after",
"agent.archive.after",
];
const seen = (logs: { message: string }[], agentId: string) =>
logs
.filter((entry) => entry.message.startsWith('{"hook":'))
.map((entry) => JSON.parse(entry.message))
.filter((entry) => entry.agentId === agentId)
.map(({ hook, background }) => ({ hook, background }));
await expect
.poll(async () => {
const logs = await client.getPluginLogs("background-hooks");
return {
helper: new Set(seen(logs, helper.id).map((entry) => JSON.stringify(entry))),
visible: new Set(seen(logs, visible.id).map((entry) => JSON.stringify(entry))),
};
})
.toEqual({
helper: new Set(hooks.map((hook) => JSON.stringify({ hook, background: true }))),
visible: new Set(hooks.map((hook) => JSON.stringify({ hook, background: false }))),
});
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
test("metadata set at creation and through setMetadata survives a daemon restart", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-metadata-restart-"));
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-cwd-"));
@@ -160,7 +160,7 @@ test("a workspace create hook that omits metadata passes on what an earlier hook
).resolves.toMatchObject({ metadata: {} });
});
test("a session-open hook that returns its own object without metadata leaves metadata unchanged", async () => {
test("a session-open hook that returns its own object without metadata or background leaves them unchanged", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.addHook("agent.open.before", async ({ request }) => ({
agentId: request.agentId,
@@ -184,6 +184,7 @@ test("a session-open hook that returns its own object without metadata leaves me
provider: "codex",
cwd: "/project",
title: null,
background: true,
labels: {},
metadata: { "linear.issue": { id: "ENG-1" } },
tools: [],
@@ -196,11 +197,12 @@ test("a session-open hook that returns its own object without metadata leaves me
);
expect(result).toMatchObject({
env: { HOOK: "1" },
background: true,
metadata: { "linear.issue": { id: "ENG-1" } },
});
});
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
test.each([{ title: "Changed" }, { labels: { changed: "true" } }, { background: true }])(
"session-open targeting metadata is read-only: %j",
async (patch) => {
const hooks = new PluginHookHandlers(() => {});
@@ -215,6 +217,7 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
provider: "codex",
cwd: "/project",
title: "Original",
background: false,
labels: {},
metadata: {},
tools: [],
@@ -103,6 +103,8 @@ const beforeSchemas = {
reason: z.enum(["create", "resume", "refresh", "import"]),
purpose: z.enum(["interactive", "history"]),
title: z.string().nullable(),
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
background: z.boolean().optional(),
labels: z.record(z.string(), z.string()),
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
@@ -166,6 +168,7 @@ export function describeHookAgent(agent: {
provider: string;
cwd: string;
title?: string | null;
background?: boolean;
labels: Record<string, string>;
metadata: Record<string, JsonValue>;
}): PluginHookAgent {
@@ -176,6 +179,7 @@ export function describeHookAgent(agent: {
provider: agent.provider,
cwd: agent.cwd,
title: agent.title ?? null,
background: agent.background === true,
metadata: agent.metadata,
};
}
@@ -219,6 +223,24 @@ export function publishAgentStream(
}
}
type SessionOpenRequest = z.infer<(typeof beforeSchemas)["agent.open.before"]>;
// Omitted `background` and `metadata` mean unchanged, so a hook returning its own object keeps working.
function changesSessionOpenTarget(previous: SessionOpenRequest, next: SessionOpenRequest): boolean {
return (
previous.agentId !== next.agentId ||
previous.workspaceId !== next.workspaceId ||
previous.provider !== next.provider ||
previous.cwd !== next.cwd ||
previous.reason !== next.reason ||
previous.purpose !== next.purpose ||
previous.title !== next.title ||
(next.background !== undefined && previous.background !== next.background) ||
!isDeepStrictEqual(previous.labels, next.labels) ||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
);
}
export async function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
name: Name,
input: PluginWireBeforeRequests[Name],
@@ -228,20 +250,10 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
if (name === "agent.open.before") {
const previous = beforeSchemas["agent.open.before"].parse(input);
const next = beforeSchemas["agent.open.before"].parse(result);
if (
previous.agentId !== next.agentId ||
previous.workspaceId !== next.workspaceId ||
previous.provider !== next.provider ||
previous.cwd !== next.cwd ||
previous.reason !== next.reason ||
previous.purpose !== next.purpose ||
previous.title !== next.title ||
!isDeepStrictEqual(previous.labels, next.labels) ||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
) {
if (changesSessionOpenTarget(previous, next)) {
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
}
return { ...result, metadata: previous.metadata };
return { ...result, background: previous.background, metadata: previous.metadata };
}
if (name === "agent.create.before") {
const previous = beforeSchemas["agent.create.before"].parse(input);
@@ -169,6 +169,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
provider: z.string(),
cwd: z.string(),
title: z.string().nullable(),
background: z.boolean(),
metadata: z.record(z.string(), z.custom<JsonValue>()),
})
.strict(),
@@ -6,7 +6,7 @@ Sets saved Codex options through `server.addHook("agent.create.before", async ..
- Sets `config.providerOptions.approval_policy` to `on-request`.
- Preserves other configuration and provider options.
Adds the `company` MCP server to Codex launches through
Adds the `company` MCP server to Codex launches, skipping background agents, through
`server.addHook("agent.open.before", async ...)`. The server is never saved in the agent's configuration;
existing saved MCP servers are preserved. Existing agents receive the server through the
[session reopen behavior](../../public-docs/plugins/hooks.md#attach-tools-to-agents).
@@ -20,7 +20,8 @@ export default function contribute(server: PluginServerContext) {
});
server.addHook("agent.open.before", async ({ request }) => {
if (request.provider !== "codex") {
// Background agents are short-lived helpers; keep the company tools for the agents users open.
if (request.provider !== "codex" || request.background) {
return;
}
+1 -1
View File
@@ -2,7 +2,7 @@
Small callbacks using the existing SDK:
- Send `Try again.` when the latest output contains `out of credits`.
- Send `Try again.` when the latest output contains `out of credits`, except to background agents.
- 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.
@@ -18,7 +18,8 @@ export default function contribute(server: PluginServerContext) {
});
server.addHook("agent.turn.after", async (event, context) => {
if (event.outcome.kind === "canceled") {
// A background agent reports to the agent that created it, which decides whether to retry.
if (event.outcome.kind === "canceled" || event.agent.background) {
return;
}
const agent = context.paseo.agents.ref(event.agent.id);
@@ -4,13 +4,21 @@ import { useAgents, type PluginSidebarViewProps } from "@getpaseo/plugin/client"
import { AgentRow, Heading } from "./rows";
export function agentTask(agent: PluginAgentSnapshot) {
if (agent.attentionReason === "permission") return "Respond";
if (agent.requiresAttention || agent.status === "error") return "Review";
if (agent.status === "running" || agent.status === "initializing") return "Wait";
if (agent.status === "error") return "Review";
// An agent whose turn ended still works while shells or monitors it started are live.
if (
agent.status === "running" ||
agent.status === "initializing" ||
agent.backgroundWork.count > 0
)
return "Wait";
if (agent.requiresAttention) return "Review";
return "Start";
}
function Task({ title, view }: { title: string; view: PluginSidebarViewProps }) {
const agents = useAgents({
where: (item) => agentTask(item) === title,
// Background agents are helpers the user never opens; leave them out like the sidebar does.
where: (item) => !item.background && agentTask(item) === title,
orderBy: ["lastActivityAt", "desc"],
});
return (
@@ -91,7 +91,12 @@ export function AgentRow({
() => ({ color: view.theme.colors.foreground, fontSize: 12 }),
[view.theme],
);
const detail = useMemo(
() => ({ color: view.theme.colors.foregroundMuted, fontSize: 11 }),
[view.theme],
);
if (!agent) return null;
const tasks = agent.backgroundWork.count;
return (
<Pressable
accessibilityRole="button"
@@ -102,6 +107,11 @@ export function AgentRow({
<Text numberOfLines={1} style={style}>
{agent.title ?? agent.provider}
</Text>
{tasks > 0 ? (
<Text numberOfLines={1} style={detail}>
{tasks === 1 ? "1 background task" : `${tasks} background tasks`}
</Text>
) : null}
</Pressable>
);
}
+19 -1
View File
@@ -76,6 +76,7 @@ interface PluginHookAgent {
provider: string;
cwd: string;
title: string | null;
readonly background: boolean;
metadata: Record<string, JsonValue>;
}
@@ -96,6 +97,7 @@ type PluginTurnOutcome =
| Field | Shape / meaning |
| ------------ | -------------------------------------------------------------------------------------- |
| `background` | `true` for a [background agent](#background-agents) |
| `turnId` | Provider-reported `string` or `null`; unique across resumed sessions |
| `timeline` | Deprecated, always `[]`; fetch history with the SDK. |
| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` |
@@ -303,6 +305,7 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
"provider": "codex",
"cwd": "/projects/shop",
"title": "Workspace notes",
"background": false,
"labels": { "notes/enabled": "true" },
"metadata": { "linear.issue": { "id": "ENG-123" } },
"tools": [],
@@ -317,6 +320,7 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
| ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | String or `null` |
| `title` | Read-only string or `null`; creation title on create, stored title on resume. |
| `background` | Read-only `true` for a [background agent](#background-agents). Fixed at creation. |
| `labels` | Read-only string map; creation labels on create, stored labels on resume. |
| `metadata` | Read-only JSON map; the [metadata](#metadata) stored on the agent. |
| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). |
@@ -350,7 +354,7 @@ the placeholder URL with your MCP endpoint:
```ts
server.addHook("agent.open.before", async ({ request }) => {
if (request.provider !== "codex") return;
if (request.provider !== "codex" || request.background) return;
return {
...request,
@@ -426,6 +430,7 @@ Select agents using any of the session-open request fields:
| Provider | `request.provider === "codex"` |
| Workspace | `request.workspaceId === "workspace-456"` |
| Individual agent | `request.agentId === chosenAgentId` |
| Background agent | `request.background` |
`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
@@ -623,8 +628,21 @@ server.addHook("agent.permission.after", async ({ agent, requestId, resolution }
});
```
## Background agents
Agent hooks exclude the daemon's private disposable helpers. Background agents and the agents in background workspaces use the same hooks.
A [background agent](/docs/sdk/agents#run-a-helper-as-a-background-agent) is a helper hidden from tabs, lists and attention. Every hook's `agent.background` and the `agent.open.before` request's `background` are `true` for one, so you can skip it without a lookup:
```ts
server.addHook("agent.turn.after", async ({ agent }) => {
if (agent.background) return; // A helper reports to the agent that created it.
await notifyTeam(agent);
});
```
`background` is fixed when the agent is created and a hook cannot change it. An agent created in a background workspace without `background: true` reads `false`. Hosts older than 0.16.0 omit the field, so it reads as `undefined`.
## Ordering and returned values
```text
+19 -1
View File
@@ -74,6 +74,7 @@ interface PluginHookAgent {
provider: string;
cwd: string;
title: string | null;
readonly background: boolean;
metadata: Record<string, JsonValue>;
}
@@ -94,6 +95,7 @@ type PluginTurnOutcome =
| 字段 | 结构 / 含义 |
| ------------ | ---------------------------------------------------------------------------------------- |
| `background` | [后台 Agent](#background-agents) 为 `true` |
| `turnId` | Provider 报告的 `string` 或 `null`;在会话恢复后仍保持唯一 |
| `timeline` | 已弃用,始终为 `[]`;通过 SDK 获取历史。 |
| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` |
@@ -299,6 +301,7 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
"provider": "codex",
"cwd": "/projects/shop",
"title": "Workspace notes",
"background": false,
"labels": { "notes/enabled": "true" },
"metadata": { "linear.issue": { "id": "ENG-123" } },
"tools": [],
@@ -313,6 +316,7 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
| ------------- | -------------------------------------------------------------------------------------------------------- |
| `workspaceId` | 字符串或 `null` |
| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 |
| `background` | 只读;[后台 Agent](#background-agents) 为 `true`。在创建时确定。 |
| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 |
| `metadata` | 只读的 JSON 映射;Agent 上已保存的[元数据](#metadata)。 |
| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 |
@@ -344,7 +348,7 @@ server.addHook("agent.open.before", async ({ request }) => {
```ts
server.addHook("agent.open.before", async ({ request }) => {
if (request.provider !== "codex") return;
if (request.provider !== "codex" || request.background) return;
return {
...request,
@@ -418,6 +422,7 @@ const agent = await paseo.agents.create({
| Provider | `request.provider === "codex"` |
| 工作区 | `request.workspaceId === "workspace-456"` |
| 单个 Agent | `request.agentId === chosenAgentId` |
| 后台 Agent | `request.background` |
每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具;
替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。
@@ -606,8 +611,21 @@ server.addHook("agent.permission.after", async ({ agent, requestId, resolution }
});
```
## 后台 Agent {#background-agents}
Agent 钩子不包括 daemon 的私有临时辅助 Agent。后台 Agent 和后台工作区中的 Agent 使用相同钩子。
[后台 Agent](/docs/sdk/agents#run-a-helper-as-a-background-agent) 是不出现在标签页、列表和提醒中的辅助 Agent。对后台 Agent,每个钩子的 `agent.background` 以及 `agent.open.before` 请求的 `background` 都为 `true`,无需额外查询即可跳过它:
```ts
server.addHook("agent.turn.after", async ({ agent }) => {
if (agent.background) return; // A helper reports to the agent that created it.
await notifyTeam(agent);
});
```
`background` 在创建 Agent 时确定,钩子无法修改。在后台工作区中创建但没有传 `background: true` 的 Agent 读取到 `false`。早于 0.16.0 的主机不发送该字段,读取结果为 `undefined`。
## 顺序与返回值 {#ordering-and-returned-values}
```text
+23
View File
@@ -1572,6 +1572,8 @@ Agent snapshot fields:
| `workspaceId` | `string` |
| `provider` | `string` |
| `status` | `"initializing" \| "idle" \| "running" \| "error" \| "closed"` |
| `backgroundWork` | `{ count: number }` |
| `background` | `boolean` |
| `createdAt` | ISO timestamp |
| `updatedAt` | ISO timestamp |
| `lastActivityAt` | ISO timestamp |
@@ -1585,6 +1587,27 @@ Agent snapshot fields:
| `parentAgentId` | `string \| null` |
| `labels` | `Record<string, string>` |
`backgroundWork.count` is the number of shells and monitors the agent left running after its turn,
and `0` when none are live. An agent can be `idle` with `attentionReason: "finished"` while that
work still runs, so combine the fields the way your plugin needs:
```tsx
const busy = useAgent(
agentId,
(agent) => agent.status === "running" || agent.backgroundWork.count > 0,
);
if (busy === null) return null; // The agent is unavailable.
```
To list or stop the work itself, use
[`paseo.agents.ref(id).backgroundWork`](/docs/sdk/reference#agent-handle).
`background` is `true` for a [background agent](/docs/sdk/agents#run-a-helper-as-a-background-agent),
a helper hidden from tabs, lists and attention. It is fixed when the agent is created. An agent in a
background workspace reads `false` unless it was created as a background agent; read the
workspace's own `background` for that. `backgroundWork` and `background` require Paseo 0.16.0 or
later.
Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC providers, and the render error boundary. A restored tab whose plugin, panel, context, workspace, or agent is unavailable stays open with an unavailable message instead of crashing the workspace.
## File panels
+16
View File
@@ -1331,6 +1331,8 @@ Agent 快照字段:
| `workspaceId` | `string` |
| `provider` | `string` |
| `status` | `"initializing" \| "idle" \| "running" \| "error" \| "closed"` |
| `backgroundWork` | `{ count: number }` |
| `background` | `boolean` |
| `createdAt` | ISO 时间戳 |
| `updatedAt` | ISO 时间戳 |
| `lastActivityAt` | ISO 时间戳 |
@@ -1344,6 +1346,20 @@ Agent 快照字段:
| `parentAgentId` | `string \| null` |
| `labels` | `Record<string, string>` |
`backgroundWork.count` 是 Agent 在回合结束后仍在运行的 shell 和监视器数量,没有时为 `0`。Agent 可能处于 `idle` 且 `attentionReason: "finished"`,同时这些工作仍在运行,因此请按插件的需要组合这些字段:
```tsx
const busy = useAgent(
agentId,
(agent) => agent.status === "running" || agent.backgroundWork.count > 0,
);
if (busy === null) return null; // Agent 不可用。
```
要列出或停止这些工作本身,使用 [`paseo.agents.ref(id).backgroundWork`](/docs/sdk/reference#agent-handle)。
[后台 Agent](/docs/sdk/agents#run-a-helper-as-a-background-agent) 的 `background` 为 `true`,它是不出现在标签页、列表和提醒中的辅助 Agent,在创建时确定。后台工作区中的 Agent 除非本身以后台 Agent 创建,否则 `background` 为 `false`;要判断工作区,读取工作区自己的 `background`。`backgroundWork` 和 `background` 需要 Paseo 0.16.0 或更高版本。
标签页的焦点、拆分、关闭、持久化、查询状态、API/RPC Provider 以及渲染错误边界都由 Paseo 负责。如果恢复的标签页所对应的插件、面板、上下文、工作区或 Agent 不可用,该标签页会保持打开并显示“不可用”消息,而不会让工作区崩溃。
## 文件面板 {#file-panels}
+2 -1
View File
@@ -110,7 +110,8 @@ Existing workspace panels can continue passing a host-local string ID to `useWor
Workspace snapshots include `project`, `pinnedAt`, `labels`, `background`, `branch`,
`lastPromptAt`, `parentAgent` and `createdAt` (null on older hosts). Use `indicator` and the
localized `indicatorLabel` to display the status Paseo shows. `status` remains the raw daemon
status. Agent snapshots include their workspace and parent refs. Project snapshots provide
status. Agent snapshots include their workspace and parent refs, `background`, and
`backgroundWork.count`. Project snapshots provide
`displayName`, `rootPath`, `kind` and `iconRevision`.
## List terminals
+1 -1
View File
@@ -86,7 +86,7 @@ const selected = useWorkspace(ref, selector, equals);
没有选择器时,自定义相等比较函数接收快照;有选择器时接收选择结果。现有工作区面板仍可向 `useWorkspace` 和 `useAgent` 传入主机本地的字符串 ID。跨主机视图应使用引用。`useHosts()` 保留现有的 `serverId`、`label` 和连接 `status` 字段。
工作区快照包含 `project`、`pinnedAt`、`labels`、`background`、`branch`、`lastPromptAt`、`parentAgent` 和 `createdAt`(旧主机上为 null)。使用 `indicator` 和本地化的 `indicatorLabel` 显示 Paseo 的状态。`status` 保持 daemon 原始状态。Agent 快照包含工作区和父 Agent 引用。项目快照提供 `displayName`、`rootPath`、`kind` 和 `iconRevision`。
工作区快照包含 `project`、`pinnedAt`、`labels`、`background`、`branch`、`lastPromptAt`、`parentAgent` 和 `createdAt`(旧主机上为 null)。使用 `indicator` 和本地化的 `indicatorLabel` 显示 Paseo 的状态。`status` 保持 daemon 原始状态。Agent 快照包含工作区和父 Agent 引用、`background` 和 `backgroundWork.count`。项目快照提供 `displayName`、`rootPath`、`kind` 和 `iconRevision`。
## 列出终端 {#list-terminals}