From a886bfbcddcae43e4d8c4d5e6a8db1be40032ff2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 11 Nov 2026 15:10:13 +0700 Subject: [PATCH] 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 --- .../plugins/client-state/snapshots.test.ts | 23 +++++- .../app/src/plugins/client-state/snapshots.ts | 2 + .../command-center/contributions.test.ts | 2 + packages/plugin/src/boundaries.test.ts | 17 ++++ packages/plugin/src/contracts.ts | 4 + packages/plugin/src/server/lifecycle.ts | 13 ++- .../src/server/agent/agent-manager.test.ts | 1 + .../server/src/server/agent/agent-manager.ts | 5 ++ .../src/server/plugins/lifecycle.e2e.test.ts | 82 +++++++++++++++++++ .../server/plugins/lifecycle/handlers.test.ts | 7 +- .../src/server/plugins/lifecycle/index.ts | 36 +++++--- .../server/plugins/plugin-process-protocol.ts | 1 + plugin-examples/agent-configuration/README.md | 2 +- .../agent-configuration/index.server.ts | 3 +- plugin-examples/lifecycle-actions/README.md | 2 +- .../lifecycle-actions/index.server.ts | 3 +- .../sidebar-views/client/agents.tsx | 14 +++- plugin-examples/sidebar-views/client/rows.tsx | 10 +++ public-docs/plugins/hooks.md | 20 ++++- public-docs/plugins/hooks.zh-CN.md | 20 ++++- public-docs/plugins/reference.md | 23 ++++++ public-docs/plugins/reference.zh-CN.md | 16 ++++ public-docs/plugins/sidebar-views.md | 3 +- public-docs/plugins/sidebar-views.zh-CN.md | 2 +- 24 files changed, 283 insertions(+), 28 deletions(-) diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index d0f09008d9..a5491d946c 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -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", diff --git a/packages/app/src/plugins/client-state/snapshots.ts b/packages/app/src/plugins/client-state/snapshots.ts index d805913201..074802c5d8 100644 --- a/packages/app/src/plugins/client-state/snapshots.ts +++ b/packages/app/src/plugins/client-state/snapshots.ts @@ -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(), diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 95e739d50b..c821bebd83 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -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", diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 2a1ddae8f7..7b78940ba4 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -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 () => {}; } diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 9bb5f0353d..dfb3ccab9e 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -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; diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index acc703ed12..a048d93706 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -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; } @@ -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>; readonly metadata: Readonly>; tools: AgentToolDefinition[]; @@ -134,7 +138,9 @@ export interface PluginHookRequests { /** What a hook may return. An omitted `metadata` stays as it was. */ type PluginHookRequestResults = Omit & { - "agent.open.before": Omit & { + // Read-only fields added after the first SDK stay optional in a result; omitted means unchanged. + "agent.open.before": Omit & { + readonly background?: boolean; metadata?: Readonly>; }; }; @@ -184,7 +190,7 @@ export interface PluginLifecycleRegistration { handler: ( input: { request: PluginBeforeRequests[Name] }, context: PluginHookContext, - ) => PluginBeforeRequests[Name] | void | Promise, + ) => PluginBeforeResults[Name] | void | Promise, ): () => 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( diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 717745ee18..1491aaa6e7 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -13225,6 +13225,7 @@ test.each([ provider: "codex", cwd: workdir, title: null, + background: false, metadata: {}, }, turnId: "hook-turn", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 808f207e84..b2570ff97e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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; 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: {}, diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index 7d5ffe3082..92fb21443e 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -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-")); diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index 49cf75fceb..bf7526ba1d 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -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: [], diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index f7ddd84b74..5e552c0111 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -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()).optional(), @@ -166,6 +168,7 @@ export function describeHookAgent(agent: { provider: string; cwd: string; title?: string | null; + background?: boolean; labels: Record; metadata: Record; }): 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: Name, input: PluginWireBeforeRequests[Name], @@ -228,20 +250,10 @@ export async function validateBeforeResult = z.dis provider: z.string(), cwd: z.string(), title: z.string().nullable(), + background: z.boolean(), metadata: z.record(z.string(), z.custom()), }) .strict(), diff --git a/plugin-examples/agent-configuration/README.md b/plugin-examples/agent-configuration/README.md index cb191bd3e1..a3399135a5 100644 --- a/plugin-examples/agent-configuration/README.md +++ b/plugin-examples/agent-configuration/README.md @@ -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). diff --git a/plugin-examples/agent-configuration/index.server.ts b/plugin-examples/agent-configuration/index.server.ts index cfb236caa1..717f796a81 100644 --- a/plugin-examples/agent-configuration/index.server.ts +++ b/plugin-examples/agent-configuration/index.server.ts @@ -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; } diff --git a/plugin-examples/lifecycle-actions/README.md b/plugin-examples/lifecycle-actions/README.md index 689ed2908f..abdc741452 100644 --- a/plugin-examples/lifecycle-actions/README.md +++ b/plugin-examples/lifecycle-actions/README.md @@ -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. diff --git a/plugin-examples/lifecycle-actions/index.server.ts b/plugin-examples/lifecycle-actions/index.server.ts index 7b1fb1e761..906cd2d565 100644 --- a/plugin-examples/lifecycle-actions/index.server.ts +++ b/plugin-examples/lifecycle-actions/index.server.ts @@ -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); diff --git a/plugin-examples/sidebar-views/client/agents.tsx b/plugin-examples/sidebar-views/client/agents.tsx index 8d0d469121..f3a96d16cf 100644 --- a/plugin-examples/sidebar-views/client/agents.tsx +++ b/plugin-examples/sidebar-views/client/agents.tsx @@ -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 ( diff --git a/plugin-examples/sidebar-views/client/rows.tsx b/plugin-examples/sidebar-views/client/rows.tsx index ef55333f15..18c81252d5 100644 --- a/plugin-examples/sidebar-views/client/rows.tsx +++ b/plugin-examples/sidebar-views/client/rows.tsx @@ -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 ( {agent.title ?? agent.provider} + {tasks > 0 ? ( + + {tasks === 1 ? "1 background task" : `${tasks} background tasks`} + + ) : null} ); } diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md index a75600f942..c93da33e04 100644 --- a/public-docs/plugins/hooks.md +++ b/public-docs/plugins/hooks.md @@ -76,6 +76,7 @@ interface PluginHookAgent { provider: string; cwd: string; title: string | null; + readonly background: boolean; metadata: Record; } @@ -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 diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md index adc8986632..d78bccd6d2 100644 --- a/public-docs/plugins/hooks.zh-CN.md +++ b/public-docs/plugins/hooks.zh-CN.md @@ -74,6 +74,7 @@ interface PluginHookAgent { provider: string; cwd: string; title: string | null; + readonly background: boolean; metadata: Record; } @@ -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 diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index c3f60393cd..cdd847ba7f 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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` | +`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 diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 7331cb086b..7ab38e8587 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -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` | +`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} diff --git a/public-docs/plugins/sidebar-views.md b/public-docs/plugins/sidebar-views.md index 542c0f6166..e32470af28 100644 --- a/public-docs/plugins/sidebar-views.md +++ b/public-docs/plugins/sidebar-views.md @@ -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 diff --git a/public-docs/plugins/sidebar-views.zh-CN.md b/public-docs/plugins/sidebar-views.zh-CN.md index 0c6ab89d33..fb7b685a15 100644 --- a/public-docs/plugins/sidebar-views.zh-CN.md +++ b/public-docs/plugins/sidebar-views.zh-CN.md @@ -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}