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
@@ -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(),