From a880c9619d686c9f326c37d2b5de51440b5aa87b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Nov 2026 07:19:21 +0700 Subject: [PATCH] feat: background agents for hidden helpers (#8683) * 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. * feat(app): hide background agents from tabs, track and status * docs(plugins): point hidden helpers at background agents * fix: keep background agents hidden across cache reload and connected hosts * fix: keep remote creation working on older hosts and inherit foreign parents' background --- docs/agent-lifecycle.md | 41 +++-- docs/glossary.md | 1 + .../app/e2e/browser/background-agents.spec.ts | 65 ++++++++ .../e2e/support/helpers/background-agents.ts | 48 ++++++ .../app/e2e/support/helpers/seed-client.ts | 1 + .../app/src/hooks/use-aggregated-agents.ts | 2 +- .../src/runtime/replica-cache/index.test.ts | 17 ++ .../app/src/runtime/replica-cache/index.ts | 49 +++--- packages/app/src/stores/session-store.ts | 2 + packages/app/src/subagents/select.test.ts | 16 ++ packages/app/src/subagents/select.ts | 1 + packages/app/src/utils/agent-snapshots.ts | 2 + .../utils/workspace-agent-activity.test.ts | 29 ++++ .../app/src/utils/workspace-agent-activity.ts | 7 +- .../workspace-tabs/agent-visibility.test.ts | 29 ++++ .../src/workspace-tabs/agent-visibility.ts | 21 ++- 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 | 36 ++++- packages/client/src/index.test.ts | 82 ++++++++++ packages/client/src/index.ts | 56 ++++--- 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 +++++--- .../agent/host-operations/index.test.ts | 149 ++++++++++++++++++ .../src/server/agent/host-operations/index.ts | 21 ++- .../src/server/agent/mcp-parity.e2e.test.ts | 133 ++++++++++++++++ .../src/server/agent/mcp-server.test.ts | 38 ++--- .../opencode/bridge-input-errors.test.ts | 2 +- .../agent/providers/opencode/bridge.test.ts | 13 +- .../src/server/agent/tools/paseo-tools.ts | 50 ++++-- packages/server/src/server/bootstrap.ts | 4 +- .../server/connected-hosts/hosts.e2e.test.ts | 71 +++++++++ .../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 +- public-docs/cli.md | 4 +- public-docs/cli.zh-CN.md | 4 +- public-docs/mcp.md | 24 +-- public-docs/mcp.zh-CN.md | 24 +-- public-docs/plugins/hooks.md | 2 +- public-docs/plugins/hooks.zh-CN.md | 2 +- public-docs/plugins/reference.md | 2 + public-docs/plugins/reference.zh-CN.md | 2 + public-docs/sdk/agents.md | 23 +++ public-docs/sdk/agents.zh-CN.md | 17 ++ public-docs/sdk/reference.md | 5 +- public-docs/sdk/reference.zh-CN.md | 2 +- public-docs/sdk/workspaces.md | 12 +- public-docs/sdk/workspaces.zh-CN.md | 4 +- 64 files changed, 1497 insertions(+), 250 deletions(-) create mode 100644 packages/app/e2e/browser/background-agents.spec.ts create mode 100644 packages/app/e2e/support/helpers/background-agents.ts create mode 100644 packages/server/src/server/agent/directory.test.ts create mode 100644 packages/server/src/server/agent/host-operations/index.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 3eec9fb566..d8ee067c6c 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -183,23 +183,42 @@ Some providers can create their own child sessions inside one provider runtime. The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle. -## Background workspaces +## Background visibility -`workspace.background` controls discovery only. Background workspaces contain ordinary durable -agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery -keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot -change it. New workspaces default to false, inherit the caller workspace's value when an agent -creates one without an override, and accept explicit true or false. +An agent is effectively background when it or its workspace is background: +`agent.background || workspace.background`. Background controls discovery only. Background agents +and the agents in background workspaces are ordinary durable agents: provider history, plugin +hooks, parentage, finish notifications to a caller, archive, and restart recovery keep their +normal behavior. Both flags are fixed at creation. -The daemon derives agent discovery from workspace ownership. Default workspace and agent lists, -including History requests, exclude background work; `filter.includeBackground` includes it. -Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only -its sidebar with **Show background**, so direct navigation and restored tabs remain available. +- **Agent.** `create_agent_request.background` marks the agent. Use it for helpers the user does + not watch, such as a plugin's classifier in the user's open workspace. A background agent never + raises attention, adds nothing to its workspace's status bucket or Last prompt time, and stays + out of tabs, the parent's subagent track and agent lists in the app. A tab opened by id still + works. +- **Workspace.** `workspace.background` hides the workspace from the sidebar unless **Show + background** is on. +- **Inheritance.** When `background` is omitted, an agent created by a caller agent takes the + caller's own `background`. A new workspace takes the caller's effective background. A request + that creates its workspace applies `background` to both the agent and the workspace. Explicit + true or false always wins. + +`AgentDirectory` (`packages/server/src/server/agent/directory.ts`) owns visibility for lists. +Default workspace and agent lists, including History requests, exclude background work; +`includeBackground` includes it. Exact-ID operations bypass discovery filtering. The app +replicates inclusively and filters at each surface. + +Sources without the `background_agents` hello capability get workspace-only visibility on +inclusive reads and subscriptions: agents in background workspaces, but no agent-level background +agents. Older apps replicate inclusively and auto-open a tab for every root agent, so a classifier +run would otherwise open a tab each turn. That visibility has its own directory sync generation. Sequenced directories keep complete canonical collections. A filtered read must never replace those collections with its subset. Inclusive and default observations have distinct cursor scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache. -Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`. +Clients gate background workspaces and inclusive discovery on `features.backgroundWorkspaces`. +They gate a background agent placed in an existing workspace or under a caller on +`features.backgroundAgents`. ### Private daemon helpers diff --git a/docs/glossary.md b/docs/glossary.md index c7fd1398c5..a6ff393c6a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -28,6 +28,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time. - **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. +- **Background agent** — An agent hidden from default discovery and from app surfaces that announce agents: tabs, the subagent track, agent lists, workspace status and attention. It is otherwise an ordinary durable agent, addressable by id. An agent is effectively background when it or its workspace is background. Fixed at creation. Code: `background` on `AgentSnapshotPayload` and `create_agent_request`. See [agent-lifecycle.md](agent-lifecycle.md#background-visibility). Don't confuse with background work (provider shells and monitors) or the daemon's private internal helpers, which are never public ([agent-lifecycle.md](agent-lifecycle.md#private-daemon-helpers)). - **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). - **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log. - **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy. diff --git a/packages/app/e2e/browser/background-agents.spec.ts b/packages/app/e2e/browser/background-agents.spec.ts new file mode 100644 index 0000000000..66c4787709 --- /dev/null +++ b/packages/app/e2e/browser/background-agents.spec.ts @@ -0,0 +1,65 @@ +import { test } from "../support/fixtures"; +import { + expectWorkspaceShownIdle, + finishAndArchiveAgent, + startBackgroundAgent, +} from "../support/helpers/background-agents"; +import { openAgentRoute } from "../support/helpers/mock-agent"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { + expectSubagentRowGone, + expectSubagentRowVisible, + openSubagentsTrack, + seedParentWithSubagent, + seedSubagent, +} from "../support/helpers/subagents"; +import { expectOnlyWorkspaceAgentTabsVisible } from "../support/helpers/workspace-tabs"; + +test.describe("Background agents", () => { + let workspace: SeededWorkspace; + + test.beforeAll(async () => { + workspace = await seedWorkspace({ + repoPrefix: "background-agents-", + title: "Classifier workspace", + }); + }); + + test.afterAll(async () => { + await workspace?.cleanup(); + }); + + test("a background agent runs in an open workspace without announcing itself", async ({ + page, + }) => { + const agents = await seedParentWithSubagent(workspace, { + parentTitle: "Feature agent", + childTitle: "Visible helper", + }); + await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id }); + await openSubagentsTrack(page); + await expectSubagentRowVisible(page, agents.child.id); + + const classifier = await test.step("a plugin starts a background agent", () => + startBackgroundAgent(workspace, { parentId: agents.parent.id, title: "Classifier" })); + // A visible child created after the classifier reaches the app on the same stream, so once + // its row shows, the app has processed the classifier too. + const marker = await seedSubagent(workspace, { + parentId: agents.parent.id, + title: "Ordering marker", + }); + + await test.step("it stays out of the track, the tabs and the sidebar status", async () => { + await expectSubagentRowVisible(page, marker.id); + await expectSubagentRowGone(page, classifier.id); + await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]); + await expectWorkspaceShownIdle(page, "Classifier workspace"); + }); + + await test.step("the plugin reads its finish and archives it", async () => { + await finishAndArchiveAgent(workspace, classifier.id); + await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]); + await expectSubagentRowGone(page, classifier.id); + }); + }); +}); diff --git a/packages/app/e2e/support/helpers/background-agents.ts b/packages/app/e2e/support/helpers/background-agents.ts new file mode 100644 index 0000000000..ffb2fc4ccf --- /dev/null +++ b/packages/app/e2e/support/helpers/background-agents.ts @@ -0,0 +1,48 @@ +import { expect, type Page } from "@playwright/test"; +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import type { SeededWorkspace } from "./seed-client"; + +/** + * Starts a background agent the way a plugin helper does: in the workspace, under a parent, + * streaming for about ten seconds. Resolves once the daemon reports it running. + */ +export async function startBackgroundAgent( + workspace: Pick, + input: { parentId: string; title: string }, +): Promise<{ id: string }> { + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: input.title, + modeId: "load-test", + model: "ten-second-stream", + initialPrompt: "classify the last turn", + labels: { [PARENT_AGENT_ID_LABEL]: input.parentId }, + background: true, + }); + await workspace.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running"); + return { id: agent.id }; +} + +/** Waits for the agent's turn to end and archives it, as the plugin does with a helper's reply. */ +export async function finishAndArchiveAgent( + workspace: Pick, + agentId: string, +): Promise { + const finished = await workspace.client.waitForFinish(agentId, 60_000); + expect(finished.status).toBe("idle"); + await workspace.client.archiveAgent(agentId); +} + +export async function expectWorkspaceShownIdle(page: Page, workspaceName: string): Promise { + const row = page.getByRole("button", { + name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`), + }); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByTestId("workspace-status-indicator-running")).toHaveCount(0); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index fe6af133f9..2debc70406 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -118,6 +118,7 @@ export interface SeedDaemonClient { featureValues?: Record; initialPrompt?: string; labels?: Record; + background?: boolean; }): Promise<{ id: string; status: string }>; fetchAgents(options?: { scope?: "active" }): Promise<{ entries: Array<{ diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 78cca7fbb3..5ea1eadc4d 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -76,7 +76,7 @@ export function useAggregatedAgents(options?: { } const serverLabel = serverLabelById.get(serverId) ?? serverId; for (const agent of agents.values()) { - if (!includeArchived && agent.archivedAt) { + if (agent.background || (!includeArchived && agent.archivedAt)) { continue; } const nextAgent: AggregatedAgent = { diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index d91d8e7ba4..0ea262f7cb 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -513,6 +513,23 @@ describe("ReplicaCache", () => { expect(storage.changes[0]?.deletes).toEqual([]); }); + it("keeps a background agent background across cache reopen", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const helper = { ...agent("helper"), background: true }; + const visible = agent("visible"); + writer.commitDirectoryMutations(SERVER_ID, [ + { kind: "agent", type: "upsert", id: helper.id, value: helper }, + { kind: "agent", type: "upsert", id: visible.id, value: visible }, + ]); + await writer.flush(); + + const restored = await createCache(storage).readDirectory(SERVER_ID); + + expect(restored.agents.get("helper")?.background).toBe(true); + expect(restored.agents.get("visible")?.background).toBe(false); + }); + it("round-trips identified and anonymous open turns without changing protocol status", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 9ad604c8eb..1032a663bf 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -247,6 +247,8 @@ const StoredAgentSnapshotSchema = z.strictObject({ lastError: z.string().optional(), title: z.string().nullable(), labels: z.record(z.string(), z.string()), + // Optional: clients without background_agents never received a background agent to cache. + background: z.boolean().optional(), requiresAttention: z.boolean().optional(), attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), attentionTimestamp: IsoDateSchema.nullable().optional(), @@ -612,6 +614,31 @@ function serializeAgentTurn(agent: Agent): NonNullable { }; } +function serializeAgentCapabilities( + capabilities: Agent["capabilities"], +): StoredAgent["snapshot"]["capabilities"] { + return { + supportsStreaming: capabilities.supportsStreaming, + supportsSessionPersistence: capabilities.supportsSessionPersistence, + ...(capabilities.supportsSessionListing !== undefined + ? { supportsSessionListing: capabilities.supportsSessionListing } + : {}), + supportsDynamicModes: capabilities.supportsDynamicModes, + supportsMcpServers: capabilities.supportsMcpServers, + supportsReasoningStream: capabilities.supportsReasoningStream, + supportsToolInvocations: capabilities.supportsToolInvocations, + ...(capabilities.supportsRewindConversation !== undefined + ? { supportsRewindConversation: capabilities.supportsRewindConversation } + : {}), + ...(capabilities.supportsRewindFiles !== undefined + ? { supportsRewindFiles: capabilities.supportsRewindFiles } + : {}), + ...(capabilities.supportsRewindBoth !== undefined + ? { supportsRewindBoth: capabilities.supportsRewindBoth } + : {}), + }; +} + function serializeAgent(agent: Agent): StoredAgent { const snapshot = { id: agent.id, @@ -632,26 +659,7 @@ function serializeAgent(agent: Agent): StoredAgent { }, } : {}), - capabilities: { - supportsStreaming: agent.capabilities.supportsStreaming, - supportsSessionPersistence: agent.capabilities.supportsSessionPersistence, - ...(agent.capabilities.supportsSessionListing !== undefined - ? { supportsSessionListing: agent.capabilities.supportsSessionListing } - : {}), - supportsDynamicModes: agent.capabilities.supportsDynamicModes, - supportsMcpServers: agent.capabilities.supportsMcpServers, - supportsReasoningStream: agent.capabilities.supportsReasoningStream, - supportsToolInvocations: agent.capabilities.supportsToolInvocations, - ...(agent.capabilities.supportsRewindConversation !== undefined - ? { supportsRewindConversation: agent.capabilities.supportsRewindConversation } - : {}), - ...(agent.capabilities.supportsRewindFiles !== undefined - ? { supportsRewindFiles: agent.capabilities.supportsRewindFiles } - : {}), - ...(agent.capabilities.supportsRewindBoth !== undefined - ? { supportsRewindBoth: agent.capabilities.supportsRewindBoth } - : {}), - }, + capabilities: serializeAgentCapabilities(agent.capabilities), currentModeId: agent.currentModeId, availableModes: [], pendingPermissions: [], @@ -659,6 +667,7 @@ function serializeAgent(agent: Agent): StoredAgent { ...(agent.lastError ? { lastError: agent.lastError } : {}), title: agent.title, labels: agent.labels, + ...(agent.background ? { background: true } : {}), requiresAttention: agent.requiresAttention ?? false, attentionReason: agent.attentionReason ?? null, attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 001ad8eed6..b1312860a9 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -97,6 +97,8 @@ export interface Agent { attentionReason?: "finished" | "error" | "permission" | null; attentionTimestamp?: Date | null; archivedAt?: Date | null; + /** Background agents stay out of tabs, the subagent track and agent lists. Open them by id. */ + background?: boolean; parentAgentId: string | null; labels: Record; projectPlacement?: ProjectPlacementPayload | null; diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index c9d7ca4096..9fd84a19e7 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -246,6 +246,22 @@ describe("selectSubagentsForParent", () => { expect(rows.map((row) => row.id)).toEqual(["child-a"]); }); + it("excludes background children from the track", () => { + setAgents([ + makeAgent({ id: "parent-a" }), + makeAgent({ id: "child-a", parentAgentId: "parent-a" }), + makeAgent({ id: "background-child", parentAgentId: "parent-a", background: true }), + ]); + + const rows = selectSubagentsForParent( + useSessionStore.getState(), + { serverId: SERVER_ID, parentAgentId: "parent-a" }, + EMPTY_PENDING_ARCHIVE_IDS, + ); + + expect(rows.map((row) => row.id)).toEqual(["child-a"]); + }); + it("excludes siblings, unrelated agents, and grandchildren", () => { setAgents([ makeAgent({ id: "parent-a" }), diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index 29f76e34d4..58a883f1c3 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -82,6 +82,7 @@ export function selectSubagentsForParent( const parent = getParentAddressFromLabels(agent.labels, serverId); if ( agent.archivedAt || + agent.background || pendingArchiveIds.has(`${serverId}:${agent.id}`) || parent?.serverId !== params.serverId || parent.agentId !== params.parentAgentId diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 13a7bcd14b..df08cdddb7 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -85,6 +85,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload { attentionReason: agent.attentionReason ?? null, attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null, archivedAt: agent.archivedAt?.toISOString() ?? null, + ...(agent.background ? { background: true } : {}), }; } @@ -133,6 +134,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: attentionReason: snapshot.attentionReason ?? null, attentionTimestamp, archivedAt, + background: snapshot.background === true, parentAgentId, labels: snapshot.labels, }; diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 7ccca095f1..66cfcd758c 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -14,6 +14,7 @@ function agent(input: { pendingPermissionCount?: number; archivedAt?: string | null; parentAgentId?: string | null; + background?: boolean; }): Agent { return { serverId: "host-a", @@ -61,6 +62,7 @@ function agent(input: { attentionTimestamp: input.attentionTimestamp ? new Date(input.attentionTimestamp) : null, archivedAt: input.archivedAt ? new Date(input.archivedAt) : null, parentAgentId: input.parentAgentId ?? null, + background: input.background ?? false, labels: {}, }; } @@ -158,6 +160,33 @@ describe("workspace agent activity index", () => { ); }); + it("does not let a running background agent change root workspace activity", () => { + const index = buildWorkspaceAgentActivityIndex( + new Map([ + [ + "root", + agent({ id: "root", workspaceId: "workspace-a", updatedAt: "2026-06-01T10:00:00.000Z" }), + ], + [ + "classifier", + agent({ + id: "classifier", + workspaceId: "workspace-a", + status: "running", + updatedAt: "2026-06-01T10:05:00.000Z", + background: true, + }), + ], + ]), + ); + + expect(index.get("workspace-a")).toEqual({ + agentId: "root", + status: "done", + enteredAt: new Date("2026-06-01T10:00:00.000Z"), + }); + }); + it("does not let archived or child agents change root workspace activity", () => { const index = buildWorkspaceAgentActivityIndex( new Map([ diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts index 69f3c23da4..d42ddafbba 100644 --- a/packages/app/src/utils/workspace-agent-activity.ts +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -22,7 +22,12 @@ export function buildWorkspaceAgentActivityIndex( for (const agent of agents.values()) { const parentAgent = agent.parentAgentId ? agents.get(agent.parentAgentId) : undefined; - if (agent.archivedAt || !agent.workspaceId || !isWorkspaceRootAgent(agent, parentAgent)) { + if ( + agent.archivedAt || + agent.background || + !agent.workspaceId || + !isWorkspaceRootAgent(agent, parentAgent) + ) { continue; } diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index cc5b8f6e04..1da4d8627d 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -18,6 +18,7 @@ function makeAgent(input: { archivedAt?: Date | null; createdAt?: Date; lastActivityAt?: Date; + background?: boolean; }): Agent { const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z"); const lastActivityAt = input.lastActivityAt ?? createdAt; @@ -58,6 +59,7 @@ function makeAgent(input: { attentionReason: null, attentionTimestamp: null, archivedAt: input.archivedAt ?? null, + background: input.background ?? false, }; } @@ -131,6 +133,33 @@ describe("workspace agent visibility", () => { expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined(); }); + it("keeps a background agent active without auto-opening its tab", () => { + const visible = makeAgent({ id: "visible", cwd: "/repo", workspaceId: WORKSPACE_ID }); + const classifier = makeAgent({ + id: "classifier", + cwd: "/repo", + workspaceId: WORKSPACE_ID, + background: true, + }); + const result = deriveWorkspaceAgentVisibility({ + workspaceId: WORKSPACE_ID, + sessionAgents: new Map([ + [visible.id, visible], + [classifier.id, classifier], + ]), + }); + + expect(result.autoOpenAgentIds).toEqual(new Set([visible.id])); + expect(result.activeAgentIds).toEqual(new Set([visible.id, classifier.id])); + expect( + shouldPruneWorkspaceAgentTab({ + agentId: classifier.id, + agentsHydrated: true, + activeAgentIds: result.activeAgentIds, + }), + ).toBe(false); + }); + it("uses directory ownership before historical details when identifying foreign agents", () => { const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID }); const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 99e127c872..8feda493c6 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -36,11 +36,9 @@ export function deriveWorkspaceAgentVisibility(input: { continue; } if (!agent.archivedAt) { + // A background agent stays active so a tab opened by id isn't pruned, but never auto-opens. activeAgentIds.add(agent.id); - const parentAgent = agent.parentAgentId - ? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId)) - : undefined; - if (isWorkspaceRootAgent(agent, parentAgent)) { + if (shouldAutoOpenAgent({ agent, sessionAgents, agentDetails })) { autoOpenAgentIds.add(agent.id); } } @@ -57,6 +55,21 @@ export function deriveWorkspaceAgentVisibility(input: { return { activeAgentIds, autoOpenAgentIds, foreignAgentIds }; } +function shouldAutoOpenAgent(input: { + agent: Agent; + sessionAgents: Map | undefined; + agentDetails: Map | undefined; +}): boolean { + const { agent, sessionAgents, agentDetails } = input; + if (agent.background) { + return false; + } + const parentAgent = agent.parentAgentId + ? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId)) + : undefined; + return isWorkspaceRootAgent(agent, parentAgent); +} + interface AgentMapsSnapshot { sessions: Record< string, 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..7454c8448f 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; @@ -2891,6 +2892,28 @@ export class DaemonClient { }); } + /** Finds one workspace by id across pages, background ones included when the host has them. */ + async findWorkspace( + workspaceId: string, + options?: { requestId?: string }, + ): Promise { + const filter = this.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}; + let cursor: string | undefined; + let requestId = options?.requestId; + do { + const result = await this.fetchWorkspaces({ + requestId, + filter, + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + const match = result.entries.find((entry) => entry.id === workspaceId); + if (match) return match; + cursor = result.pageInfo.nextCursor ?? undefined; + requestId = undefined; + } while (cursor); + return null; + } + listWorkspaceLabels( options: { sync?: { generation: string; afterSeq: number }; @@ -3293,13 +3316,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..568c23896b 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; @@ -609,12 +613,14 @@ export function createPaseoApi( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, ) => { + const serverId = daemonClient.getLastServerInfoMessage()?.serverId; + const foreignParent = + options.background === undefined ? foreignParentHandle(options.parent, serverId) : null; + const resolved = foreignParent + ? await inheritParentBackground(options, foreignParent) + : options; const agent = await daemonClient.createAgent( - toDaemonAgentCreateOptions( - options, - placement, - daemonClient.getLastServerInfoMessage()?.serverId, - ), + toDaemonAgentCreateOptions(resolved, placement, serverId), ); return createAgentHandle(agent); }; @@ -819,25 +825,7 @@ function createWorkspaceHandleFactory( let current = typeof workspace === "string" ? null : workspace; const refresh = async (options?: { requestId?: string }) => { - let cursor: string | undefined; - let requestId = options?.requestId; - // A ref addresses one workspace by id, so a background one resolves too. - const filter = daemonClient.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}; - do { - const result = await daemonClient.fetchWorkspaces({ - requestId, - filter, - page: { limit: 200, ...(cursor ? { cursor } : {}) }, - }); - const match = result.entries.find((entry) => entry.id === id); - if (match) { - current = match; - return current; - } - cursor = result.pageInfo.nextCursor ?? undefined; - requestId = undefined; - } while (cursor); - current = null; + current = await daemonClient.findWorkspace(id, options); return current; }; @@ -1066,6 +1054,24 @@ function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string { return typeof workspace === "string" ? workspace : workspace.id; } +function foreignParentHandle( + parent: PaseoAgentCreateOptions["parent"], + serverId: string | undefined, +): PaseoAgentHandle | null { + if (!parent || typeof parent === "string" || !("address" in parent)) return null; + return parent.address.serverId === serverId ? null : parent; +} + +// A host inherits background from a caller it can see. A parent on another host is invisible to +// it, so a handle passes its own value on. A bare foreign address can't; pass background then. +async function inheritParentBackground( + options: PaseoAgentCreateOptions, + parent: PaseoAgentHandle, +): Promise { + const snapshot = parent.current() ?? (await parent.refresh())?.agent; + return snapshot?.background ? { ...options, background: true } : options; +} + function encodeParent( parent: string | AgentAddress | PaseoAgentHandle, serverId?: string, 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 dd6b7cfbb7..fa066989ff 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, @@ -2181,6 +2197,7 @@ export class AgentManager { lastError: record.lastError ?? undefined, attention, internal: record.internal, + background: record.background, labels: record.labels, }, }); @@ -3807,6 +3824,7 @@ export class AgentManager { publishWhenReady?: boolean; workspaceId?: string; owner?: AgentOwner; + background?: boolean; launchEnv?: Record; }, ): Promise { @@ -3836,6 +3854,7 @@ export class AgentManager { config, now, durableTimelineHasRows, + background: options?.background === true, options, }); @@ -3980,6 +3999,7 @@ export class AgentManager { config: AgentSessionConfig; now: Date; durableTimelineHasRows: boolean; + background: boolean; options: | { createdAt?: Date; @@ -4034,6 +4054,7 @@ export class AgentManager { lastError: options?.lastError, attention: resolveInitialAttention(options?.attention), internal: config.internal ?? false, + background: params.background, labels: options?.labels ?? {}, } as ActiveManagedAgent; } @@ -4967,7 +4988,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); @@ -5222,8 +5243,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.test.ts b/packages/server/src/server/agent/host-operations/index.test.ts new file mode 100644 index 0000000000..fbd492e881 --- /dev/null +++ b/packages/server/src/server/agent/host-operations/index.test.ts @@ -0,0 +1,149 @@ +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { afterEach, expect, test } from "vitest"; + +import { createHostResolver, type HostOperations } from "./index.js"; + +type Handler = ((event?: unknown) => void) | null; + +/** A connected host from before background agents: background workspaces, no background agents. */ +class OlderHostSocket { + readyState = 1; + readonly requests: Array> = []; + onopen: Handler = null; + onmessage: Handler = null; + onclose: Handler = null; + onerror: Handler = null; + private readonly createSent = Promise.withResolvers>(); + + constructor() { + queueMicrotask(() => this.onopen?.()); + } + + createRequest(): Promise> { + return this.createSent.promise; + } + + send(data: string): void { + const frame = JSON.parse(data); + if (frame.type === "hello") { + this.reply({ + type: "status", + payload: { + status: "server_info", + serverId: "srv_older", + hostname: null, + version: null, + features: { + providerUsageList: true, + providersSnapshotCwd: true, + ownedSubscriptions: true, + backgroundWorkspaces: true, + }, + }, + }); + return; + } + if (frame.type !== "session") return; + const message = frame.message; + this.requests.push(message); + if (message.type === "fetch_workspaces_request") { + this.reply({ + type: "fetch_workspaces_response", + payload: { + requestId: message.requestId, + entries: [workspace], + pageInfo: emptyPage, + }, + }); + } + if (message.type === "create_agent_request") this.createSent.resolve(message); + } + + close(): void { + this.readyState = 3; + this.onclose?.({ code: 1000, reason: "closed" }); + } + + private reply(message: object): void { + this.onmessage?.({ data: JSON.stringify({ type: "session", message }) }); + } +} + +const emptyPage = { nextCursor: null, prevCursor: null, hasMore: false }; + +const workspace = { + id: "workspace_older", + projectId: "project_older", + projectDisplayName: "Older", + projectRootPath: "/repo/older", + workspaceDirectory: "/repo/older", + projectKind: "git", + workspaceKind: "directory", + name: "older", + archivingAt: null, + status: "done", + statusEnteredAt: null, + activityAt: "2026-05-16T00:00:00.000Z", + scripts: [], + gitRuntime: null, + githubRuntime: null, +}; + +const clients: DaemonClient[] = []; +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); +}); + +async function olderHost() { + const socket = new OlderHostSocket(); + const client = new DaemonClient({ + url: "ws://older.test", + clientId: "older-host-test", + webSocketFactory: () => socket, + reconnect: { enabled: false }, + }); + clients.push(client); + await client.connect(); + return { client, socket }; +} + +function resolverFor(client: DaemonClient, callerBackground: boolean) { + return createHostResolver({ + serverId: "origin", + callerAgentId: "caller", + caller: () => ({ background: callerBackground }), + local: {} as HostOperations, + hosts: () => ({ + directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }), + use: (_serverId, action) => action(client), + }), + }); +} + +const createArgs = { + workspaceId: workspace.id, + provider: "codex/gpt-5.4", + title: "Helper", + initialPrompt: "Summarize", +}; + +test("a visible caller still creates agents on a host without background agents", async () => { + const { client, socket } = await olderHost(); + const creating = resolverFor(client, false).use("srv_older", (host) => + host.createAgent(createArgs, {} as never), + ); + creating.catch(() => {}); + + const request = await socket.createRequest(); + expect(request).toMatchObject({ workspaceId: workspace.id }); + expect(request).not.toHaveProperty("background"); +}); + +test("a background caller's remote child asks the host to update instead of turning visible", async () => { + const { client, socket } = await olderHost(); + + await expect( + resolverFor(client, true).use("srv_older", (host) => host.createAgent(createArgs, {} as never)), + ).rejects.toThrow("Update the host to use background agents."); + expect(socket.requests.map((request) => request.type)).not.toContain("create_agent_request"); +}); diff --git a/packages/server/src/server/agent/host-operations/index.ts b/packages/server/src/server/agent/host-operations/index.ts index 25f1893bdd..f6ae774375 100644 --- a/packages/server/src/server/agent/host-operations/index.ts +++ b/packages/server/src/server/agent/host-operations/index.ts @@ -16,6 +16,11 @@ import type { ConnectedHosts } from "../../connected-hosts/index.js"; import { resolveRequiredProviderModel } from "../mcp-shared.js"; import { selectAgentListItems } from "../agent-projections.js"; import { summarizeAgentActivity } from "../activity-curator.js"; +import { + inheritAgentBackground, + inheritWorkspaceBackground, + type BackgroundCaller, +} from "../create-agent/intent.js"; import { expandUserPath } from "../../path-utils.js"; import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js"; @@ -177,6 +182,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({ @@ -197,6 +203,8 @@ export function createHostResolver(input: { local: HostOperations; hosts: () => ToolHosts; callerAgentId?: string; + /** The destination can't see the caller, so its background defaults resolve before routing. */ + caller: () => BackgroundCaller | null; lockedCwd?: string; }) { return { @@ -214,9 +222,7 @@ export function createHostResolver(input: { async createAgent(args) { const parsed = remoteCreate.parse(args); const { provider, model } = resolveRequiredProviderModel(parsed.provider); - const workspace = (await client.fetchWorkspaces({})).entries.find( - (row) => row.id === parsed.workspaceId, - ); + const workspace = await client.findWorkspace(parsed.workspaceId); if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`); const labels = { ...parsed.labels, @@ -235,6 +241,10 @@ export function createHostResolver(input: { featureValues: parsed.settings?.features, }, workspaceId: parsed.workspaceId, + // Older hosts reject any background with a workspaceId, so a visible create omits it. + ...(inheritAgentBackground(parsed.background, input.caller()) + ? { background: true } + : {}), labels, initialPrompt: parsed.initialPrompt, idempotencyKey: parsed.idempotencyKey, @@ -359,7 +369,10 @@ export function createHostResolver(input: { worktreeSlug: args.worktreeSlug as string | undefined, }, title: args.title as string | undefined, - background: args.background as boolean | undefined, + background: inheritWorkspaceBackground( + args.background as boolean | undefined, + input.caller(), + ), }); if (response.error || !response.workspace) throw new Error(response.error ?? "Workspace creation failed"); 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/providers/opencode/bridge-input-errors.test.ts b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts index 277cad58fe..0960f9ca62 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts @@ -94,7 +94,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as const { tools } = await manifest.json(); expect( tools.find((tool: { name: string }) => tool.name === "create_agent").inputSchema.properties, - ).not.toHaveProperty("background"); + ).toHaveProperty("background"); expect( tools.find((tool: { name: string }) => tool.name === "update_schedule").inputSchema .properties, diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 468baabe14..aa7acb830c 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -181,13 +181,18 @@ describe("OpenCodeBridge", () => { expect(createDefinition.inputSchema).toMatchObject({ properties: { notifyOnFinish: { default: true } }, }); - expect(createDefinition.inputSchema.properties).not.toHaveProperty("background"); + expect(createDefinition.inputSchema.properties).toHaveProperty("background"); expect(sendDefinition.inputSchema).toMatchObject({ properties: { notifyOnFinish: { default: true } }, }); - await expect( - catalog.executeTool("create_agent", { ...createInput, background: true }), - ).rejects.toThrow("background"); + await catalog.executeTool("create_agent", { + ...createInput, + title: "Background child", + background: true, + }); + expect( + agentManager.listAgents().find((agent) => agent.config.title === "Background child"), + ).toMatchObject({ background: true }); const v1Module = await import(plugin.pluginUrl); const hooks = await v1Module.default({}, plugin); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index a5569685e2..fe2d4187b9 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; @@ -718,6 +719,7 @@ function createToolCatalog( const hostResolver = createHostResolver({ serverId: options.serverId ?? "self", callerAgentId, + caller: () => resolveCallerAgent(), lockedCwd: callerContext?.lockedCwd, hosts: () => { if (!options.hosts) throw new Error("Connected hosts are not configured"); @@ -860,6 +862,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 +1201,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 +1413,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 +1483,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 +1519,7 @@ function createToolCatalog( ...(worktreeSlug ? { worktreeSlug } : {}), ...worktreeTarget, ...(title ? { title } : {}), - background, - callerWorkspaceId: resolveCallerAgent()?.workspaceId, + ...workspaceBackgroundIntent(background), }, ); if (!result.ok) { @@ -1648,6 +1665,7 @@ function createToolCatalog( mode: parsedArgs.settings?.modeId, promptFailure: "throw", notifyOnFinish, + background: parsedArgs.background, detached: resolvedArgs.detached, callerAgentId, callerContext, @@ -1821,9 +1839,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 +1947,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/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 66e30a0b65..416c6adb31 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1732,6 +1732,77 @@ test( }, ); +test( + "a background caller's MCP and SDK children and workspaces on B are background unless it overrides", + { timeout: 30000 }, + async () => { + const a = await daemon(); + const b = await daemon(); + const { paseo } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${b.port}`, label: "Fleet B" }); + const directory = await online(paseo); + const workspace = await paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test" }, + title: "Background parent", + background: true, + }); + const mcp = new Client({ name: "background-delegation", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const call = async (name: string, args: Record) => { + const response = await mcp.callTool({ name, arguments: args }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return z.record(z.string(), z.unknown()).parse(response.structuredContent); + }; + const serverId = directory.hosts[0]!.serverId!; + const hidden = await call("create_workspace", { + serverId, + isolation: "local", + path: b.staticDir, + title: "Inherited workspace", + }); + const helper = await call("create_agent", { + serverId, + workspaceId: hidden.workspaceId, + title: "Inherited helper", + provider: "claude/claude-test", + initialPrompt: "Summarize", + }); + const visible = await call("create_agent", { + serverId, + workspaceId: hidden.workspaceId, + title: "Visible helper", + provider: "claude/claude-test", + initialPrompt: "Summarize", + background: false, + }); + + const bApi = await api(b); + const workspaces = await bApi.client.fetchWorkspaces({ includeBackground: true }); + expect(workspaces.entries.find((entry) => entry.id === hidden.workspaceId)).toMatchObject({ + background: true, + }); + const snapshot = async (agentId: unknown) => + (await bApi.paseo.agents.ref(String(agentId)).refresh())?.agent; + expect(await snapshot(helper.agentId)).toMatchObject({ background: true }); + expect((await snapshot(visible.agentId))?.background).toBeUndefined(); + + const sdkChild = await bApi.paseo.workspaces.ref(String(hidden.workspaceId)).agents.create({ + config: { provider: "claude/claude-test" }, + title: "SDK helper", + parent, + }); + expect(sdkChild.current()).toMatchObject({ background: true }); + }, +); + test( "remote agent reports, local CLI sends and automatic completion reach the same recipient", { timeout: 30000 }, 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, diff --git a/public-docs/cli.md b/public-docs/cli.md index dd61ef60ae..f28911ac5a 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -289,10 +289,10 @@ behavior. ```bash paseo ls # Non-archived agents in active workspaces -paseo ls -a # Also include archived agents +paseo ls -a # Also include archived and background agents, with a BACKGROUND column paseo ls -g # Non-archived agents across all workspaces paseo ls -a -g --json # All agents, including archived, as JSON -paseo ls --background # Also include agents in background workspaces +paseo ls --background # Also include background agents and agents in background workspaces ``` ## Streaming output diff --git a/public-docs/cli.zh-CN.md b/public-docs/cli.zh-CN.md index 4d2cec8372..6e47391771 100644 --- a/public-docs/cli.zh-CN.md +++ b/public-docs/cli.zh-CN.md @@ -270,10 +270,10 @@ paseo plugin remove my-plugin ```bash paseo ls # Non-archived agents in active workspaces -paseo ls -a # Also include archived agents +paseo ls -a # Also include archived and background agents, with a BACKGROUND column paseo ls -g # Non-archived agents across all workspaces paseo ls -a -g --json # All agents, including archived, as JSON -paseo ls --background # Also include agents in background workspaces +paseo ls --background # Also include background agents and agents in background workspaces ``` ## 流式输出 {#streaming-output} diff --git a/public-docs/mcp.md b/public-docs/mcp.md index f90ae2a50b..68b484e852 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -87,18 +87,18 @@ MCP does not expose an agent-detach tool. Detaching is a manual user action in t ### Agents -| Tool | Function | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `create_agent` | Create an agent, optionally placing it in an existing workspace with `workspaceId`. Use `initialPrompt` to start work; returns without waiting for completion. | -| `send_agent_prompt` | Send a prompt using `agentId` and `prompt`; returns without waiting for completion. | -| `get_agent_status` | Return the latest snapshot for an agent. | -| `list_agents` | List recent agents as compact metadata; `includeBackground: true` includes background work. | -| `cancel_agent` | Abort an agent's current run but keep the agent alive. | -| `archive_agent` | Soft-delete an agent and remove it from the active list. | -| `kill_agent` | Terminate an agent session permanently. | -| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. | -| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | -| `set_agent_mode` | Switch an agent's session mode. | +| Tool | Function | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `create_agent` | Create an agent, optionally placing it in an existing workspace with `workspaceId`. Use `initialPrompt` to start work; returns without waiting for completion. `background: true` hides it from tabs, default lists, your subagent track and notifications; you still get its finish notification. Omission inherits your own value. | +| `send_agent_prompt` | Send a prompt using `agentId` and `prompt`; returns without waiting for completion. | +| `get_agent_status` | Return the latest snapshot for an agent. | +| `list_agents` | List recent agents as compact metadata; `includeBackground: true` includes background work. | +| `cancel_agent` | Abort an agent's current run but keep the agent alive. | +| `archive_agent` | Soft-delete an agent and remove it from the active list. | +| `kill_agent` | Terminate an agent session permanently. | +| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. | +| `get_agent_activity` | Return recent agent timeline entries as a curated summary. | +| `set_agent_mode` | Switch an agent's session mode. | Paseo agents receive finish, error, and permission notifications by default; set `notifyOnFinish: false` to disable them. External MCP clients use `get_agent_status` and `get_agent_activity` to check results. diff --git a/public-docs/mcp.zh-CN.md b/public-docs/mcp.zh-CN.md index 2cb81f4e30..36f0ff54d9 100644 --- a/public-docs/mcp.zh-CN.md +++ b/public-docs/mcp.zh-CN.md @@ -79,18 +79,18 @@ MCP 不提供解除 Agent 父子关系的工具。解除关系是用户在应用 ### Agent {#agents} -| 工具 | 功能 | -| -------------------- | ---------------------------------------------------------------------------------------------------- | -| `create_agent` | 创建 Agent,可用 `workspaceId` 把它放到已有的工作区。用 `initialPrompt` 开始工作;不等待完成就返回。 | -| `send_agent_prompt` | 用 `agentId` 和 `prompt` 发送提示词;不等待完成就返回。 | -| `get_agent_status` | 返回 Agent 的最新快照。 | -| `list_agents` | 以精简元数据列出最近的 Agent;`includeBackground: true` 会包含后台工作。 | -| `cancel_agent` | 中止 Agent 当前的运行,但保留该 Agent。 | -| `archive_agent` | 软删除 Agent,并把它从活动列表中移除。 | -| `kill_agent` | 永久终止 Agent 会话。 | -| `update_agent` | 更新 Agent 的名称、标签或运行时设置,例如模式、模型、思考选项和功能。 | -| `get_agent_activity` | 以整理后的摘要返回 Agent 最近的时间线条目。 | -| `set_agent_mode` | 切换 Agent 的会话模式。 | +| 工具 | 功能 | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create_agent` | 创建 Agent,可用 `workspaceId` 把它放到已有的工作区。用 `initialPrompt` 开始工作;不等待完成就返回。`background: true` 会把它从标签页、默认列表、你的子 Agent 列表和通知中隐藏;你仍会收到它的完成通知。省略时继承你自己的设置。 | +| `send_agent_prompt` | 用 `agentId` 和 `prompt` 发送提示词;不等待完成就返回。 | +| `get_agent_status` | 返回 Agent 的最新快照。 | +| `list_agents` | 以精简元数据列出最近的 Agent;`includeBackground: true` 会包含后台工作。 | +| `cancel_agent` | 中止 Agent 当前的运行,但保留该 Agent。 | +| `archive_agent` | 软删除 Agent,并把它从活动列表中移除。 | +| `kill_agent` | 永久终止 Agent 会话。 | +| `update_agent` | 更新 Agent 的名称、标签或运行时设置,例如模式、模型、思考选项和功能。 | +| `get_agent_activity` | 以整理后的摘要返回 Agent 最近的时间线条目。 | +| `set_agent_mode` | 切换 Agent 的会话模式。 | Paseo Agent 默认会收到完成、错误和权限通知;设置 `notifyOnFinish: false` 可以关闭它们。外部 MCP 客户端使用 `get_agent_status` 和 `get_agent_activity` 查看结果。 diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md index b535821e62..25c8cf0527 100644 --- a/public-docs/plugins/hooks.md +++ b/public-docs/plugins/hooks.md @@ -588,7 +588,7 @@ server.addHook("agent.permission.after", async ({ agent, requestId, resolution } }); ``` -Agent hooks exclude the daemon's private disposable helpers. Public background workspaces and their ordinary agents use the same hooks. +Agent hooks exclude the daemon's private disposable helpers. Background agents and the agents in background workspaces use the same hooks. ## Ordering and returned values diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md index eecf370a29..5469582b3f 100644 --- a/public-docs/plugins/hooks.zh-CN.md +++ b/public-docs/plugins/hooks.zh-CN.md @@ -571,7 +571,7 @@ server.addHook("agent.permission.after", async ({ agent, requestId, resolution } }); ``` -Agent 钩子不包括 daemon 的私有临时辅助 Agent。公开的后台工作区及其普通 Agent 使用相同钩子。 +Agent 钩子不包括 daemon 的私有临时辅助 Agent。后台 Agent 和后台工作区中的 Agent 使用相同钩子。 ## 顺序与返回值 {#ordering-and-returned-values} diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 33d38f4fc9..5e88cf9bf5 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2084,6 +2084,8 @@ function PullRequestAction({ theme }: PluginSurfaceProps) { The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection. +To run a helper the user shouldn't see, such as a classifier or summarizer, create it as a [background agent](/docs/sdk/agents#run-a-helper-as-a-background-agent) in an existing workspace. + ### Discover hosts and target another host Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index a273a70aa7..bb979f52e2 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1779,6 +1779,8 @@ function PullRequestAction({ theme }: PluginSurfaceProps) { 返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。 +如果要运行用户不需要看到的辅助任务,例如分类或摘要,请在已有工作区中把它创建为[后台 Agent](/docs/sdk/agents#run-a-helper-as-a-background-agent)。 + ### 发现主机并指定另一台主机 {#discover-hosts-and-target-another-host} 使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机 diff --git a/public-docs/sdk/agents.md b/public-docs/sdk/agents.md index 87d3e993cf..4d9a9a5b68 100644 --- a/public-docs/sdk/agents.md +++ b/public-docs/sdk/agents.md @@ -130,6 +130,29 @@ use `client.agents.list({ filter: { includeBackground: true } })` to include the Exact-ID handles work normally. See [workspace creation](./workspaces.md#create-a-fresh-workspace) for defaults and caller inheritance. +## Run a helper as a background agent + +Pass `background: true` to run a helper in a workspace the user may have open. A background agent +opens no tab, stays out of its parent's subagent track and the sidebar status, and raises no +notifications. Default lists leave it out. + +```ts +const helper = await client.workspaces.ref(workspaceId).agents.create({ + config: { provider: "codex/gpt-5.5" }, + prompt: "Summarize the last turn in one line.", + background: true, +}); +const result = await helper.waitForFinish(); +console.log(result.lastMessage); +await helper.archive(); +``` + +An agent created under a background `parent` is background unless you pass `background: false`. +A parent on another host passes this on only as a handle; with a bare address, pass `background`. +Placing a background agent in an existing workspace or under a parent requires +`features.backgroundAgents`; on an older host the SDK throws "Update the host to use background +agents." before sending. + ## Request structured output ```ts diff --git a/public-docs/sdk/agents.zh-CN.md b/public-docs/sdk/agents.zh-CN.md index dd2997f61b..8731ed03e2 100644 --- a/public-docs/sdk/agents.zh-CN.md +++ b/public-docs/sdk/agents.zh-CN.md @@ -124,6 +124,23 @@ const workspace = await client.workspaces.create({ 后台 Agent 会保留历史记录,并在 daemon 重启后继续存在。默认的发现结果会隐藏它们;使用 `client.agents.list({ filter: { includeBackground: true } })` 可以包含它们。精确 ID 的句柄可以正常使用。默认值和调用方继承规则见[创建工作区](./workspaces.md#create-a-fresh-workspace)。 +## 以后台 Agent 运行辅助任务 {#run-a-helper-as-a-background-agent} + +传入 `background: true`,可以在用户可能正打开的工作区中运行辅助 Agent。后台 Agent 不会打开标签页,不会出现在父 Agent 的子 Agent 列表和侧边栏状态中,也不会触发通知。默认列表不包含它。 + +```ts +const helper = await client.workspaces.ref(workspaceId).agents.create({ + config: { provider: "codex/gpt-5.5" }, + prompt: "Summarize the last turn in one line.", + background: true, +}); +const result = await helper.waitForFinish(); +console.log(result.lastMessage); +await helper.archive(); +``` + +在后台 `parent` 下创建的 Agent 默认也是后台 Agent,除非传入 `background: false`。位于其他主机的父 Agent 只有以句柄传入时才会传递该设置;只传地址时,请显式传入 `background`。把后台 Agent 放到已有工作区或某个父 Agent 下需要 `features.backgroundAgents`;在较旧的主机上,SDK 会在发送前抛出 "Update the host to use background agents."。 + ## 请求结构化输出 {#request-structured-output} ```ts diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 2867261c32..6919cc5aa0 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -61,8 +61,9 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o `parent` accepts a host-local agent ID, an `AgentAddress` (`{ serverId, agentId }`), or an agent handle. A handle’s `address` keeps its host identity after disconnect. When creating through `hosts.client(serverId)`, pass the parent handle or complete address; the SDK carries parent metadata to the destination. -`background` is workspace creation intent. It cannot accompany an existing `workspaceId`. -See [background workspaces](./workspaces.md) for caller inheritance and inclusive discovery. +`background` creates a [background agent](./agents.md#run-a-helper-as-a-background-agent). A +workspace the call creates is background too. With a `parent` and no value, the parent's own +`background` applies. See [background workspaces](./workspaces.md) for inclusive discovery. `config` accepts: diff --git a/public-docs/sdk/reference.zh-CN.md b/public-docs/sdk/reference.zh-CN.md index fcda38bc89..c7848240c4 100644 --- a/public-docs/sdk/reference.zh-CN.md +++ b/public-docs/sdk/reference.zh-CN.md @@ -59,7 +59,7 @@ nav: API 参考 `parent` 接受主机本地的 Agent ID、`AgentAddress`(`{ serverId, agentId }`)或 Agent 句柄。句柄的 `address` 在断开连接后仍保留其主机身份。通过 `hosts.client(serverId)` 创建时,请传入父 Agent 句柄或完整地址;SDK 会把父级元数据带到目标主机。 -`background` 表示创建工作区的意图。它不能与已有的 `workspaceId` 一起使用。调用方继承规则和包含后台的发现方式见[后台工作区](./workspaces.md)。 +`background` 会创建一个[后台 Agent](./agents.md#run-a-helper-as-a-background-agent)。本次调用创建的工作区也是后台工作区。带 `parent` 且省略该值时,沿用父 Agent 自己的 `background`。包含后台的发现方式见[后台工作区](./workspaces.md)。 `config` 接受: diff --git a/public-docs/sdk/workspaces.md b/public-docs/sdk/workspaces.md index 2bf1dd7ee3..e62d30ffcd 100644 --- a/public-docs/sdk/workspaces.md +++ b/public-docs/sdk/workspaces.md @@ -61,10 +61,10 @@ remain durable, with ordinary history, lifecycle hooks, archive, and exact-ID ac sidebar offers **Show background** without dropping replicated state. `background` is creation-only. Omission defaults to false; when `callerAgentId` identifies an agent -creating a new workspace, omission inherits its workspace's value. Explicit false or true overrides -inheritance. Agent creation with `workspaceId` rejects explicit background creation intent; configure -visibility when creating the workspace. SDK/RPC implicit agent creation also accepts `background` -as workspace creation intent, never as an agent property. +creating a new workspace, omission inherits the caller's visibility: true when the caller agent or +its workspace is background. Explicit false or true overrides inheritance. Agent creation that +creates its workspace applies `background` to both the workspace and the agent. To hide one agent +in an existing workspace, see [background agents](./agents.md#run-a-helper-as-a-background-agent). Creation and inclusive discovery require `features.backgroundWorkspaces`; the client rejects an unsupported host with an update-host message. @@ -135,8 +135,8 @@ do { Background workspaces are left out unless `filter.includeBackground` is true. Use `client.workspaces.list({ filter: { includeBackground: true } })` or -`client.agents.list({ filter: { includeBackground: true } })`. Workspace descriptors carry -`background`; agents derive visibility from their workspace and have no separate setting. +`client.agents.list({ filter: { includeBackground: true } })`. Workspace and agent snapshots carry +`background`. An agent is hidden when it or its workspace is background. ## Refresh and archive a handle diff --git a/public-docs/sdk/workspaces.zh-CN.md b/public-docs/sdk/workspaces.zh-CN.md index 0fb887b4e9..c61ff30d80 100644 --- a/public-docs/sdk/workspaces.zh-CN.md +++ b/public-docs/sdk/workspaces.zh-CN.md @@ -54,7 +54,7 @@ const workspace = await client.workspaces.create({ 传入 `background: true` 可以让工作区不出现在默认的发现结果中。它的 Agent 和终端仍然是持久的,有正常的历史记录、生命周期钩子、归档和精确 ID 访问。应用侧边栏提供 **显示后台工作区**,不会丢弃已同步的状态。 -`background` 只能在创建时设置。省略时默认为 false;当 `callerAgentId` 指明是某个 Agent 在创建新工作区时,省略则继承该 Agent 所在工作区的值。显式的 false 或 true 会覆盖继承。带 `workspaceId` 创建 Agent 时会拒绝显式的后台创建意图;请在创建工作区时配置可见性。SDK/RPC 的隐式 Agent 创建也接受 `background`,但它表示工作区的创建意图,而不是 Agent 的属性。 +`background` 只能在创建时设置。省略时默认为 false;当 `callerAgentId` 指明是某个 Agent 在创建新工作区时,省略则继承调用方的可见性:调用方 Agent 或其工作区为后台时即为 true。显式的 false 或 true 会覆盖继承。同时创建工作区的 Agent 创建请求会把 `background` 同时应用到工作区和 Agent。要在已有工作区中隐藏单个 Agent,见[后台 Agent](./agents.md#run-a-helper-as-a-background-agent)。 创建和包含后台工作区的发现都需要 `features.backgroundWorkspaces`;对于不支持的主机,客户端会拒绝并提示更新主机。 @@ -122,7 +122,7 @@ do { } while (cursor); ``` -除非 `filter.includeBackground` 为 true,否则后台工作区不会被列出。使用 `client.workspaces.list({ filter: { includeBackground: true } })` 或 `client.agents.list({ filter: { includeBackground: true } })`。工作区描述符带有 `background`;Agent 的可见性来自它所在的工作区,没有单独的设置。 +除非 `filter.includeBackground` 为 true,否则后台工作区不会被列出。使用 `client.workspaces.list({ filter: { includeBackground: true } })` 或 `client.agents.list({ filter: { includeBackground: true } })`。工作区和 Agent 快照都带有 `background`。Agent 本身或其工作区为后台时,Agent 会被隐藏。 ## 刷新和归档句柄 {#refresh-and-archive-a-handle}