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.
This commit is contained in:
Mohamed Boudra
2026-11-03 13:38:08 +01:00
parent eb8b5b8f94
commit 129102d5c6
30 changed files with 823 additions and 139 deletions
@@ -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");
});
});
+13 -2
View File
@@ -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 <key=value>",
"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<AgentListItem> = {
],
};
/** Inclusive listings mark background agents so they read apart from visible ones. */
const agentLsInclusiveSchema: OutputSchema<AgentListItem> = {
...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(() => {});
+1
View File
@@ -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<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
@@ -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",
+13 -1
View File
@@ -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<AgentSnapshotPayload> {
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),
+82
View File
@@ -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<string, boolean>) {
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();
+5 -1
View File
@@ -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<string, string>;
@@ -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;
+8 -1
View File
@@ -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<typeof AgentSnapshotPayloadSchema>;
@@ -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()
@@ -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");
@@ -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<ManagedAgent, "internal" | "background">): 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<string, string>;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
attention?: AttentionState;
},
resumeOptions?: AgentResumeSessionOptions,
@@ -1466,6 +1480,7 @@ export class AgentManager {
labels?: Record<string, string>;
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<string, string>;
},
): Promise<ManagedAgent> {
@@ -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;
}
@@ -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<AgentSnapshotPayload, "internal" | "background"> {
return {
...(agent.internal ? { internal: true } : {}),
...(agent.background ? { background: true } : {}),
};
}
export function buildStoredAgentPayload(
record: StoredAgentRecord,
validProviders: Iterable<AgentProvider>,
@@ -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 }),
@@ -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;
@@ -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<string>;
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<string, string>;
@@ -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<string | undefined> {
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,
@@ -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;
@@ -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<string[]> {
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"]]),
);
});
+49 -18
View File
@@ -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<AgentSnapshotPayload, "workspaceId" | "internal">,
includeBackground = false,
agent: DiscoverableAgent,
visibility: BackgroundVisibility = false,
): Promise<boolean> {
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<string, string>;
includeArchived?: boolean;
includeBackground?: boolean;
includeBackground?: BackgroundVisibility;
includeUnavailablePersisted?: boolean;
}): Promise<AgentSnapshotPayload[]> {
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<string, string>;
}) =>
const matches = (
agent: DiscoverableAgent & { archivedAt?: string | null; labels?: Record<string, string> },
) =>
(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;
}
@@ -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,
@@ -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);
});
@@ -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 },
);
});
@@ -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<string>;
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<AgentSessionConfig, "providerOptions"> | 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),
),
};
}
+2 -2
View File
@@ -1119,7 +1119,7 @@ export async function createPaseoDaemon(
const ensureWorkspaceForCreateExternal = async (
cwd: string,
firstAgentContext?: FirstAgentContext,
context?: { callerWorkspaceId?: string },
context?: { background?: boolean; callerWorkspaceId?: string },
): Promise<string> => {
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<string> => {
const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext, context);
await emitWorkspaceUpdatesExternal([workspaceId]);
@@ -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<WorkspaceDescriptorPayload>,
cursor: DirectorySyncCursor,
includeBackground = false,
includeBackground: BackgroundVisibility = false,
): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> {
this.workspaces.replaceAll(snapshot);
const read = this.read(this.workspaces, cursor, includeBackground);
@@ -84,7 +85,7 @@ export class DirectorySyncService {
synchronizeAgents(
snapshot: Iterable<AgentDirectoryEntry>,
cursor: DirectorySyncCursor,
includeBackground = false,
includeBackground: BackgroundVisibility = false,
): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> {
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<DirectoryVersion> {
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<DirectoryVersion> {
if (value) {
this.agents.replace(value);
@@ -148,7 +149,7 @@ export class DirectorySyncService {
);
}
private metadata(read: CollectionRead<unknown>, includeBackground = false) {
private metadata(read: CollectionRead<unknown>, 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<DirectoryVersion> {
return version && includeSequence
? { ...payload, ...version, generation: this.generationFor(includeBackground) }
@@ -172,7 +173,7 @@ export class DirectorySyncService {
private read<T>(
collection: VersionedCollection<T>,
cursor: DirectorySyncCursor,
includeBackground = false,
includeBackground: BackgroundVisibility = false,
): CollectionRead<T> {
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;
}
@@ -141,6 +141,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
labels?: Record<string, string>;
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,
};
}
@@ -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);
+67 -40
View File
@@ -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<SessionInboundMessage, { type: "create_
interface ResolvedSessionCreateAgentIntent {
config: AgentSessionConfig;
intent: CreateAgentIntent;
background: boolean;
createdDirectoryWorkspace: boolean;
}
@@ -1162,8 +1168,8 @@ export class Session {
),
});
this.agentUpdates = createAgentUpdatesService({
includesWorkspace: (agent, includeBackground) =>
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<string, number>();
for (const entry of payload.entries) {
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
@@ -6479,7 +6492,10 @@ export class Session {
request: Extract<SessionInboundMessage, { type: "fetch_agent_history_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "fetch_agents_request" }>,
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)
@@ -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<SessionOutboundMessage, { type: "agent_update" }>["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<string, AgentUpdatePayload>;
@@ -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<boolean>;
includesWorkspace?(
agent: AgentSnapshotPayload,
backgroundVisibility: BackgroundVisibility,
): Promise<boolean>;
emit(message: SessionOutboundMessage): void;
enrichAgentPayload(payload: AgentSnapshotPayload): Promise<AgentSnapshotPayload>;
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,
@@ -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.
@@ -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();
@@ -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,