From 129102d5c6ed4e6d2dca0f1ee387b97b63d237c1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Nov 2026 13:38:08 +0100 Subject: [PATCH] feat(server): add background agents An agent can be created background in any workspace. It stays a durable, addressable agent, but default discovery, workspace status and attention leave it out. Effective background is agent.background || workspace.background. - Protocol: `create_agent_request.background` now marks the agent itself and stays the workspace intent for a workspace the request creates. Snapshots carry `background`. The daemon advertises `features.backgroundAgents`. Clients advertise the `background_agents` capability. - Daemon: the flag persists in the agent record, so restart and resume keep it. AgentDirectory owns visibility. Inclusive reads and subscriptions from sources without `background_agents` see only agents in background workspaces, because older apps would otherwise open a tab for each one. - Inheritance: omitted on agent-scoped creation, children and new workspaces take the caller agent's background. - MCP: create_agent accepts `background`. - SDK: placing a background agent in an existing workspace, or under a caller, fails fast with "Update the host to use background agents." on older hosts. - CLI: `ls -a` and `ls --background` add a BACKGROUND column. --- packages/cli/src/commands/agent/ls.test.ts | 23 +++ packages/cli/src/commands/agent/ls.ts | 15 +- packages/client/src/connection/index.ts | 1 + packages/client/src/daemon-client.test.ts | 1 + packages/client/src/daemon-client.ts | 14 +- packages/client/src/index.test.ts | 82 +++++++++++ packages/client/src/index.ts | 6 +- packages/protocol/src/client-capabilities.ts | 4 + packages/protocol/src/messages.ts | 9 +- .../server/src/server/agent/agent-loading.ts | 1 + .../server/src/server/agent/agent-manager.ts | 26 +++- .../src/server/agent/agent-projections.ts | 16 ++- .../server/src/server/agent/agent-storage.ts | 3 + .../src/server/agent/create-agent/create.ts | 39 +++-- .../src/server/agent/create-agent/intent.ts | 24 ++++ .../server/src/server/agent/directory.test.ts | 114 +++++++++++++++ packages/server/src/server/agent/directory.ts | 67 ++++++--- .../src/server/agent/host-operations/index.ts | 2 + .../src/server/agent/mcp-parity.e2e.test.ts | 133 ++++++++++++++++++ .../src/server/agent/mcp-server.test.ts | 38 ++--- .../src/server/agent/tools/paseo-tools.ts | 49 +++++-- packages/server/src/server/bootstrap.ts | 4 +- .../server/src/server/directory-sync/index.ts | 19 +-- .../server/src/server/persistence-hooks.ts | 2 + ...ate-agent-worktree-autoarchive.e2e.test.ts | 124 +++++++++++++++- packages/server/src/server/session.ts | 107 ++++++++------ .../agent-updates/agent-updates-service.ts | 20 ++- .../server/src/server/websocket-server.ts | 2 + .../src/server/workspace-directory.test.ts | 11 ++ .../server/src/server/workspace-directory.ts | 6 +- 30 files changed, 823 insertions(+), 139 deletions(-) create mode 100644 packages/server/src/server/agent/directory.test.ts diff --git a/packages/cli/src/commands/agent/ls.test.ts b/packages/cli/src/commands/agent/ls.test.ts index 76b1787ce3..82d6c09a61 100644 --- a/packages/cli/src/commands/agent/ls.test.ts +++ b/packages/cli/src/commands/agent/ls.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { render } from "../../output/index.js"; import { buildAgentLsFetchOptions, listAgents } from "./ls.js"; const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; @@ -130,4 +131,26 @@ describe("runLsCommand", () => { expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]); }); + + it("marks background agents in a BACKGROUND column only for inclusive listings", async () => { + const client = createPagingClient(); + const fetchAgents: typeof client.fetchAgents = async (options) => { + const page = await client.fetchAgents(options); + return { + ...page, + entries: page.entries.map(({ agent }) => ({ + agent: agent.title === "agent 2" ? { ...agent, background: true } : agent, + })), + }; + }; + const connect = async () => ({ ...client, fetchAgents }); + + const inclusive = await listAgents({ daemonTarget, all: true, global: true }, connect); + const rows = render(inclusive, { noColor: true }).split("\n"); + expect(rows[0]).toMatch(/BACKGROUND\s*$/); + expect(rows.slice(1).map((row) => row.trim().split(/\s+/).at(-1))).toEqual(["-", "yes", "-"]); + + const ordinary = await listAgents({ daemonTarget, global: true }, connect); + expect(render(ordinary, { noColor: true })).not.toContain("BACKGROUND"); + }); }); diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index e0b6358a38..d60ad0ce97 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -15,7 +15,7 @@ export function addLsOptions(cmd: Command): Command { .description("List agents. By default excludes archived agents.") .option("-a, --all", "Include archived and background agents") .option("-g, --global", "List agents across all directories") - .option("--background", "Include background workspace agents") + .option("--background", "Include background agents and agents in background workspaces") .option( "--label ", "Filter by label (can be used multiple times)", @@ -35,6 +35,7 @@ export interface AgentListItem { status: string; cwd: string; created: string; + background: boolean; } /** Helper to get relative time string */ @@ -89,6 +90,15 @@ export const agentLsSchema: OutputSchema = { ], }; +/** Inclusive listings mark background agents so they read apart from visible ones. */ +const agentLsInclusiveSchema: OutputSchema = { + ...agentLsSchema, + columns: [ + ...agentLsSchema.columns, + { header: "BACKGROUND", field: (item) => (item.background ? "yes" : "-"), width: 10 }, + ], +}; + /** Transform agent snapshot to AgentListItem */ function toListItem(agent: AgentSnapshotPayload): AgentListItem { const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model); @@ -101,6 +111,7 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem { status: agent.status, cwd: shortenPath(agent.cwd), created: relativeTime(agent.createdAt), + background: agent.background === true, }; } @@ -259,7 +270,7 @@ export async function listAgents( return { type: "list", data: items, - schema: agentLsSchema, + schema: options.all || options.background ? agentLsInclusiveSchema : agentLsSchema, }; } catch (err) { await client.close().catch(() => {}); diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index f1325af4bf..bf2388e663 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -145,6 +145,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = { [CLIENT_CAPS.workspaceSetupBlocked]: true, [CLIENT_CAPS.workspaceSetupObservation]: true, [CLIENT_CAPS.pluginBinaryPayloads]: true, + [CLIENT_CAPS.backgroundAgents]: true, [CLIENT_CAPS.explicitEventSubscriptions]: true, } satisfies Record, true>; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index b2d7f46651..eb0dcdb6ca 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1110,6 +1110,7 @@ test("advertises client capabilities in hello", async () => { workspace_setup_blocked: true, workspace_setup_observation: true, hello_rejection: true, + background_agents: true, browser_host: { supportedCommands: ["list_tabs"], hostKind: "desktop app", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index bfc97f5674..810c90643c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -504,6 +504,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { git?: GitSetupOptions; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; + /** Creates a background agent. A workspace this request creates inherits it. */ background?: CreateAgentRequestMessage["background"]; /** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */ worktreeName?: string; @@ -3293,13 +3294,24 @@ export class DaemonClient { return this.lastServerInfoMessage?.features?.backgroundWorkspaces === true; } + // Background on a request that creates its workspace predates background agents: older hosts + // apply it to the new workspace. Placing a background agent in an existing workspace, or + // under a caller, needs a host that knows background agents. + private requireBackgroundCreation(options: CreateAgentRequestOptions): void { + if (options.workspaceId === undefined && options.callerAgentId === undefined) { + this.requireBackgroundWorkspaces(); + } else if (this.lastServerInfoMessage?.features?.backgroundAgents !== true) { + throw new Error("Update the host to use background agents."); + } + } + async createAgent(options: CreateAgentRequestOptions): Promise { if (options.git !== undefined || options.worktreeName !== undefined) { throw new Error( "Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) or create a workspace first and pass workspaceId.", ); } - if (options.background !== undefined) this.requireBackgroundWorkspaces(); + if (options.background !== undefined) this.requireBackgroundCreation(options); const result = await this.creations.createAgent({ ...options, config: resolveAgentConfig(options), diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 1b11a3032f..62ee8dda6a 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -747,6 +747,88 @@ test("agents.create with internal fails before sending on a host without interna await client.close(); }); +async function createWorkspaceHandle(features: Record) { + const { client, ws } = await connectClient(features); + const creating = client.workspaces.create({ + source: { kind: "directory", path: "/repo/sdk" }, + }); + const request = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "workspace.create.response", + payload: { + requestId: request.requestId, + workspace: createWorkspace({ id: "workspace_open" }), + setupTerminalId: null, + error: null, + }, + }), + ); + return { client, ws, workspace: await creating }; +} + +test("a background agent in an existing workspace fails before sending on a host without background agents", async () => { + const { client, ws, workspace } = await createWorkspaceHandle({ + providerUsageList: true, + providersSnapshotCwd: true, + ownedSubscriptions: true, + backgroundWorkspaces: true, + }); + const sentBefore = ws.sent.length; + + await expect( + workspace.agents.create({ config: { provider: "codex/gpt-5.4" }, background: true }), + ).rejects.toThrow("Update the host to use background agents."); + expect(ws.sent).toHaveLength(sentBefore); + + const creating = client.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/repo/sdk", + background: true, + }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "create_agent_request", + background: true, + }); + expect(parseSentSessionMessage(ws.sent.at(-1))).not.toHaveProperty("workspaceId"); + creating.catch(() => {}); + await client.close(); +}); + +test("a background agent in an existing workspace is sent to a host with background agents", async () => { + const { client, ws, workspace } = await createWorkspaceHandle({ + providerUsageList: true, + providersSnapshotCwd: true, + ownedSubscriptions: true, + backgroundWorkspaces: true, + backgroundAgents: true, + }); + + const creating = workspace.agents.create({ + config: { provider: "codex/gpt-5.4" }, + background: true, + }); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ + type: "create_agent_request", + workspaceId: "workspace_open", + background: true, + }); + ws.message( + sessionMessage({ + type: "status", + payload: { + status: "agent_created", + requestId: request.requestId, + agentId: "agent_background", + agent: createAgent({ id: "agent_background", workspaceId: "workspace_open" }), + }, + }), + ); + expect((await creating).id).toBe("agent_background"); + await client.close(); +}); + test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => { const { client, ws } = await connectClient(); const createdAgent = createAgent(); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3655b0023a..2ea2cc6dda 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -264,7 +264,11 @@ export interface PaseoAgentCreateOptions { git?: CreateAgentRequestMessage["git"]; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; - /** Visibility intent for a NEW workspace. Existing targets reject this option. */ + /** + * Creates a background agent: hidden from default lists, tabs, the parent's subagent track + * and notifications, and still addressable by id. A workspace this call creates is + * background too. With a parent and no value, the parent's own background applies. + */ background?: CreateAgentRequestMessage["background"]; requestId?: string; labels?: Record; diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index ac35ff7f63..bb03d72b89 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -51,6 +51,10 @@ export const CLIENT_CAPS = { pluginBinaryPayloads: "plugin_binary_payloads", // COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. workspaceSetupBlocked: "workspace_setup_blocked", + // COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor >= v0.15.0. + // Sources without it never receive agent-level background agents in inclusive lists or + // update subscriptions, because older apps auto-open a tab for every replicated root agent. + backgroundAgents: "background_agents", browserHost: "browser_host", } as const; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b5d368928e..b5fa571914 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -891,6 +891,9 @@ export const AgentSnapshotPayloadSchema = z.object({ providerUnavailable: z.boolean().optional(), // Private helper marker, retained for exact-ID snapshots and older wire messages. internal: z.boolean().optional(), + // Background agent: hidden from default discovery, tabs, tracks and notifications. + // Fixed at creation. Effective background also includes the workspace's flag. + background: z.boolean().optional(), }); export type AgentSnapshotPayload = z.infer; @@ -1802,7 +1805,8 @@ export const CreateAgentRequestMessageSchema = z.object({ // in the config schema, which is reused for updates. Retained for wire parsing; // public internal creation is no longer supported. internal: z.boolean().optional(), - // Workspace creation intent, rejected when selecting an existing workspace. + // The created agent is a background agent. A workspace this request creates inherits it. + // Omitted on agent-scoped creation, the caller agent's own value applies. background: z.boolean().optional(), labels: z.record(z.string(), z.string()).default({}), requestId: z.string(), @@ -3694,6 +3698,8 @@ export const ServerInfoStatusPayloadSchema = z // COMPAT(internalWorkspaces): added in v0.9.0; remove gate after 2027-03-17. internalWorkspaces: z.boolean().optional(), backgroundWorkspaces: z.boolean().optional(), + // COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03. + backgroundAgents: z.boolean().optional(), providersSnapshot: z.boolean().optional(), connectedHosts: z.boolean().optional(), hostMultiplexing: z.boolean().optional(), @@ -7802,6 +7808,7 @@ export const WSHelloMessageSchema = z.object({ [CLIENT_CAPS.providerSnapshotReferences]: z.boolean().optional(), [CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(), [CLIENT_CAPS.timelineNotifications]: z.boolean().optional(), + [CLIENT_CAPS.backgroundAgents]: z.boolean().optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), }) .passthrough() diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index ba444a394d..fdfdbcf031 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -135,6 +135,7 @@ export async function ensureAgentLoaded( labels: record.labels, workspaceId: record.workspaceId, owner: record.owner, + background: record.background, env: record.launchEnv, }); deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 5c01be317e..11a4a3c927 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -324,6 +324,8 @@ export interface CreateAgentOptions { // undefined is an explicit decision: the agent never appears in the sidebar. workspaceId: string | undefined; owner?: AgentOwner; + /** Background agents stay out of discovery, tabs, tracks and attention. Fixed at creation. */ + background?: boolean; } export interface AgentManagerOptions { @@ -387,6 +389,11 @@ export type AttentionState = attentionTimestamp: Date; }; +/** Private helpers and background agents report to their creator, never to the user. */ +function raisesAttention(agent: Pick): boolean { + return !agent.internal && !agent.background; +} + function resolveInitialAttention(input: AttentionState | undefined): AttentionState { if (input == null || !input.requiresAttention) { return { requiresAttention: false }; @@ -452,6 +459,11 @@ interface ManagedAgentBase { * Internal agents are hidden from listings and don't trigger notifications. */ internal?: boolean; + /** + * Background agents are durable but hidden from default discovery and never raise attention. + * Fixed at creation. + */ + background?: boolean; /** * User-defined labels for categorizing agents (e.g., { surface: "workspace" }). */ @@ -1402,6 +1414,7 @@ export class AgentManager { initialTitle: options.initialTitle, workspaceId: options.workspaceId, owner: options.owner, + background: options.background, launchEnv: options.env ? { ...options.env } : undefined, historyPrimed: true, }); @@ -1434,6 +1447,7 @@ export class AgentManager { labels?: Record; workspaceId?: string; owner?: AgentOwner; + background?: boolean; attention?: AttentionState; }, resumeOptions?: AgentResumeSessionOptions, @@ -1466,6 +1480,7 @@ export class AgentManager { labels?: Record; workspaceId?: string; owner?: AgentOwner; + background?: boolean; attention?: AttentionState; }, resumeOptions?: AgentResumeSessionOptions, @@ -1727,6 +1742,7 @@ export class AgentManager { labels: existing.labels, workspaceId: existing.workspaceId, owner: existing.owner, + background: existing.background, launchEnv: existing.launchEnv, createdAt: existing.createdAt, updatedAt: existing.updatedAt, @@ -2180,6 +2196,7 @@ export class AgentManager { lastError: record.lastError ?? undefined, attention, internal: record.internal, + background: record.background, labels: record.labels, }, }); @@ -3806,6 +3823,7 @@ export class AgentManager { publishWhenReady?: boolean; workspaceId?: string; owner?: AgentOwner; + background?: boolean; launchEnv?: Record; }, ): Promise { @@ -3835,6 +3853,7 @@ export class AgentManager { config, now, durableTimelineHasRows, + background: options?.background === true, options, }); @@ -3979,6 +3998,7 @@ export class AgentManager { config: AgentSessionConfig; now: Date; durableTimelineHasRows: boolean; + background: boolean; options: | { createdAt?: Date; @@ -4033,6 +4053,7 @@ export class AgentManager { lastError: options?.lastError, attention: resolveInitialAttention(options?.attention), internal: config.internal ?? false, + background: params.background, labels: options?.labels ?? {}, } as ActiveManagedAgent; } @@ -4966,7 +4987,7 @@ export class AgentManager { const hadPendingPermissions = agent.pendingPermissions.size > 0; agent.pendingPermissions.set(event.request.id, event.request); this.refreshSessionPersistence(agent); - if (!hadPendingPermissions && !agent.internal) { + if (!hadPendingPermissions && raisesAttention(agent)) { this.broadcastAgentAttention(agent, "permission"); } this.emitState(agent); @@ -5221,8 +5242,7 @@ export class AgentManager { // Track the new status this.previousStatuses.set(agent.id, currentStatus); - // Skip attention tracking for internal agents - if (agent.internal) { + if (!raisesAttention(agent)) { return; } diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index b388143d8e..3229a70e0f 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -106,6 +106,7 @@ export function toStoredAgentRecord( ? agent.attention.attentionTimestamp.toISOString() : null, internal: options?.internal, + ...(agent.background ? { background: true } : {}), owner: agent.owner, } satisfies StoredAgentRecord; } @@ -149,7 +150,7 @@ export function toAgentPayload( title: options?.title ?? null, labels: agent.labels, archivedAt: options?.archivedAt ?? null, - ...(agent.internal ? { internal: true } : {}), + ...visibilityFlags(agent), }; const usage = sanitizeUsage(agent.lastUsage); @@ -206,6 +207,17 @@ function buildStoredPersistenceHandle( return toAgentPersistenceHandle(validProviders, record.persistence); } +/** The wire carries `internal` and `background` only when set. */ +function visibilityFlags(agent: { + internal?: boolean; + background?: boolean; +}): Pick { + return { + ...(agent.internal ? { internal: true } : {}), + ...(agent.background ? { background: true } : {}), + }; +} + export function buildStoredAgentPayload( record: StoredAgentRecord, validProviders: Iterable, @@ -258,7 +270,7 @@ export function buildStoredAgentPayload( attentionReason: record.attentionReason ?? null, attentionTimestamp: record.attentionTimestamp ?? null, archivedAt: record.archivedAt ?? null, - ...(record.internal ? { internal: true } : {}), + ...visibilityFlags(record), labels: normalizeLabels(record.labels), ...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}), ...(providerAvailable ? {} : { providerUnavailable: true }), diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index a097bd7f82..6b1e8075b3 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -75,6 +75,7 @@ const STORED_AGENT_SCHEMA = z.object({ attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), attentionTimestamp: z.string().nullable().optional(), internal: z.boolean().optional(), + background: z.boolean().optional(), archivedAt: z.string().nullable().optional(), owner: AgentOwnerSchema.optional(), }); @@ -261,6 +262,8 @@ export class AgentStorage { if (existing && existing.archivedAt !== undefined) { record.archivedAt = existing.archivedAt; } + // Background is fixed at creation; a runtime opened without it cannot clear it. + if (existing?.background) record.background = true; record.resumeOnStartup = existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error"; return record; diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 77cdde673b..a46fcb4312 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -21,7 +21,17 @@ import { resolveCreateAgentTitles } from "../create-agent-title.js"; import { buildAgentPrompt } from "../prompt-attachments.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js"; -import { resolveCreateAgentIntent } from "./intent.js"; +import { + inheritAgentBackground, + inheritWorkspaceBackground, + resolveCreateAgentIntent, +} from "./intent.js"; + +/** How a workspace this creation makes inherits its creator's visibility. */ +interface McpWorkspaceIntent { + background?: boolean; + callerWorkspaceId?: string; +} export interface CreateAgentCommandDependencies { finishSubscriptions?: FinishSubscriptions; @@ -40,7 +50,7 @@ export interface CreateAgentCommandDependencies { export type EnsureWorkspaceForCreate = ( cwd: string, firstAgentContext?: FirstAgentContext, - context?: { callerWorkspaceId?: string }, + context?: { background?: boolean; callerWorkspaceId?: string }, ) => Promise; export interface CreateAgentFromSessionInput { @@ -49,6 +59,7 @@ export interface CreateAgentFromSessionInput { agentId?: string; config: AgentSessionConfig; workspaceId: string; + background: boolean; initialPrompt?: string; source?: AgentPromptSource; clientMessageId?: string; @@ -77,6 +88,8 @@ export interface CreateAgentFromMcpInput { promptFailure?: CreateAgentPromptFailureMode; notifyOnFinish: boolean; internal?: boolean; + /** Omitted with a caller, the caller agent's own background applies. */ + background?: boolean; detached?: boolean; owner?: AgentOwner; env?: Record; @@ -255,6 +268,7 @@ async function resolveSessionCreateAgent( // provider session either. Same as the daemon's own ephemeral helpers. ...(sessionConfig.internal ? { persistSession: false } : {}), workspaceId: requireResolvedWorkspaceId(workspaceId), + background: input.background, }, prompt: hasPromptContent ? prompt : undefined, source: input.source, @@ -277,12 +291,16 @@ async function resolveMcpCreateAgent( ? requireParentAgent(dependencies.agentManager, input.callerAgentId) : null; const cwd = resolveMcpInitialCwd(input, parentAgent); + const workspaceIntent = { + background: inheritWorkspaceBackground(input.background, parentAgent), + callerWorkspaceId: parentAgent?.workspaceId, + }; const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({ dependencies, cwd, worktree: input.worktree, initialPrompt: input.initialPrompt ?? "", - callerWorkspaceId: parentAgent?.workspaceId, + workspaceIntent, }); if (createdWorktree) input.onWorktreeCreated?.(createdWorktree); const intent = await resolveCreateAgentIntent({ @@ -300,7 +318,7 @@ async function resolveMcpCreateAgent( dependencies, resolvedCwd, input.initialPrompt ?? "", - parentAgent?.workspaceId, + workspaceIntent, ), ), cwd: resolvedCwd, @@ -329,6 +347,7 @@ async function resolveMcpCreateAgent( ...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}), workspaceId: intent.workspaceId, owner: input.owner, + background: inheritAgentBackground(input.background, parentAgent), env: input.env, }, prompt: trimmedPrompt ? trimmedPrompt : undefined, @@ -410,16 +429,12 @@ async function ensureWorkspaceForMcpCreate( dependencies: CreateAgentCommandDependencies, cwd: string, initialPrompt: string, - callerWorkspaceId?: string, + workspaceIntent: McpWorkspaceIntent, ): Promise { if (!dependencies.ensureWorkspaceForCreate) { return undefined; } - return dependencies.ensureWorkspaceForCreate( - cwd, - { prompt: initialPrompt }, - { callerWorkspaceId }, - ); + return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }, workspaceIntent); } async function sendInitialPrompt( @@ -512,7 +527,7 @@ async function resolveMcpCwd(params: { dependencies: CreateAgentCommandDependencies; cwd: string; initialPrompt: string; - callerWorkspaceId?: string; + workspaceIntent: McpWorkspaceIntent; worktree: CreateAgentFromMcpInput["worktree"]; }): Promise<{ resolvedCwd: string; @@ -542,7 +557,7 @@ async function resolveMcpCwd(params: { const createdWorktree = await createMcpWorktree({ input: { cwd: params.cwd, - callerWorkspaceId: params.callerWorkspaceId, + ...params.workspaceIntent, worktreeSlug: worktree.worktreeName, branchName: worktree.branchName, refName: worktree.refName, diff --git a/packages/server/src/server/agent/create-agent/intent.ts b/packages/server/src/server/agent/create-agent/intent.ts index c811144b74..d9ea675b6f 100644 --- a/packages/server/src/server/agent/create-agent/intent.ts +++ b/packages/server/src/server/agent/create-agent/intent.ts @@ -6,6 +6,30 @@ export interface CreateAgentCaller { workspaceId?: string; } +/** The agent on whose behalf something is created, as far as background inheritance goes. */ +export interface BackgroundCaller { + background?: boolean; +} + +/** Omitted, an agent inherits its caller agent's own background; without a caller it is visible. */ +export function inheritAgentBackground( + requested: boolean | undefined, + caller: BackgroundCaller | null | undefined, +): boolean { + return requested ?? caller?.background === true; +} + +/** + * Omitted, a new workspace inherits its caller's effective background. A background caller + * agent decides it here; otherwise `undefined` lets provisioning apply the caller workspace's value. + */ +export function inheritWorkspaceBackground( + requested: boolean | undefined, + caller: BackgroundCaller | null | undefined, +): boolean | undefined { + return requested ?? (caller?.background === true ? true : undefined); +} + export interface CreateAgentPlacement { workspaceId: string; cwd: string; diff --git a/packages/server/src/server/agent/directory.test.ts b/packages/server/src/server/agent/directory.test.ts new file mode 100644 index 0000000000..58bac6c183 --- /dev/null +++ b/packages/server/src/server/agent/directory.test.ts @@ -0,0 +1,114 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { buildStoredAgentPayload } from "./agent-projections.js"; +import { AgentStorage } from "./agent-storage.js"; +import { AgentDirectory, type BackgroundVisibility } from "./directory.js"; +import type { PersistedWorkspaceRecord } from "../workspace-registry.js"; + +const NOW = "2026-03-01T12:00:00.000Z"; + +let home: string; +let storage: AgentStorage; +let directory: AgentDirectory; + +function workspace(workspaceId: string, background: boolean): PersistedWorkspaceRecord { + return { + workspaceId, + projectId: "project", + cwd: home, + kind: "local_checkout", + displayName: workspaceId, + background, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + }; +} + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), "agent-directory-")); + storage = new AgentStorage(home, createTestLogger()); + const base = { + provider: "codex", + cwd: home, + createdAt: NOW, + updatedAt: NOW, + labels: {}, + lastStatus: "idle" as const, + }; + await storage.upsert({ ...base, id: "visible", workspaceId: "open" }); + await storage.upsert({ ...base, id: "background", workspaceId: "open", background: true }); + await storage.upsert({ ...base, id: "in-background-workspace", workspaceId: "hidden" }); + await storage.upsert({ ...base, id: "internal", workspaceId: "open", internal: true }); + const workspaces = [workspace("open", false), workspace("hidden", true)]; + directory = new AgentDirectory({ + storage, + manager: { listAgents: () => [] }, + workspaces: { + get: async (id) => workspaces.find((entry) => entry.workspaceId === id) ?? null, + list: async () => workspaces, + }, + projectLive: () => { + throw new Error("No live agents in this suite"); + }, + projectStored: (record) => buildStoredAgentPayload(record, ["codex"]), + isProviderVisible: () => true, + isStoredProviderAvailable: () => true, + }); +}); + +afterEach(async () => { + await rm(home, { recursive: true, force: true }); +}); + +async function listedIds(includeBackground: BackgroundVisibility): Promise { + return (await directory.list({ includeBackground })).map((agent) => agent.id).toSorted(); +} + +test("default lists hide agents that are background or live in a background workspace", async () => { + expect(await listedIds(false)).toEqual(["visible"]); +}); + +test("inclusive lists show every background agent", async () => { + expect(await listedIds(true)).toEqual(["background", "in-background-workspace", "visible"]); +}); + +test("workspace-only visibility hides agent-level background agents from older clients", async () => { + expect(await listedIds("workspaces")).toEqual(["in-background-workspace", "visible"]); +}); + +test("includes applies the same visibility to a single agent", async () => { + const background = { workspaceId: "open", background: true }; + const inBackgroundWorkspace = { workspaceId: "hidden" }; + expect(await directory.includes(background)).toBe(false); + expect(await directory.includes(background, "workspaces")).toBe(false); + expect(await directory.includes(background, true)).toBe(true); + expect(await directory.includes(inBackgroundWorkspace)).toBe(false); + expect(await directory.includes(inBackgroundWorkspace, "workspaces")).toBe(true); +}); + +test("prompt recency ignores background agents", async () => { + const base = { + provider: "codex", + cwd: home, + workspaceId: "open", + createdAt: NOW, + updatedAt: NOW, + labels: {}, + lastStatus: "idle" as const, + }; + await storage.upsert({ ...base, id: "visible", lastUserMessageAt: "2026-03-02T00:00:00.000Z" }); + await storage.upsert({ + ...base, + id: "background", + background: true, + lastUserMessageAt: "2026-03-03T00:00:00.000Z", + }); + expect(await directory.workspaceLastUserMessageTimes()).toEqual( + new Map([["open", "2026-03-02T00:00:00.000Z"]]), + ); +}); diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts index b15053ff04..e7e1edd9e9 100644 --- a/packages/server/src/server/agent/directory.ts +++ b/packages/server/src/server/agent/directory.ts @@ -3,7 +3,25 @@ import type { WorkspaceRegistry } from "../workspace-registry.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; -/** Public agent discovery owns workspace visibility for live and durable records. */ +/** + * Which background agents a discovery read admits. An agent is effectively background when + * it or its workspace is background. + * - `false`: none, the default for lists. + * - `true`: all of them. + * - `"workspaces"`: only agents in background workspaces. + * COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor + * >= v0.15.0. Inclusive reads from sources without `background_agents` use it: older apps + * replicate inclusively and auto-open a tab for every root agent. + */ +export type BackgroundVisibility = boolean | "workspaces"; + +interface DiscoverableAgent { + workspaceId?: string; + internal?: boolean; + background?: boolean; +} + +/** Public agent discovery owns background visibility for live and durable records. */ export class AgentDirectory { constructor( private readonly deps: { @@ -28,50 +46,53 @@ export class AgentDirectory { if (!previous || at > previous) times.set(workspaceId, at); }; for (const agent of await this.deps.storage.list()) { - if (!agent.internal && !liveIds.has(agent.id)) + if (!agent.internal && !agent.background && !liveIds.has(agent.id)) record(agent.workspaceId, agent.lastUserMessageAt); } for (const agent of live) { - if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString()); + if (!agent.internal && !agent.background) + record(agent.workspaceId, agent.lastUserMessageAt?.toISOString()); } return times; } async includes( - agent: Pick, - includeBackground = false, + agent: DiscoverableAgent, + visibility: BackgroundVisibility = false, ): Promise { - if (agent.internal) return false; - if (includeBackground || !agent.workspaceId) return true; - return !(await this.deps.workspaces.get(agent.workspaceId))?.background; + const workspace = agent.workspaceId ? await this.deps.workspaces.get(agent.workspaceId) : null; + return admits(agent, workspace?.background === true, visibility); } async list(filter?: { labels?: Record; includeArchived?: boolean; - includeBackground?: boolean; + includeBackground?: BackgroundVisibility; includeUnavailablePersisted?: boolean; }): Promise { const live = this.deps.manager.listAgents(); const liveIds = new Set(live.map((agent) => agent.id)); const labels = Object.entries(filter?.labels ?? {}); - const backgroundIds = new Set( - filter?.includeBackground + const visibility = filter?.includeBackground ?? false; + const backgroundWorkspaceIds = new Set( + visibility === true ? [] : (await this.deps.workspaces.list()) .filter((workspace) => workspace.background) .map((workspace) => workspace.workspaceId), ); - const matches = (agent: { - archivedAt?: string | null; - workspaceId?: string; - labels?: Record; - }) => + const matches = ( + agent: DiscoverableAgent & { archivedAt?: string | null; labels?: Record }, + ) => (filter?.includeArchived || !agent.archivedAt) && - (filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) && + admits( + agent, + agent.workspaceId !== undefined && backgroundWorkspaceIds.has(agent.workspaceId), + visibility, + ) && labels.every(([key, value]) => agent.labels?.[key] === value); const stored = (await this.deps.storage.list()) - .filter((record) => !record.internal && !liveIds.has(record.id) && matches(record)) + .filter((record) => !liveIds.has(record.id) && matches(record)) .filter( (record) => filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record), @@ -85,3 +106,13 @@ export class AgentDirectory { .filter((agent) => this.deps.isProviderVisible(agent.provider)); } } + +function admits( + agent: DiscoverableAgent, + inBackgroundWorkspace: boolean, + visibility: BackgroundVisibility, +): boolean { + if (agent.internal) return false; + if (agent.background) return visibility === true; + return !inBackgroundWorkspace || visibility !== false; +} diff --git a/packages/server/src/server/agent/host-operations/index.ts b/packages/server/src/server/agent/host-operations/index.ts index 25f1893bdd..040a94fcfd 100644 --- a/packages/server/src/server/agent/host-operations/index.ts +++ b/packages/server/src/server/agent/host-operations/index.ts @@ -177,6 +177,7 @@ const remoteCreate = z workspaceId: z.string().min(1), labels: z.record(z.string(), z.string()).optional(), notifyOnFinish: z.boolean().optional(), + background: z.boolean().optional(), idempotencyKey: z.string().optional(), settings: z .object({ @@ -235,6 +236,7 @@ export function createHostResolver(input: { featureValues: parsed.settings?.features, }, workspaceId: parsed.workspaceId, + ...(parsed.background !== undefined ? { background: parsed.background } : {}), labels, initialPrompt: parsed.initialPrompt, idempotencyKey: parsed.idempotencyKey, diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index cddecc970c..80d6da20a7 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -1211,4 +1211,137 @@ describe("Suite E: Workspace Tools", () => { .catch(() => undefined); } }); + + test("a background agent's children and workspaces are background unless it overrides", async () => { + const visibleAgentId = await createTopLevelAgent(); + const workspaceId = daemonHandle.daemon.agentManager.getAgent(visibleAgentId)?.workspaceId; + const caller = await callToolStructured(topLevelClient, "create_agent", { + workspaceId, + title: "Background caller", + provider: "claude/claude-test-model", + initialPrompt: "Say done.", + background: true, + }); + const callerId = str(caller.agentId); + const background = (agentId: string) => + daemonHandle.daemon.agentManager.getAgent(agentId)?.background; + expect(background(callerId)).toBe(true); + const scoped = await createMcpClient( + `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${callerId}`, + ); + const ownedWorkspaces: string[] = []; + try { + const inheritedChild = await callToolStructured(scoped, "create_agent", { + relationship: { kind: "subagent" }, + workspace: { kind: "current" }, + title: "Inherited child", + provider: "claude/claude-test-model", + initialPrompt: "Say done.", + notifyOnFinish: false, + }); + expect(background(str(inheritedChild.agentId))).toBe(true); + const visibleChild = await callToolStructured(scoped, "create_agent", { + relationship: { kind: "subagent" }, + workspace: { kind: "current" }, + title: "Visible child", + provider: "claude/claude-test-model", + initialPrompt: "Say done.", + notifyOnFinish: false, + background: false, + }); + expect(background(str(visibleChild.agentId))).toBe(false); + const inheritedWorkspace = await callToolStructured(scoped, "create_workspace", { + isolation: "local", + }); + ownedWorkspaces.push(str(inheritedWorkspace.workspaceId)); + expect(inheritedWorkspace.background).toBe(true); + } finally { + await scoped.close(); + await archiveAgentIfPresent(callerId); + await archiveAgentIfPresent(visibleAgentId); + for (const id of ownedWorkspaces) + await topLevelClient + .callTool({ name: "archive_workspace", args: { workspaceId: id } }) + .catch(() => undefined); + } + }); + + test("a background subagent stays out of default lists, notifies its parent and archives with it", async () => { + const callerPrompts: string[] = []; + const daemon = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + onStartTurn(prompt, config) { + if (config.title === "Background parent" && typeof prompt === "string") + callerPrompts.push(prompt); + }, + }), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + let mcp: McpClient | undefined; + try { + await client.connect(); + const caller = await client.createAgent({ + provider: "claude", + cwd: daemon.paseoHome, + title: "Background parent", + modeId: "bypassPermissions", + }); + mcp = await createMcpClient( + buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: caller.id }), + ); + const created = await callToolStructured(mcp, "create_agent", { + relationship: { kind: "subagent" }, + workspace: { kind: "current" }, + provider: "codex/gpt-5.4", + title: "Background child", + initialPrompt: "say done and stop", + notifyOnFinish: true, + background: true, + settings: { modeId: "full-access" }, + }); + const childId = str(created.agentId); + expect((await client.fetchAgent(childId))?.agent).toMatchObject({ + background: true, + workspaceId: caller.workspaceId, + labels: { [PARENT_AGENT_ID_LABEL]: caller.id }, + }); + expect((await client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain( + childId, + ); + expect( + (await client.fetchAgents({ filter: { includeBackground: true } })).entries.map( + (entry) => entry.agent.id, + ), + ).toContain(childId); + + await waitFor({ + timeoutMs: 10000, + label: "parent finish notification", + check: () => (callerPrompts.length > 0 ? true : null), + }); + expect(parseAgentMessage(callerPrompts[0]).source).toMatchObject({ + kind: "agent-notification", + agentId: childId, + event: "finished", + }); + expect((await client.fetchAgent(childId))?.agent.requiresAttention).toBe(false); + + await waitFor({ + timeoutMs: 10000, + label: "parent idle", + check: () => + daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null, + }); + await client.archiveAgent(caller.id); + await waitFor({ + timeoutMs: 10000, + label: "child archived with parent", + check: async () => ((await client.fetchAgent(childId))?.agent.archivedAt ? true : null), + }); + } finally { + await mcp?.close(); + await client.close(); + await daemon.close(); + } + }, 30_000); }); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ecf2d912a5..b85a9786c3 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1647,11 +1647,11 @@ describe("create_agent MCP tool", () => { expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ cwd: existingCwd }), undefined, - { workspaceId: "workspace-created" }, + { workspaceId: "workspace-created", background: false }, ); }); - it("rejects public internal helpers and the old top-level execution background option", async () => { + it("rejects public internal helpers", async () => { const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const agentManager = new AgentManager({ @@ -1669,9 +1669,6 @@ describe("create_agent MCP tool", () => { const tool = registeredTool(server, "create_agent"); const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" }; await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/); - await expect(tool.handler({ ...input, background: true })).rejects.toThrow( - /Unrecognized key/, - ); } finally { await removeAgentStateDir(agentManager, storage, workdir); } @@ -1793,7 +1790,7 @@ describe("create_agent MCP tool", () => { cwd: existingCwd, }), undefined, - { workspaceId: "wks_existing" }, + { workspaceId: "wks_existing", background: false }, ); }); @@ -1837,7 +1834,7 @@ describe("create_agent MCP tool", () => { featureValues: { fast_mode: true }, }), undefined, - { workspaceId: "workspace-created" }, + { workspaceId: "workspace-created", background: false }, ); }); @@ -2033,7 +2030,7 @@ describe("create_agent MCP tool", () => { title: "Fix auth bug", }), undefined, - { workspaceId: "workspace-created" }, + { workspaceId: "workspace-created", background: false }, ); }); @@ -2068,7 +2065,7 @@ describe("create_agent MCP tool", () => { title: "Fix auth", }), undefined, - { workspaceId: "workspace-created" }, + { workspaceId: "workspace-created", background: false }, ); }); @@ -2112,6 +2109,7 @@ describe("create_agent MCP tool", () => { { labels: { source: "mcp" }, workspaceId: "workspace-created", + background: false, }, ); }); @@ -2214,7 +2212,7 @@ describe("create_agent MCP tool", () => { title: "Directory agent", }), undefined, - { workspaceId: "workspace-directory-auto-title" }, + { workspaceId: "workspace-directory-auto-title", background: false }, ); expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({ title: "Directory Workspace Title", @@ -2828,12 +2826,13 @@ describe("create_agent MCP tool", () => { source: "voice", }, workspaceId: "wks_voice", + background: false, }, ); await rm(baseDir, { recursive: true, force: true }); }); - it("rejects background from caller agents and defaults notify-on-finish on", async () => { + it("defaults notify-on-finish on for caller agents", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", @@ -2853,16 +2852,6 @@ describe("create_agent MCP tool", () => { }); const tool = registeredTool(server, "create_agent"); - await expect( - tool.handler({ - ...subagentCurrentWorkspace(), - title: "Child", - provider: "codex/gpt-5.4", - initialPrompt: "Do work", - background: false, - }), - ).rejects.toThrow(/Unrecognized key/); - const parsed = await tool.inputSchema.safeParseAsync({ ...subagentCurrentWorkspace(), title: "Child", @@ -3019,6 +3008,7 @@ describe("create_agent MCP tool", () => { source: "handoff", }, workspaceId: "wks_parent", + background: false, }, ); }); @@ -3080,6 +3070,7 @@ describe("create_agent MCP tool", () => { [PARENT_AGENT_ID_LABEL]: "parent-agent", }, workspaceId: "wks_parent", + background: false, }, ); }); @@ -3218,6 +3209,7 @@ describe("create_agent MCP tool", () => { expect(agentIdArg).toBeUndefined(); expect(optionsArg).toEqual({ workspaceId: "workspace-created", + background: false, }); }); @@ -3295,7 +3287,7 @@ describe("create_agent MCP tool", () => { expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ modeId: "dynamic" }), undefined, - { workspaceId: "workspace-created" }, + { workspaceId: "workspace-created", background: false }, ); }); @@ -3334,7 +3326,7 @@ describe("create_agent MCP tool", () => { expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }), undefined, - { workspaceId: "workspace-created" }, + { workspaceId: "workspace-created", background: false }, ); }); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index a5569685e2..b678d6dbe6 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -42,6 +42,7 @@ import { type ArchiveDependencies, } from "../../workspace-archive-service.js"; import { createAgentCommand } from "../create-agent/create.js"; +import { inheritWorkspaceBackground } from "../create-agent/intent.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { FirstAgentContext } from "../../messages.js"; import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence"; @@ -153,7 +154,7 @@ export interface PaseoToolHostDependencies { ensureWorkspaceForCreate?: ( cwd: string, firstAgentContext?: FirstAgentContext, - context?: { callerWorkspaceId?: string }, + context?: { background?: boolean; callerWorkspaceId?: string }, ) => Promise; browserToolsEnabled?: boolean; browserToolsBroker?: BrowserToolsBroker | null; @@ -860,6 +861,14 @@ function createToolCatalog( return parentAgent; }; + const workspaceBackgroundIntent = (background: boolean | undefined) => { + const caller = resolveCallerAgent(); + return { + background: inheritWorkspaceBackground(background, caller), + callerWorkspaceId: caller?.workspaceId, + }; + }; + const resolveInheritedProviderConfig = ( selectedProvider: string, ): Pick | undefined => { @@ -1191,6 +1200,12 @@ function createToolCatalog( .trim() .min(1, "initialPrompt is required") .describe("Required first task to run immediately after creation."), + background: z + .boolean() + .optional() + .describe( + "True creates a background agent: hidden from tabs, default lists, your subagent track and notifications. You still get its finish notification and can address it by id. Omit to inherit your own value (false without a caller); use it for helpers the user does not need to watch.", + ), }; const legacyCreateAgentPlacementFields = { relationship: AgentRelationshipInputSchema.describe( @@ -1397,7 +1412,7 @@ function createToolCatalog( .boolean() .optional() .describe( - "True hides the workspace from default lists and the sidebar while its contents remain durable and accessible; omit background to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks.", + "True hides the workspace from default lists and the sidebar while its contents remain durable and accessible; omit background to inherit your own visibility (true when you or your current workspace are background, false without a caller), and override it only when the user explicitly asks.", ), mode: z .enum(["branch-off", "checkout-branch", "checkout-pr"]) @@ -1467,10 +1482,12 @@ function createToolCatalog( if (!options.createDirectoryWorkspace) { throw new Error("Workspace provisioning is not configured"); } - workspace = await options.createDirectoryWorkspace(cwd, title, projectId, { - background, - callerWorkspaceId: resolveCallerAgent()?.workspaceId, - }); + workspace = await options.createDirectoryWorkspace( + cwd, + title, + projectId, + workspaceBackgroundIntent(background), + ); } else { let cwd = path !== undefined || !projectId ? resolveScopedCwd(path, { required: true }) : null; @@ -1501,8 +1518,7 @@ function createToolCatalog( ...(worktreeSlug ? { worktreeSlug } : {}), ...worktreeTarget, ...(title ? { title } : {}), - background, - callerWorkspaceId: resolveCallerAgent()?.workspaceId, + ...workspaceBackgroundIntent(background), }, ); if (!result.ok) { @@ -1648,6 +1664,7 @@ function createToolCatalog( mode: parsedArgs.settings?.modeId, promptFailure: "throw", notifyOnFinish, + background: parsedArgs.background, detached: resolvedArgs.detached, callerAgentId, callerContext, @@ -1821,9 +1838,11 @@ function createToolCatalog( const cwd = process.cwd(); return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { - callerWorkspaceId: resolveCallerAgent()?.workspaceId, - }), + workspaceId: await options.ensureWorkspaceForCreate( + cwd, + firstAgentContext, + workspaceBackgroundIntent(undefined), + ), }; } const caller = resolveCallerAgent(); @@ -1927,9 +1946,11 @@ function createToolCatalog( } return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { - callerWorkspaceId: resolveCallerAgent()?.workspaceId, - }), + workspaceId: await options.ensureWorkspaceForCreate( + cwd, + firstAgentContext, + workspaceBackgroundIntent(undefined), + ), }; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index cf62c525ba..e2c00828bc 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1119,7 +1119,7 @@ export async function createPaseoDaemon( const ensureWorkspaceForCreateExternal = async ( cwd: string, firstAgentContext?: FirstAgentContext, - context?: { callerWorkspaceId?: string }, + context?: { background?: boolean; callerWorkspaceId?: string }, ): Promise => { const workspace = await workspaceProvisioning.createWorkspaceForDirectory( cwd, @@ -1172,7 +1172,7 @@ export async function createPaseoDaemon( const ensureWorkspaceForCreateAndBroadcastExternal = async ( cwd: string, firstAgentContext?: FirstAgentContext, - context?: { callerWorkspaceId?: string }, + context?: { background?: boolean; callerWorkspaceId?: string }, ): Promise => { const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext, context); await emitWorkspaceUpdatesExternal([workspaceId]); diff --git a/packages/server/src/server/directory-sync/index.ts b/packages/server/src/server/directory-sync/index.ts index e16e20302f..d115da3dec 100644 --- a/packages/server/src/server/directory-sync/index.ts +++ b/packages/server/src/server/directory-sync/index.ts @@ -6,6 +6,7 @@ import type { WorkspaceDescriptorPayload, WorkspaceProjectDescriptorPayload, } from "@getpaseo/protocol/messages"; +import type { BackgroundVisibility } from "../agent/directory.js"; import { type CollectionRead, VersionedCollection } from "./internal/versioned-collection.js"; interface AgentDirectoryEntry { @@ -69,7 +70,7 @@ export class DirectorySyncService { synchronizeWorkspaces( snapshot: Iterable, cursor: DirectorySyncCursor, - includeBackground = false, + includeBackground: BackgroundVisibility = false, ): Pick { this.workspaces.replaceAll(snapshot); const read = this.read(this.workspaces, cursor, includeBackground); @@ -84,7 +85,7 @@ export class DirectorySyncService { synchronizeAgents( snapshot: Iterable, cursor: DirectorySyncCursor, - includeBackground = false, + includeBackground: BackgroundVisibility = false, ): Pick { this.agents.replaceAll(snapshot); const read = this.read(this.agents, cursor, includeBackground); @@ -113,7 +114,7 @@ export class DirectorySyncService { value: WorkspaceDescriptorPayload | null, id: string, includeSequence: boolean, - includeBackground = false, + includeBackground: BackgroundVisibility = false, ): T & Partial { if (value) { this.workspaces.replace(value); @@ -133,7 +134,7 @@ export class DirectorySyncService { value: AgentDirectoryEntry | null, id: string, includeSequence: boolean, - includeBackground = false, + includeBackground: BackgroundVisibility = false, ): T & Partial { if (value) { this.agents.replace(value); @@ -148,7 +149,7 @@ export class DirectorySyncService { ); } - private metadata(read: CollectionRead, includeBackground = false) { + private metadata(read: CollectionRead, includeBackground: BackgroundVisibility = false) { return { generation: this.generationFor(includeBackground), headSeq: read.headSeq, @@ -162,7 +163,7 @@ export class DirectorySyncService { payload: T, includeSequence: boolean, version: DirectoryVersion | null, - includeBackground = false, + includeBackground: BackgroundVisibility = false, ): T & Partial { return version && includeSequence ? { ...payload, ...version, generation: this.generationFor(includeBackground) } @@ -172,7 +173,7 @@ export class DirectorySyncService { private read( collection: VersionedCollection, cursor: DirectorySyncCursor, - includeBackground = false, + includeBackground: BackgroundVisibility = false, ): CollectionRead { if (cursor.generation !== this.generationFor(includeBackground)) { return collection.readSnapshot( @@ -185,7 +186,9 @@ export class DirectorySyncService { return collection.readAfter(cursor.afterSeq); } - private generationFor(includeBackground: boolean): string { + // Each visibility scope admits a different subset, so cursors never cross scopes. + private generationFor(includeBackground: BackgroundVisibility): string { + if (includeBackground === "workspaces") return `${this.generation}:background-workspaces`; return includeBackground ? `${this.generation}:background` : this.generation; } diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index e95743d6be..4889eab081 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -141,6 +141,7 @@ export function extractTimestamps(record: StoredAgentRecord): { labels?: Record; workspaceId?: string; owner?: StoredAgentRecord["owner"]; + background?: boolean; } { return { createdAt: new Date(record.createdAt), @@ -149,6 +150,7 @@ export function extractTimestamps(record: StoredAgentRecord): { labels: record.labels, workspaceId: record.workspaceId, owner: record.owner, + background: record.background, }; } diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index 34d1483023..313300d82a 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -14,6 +14,7 @@ import { import type { CreateAgentOptions } from "./test-utils/index.js"; import type { CreateAgentWorktreeTarget } from "./messages.js"; import { createRealpathAwarePathMatcher } from "../utils/path.js"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; let ctx: DaemonTestContext; const tempRoots: string[] = []; @@ -477,7 +478,7 @@ test("background workspaces keep durable agents addressable through restart and ), ).toContain(hidden.id); const child = await ctx.client.createAgent({ config, callerAgentId: hidden.id }); - expect(child.workspaceId).toBe(hidden.workspaceId); + expect(child).toMatchObject({ workspaceId: hidden.workspaceId, background: true }); const inherited = await ctx.client.createWorkspace({ source: { kind: "directory", path: cwd }, callerAgentId: hidden.id, @@ -509,9 +510,16 @@ test("background workspaces keep durable agents addressable through restart and false, ); - await expect( - ctx.client.createAgent({ config, workspaceId: hidden.workspaceId, background: false }), - ).rejects.toThrow("workspace creation intent"); + expect(hidden.background).toBe(true); + const visibleInHidden = await ctx.client.createAgent({ + config, + workspaceId: hidden.workspaceId, + background: false, + }); + expect(visibleInHidden.background).toBeUndefined(); + expect((await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain( + visibleInHidden.id, + ); await ctx.client.sendAgentMessage(hidden.id, "Say done."); await ctx.client.waitForFinish(hidden.id, 10_000); expect( @@ -616,3 +624,111 @@ test("default and inclusive observers retain independent snapshots, live updates await otherClient.close(); } }, 30_000); + +test("a background agent in a visible workspace is hidden from default discovery through restart", async () => { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const visible = await ctx.client.createAgent({ config }); + const hidden = await ctx.client.createAgent({ + config, + workspaceId: visible.workspaceId, + background: true, + }); + expect(hidden).toMatchObject({ workspaceId: visible.workspaceId, background: true }); + expect(visible.background).toBeUndefined(); + const defaultIds = async () => + (await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id); + const inclusiveIds = async () => + (await ctx.client.fetchAgents({ filter: { includeBackground: true } })).entries.map( + (entry) => entry.agent.id, + ); + expect(await defaultIds()).toEqual([visible.id]); + expect(await inclusiveIds()).toEqual(expect.arrayContaining([visible.id, hidden.id])); + expect((await ctx.client.fetchWorkspaces()).entries).toContainEqual( + expect.objectContaining({ id: visible.workspaceId, background: false }), + ); + + await ctx.client.sendAgentMessage(hidden.id, "Say done."); + await ctx.client.waitForFinish(hidden.id, 10_000); + expect((await ctx.client.fetchAgent(hidden.id))?.agent).toMatchObject({ + background: true, + requiresAttention: false, + }); + + const homeRoot = path.dirname(ctx.daemon.paseoHome); + tempRoots.push(ctx.daemon.staticDir); + await ctx.client.close(); + await ctx.daemon.daemon.stop(); + await ctx.daemon.daemon.agentManager.flush(); + ctx = await createDaemonTestContext({ paseoHomeRoot: homeRoot }); + + expect(await defaultIds()).toEqual([visible.id]); + expect(await inclusiveIds()).toContain(hidden.id); + await ctx.client.sendAgentMessage(hidden.id, "Say done again."); + await ctx.client.waitForFinish(hidden.id, 10_000); + expect((await ctx.client.fetchAgent(hidden.id))?.agent.background).toBe(true); + expect(await defaultIds()).toEqual([visible.id]); + await ctx.client.archiveAgent(hidden.id); + expect( + (await ctx.client.fetchAgentHistory()).entries.map((entry) => entry.agent.id), + ).not.toContain(hidden.id); + expect( + (await ctx.client.fetchAgentHistory({ filter: { includeBackground: true } })).entries.map( + (entry) => entry.agent.id, + ), + ).toContain(hidden.id); +}, 30_000); + +test("a client without background_agents never receives background agents, even in inclusive reads", async () => { + const oldApp = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + capabilities: { [CLIENT_CAPS.backgroundAgents]: false }, + }); + await oldApp.connect(); + try { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const visible = await ctx.client.createAgent({ config }); + const hidden = await ctx.client.createAgent({ + config, + workspaceId: visible.workspaceId, + background: true, + }); + const backgroundWorkspace = await ctx.client.createWorkspace({ + source: { kind: "directory", path: cwd }, + background: true, + }); + const workspaceAgent = await ctx.client.createAgent({ + config, + workspaceId: backgroundWorkspace.workspace?.id, + }); + const observed = oldApp.observeAgents({ scope: "active", sync: {}, includeBackground: true }); + const snapshot = await observed.ready; + expect(snapshot.entries.map((entry) => entry.agent.id).toSorted()).toEqual( + [visible.id, workspaceAgent.id].toSorted(), + ); + const upserts: string[] = []; + observed.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update" && message.payload.kind === "upsert") + upserts.push(message.payload.agent.id); + }, + }); + await ctx.client.sendAgentMessage(hidden.id, "Say done."); + await ctx.client.waitForFinish(hidden.id, 10_000); + await ctx.client.sendAgentMessage(visible.id, "Say done."); + await ctx.client.waitForFinish(visible.id, 10_000); + await expect.poll(() => upserts).toContain(visible.id); + expect(upserts).not.toContain(hidden.id); + expect( + (await oldApp.fetchAgents({ filter: { includeBackground: true } })).entries.map( + (entry) => entry.agent.id, + ), + ).not.toContain(hidden.id); + expect((await oldApp.fetchAgent(hidden.id))?.agent.id).toBe(hidden.id); + await observed.release(); + } finally { + await oldApp.close(); + } +}, 30_000); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8911f9acf1..22dd0ae35c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,6 +1,6 @@ import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import type { AgentMessageDelivery } from "./agent/agent-messages/index.js"; -import { AgentDirectory } from "./agent/directory.js"; +import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js"; import { ConnectedHostSession } from "./connected-hosts/index.js"; import type { ConnectedHosts } from "./connected-hosts/index.js"; import { searchTimeline } from "./agent/chat-search/index.js"; @@ -119,7 +119,12 @@ import type { ManagedAgent, } from "./agent/agent-manager.js"; import { createAgentCommand } from "./agent/create-agent/create.js"; -import { resolveCreateAgentIntent, type CreateAgentIntent } from "./agent/create-agent/intent.js"; +import { + inheritAgentBackground, + inheritWorkspaceBackground, + resolveCreateAgentIntent, + type CreateAgentIntent, +} from "./agent/create-agent/intent.js"; import { archiveAgentCommand, cancelAgentRunCommand, @@ -388,6 +393,7 @@ type CreateAgentRequestMessage = Extract - this.agentDirectory.includes(agent, includeBackground), + includesWorkspace: (agent, backgroundVisibility) => + this.agentDirectory.includes(agent, backgroundVisibility), emit: (message) => this.emit(message), enrichAgentPayload: (payload) => this.enrichAgentPayload(payload), buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record), @@ -4527,14 +4533,6 @@ export class Session { throw new Error( "Public internal agents were removed; create a background workspace instead.", ); - if ( - request.background !== undefined && - (request.workspaceId || (request.callerAgentId && !request.worktree)) - ) { - throw new Error( - "background is workspace creation intent; configure the selected workspace at creation.", - ); - } } private async createSessionAgent( @@ -4585,8 +4583,7 @@ export class Session { cwd: config.cwd, target: worktree, firstAgentContext, - background: msg.background, - callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId), + ...this.workspaceBackgroundIntent(msg.background, msg.callerAgentId), }); createdWorktreeForCleanup = createdWorktree; const resolvedIntent = await this.resolveSessionCreateAgentIntent({ @@ -4617,6 +4614,7 @@ export class Session { agentId, config: resolvedIntent.config, workspaceId: resolvedIntent.intent.workspaceId, + background: resolvedIntent.background, initialPrompt, source: msg.callerAgentId ? { kind: "agent-message", agentId: msg.callerAgentId } @@ -4701,7 +4699,7 @@ export class Session { createdWorktree: null, cwd: config.cwd, initialTitle: input.workspacePromptTitle, - background: request.background, + background: inheritWorkspaceBackground(request.background, callerAgent), callerWorkspaceId: callerAgent?.workspaceId, }), cwd: config.cwd, @@ -4712,6 +4710,7 @@ export class Session { return { config, intent, + background: inheritAgentBackground(request.background, callerAgent), createdDirectoryWorkspace: !createdWorktree && !request.workspaceId && !callerAgent, }; } @@ -5663,7 +5662,22 @@ export class Session { return matchedEntries; } - private async listFetchAgentsEntries(request: AgentDirectoryRequestMessage): Promise<{ + /** + * Inclusive discovery for the requesting source. Exact-ID reads never pass through this. + * COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor + * >= v0.15.0. Sources without the capability see background workspaces' agents only. + */ + private resolveBackgroundVisibility( + includeBackground: boolean | undefined, + ): BackgroundVisibility { + if (!includeBackground) return false; + return this.supports(CLIENT_CAPS.backgroundAgents) ? true : "workspaces"; + } + + private async listFetchAgentsEntries( + request: AgentDirectoryRequestMessage, + backgroundVisibility: BackgroundVisibility, + ): Promise<{ entries: FetchAgentsResponseEntry[]; pageInfo: FetchAgentsResponsePageInfo; searchTruncated?: boolean; @@ -5679,9 +5693,7 @@ export class Session { let agents = await this.listAgentPayloads({ labels: filter?.labels, includeArchived: filter?.includeArchived, - includeBackground: - filter?.includeBackground || - (request.type === "fetch_agents_request" && request.includeBackground), + includeBackground: backgroundVisibility, includeUnavailablePersisted: request.type === "fetch_agent_history_request", }); const activePlacementsByWorkspaceId = @@ -6415,16 +6427,17 @@ export class Session { }) : null; const subscriptionId = owner?.responseId; + const backgroundVisibility = this.resolveBackgroundVisibility( + request.includeBackground || request.filter?.includeBackground, + ); try { if (owner) { this.agentUpdates.beginSubscription({ subscriptionId: owner.id, isProviderVisible: (provider) => this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)), - filter: { - ...request.filter, - ...(request.includeBackground ? { includeBackground: true } : {}), - }, + filter: request.filter, + backgroundVisibility, syncEnabled: Boolean(request.sync), emit: (message) => { if (message.type === "agent_update") owner.emit(message); @@ -6434,8 +6447,8 @@ export class Session { } const payload = request.sync - ? await this.readAgentDirectorySync(request) - : await this.listFetchAgentsEntries(request); + ? await this.readAgentDirectorySync(request, backgroundVisibility) + : await this.listFetchAgentsEntries(request, backgroundVisibility); const snapshotUpdatedAtByAgentId = new Map(); for (const entry of payload.entries) { const parsedUpdatedAt = Date.parse(entry.agent.updatedAt); @@ -6479,7 +6492,10 @@ export class Session { request: Extract, ): Promise { try { - const payload = await this.listFetchAgentsEntries(request); + const payload = await this.listFetchAgentsEntries( + request, + this.resolveBackgroundVisibility(request.filter?.includeBackground), + ); this.emit({ type: "fetch_agent_history_response", payload: { @@ -6844,6 +6860,7 @@ export class Session { private async readAgentDirectorySync( request: Extract, + backgroundVisibility: BackgroundVisibility, ) { if (request.scope !== "active" || request.filter) { throw new SessionRequestError( @@ -6851,19 +6868,19 @@ export class Session { "Sequenced agent directory reads require scope=active and no filter.", ); } - const snapshot = await this.listFetchAgentsEntries({ - ...request, - includeBackground: true, - page: { limit: Number.MAX_SAFE_INTEGER }, - }); + // The canonical collection is complete; only the read below is scoped. + const snapshot = await this.listFetchAgentsEntries( + { ...request, page: { limit: Number.MAX_SAFE_INTEGER } }, + true, + ); const result = this.directorySync.synchronizeAgents( snapshot.entries, request.sync ?? {}, - request.includeBackground, + backgroundVisibility, ); const entries = await Promise.all( result.entries.map(async (entry) => - (await this.agentDirectory.includes(entry.agent, request.includeBackground)) ? entry : null, + (await this.agentDirectory.includes(entry.agent, backgroundVisibility)) ? entry : null, ), ); return { ...result, entries: entries.filter((entry) => entry !== null) }; @@ -6964,11 +6981,22 @@ export class Session { } } - private getCallerWorkspaceId(callerAgentId?: string): string | undefined { + private getCallerAgent(callerAgentId?: string): ManagedAgent | undefined { if (!callerAgentId) return undefined; const caller = this.agentManager.getAgent(callerAgentId); if (!caller) throw new Error(`Caller agent ${callerAgentId} not found`); - return caller.workspaceId; + return caller; + } + + private workspaceBackgroundIntent( + background: boolean | undefined, + callerAgentId: string | undefined, + ): { background?: boolean; callerWorkspaceId?: string } { + const caller = this.getCallerAgent(callerAgentId); + return { + background: inheritWorkspaceBackground(background, caller), + callerWorkspaceId: caller?.workspaceId, + }; } private async createRequestedWorkspace( @@ -7017,8 +7045,7 @@ export class Session { { expectsInitialAgent: Boolean(request.firstAgentContext), workspaceId, - background: request.background, - callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId), + ...this.workspaceBackgroundIntent(request.background, request.callerAgentId), }, ); await this.syncWorkspaceGitObserverForWorkspace(workspace); @@ -7081,8 +7108,7 @@ export class Session { githubPrNumber: source.githubPrNumber, firstAgentContext: request.firstAgentContext, title: request.title, - background: request.background, - callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId), + ...this.workspaceBackgroundIntent(request.background, request.callerAgentId), }, source.baseBranch ? { resolveDefaultBranch: async () => source.baseBranch as string } @@ -7900,7 +7926,8 @@ export class Session { ); const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const)); const candidates = agents - .filter((agent) => !agent.archivedAt && agent.workspaceId === workspace.workspaceId) + .filter((agent) => !agent.archivedAt && !agent.background) + .filter((agent) => agent.workspaceId === workspace.workspaceId) .filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id) .filter((agent) => agent.status === "idle" || agent.status === "closed") .filter((agent) => agent.requiresAttention !== true) diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index f17e2a2067..789dcbe5c3 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -7,6 +7,7 @@ import type { } from "../../messages.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; import type { StoredAgentRecord } from "../../agent/agent-storage.js"; +import type { BackgroundVisibility } from "../../agent/directory.js"; import { resolveEffectiveThinkingOptionId, toAgentPayload } from "../../agent/agent-projections.js"; type AgentUpdatePayload = Extract["payload"]; @@ -19,6 +20,7 @@ interface AgentUpdatesSubscriptionState { emit: (message: SessionOutboundMessage) => void; syncEnabled: boolean; filter?: AgentUpdatesFilter; + backgroundVisibility: BackgroundVisibility; isProviderVisible: (provider: string) => boolean; isBootstrapping: boolean; pendingUpdatesByAgentId: Map; @@ -31,6 +33,7 @@ export interface AgentUpdatesService { isProviderVisible?: (provider: string) => boolean; emit?: (message: SessionOutboundMessage) => void; filter?: AgentUpdatesFilter; + backgroundVisibility?: BackgroundVisibility; syncEnabled?: boolean; }): void; flushBootstrapped( @@ -47,7 +50,10 @@ export interface AgentUpdatesService { } export interface AgentUpdatesServiceDeps { - includesWorkspace?(agent: AgentSnapshotPayload, includeBackground?: boolean): Promise; + includesWorkspace?( + agent: AgentSnapshotPayload, + backgroundVisibility: BackgroundVisibility, + ): Promise; emit(message: SessionOutboundMessage): void; enrichAgentPayload(payload: AgentSnapshotPayload): Promise; buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload; @@ -60,7 +66,7 @@ export interface AgentUpdatesServiceDeps { project: ProjectPlacementPayload | null, agentId: string, includeSequence: boolean, - includeBackground?: boolean, + backgroundVisibility: BackgroundVisibility, ): T; logger: pino.Logger; } @@ -173,7 +179,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU project, agentId, sub.syncEnabled === true, - sub.filter?.includeBackground, + sub.backgroundVisibility, ); function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void { @@ -194,10 +200,12 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU isProviderVisible?: (provider: string) => boolean; emit?: (message: SessionOutboundMessage) => void; filter?: AgentUpdatesFilter; + backgroundVisibility?: BackgroundVisibility; syncEnabled?: boolean; }): void { subscriptions.set(input.subscriptionId, { ...input, + backgroundVisibility: input.backgroundVisibility ?? false, emit: input.emit ?? deps.emit, syncEnabled: input.syncEnabled ?? false, isProviderVisible: input.isProviderVisible ?? deps.isProviderVisibleToClient, @@ -249,9 +257,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) : null; const backgroundVisible = await Promise.all( - observers.map( - (sub) => deps.includesWorkspace?.(payload, sub.filter?.includeBackground) ?? true, - ), + observers.map((sub) => deps.includesWorkspace?.(payload, sub.backgroundVisibility) ?? true), ); return ( project !== null && @@ -273,7 +279,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU for (const sub of observers) { const matches = project && - ((await deps.includesWorkspace?.(payload, sub.filter?.includeBackground)) ?? true) && + ((await deps.includesWorkspace?.(payload, sub.backgroundVisibility)) ?? true) && matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }); bufferOrEmit( sub, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index a78f88bfc0..b04e722406 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2013,6 +2013,8 @@ export class VoiceAssistantWebSocketServer { creationLifecycle: true, hubAgentRpc: true, backgroundWorkspaces: true, + // COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03. + backgroundAgents: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index be59c3a24a..a319860a9a 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -262,6 +262,7 @@ interface AgentState { pendingPermissionCount?: number; requiresAttention?: boolean; attentionReason?: AgentSnapshotPayload["attentionReason"]; + background?: boolean; } function createAgent( @@ -273,6 +274,7 @@ function createAgent( provider: "codex", cwd: input.cwd, ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + ...(input.background ? { background: true } : {}), model: null, thinkingOptionId: null, effectiveThinkingOptionId: null, @@ -330,6 +332,15 @@ describe("WorkspaceDirectory", () => { await expect(workspace.workspaceStatus()).resolves.toBe("running"); }); + test("a running background agent does not make its workspace running", async () => { + const workspace = new WorkspaceStatus(); + + workspace.hasRootAgent({ id: "root-agent", status: "idle" }); + workspace.hasRootAgent({ id: "classifier", status: "running", background: true }); + + await expect(workspace.workspaceStatus()).resolves.toBe("done"); + }); + test("same-cwd workspaces attribute agent status only to the owner", async () => { const workspace = new WorkspaceStatus(); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 50bc58a06d..24f4598799 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -267,8 +267,12 @@ export class WorkspaceDirectory { }); } + // Background agents report to their creator; they never move the workspace's status. const activeAgents = agents.filter( - (agent) => !agent.archivedAt && this.deps.isProviderVisibleToClient(agent.provider), + (agent) => + !agent.archivedAt && + !agent.background && + this.deps.isProviderVisibleToClient(agent.provider), ); this.applyAgentBucketContributions({ activeAgents,