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
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>;