mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
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
This commit is contained in:
+30
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
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<SeededWorkspace, "client">,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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, "\\$&");
|
||||
}
|
||||
@@ -118,6 +118,7 @@ export interface SeedDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
labels?: Record<string, string>;
|
||||
background?: boolean;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<StoredAgent["turn"]> {
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
projectPlacement?: ProjectPlacementPayload | null;
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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<string, Agent> | undefined;
|
||||
agentDetails: Map<string, Agent> | 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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export function addLsOptions(cmd: Command): Command {
|
||||
.description("List agents. By default excludes archived agents.")
|
||||
.option("-a, --all", "Include archived and background agents")
|
||||
.option("-g, --global", "List agents across all directories")
|
||||
.option("--background", "Include background workspace agents")
|
||||
.option("--background", "Include background agents and agents in background workspaces")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Filter by label (can be used multiple times)",
|
||||
@@ -35,6 +35,7 @@ export interface AgentListItem {
|
||||
status: string;
|
||||
cwd: string;
|
||||
created: string;
|
||||
background: boolean;
|
||||
}
|
||||
|
||||
/** Helper to get relative time string */
|
||||
@@ -89,6 +90,15 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Inclusive listings mark background agents so they read apart from visible ones. */
|
||||
const agentLsInclusiveSchema: OutputSchema<AgentListItem> = {
|
||||
...agentLsSchema,
|
||||
columns: [
|
||||
...agentLsSchema.columns,
|
||||
{ header: "BACKGROUND", field: (item) => (item.background ? "yes" : "-"), width: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
/** Transform agent snapshot to AgentListItem */
|
||||
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model);
|
||||
@@ -101,6 +111,7 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
status: agent.status,
|
||||
cwd: shortenPath(agent.cwd),
|
||||
created: relativeTime(agent.createdAt),
|
||||
background: agent.background === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,7 +270,7 @@ export async function listAgents(
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: agentLsSchema,
|
||||
schema: options.all || options.background ? agentLsInclusiveSchema : agentLsSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
@@ -145,6 +145,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.workspaceSetupBlocked]: true,
|
||||
[CLIENT_CAPS.workspaceSetupObservation]: true,
|
||||
[CLIENT_CAPS.pluginBinaryPayloads]: true,
|
||||
[CLIENT_CAPS.backgroundAgents]: true,
|
||||
[CLIENT_CAPS.explicitEventSubscriptions]: true,
|
||||
} satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
|
||||
|
||||
|
||||
@@ -1110,6 +1110,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
workspace_setup_blocked: true,
|
||||
workspace_setup_observation: true,
|
||||
hello_rejection: true,
|
||||
background_agents: true,
|
||||
browser_host: {
|
||||
supportedCommands: ["list_tabs"],
|
||||
hostKind: "desktop app",
|
||||
|
||||
@@ -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<FetchWorkspacesEntry | null> {
|
||||
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<AgentSnapshotPayload> {
|
||||
if (options.git !== undefined || options.worktreeName !== undefined) {
|
||||
throw new Error(
|
||||
"Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) or create a workspace first and pass workspaceId.",
|
||||
);
|
||||
}
|
||||
if (options.background !== undefined) this.requireBackgroundWorkspaces();
|
||||
if (options.background !== undefined) this.requireBackgroundCreation(options);
|
||||
const result = await this.creations.createAgent({
|
||||
...options,
|
||||
config: resolveAgentConfig(options),
|
||||
|
||||
@@ -747,6 +747,88 @@ test("agents.create with internal fails before sending on a host without interna
|
||||
await client.close();
|
||||
});
|
||||
|
||||
async function createWorkspaceHandle(features: Record<string, boolean>) {
|
||||
const { client, ws } = await connectClient(features);
|
||||
const creating = client.workspaces.create({
|
||||
source: { kind: "directory", path: "/repo/sdk" },
|
||||
});
|
||||
const request = parseSentSessionMessage(ws.sent.at(-1));
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: createWorkspace({ id: "workspace_open" }),
|
||||
setupTerminalId: null,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { client, ws, workspace: await creating };
|
||||
}
|
||||
|
||||
test("a background agent in an existing workspace fails before sending on a host without background agents", async () => {
|
||||
const { client, ws, workspace } = await createWorkspaceHandle({
|
||||
providerUsageList: true,
|
||||
providersSnapshotCwd: true,
|
||||
ownedSubscriptions: true,
|
||||
backgroundWorkspaces: true,
|
||||
});
|
||||
const sentBefore = ws.sent.length;
|
||||
|
||||
await expect(
|
||||
workspace.agents.create({ config: { provider: "codex/gpt-5.4" }, background: true }),
|
||||
).rejects.toThrow("Update the host to use background agents.");
|
||||
expect(ws.sent).toHaveLength(sentBefore);
|
||||
|
||||
const creating = client.agents.create({
|
||||
config: { provider: "codex/gpt-5.4" },
|
||||
cwd: "/repo/sdk",
|
||||
background: true,
|
||||
});
|
||||
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
|
||||
type: "create_agent_request",
|
||||
background: true,
|
||||
});
|
||||
expect(parseSentSessionMessage(ws.sent.at(-1))).not.toHaveProperty("workspaceId");
|
||||
creating.catch(() => {});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("a background agent in an existing workspace is sent to a host with background agents", async () => {
|
||||
const { client, ws, workspace } = await createWorkspaceHandle({
|
||||
providerUsageList: true,
|
||||
providersSnapshotCwd: true,
|
||||
ownedSubscriptions: true,
|
||||
backgroundWorkspaces: true,
|
||||
backgroundAgents: true,
|
||||
});
|
||||
|
||||
const creating = workspace.agents.create({
|
||||
config: { provider: "codex/gpt-5.4" },
|
||||
background: true,
|
||||
});
|
||||
const request = parseSentSessionMessage(ws.sent.at(-1));
|
||||
expect(request).toMatchObject({
|
||||
type: "create_agent_request",
|
||||
workspaceId: "workspace_open",
|
||||
background: true,
|
||||
});
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_created",
|
||||
requestId: request.requestId,
|
||||
agentId: "agent_background",
|
||||
agent: createAgent({ id: "agent_background", workspaceId: "workspace_open" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect((await creating).id).toBe("agent_background");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
const createdAgent = createAgent();
|
||||
|
||||
@@ -264,7 +264,11 @@ export interface PaseoAgentCreateOptions {
|
||||
git?: CreateAgentRequestMessage["git"];
|
||||
worktree?: CreateAgentRequestMessage["worktree"];
|
||||
autoArchive?: CreateAgentRequestMessage["autoArchive"];
|
||||
/** Visibility intent for a NEW workspace. Existing targets reject this option. */
|
||||
/**
|
||||
* Creates a background agent: hidden from default lists, tabs, the parent's subagent track
|
||||
* and notifications, and still addressable by id. A workspace this call creates is
|
||||
* background too. With a parent and no value, the parent's own background applies.
|
||||
*/
|
||||
background?: CreateAgentRequestMessage["background"];
|
||||
requestId?: string;
|
||||
labels?: Record<string, string>;
|
||||
@@ -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<PaseoAgentCreateOptions> {
|
||||
const snapshot = parent.current() ?? (await parent.refresh())?.agent;
|
||||
return snapshot?.background ? { ...options, background: true } : options;
|
||||
}
|
||||
|
||||
function encodeParent(
|
||||
parent: string | AgentAddress | PaseoAgentHandle,
|
||||
serverId?: string,
|
||||
|
||||
@@ -51,6 +51,10 @@ export const CLIENT_CAPS = {
|
||||
pluginBinaryPayloads: "plugin_binary_payloads",
|
||||
// COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
|
||||
workspaceSetupBlocked: "workspace_setup_blocked",
|
||||
// COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor >= v0.15.0.
|
||||
// Sources without it never receive agent-level background agents in inclusive lists or
|
||||
// update subscriptions, because older apps auto-open a tab for every replicated root agent.
|
||||
backgroundAgents: "background_agents",
|
||||
browserHost: "browser_host",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -891,6 +891,9 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
providerUnavailable: z.boolean().optional(),
|
||||
// Private helper marker, retained for exact-ID snapshots and older wire messages.
|
||||
internal: z.boolean().optional(),
|
||||
// Background agent: hidden from default discovery, tabs, tracks and notifications.
|
||||
// Fixed at creation. Effective background also includes the workspace's flag.
|
||||
background: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>;
|
||||
@@ -1802,7 +1805,8 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
// in the config schema, which is reused for updates. Retained for wire parsing;
|
||||
// public internal creation is no longer supported.
|
||||
internal: z.boolean().optional(),
|
||||
// Workspace creation intent, rejected when selecting an existing workspace.
|
||||
// The created agent is a background agent. A workspace this request creates inherits it.
|
||||
// Omitted on agent-scoped creation, the caller agent's own value applies.
|
||||
background: z.boolean().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
requestId: z.string(),
|
||||
@@ -3694,6 +3698,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
// COMPAT(internalWorkspaces): added in v0.9.0; remove gate after 2027-03-17.
|
||||
internalWorkspaces: z.boolean().optional(),
|
||||
backgroundWorkspaces: z.boolean().optional(),
|
||||
// COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03.
|
||||
backgroundAgents: z.boolean().optional(),
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
connectedHosts: z.boolean().optional(),
|
||||
hostMultiplexing: z.boolean().optional(),
|
||||
@@ -7802,6 +7808,7 @@ export const WSHelloMessageSchema = z.object({
|
||||
[CLIENT_CAPS.providerSnapshotReferences]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.timelineNotifications]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.backgroundAgents]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -135,6 +135,7 @@ export async function ensureAgentLoaded(
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
background: record.background,
|
||||
env: record.launchEnv,
|
||||
});
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
|
||||
@@ -324,6 +324,8 @@ export interface CreateAgentOptions {
|
||||
// undefined is an explicit decision: the agent never appears in the sidebar.
|
||||
workspaceId: string | undefined;
|
||||
owner?: AgentOwner;
|
||||
/** Background agents stay out of discovery, tabs, tracks and attention. Fixed at creation. */
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentManagerOptions {
|
||||
@@ -387,6 +389,11 @@ export type AttentionState =
|
||||
attentionTimestamp: Date;
|
||||
};
|
||||
|
||||
/** Private helpers and background agents report to their creator, never to the user. */
|
||||
function raisesAttention(agent: Pick<ManagedAgent, "internal" | "background">): boolean {
|
||||
return !agent.internal && !agent.background;
|
||||
}
|
||||
|
||||
function resolveInitialAttention(input: AttentionState | undefined): AttentionState {
|
||||
if (input == null || !input.requiresAttention) {
|
||||
return { requiresAttention: false };
|
||||
@@ -452,6 +459,11 @@ interface ManagedAgentBase {
|
||||
* Internal agents are hidden from listings and don't trigger notifications.
|
||||
*/
|
||||
internal?: boolean;
|
||||
/**
|
||||
* Background agents are durable but hidden from default discovery and never raise attention.
|
||||
* Fixed at creation.
|
||||
*/
|
||||
background?: boolean;
|
||||
/**
|
||||
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
||||
*/
|
||||
@@ -1402,6 +1414,7 @@ export class AgentManager {
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
background: options.background,
|
||||
launchEnv: options.env ? { ...options.env } : undefined,
|
||||
historyPrimed: true,
|
||||
});
|
||||
@@ -1434,6 +1447,7 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
@@ -1466,6 +1480,7 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
@@ -1727,6 +1742,7 @@ export class AgentManager {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
background: existing.background,
|
||||
launchEnv: existing.launchEnv,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
@@ -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<string, string>;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export function toStoredAgentRecord(
|
||||
? agent.attention.attentionTimestamp.toISOString()
|
||||
: null,
|
||||
internal: options?.internal,
|
||||
...(agent.background ? { background: true } : {}),
|
||||
owner: agent.owner,
|
||||
} satisfies StoredAgentRecord;
|
||||
}
|
||||
@@ -149,7 +150,7 @@ export function toAgentPayload(
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
archivedAt: options?.archivedAt ?? null,
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
...visibilityFlags(agent),
|
||||
};
|
||||
|
||||
const usage = sanitizeUsage(agent.lastUsage);
|
||||
@@ -206,6 +207,17 @@ function buildStoredPersistenceHandle(
|
||||
return toAgentPersistenceHandle(validProviders, record.persistence);
|
||||
}
|
||||
|
||||
/** The wire carries `internal` and `background` only when set. */
|
||||
function visibilityFlags(agent: {
|
||||
internal?: boolean;
|
||||
background?: boolean;
|
||||
}): Pick<AgentSnapshotPayload, "internal" | "background"> {
|
||||
return {
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
...(agent.background ? { background: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStoredAgentPayload(
|
||||
record: StoredAgentRecord,
|
||||
validProviders: Iterable<AgentProvider>,
|
||||
@@ -258,7 +270,7 @@ export function buildStoredAgentPayload(
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
attentionTimestamp: record.attentionTimestamp ?? null,
|
||||
archivedAt: record.archivedAt ?? null,
|
||||
...(record.internal ? { internal: true } : {}),
|
||||
...visibilityFlags(record),
|
||||
labels: normalizeLabels(record.labels),
|
||||
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
|
||||
...(providerAvailable ? {} : { providerUnavailable: true }),
|
||||
|
||||
@@ -75,6 +75,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
internal: z.boolean().optional(),
|
||||
background: z.boolean().optional(),
|
||||
archivedAt: z.string().nullable().optional(),
|
||||
owner: AgentOwnerSchema.optional(),
|
||||
});
|
||||
@@ -261,6 +262,8 @@ export class AgentStorage {
|
||||
if (existing && existing.archivedAt !== undefined) {
|
||||
record.archivedAt = existing.archivedAt;
|
||||
}
|
||||
// Background is fixed at creation; a runtime opened without it cannot clear it.
|
||||
if (existing?.background) record.background = true;
|
||||
record.resumeOnStartup =
|
||||
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
|
||||
return record;
|
||||
|
||||
@@ -21,7 +21,17 @@ import { resolveCreateAgentTitles } from "../create-agent-title.js";
|
||||
import { buildAgentPrompt } from "../prompt-attachments.js";
|
||||
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
||||
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
import {
|
||||
inheritAgentBackground,
|
||||
inheritWorkspaceBackground,
|
||||
resolveCreateAgentIntent,
|
||||
} from "./intent.js";
|
||||
|
||||
/** How a workspace this creation makes inherits its creator's visibility. */
|
||||
interface McpWorkspaceIntent {
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentCommandDependencies {
|
||||
finishSubscriptions?: FinishSubscriptions;
|
||||
@@ -40,7 +50,7 @@ export interface CreateAgentCommandDependencies {
|
||||
export type EnsureWorkspaceForCreate = (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
) => Promise<string>;
|
||||
|
||||
export interface CreateAgentFromSessionInput {
|
||||
@@ -49,6 +59,7 @@ export interface CreateAgentFromSessionInput {
|
||||
agentId?: string;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
background: boolean;
|
||||
initialPrompt?: string;
|
||||
source?: AgentPromptSource;
|
||||
clientMessageId?: string;
|
||||
@@ -77,6 +88,8 @@ export interface CreateAgentFromMcpInput {
|
||||
promptFailure?: CreateAgentPromptFailureMode;
|
||||
notifyOnFinish: boolean;
|
||||
internal?: boolean;
|
||||
/** Omitted with a caller, the caller agent's own background applies. */
|
||||
background?: boolean;
|
||||
detached?: boolean;
|
||||
owner?: AgentOwner;
|
||||
env?: Record<string, string>;
|
||||
@@ -255,6 +268,7 @@ async function resolveSessionCreateAgent(
|
||||
// provider session either. Same as the daemon's own ephemeral helpers.
|
||||
...(sessionConfig.internal ? { persistSession: false } : {}),
|
||||
workspaceId: requireResolvedWorkspaceId(workspaceId),
|
||||
background: input.background,
|
||||
},
|
||||
prompt: hasPromptContent ? prompt : undefined,
|
||||
source: input.source,
|
||||
@@ -277,12 +291,16 @@ async function resolveMcpCreateAgent(
|
||||
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
|
||||
: null;
|
||||
const cwd = resolveMcpInitialCwd(input, parentAgent);
|
||||
const workspaceIntent = {
|
||||
background: inheritWorkspaceBackground(input.background, parentAgent),
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
};
|
||||
const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt ?? "",
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
workspaceIntent,
|
||||
});
|
||||
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
@@ -300,7 +318,7 @@ async function resolveMcpCreateAgent(
|
||||
dependencies,
|
||||
resolvedCwd,
|
||||
input.initialPrompt ?? "",
|
||||
parentAgent?.workspaceId,
|
||||
workspaceIntent,
|
||||
),
|
||||
),
|
||||
cwd: resolvedCwd,
|
||||
@@ -329,6 +347,7 @@ async function resolveMcpCreateAgent(
|
||||
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
||||
workspaceId: intent.workspaceId,
|
||||
owner: input.owner,
|
||||
background: inheritAgentBackground(input.background, parentAgent),
|
||||
env: input.env,
|
||||
},
|
||||
prompt: trimmedPrompt ? trimmedPrompt : undefined,
|
||||
@@ -410,16 +429,12 @@ async function ensureWorkspaceForMcpCreate(
|
||||
dependencies: CreateAgentCommandDependencies,
|
||||
cwd: string,
|
||||
initialPrompt: string,
|
||||
callerWorkspaceId?: string,
|
||||
workspaceIntent: McpWorkspaceIntent,
|
||||
): Promise<string | undefined> {
|
||||
if (!dependencies.ensureWorkspaceForCreate) {
|
||||
return undefined;
|
||||
}
|
||||
return dependencies.ensureWorkspaceForCreate(
|
||||
cwd,
|
||||
{ prompt: initialPrompt },
|
||||
{ callerWorkspaceId },
|
||||
);
|
||||
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }, workspaceIntent);
|
||||
}
|
||||
|
||||
async function sendInitialPrompt(
|
||||
@@ -512,7 +527,7 @@ async function resolveMcpCwd(params: {
|
||||
dependencies: CreateAgentCommandDependencies;
|
||||
cwd: string;
|
||||
initialPrompt: string;
|
||||
callerWorkspaceId?: string;
|
||||
workspaceIntent: McpWorkspaceIntent;
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
}): Promise<{
|
||||
resolvedCwd: string;
|
||||
@@ -542,7 +557,7 @@ async function resolveMcpCwd(params: {
|
||||
const createdWorktree = await createMcpWorktree({
|
||||
input: {
|
||||
cwd: params.cwd,
|
||||
callerWorkspaceId: params.callerWorkspaceId,
|
||||
...params.workspaceIntent,
|
||||
worktreeSlug: worktree.worktreeName,
|
||||
branchName: worktree.branchName,
|
||||
refName: worktree.refName,
|
||||
|
||||
@@ -6,6 +6,30 @@ export interface CreateAgentCaller {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/** The agent on whose behalf something is created, as far as background inheritance goes. */
|
||||
export interface BackgroundCaller {
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
/** Omitted, an agent inherits its caller agent's own background; without a caller it is visible. */
|
||||
export function inheritAgentBackground(
|
||||
requested: boolean | undefined,
|
||||
caller: BackgroundCaller | null | undefined,
|
||||
): boolean {
|
||||
return requested ?? caller?.background === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Omitted, a new workspace inherits its caller's effective background. A background caller
|
||||
* agent decides it here; otherwise `undefined` lets provisioning apply the caller workspace's value.
|
||||
*/
|
||||
export function inheritWorkspaceBackground(
|
||||
requested: boolean | undefined,
|
||||
caller: BackgroundCaller | null | undefined,
|
||||
): boolean | undefined {
|
||||
return requested ?? (caller?.background === true ? true : undefined);
|
||||
}
|
||||
|
||||
export interface CreateAgentPlacement {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { buildStoredAgentPayload } from "./agent-projections.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { AgentDirectory, type BackgroundVisibility } from "./directory.js";
|
||||
import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
|
||||
const NOW = "2026-03-01T12:00:00.000Z";
|
||||
|
||||
let home: string;
|
||||
let storage: AgentStorage;
|
||||
let directory: AgentDirectory;
|
||||
|
||||
function workspace(workspaceId: string, background: boolean): PersistedWorkspaceRecord {
|
||||
return {
|
||||
workspaceId,
|
||||
projectId: "project",
|
||||
cwd: home,
|
||||
kind: "local_checkout",
|
||||
displayName: workspaceId,
|
||||
background,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), "agent-directory-"));
|
||||
storage = new AgentStorage(home, createTestLogger());
|
||||
const base = {
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
labels: {},
|
||||
lastStatus: "idle" as const,
|
||||
};
|
||||
await storage.upsert({ ...base, id: "visible", workspaceId: "open" });
|
||||
await storage.upsert({ ...base, id: "background", workspaceId: "open", background: true });
|
||||
await storage.upsert({ ...base, id: "in-background-workspace", workspaceId: "hidden" });
|
||||
await storage.upsert({ ...base, id: "internal", workspaceId: "open", internal: true });
|
||||
const workspaces = [workspace("open", false), workspace("hidden", true)];
|
||||
directory = new AgentDirectory({
|
||||
storage,
|
||||
manager: { listAgents: () => [] },
|
||||
workspaces: {
|
||||
get: async (id) => workspaces.find((entry) => entry.workspaceId === id) ?? null,
|
||||
list: async () => workspaces,
|
||||
},
|
||||
projectLive: () => {
|
||||
throw new Error("No live agents in this suite");
|
||||
},
|
||||
projectStored: (record) => buildStoredAgentPayload(record, ["codex"]),
|
||||
isProviderVisible: () => true,
|
||||
isStoredProviderAvailable: () => true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function listedIds(includeBackground: BackgroundVisibility): Promise<string[]> {
|
||||
return (await directory.list({ includeBackground })).map((agent) => agent.id).toSorted();
|
||||
}
|
||||
|
||||
test("default lists hide agents that are background or live in a background workspace", async () => {
|
||||
expect(await listedIds(false)).toEqual(["visible"]);
|
||||
});
|
||||
|
||||
test("inclusive lists show every background agent", async () => {
|
||||
expect(await listedIds(true)).toEqual(["background", "in-background-workspace", "visible"]);
|
||||
});
|
||||
|
||||
test("workspace-only visibility hides agent-level background agents from older clients", async () => {
|
||||
expect(await listedIds("workspaces")).toEqual(["in-background-workspace", "visible"]);
|
||||
});
|
||||
|
||||
test("includes applies the same visibility to a single agent", async () => {
|
||||
const background = { workspaceId: "open", background: true };
|
||||
const inBackgroundWorkspace = { workspaceId: "hidden" };
|
||||
expect(await directory.includes(background)).toBe(false);
|
||||
expect(await directory.includes(background, "workspaces")).toBe(false);
|
||||
expect(await directory.includes(background, true)).toBe(true);
|
||||
expect(await directory.includes(inBackgroundWorkspace)).toBe(false);
|
||||
expect(await directory.includes(inBackgroundWorkspace, "workspaces")).toBe(true);
|
||||
});
|
||||
|
||||
test("prompt recency ignores background agents", async () => {
|
||||
const base = {
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
workspaceId: "open",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
labels: {},
|
||||
lastStatus: "idle" as const,
|
||||
};
|
||||
await storage.upsert({ ...base, id: "visible", lastUserMessageAt: "2026-03-02T00:00:00.000Z" });
|
||||
await storage.upsert({
|
||||
...base,
|
||||
id: "background",
|
||||
background: true,
|
||||
lastUserMessageAt: "2026-03-03T00:00:00.000Z",
|
||||
});
|
||||
expect(await directory.workspaceLastUserMessageTimes()).toEqual(
|
||||
new Map([["open", "2026-03-02T00:00:00.000Z"]]),
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,25 @@ import type { WorkspaceRegistry } from "../workspace-registry.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
|
||||
/** Public agent discovery owns workspace visibility for live and durable records. */
|
||||
/**
|
||||
* Which background agents a discovery read admits. An agent is effectively background when
|
||||
* it or its workspace is background.
|
||||
* - `false`: none, the default for lists.
|
||||
* - `true`: all of them.
|
||||
* - `"workspaces"`: only agents in background workspaces.
|
||||
* COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor
|
||||
* >= v0.15.0. Inclusive reads from sources without `background_agents` use it: older apps
|
||||
* replicate inclusively and auto-open a tab for every root agent.
|
||||
*/
|
||||
export type BackgroundVisibility = boolean | "workspaces";
|
||||
|
||||
interface DiscoverableAgent {
|
||||
workspaceId?: string;
|
||||
internal?: boolean;
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
/** Public agent discovery owns background visibility for live and durable records. */
|
||||
export class AgentDirectory {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
@@ -28,50 +46,53 @@ export class AgentDirectory {
|
||||
if (!previous || at > previous) times.set(workspaceId, at);
|
||||
};
|
||||
for (const agent of await this.deps.storage.list()) {
|
||||
if (!agent.internal && !liveIds.has(agent.id))
|
||||
if (!agent.internal && !agent.background && !liveIds.has(agent.id))
|
||||
record(agent.workspaceId, agent.lastUserMessageAt);
|
||||
}
|
||||
for (const agent of live) {
|
||||
if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
|
||||
if (!agent.internal && !agent.background)
|
||||
record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
async includes(
|
||||
agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">,
|
||||
includeBackground = false,
|
||||
agent: DiscoverableAgent,
|
||||
visibility: BackgroundVisibility = false,
|
||||
): Promise<boolean> {
|
||||
if (agent.internal) return false;
|
||||
if (includeBackground || !agent.workspaceId) return true;
|
||||
return !(await this.deps.workspaces.get(agent.workspaceId))?.background;
|
||||
const workspace = agent.workspaceId ? await this.deps.workspaces.get(agent.workspaceId) : null;
|
||||
return admits(agent, workspace?.background === true, visibility);
|
||||
}
|
||||
|
||||
async list(filter?: {
|
||||
labels?: Record<string, string>;
|
||||
includeArchived?: boolean;
|
||||
includeBackground?: boolean;
|
||||
includeBackground?: BackgroundVisibility;
|
||||
includeUnavailablePersisted?: boolean;
|
||||
}): Promise<AgentSnapshotPayload[]> {
|
||||
const live = this.deps.manager.listAgents();
|
||||
const liveIds = new Set(live.map((agent) => agent.id));
|
||||
const labels = Object.entries(filter?.labels ?? {});
|
||||
const backgroundIds = new Set(
|
||||
filter?.includeBackground
|
||||
const visibility = filter?.includeBackground ?? false;
|
||||
const backgroundWorkspaceIds = new Set(
|
||||
visibility === true
|
||||
? []
|
||||
: (await this.deps.workspaces.list())
|
||||
.filter((workspace) => workspace.background)
|
||||
.map((workspace) => workspace.workspaceId),
|
||||
);
|
||||
const matches = (agent: {
|
||||
archivedAt?: string | null;
|
||||
workspaceId?: string;
|
||||
labels?: Record<string, string>;
|
||||
}) =>
|
||||
const matches = (
|
||||
agent: DiscoverableAgent & { archivedAt?: string | null; labels?: Record<string, string> },
|
||||
) =>
|
||||
(filter?.includeArchived || !agent.archivedAt) &&
|
||||
(filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) &&
|
||||
admits(
|
||||
agent,
|
||||
agent.workspaceId !== undefined && backgroundWorkspaceIds.has(agent.workspaceId),
|
||||
visibility,
|
||||
) &&
|
||||
labels.every(([key, value]) => agent.labels?.[key] === value);
|
||||
const stored = (await this.deps.storage.list())
|
||||
.filter((record) => !record.internal && !liveIds.has(record.id) && matches(record))
|
||||
.filter((record) => !liveIds.has(record.id) && matches(record))
|
||||
.filter(
|
||||
(record) =>
|
||||
filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record),
|
||||
@@ -85,3 +106,13 @@ export class AgentDirectory {
|
||||
.filter((agent) => this.deps.isProviderVisible(agent.provider));
|
||||
}
|
||||
}
|
||||
|
||||
function admits(
|
||||
agent: DiscoverableAgent,
|
||||
inBackgroundWorkspace: boolean,
|
||||
visibility: BackgroundVisibility,
|
||||
): boolean {
|
||||
if (agent.internal) return false;
|
||||
if (agent.background) return visibility === true;
|
||||
return !inBackgroundWorkspace || visibility !== false;
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
onopen: Handler = null;
|
||||
onmessage: Handler = null;
|
||||
onclose: Handler = null;
|
||||
onerror: Handler = null;
|
||||
private readonly createSent = Promise.withResolvers<Record<string, unknown>>();
|
||||
|
||||
constructor() {
|
||||
queueMicrotask(() => this.onopen?.());
|
||||
}
|
||||
|
||||
createRequest(): Promise<Record<string, unknown>> {
|
||||
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");
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -1211,4 +1211,137 @@ describe("Suite E: Workspace Tools", () => {
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("a background agent's children and workspaces are background unless it overrides", async () => {
|
||||
const visibleAgentId = await createTopLevelAgent();
|
||||
const workspaceId = daemonHandle.daemon.agentManager.getAgent(visibleAgentId)?.workspaceId;
|
||||
const caller = await callToolStructured(topLevelClient, "create_agent", {
|
||||
workspaceId,
|
||||
title: "Background caller",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
background: true,
|
||||
});
|
||||
const callerId = str(caller.agentId);
|
||||
const background = (agentId: string) =>
|
||||
daemonHandle.daemon.agentManager.getAgent(agentId)?.background;
|
||||
expect(background(callerId)).toBe(true);
|
||||
const scoped = await createMcpClient(
|
||||
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${callerId}`,
|
||||
);
|
||||
const ownedWorkspaces: string[] = [];
|
||||
try {
|
||||
const inheritedChild = await callToolStructured(scoped, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Inherited child",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
notifyOnFinish: false,
|
||||
});
|
||||
expect(background(str(inheritedChild.agentId))).toBe(true);
|
||||
const visibleChild = await callToolStructured(scoped, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Visible child",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
notifyOnFinish: false,
|
||||
background: false,
|
||||
});
|
||||
expect(background(str(visibleChild.agentId))).toBe(false);
|
||||
const inheritedWorkspace = await callToolStructured(scoped, "create_workspace", {
|
||||
isolation: "local",
|
||||
});
|
||||
ownedWorkspaces.push(str(inheritedWorkspace.workspaceId));
|
||||
expect(inheritedWorkspace.background).toBe(true);
|
||||
} finally {
|
||||
await scoped.close();
|
||||
await archiveAgentIfPresent(callerId);
|
||||
await archiveAgentIfPresent(visibleAgentId);
|
||||
for (const id of ownedWorkspaces)
|
||||
await topLevelClient
|
||||
.callTool({ name: "archive_workspace", args: { workspaceId: id } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("a background subagent stays out of default lists, notifies its parent and archives with it", async () => {
|
||||
const callerPrompts: string[] = [];
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn(prompt, config) {
|
||||
if (config.title === "Background parent" && typeof prompt === "string")
|
||||
callerPrompts.push(prompt);
|
||||
},
|
||||
}),
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let mcp: McpClient | undefined;
|
||||
try {
|
||||
await client.connect();
|
||||
const caller = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.paseoHome,
|
||||
title: "Background parent",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
mcp = await createMcpClient(
|
||||
buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: caller.id }),
|
||||
);
|
||||
const created = await callToolStructured(mcp, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Background child",
|
||||
initialPrompt: "say done and stop",
|
||||
notifyOnFinish: true,
|
||||
background: true,
|
||||
settings: { modeId: "full-access" },
|
||||
});
|
||||
const childId = str(created.agentId);
|
||||
expect((await client.fetchAgent(childId))?.agent).toMatchObject({
|
||||
background: true,
|
||||
workspaceId: caller.workspaceId,
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: caller.id },
|
||||
});
|
||||
expect((await client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain(
|
||||
childId,
|
||||
);
|
||||
expect(
|
||||
(await client.fetchAgents({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
),
|
||||
).toContain(childId);
|
||||
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "parent finish notification",
|
||||
check: () => (callerPrompts.length > 0 ? true : null),
|
||||
});
|
||||
expect(parseAgentMessage(callerPrompts[0]).source).toMatchObject({
|
||||
kind: "agent-notification",
|
||||
agentId: childId,
|
||||
event: "finished",
|
||||
});
|
||||
expect((await client.fetchAgent(childId))?.agent.requiresAttention).toBe(false);
|
||||
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "parent idle",
|
||||
check: () =>
|
||||
daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null,
|
||||
});
|
||||
await client.archiveAgent(caller.id);
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "child archived with parent",
|
||||
check: async () => ((await client.fetchAgent(childId))?.agent.archivedAt ? true : null),
|
||||
});
|
||||
} finally {
|
||||
await mcp?.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -1647,11 +1647,11 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: existingCwd }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects public internal helpers and the old top-level execution background option", async () => {
|
||||
it("rejects public internal helpers", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = new AgentManager({
|
||||
@@ -1669,9 +1669,6 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" };
|
||||
await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/);
|
||||
await expect(tool.handler({ ...input, background: true })).rejects.toThrow(
|
||||
/Unrecognized key/,
|
||||
);
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
@@ -1793,7 +1790,7 @@ describe("create_agent MCP tool", () => {
|
||||
cwd: existingCwd,
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "wks_existing" },
|
||||
{ workspaceId: "wks_existing", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1837,7 +1834,7 @@ describe("create_agent MCP tool", () => {
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2033,7 +2030,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Fix auth bug",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2068,7 +2065,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Fix auth",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2112,6 +2109,7 @@ describe("create_agent MCP tool", () => {
|
||||
{
|
||||
labels: { source: "mcp" },
|
||||
workspaceId: "workspace-created",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -2214,7 +2212,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Directory agent",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-directory-auto-title" },
|
||||
{ workspaceId: "workspace-directory-auto-title", background: false },
|
||||
);
|
||||
expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({
|
||||
title: "Directory Workspace Title",
|
||||
@@ -2828,12 +2826,13 @@ describe("create_agent MCP tool", () => {
|
||||
source: "voice",
|
||||
},
|
||||
workspaceId: "wks_voice",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
await rm(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects background from caller agents and defaults notify-on-finish on", async () => {
|
||||
it("defaults notify-on-finish on for caller agents", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
@@ -2853,16 +2852,6 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await expect(
|
||||
tool.handler({
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: false,
|
||||
}),
|
||||
).rejects.toThrow(/Unrecognized key/);
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Child",
|
||||
@@ -3019,6 +3008,7 @@ describe("create_agent MCP tool", () => {
|
||||
source: "handoff",
|
||||
},
|
||||
workspaceId: "wks_parent",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3080,6 +3070,7 @@ describe("create_agent MCP tool", () => {
|
||||
[PARENT_AGENT_ID_LABEL]: "parent-agent",
|
||||
},
|
||||
workspaceId: "wks_parent",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3218,6 +3209,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(agentIdArg).toBeUndefined();
|
||||
expect(optionsArg).toEqual({
|
||||
workspaceId: "workspace-created",
|
||||
background: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3295,7 +3287,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "dynamic" }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3334,7 +3326,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type ArchiveDependencies,
|
||||
} from "../../workspace-archive-service.js";
|
||||
import { createAgentCommand } from "../create-agent/create.js";
|
||||
import { inheritWorkspaceBackground } from "../create-agent/intent.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
|
||||
import type { FirstAgentContext } from "../../messages.js";
|
||||
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
|
||||
@@ -153,7 +154,7 @@ export interface PaseoToolHostDependencies {
|
||||
ensureWorkspaceForCreate?: (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
) => Promise<string>;
|
||||
browserToolsEnabled?: boolean;
|
||||
browserToolsBroker?: BrowserToolsBroker | null;
|
||||
@@ -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<AgentSessionConfig, "providerOptions"> | 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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1119,7 +1119,7 @@ export async function createPaseoDaemon(
|
||||
const ensureWorkspaceForCreateExternal = async (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
): Promise<string> => {
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
@@ -1172,7 +1172,7 @@ export async function createPaseoDaemon(
|
||||
const ensureWorkspaceForCreateAndBroadcastExternal = async (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
): Promise<string> => {
|
||||
const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext, context);
|
||||
await emitWorkspaceUpdatesExternal([workspaceId]);
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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 },
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WorkspaceDescriptorPayload,
|
||||
WorkspaceProjectDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { BackgroundVisibility } from "../agent/directory.js";
|
||||
import { type CollectionRead, VersionedCollection } from "./internal/versioned-collection.js";
|
||||
|
||||
interface AgentDirectoryEntry {
|
||||
@@ -69,7 +70,7 @@ export class DirectorySyncService {
|
||||
synchronizeWorkspaces(
|
||||
snapshot: Iterable<WorkspaceDescriptorPayload>,
|
||||
cursor: DirectorySyncCursor,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> {
|
||||
this.workspaces.replaceAll(snapshot);
|
||||
const read = this.read(this.workspaces, cursor, includeBackground);
|
||||
@@ -84,7 +85,7 @@ export class DirectorySyncService {
|
||||
synchronizeAgents(
|
||||
snapshot: Iterable<AgentDirectoryEntry>,
|
||||
cursor: DirectorySyncCursor,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> {
|
||||
this.agents.replaceAll(snapshot);
|
||||
const read = this.read(this.agents, cursor, includeBackground);
|
||||
@@ -113,7 +114,7 @@ export class DirectorySyncService {
|
||||
value: WorkspaceDescriptorPayload | null,
|
||||
id: string,
|
||||
includeSequence: boolean,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): T & Partial<DirectoryVersion> {
|
||||
if (value) {
|
||||
this.workspaces.replace(value);
|
||||
@@ -133,7 +134,7 @@ export class DirectorySyncService {
|
||||
value: AgentDirectoryEntry | null,
|
||||
id: string,
|
||||
includeSequence: boolean,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): T & Partial<DirectoryVersion> {
|
||||
if (value) {
|
||||
this.agents.replace(value);
|
||||
@@ -148,7 +149,7 @@ export class DirectorySyncService {
|
||||
);
|
||||
}
|
||||
|
||||
private metadata(read: CollectionRead<unknown>, includeBackground = false) {
|
||||
private metadata(read: CollectionRead<unknown>, includeBackground: BackgroundVisibility = false) {
|
||||
return {
|
||||
generation: this.generationFor(includeBackground),
|
||||
headSeq: read.headSeq,
|
||||
@@ -162,7 +163,7 @@ export class DirectorySyncService {
|
||||
payload: T,
|
||||
includeSequence: boolean,
|
||||
version: DirectoryVersion | null,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): T & Partial<DirectoryVersion> {
|
||||
return version && includeSequence
|
||||
? { ...payload, ...version, generation: this.generationFor(includeBackground) }
|
||||
@@ -172,7 +173,7 @@ export class DirectorySyncService {
|
||||
private read<T>(
|
||||
collection: VersionedCollection<T>,
|
||||
cursor: DirectorySyncCursor,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): CollectionRead<T> {
|
||||
if (cursor.generation !== this.generationFor(includeBackground)) {
|
||||
return collection.readSnapshot(
|
||||
@@ -185,7 +186,9 @@ export class DirectorySyncService {
|
||||
return collection.readAfter(cursor.afterSeq);
|
||||
}
|
||||
|
||||
private generationFor(includeBackground: boolean): string {
|
||||
// Each visibility scope admits a different subset, so cursors never cross scopes.
|
||||
private generationFor(includeBackground: BackgroundVisibility): string {
|
||||
if (includeBackground === "workspaces") return `${this.generation}:background-workspaces`;
|
||||
return includeBackground ? `${this.generation}:background` : this.generation;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: StoredAgentRecord["owner"];
|
||||
background?: boolean;
|
||||
} {
|
||||
return {
|
||||
createdAt: new Date(record.createdAt),
|
||||
@@ -149,6 +150,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
background: record.background,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type { CreateAgentOptions } from "./test-utils/index.js";
|
||||
import type { CreateAgentWorktreeTarget } from "./messages.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
const tempRoots: string[] = [];
|
||||
@@ -477,7 +478,7 @@ test("background workspaces keep durable agents addressable through restart and
|
||||
),
|
||||
).toContain(hidden.id);
|
||||
const child = await ctx.client.createAgent({ config, callerAgentId: hidden.id });
|
||||
expect(child.workspaceId).toBe(hidden.workspaceId);
|
||||
expect(child).toMatchObject({ workspaceId: hidden.workspaceId, background: true });
|
||||
const inherited = await ctx.client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
callerAgentId: hidden.id,
|
||||
@@ -509,9 +510,16 @@ test("background workspaces keep durable agents addressable through restart and
|
||||
false,
|
||||
);
|
||||
|
||||
await expect(
|
||||
ctx.client.createAgent({ config, workspaceId: hidden.workspaceId, background: false }),
|
||||
).rejects.toThrow("workspace creation intent");
|
||||
expect(hidden.background).toBe(true);
|
||||
const visibleInHidden = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: hidden.workspaceId,
|
||||
background: false,
|
||||
});
|
||||
expect(visibleInHidden.background).toBeUndefined();
|
||||
expect((await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain(
|
||||
visibleInHidden.id,
|
||||
);
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
expect(
|
||||
@@ -616,3 +624,111 @@ test("default and inclusive observers retain independent snapshots, live updates
|
||||
await otherClient.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("a background agent in a visible workspace is hidden from default discovery through restart", async () => {
|
||||
const cwd = createGitRepo();
|
||||
const config = { ...getFullAccessConfig("codex"), cwd };
|
||||
const visible = await ctx.client.createAgent({ config });
|
||||
const hidden = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: visible.workspaceId,
|
||||
background: true,
|
||||
});
|
||||
expect(hidden).toMatchObject({ workspaceId: visible.workspaceId, background: true });
|
||||
expect(visible.background).toBeUndefined();
|
||||
const defaultIds = async () =>
|
||||
(await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id);
|
||||
const inclusiveIds = async () =>
|
||||
(await ctx.client.fetchAgents({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
);
|
||||
expect(await defaultIds()).toEqual([visible.id]);
|
||||
expect(await inclusiveIds()).toEqual(expect.arrayContaining([visible.id, hidden.id]));
|
||||
expect((await ctx.client.fetchWorkspaces()).entries).toContainEqual(
|
||||
expect.objectContaining({ id: visible.workspaceId, background: false }),
|
||||
);
|
||||
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
expect((await ctx.client.fetchAgent(hidden.id))?.agent).toMatchObject({
|
||||
background: true,
|
||||
requiresAttention: false,
|
||||
});
|
||||
|
||||
const homeRoot = path.dirname(ctx.daemon.paseoHome);
|
||||
tempRoots.push(ctx.daemon.staticDir);
|
||||
await ctx.client.close();
|
||||
await ctx.daemon.daemon.stop();
|
||||
await ctx.daemon.daemon.agentManager.flush();
|
||||
ctx = await createDaemonTestContext({ paseoHomeRoot: homeRoot });
|
||||
|
||||
expect(await defaultIds()).toEqual([visible.id]);
|
||||
expect(await inclusiveIds()).toContain(hidden.id);
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done again.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
expect((await ctx.client.fetchAgent(hidden.id))?.agent.background).toBe(true);
|
||||
expect(await defaultIds()).toEqual([visible.id]);
|
||||
await ctx.client.archiveAgent(hidden.id);
|
||||
expect(
|
||||
(await ctx.client.fetchAgentHistory()).entries.map((entry) => entry.agent.id),
|
||||
).not.toContain(hidden.id);
|
||||
expect(
|
||||
(await ctx.client.fetchAgentHistory({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
),
|
||||
).toContain(hidden.id);
|
||||
}, 30_000);
|
||||
|
||||
test("a client without background_agents never receives background agents, even in inclusive reads", async () => {
|
||||
const oldApp = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
capabilities: { [CLIENT_CAPS.backgroundAgents]: false },
|
||||
});
|
||||
await oldApp.connect();
|
||||
try {
|
||||
const cwd = createGitRepo();
|
||||
const config = { ...getFullAccessConfig("codex"), cwd };
|
||||
const visible = await ctx.client.createAgent({ config });
|
||||
const hidden = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: visible.workspaceId,
|
||||
background: true,
|
||||
});
|
||||
const backgroundWorkspace = await ctx.client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
background: true,
|
||||
});
|
||||
const workspaceAgent = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: backgroundWorkspace.workspace?.id,
|
||||
});
|
||||
const observed = oldApp.observeAgents({ scope: "active", sync: {}, includeBackground: true });
|
||||
const snapshot = await observed.ready;
|
||||
expect(snapshot.entries.map((entry) => entry.agent.id).toSorted()).toEqual(
|
||||
[visible.id, workspaceAgent.id].toSorted(),
|
||||
);
|
||||
const upserts: string[] = [];
|
||||
observed.subscribe({
|
||||
snapshot: () => {},
|
||||
update: (message) => {
|
||||
if (message.type === "agent_update" && message.payload.kind === "upsert")
|
||||
upserts.push(message.payload.agent.id);
|
||||
},
|
||||
});
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
await ctx.client.sendAgentMessage(visible.id, "Say done.");
|
||||
await ctx.client.waitForFinish(visible.id, 10_000);
|
||||
await expect.poll(() => upserts).toContain(visible.id);
|
||||
expect(upserts).not.toContain(hidden.id);
|
||||
expect(
|
||||
(await oldApp.fetchAgents({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
),
|
||||
).not.toContain(hidden.id);
|
||||
expect((await oldApp.fetchAgent(hidden.id))?.agent.id).toBe(hidden.id);
|
||||
await observed.release();
|
||||
} finally {
|
||||
await oldApp.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
|
||||
import { AgentDirectory } from "./agent/directory.js";
|
||||
import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js";
|
||||
import { ConnectedHostSession } from "./connected-hosts/index.js";
|
||||
import type { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import { searchTimeline } from "./agent/chat-search/index.js";
|
||||
@@ -119,7 +119,12 @@ import type {
|
||||
ManagedAgent,
|
||||
} from "./agent/agent-manager.js";
|
||||
import { createAgentCommand } from "./agent/create-agent/create.js";
|
||||
import { resolveCreateAgentIntent, type CreateAgentIntent } from "./agent/create-agent/intent.js";
|
||||
import {
|
||||
inheritAgentBackground,
|
||||
inheritWorkspaceBackground,
|
||||
resolveCreateAgentIntent,
|
||||
type CreateAgentIntent,
|
||||
} from "./agent/create-agent/intent.js";
|
||||
import {
|
||||
archiveAgentCommand,
|
||||
cancelAgentRunCommand,
|
||||
@@ -388,6 +393,7 @@ type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_
|
||||
interface ResolvedSessionCreateAgentIntent {
|
||||
config: AgentSessionConfig;
|
||||
intent: CreateAgentIntent;
|
||||
background: boolean;
|
||||
createdDirectoryWorkspace: boolean;
|
||||
}
|
||||
|
||||
@@ -1162,8 +1168,8 @@ export class Session {
|
||||
),
|
||||
});
|
||||
this.agentUpdates = createAgentUpdatesService({
|
||||
includesWorkspace: (agent, includeBackground) =>
|
||||
this.agentDirectory.includes(agent, includeBackground),
|
||||
includesWorkspace: (agent, backgroundVisibility) =>
|
||||
this.agentDirectory.includes(agent, backgroundVisibility),
|
||||
emit: (message) => this.emit(message),
|
||||
enrichAgentPayload: (payload) => this.enrichAgentPayload(payload),
|
||||
buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record),
|
||||
@@ -4527,14 +4533,6 @@ export class Session {
|
||||
throw new Error(
|
||||
"Public internal agents were removed; create a background workspace instead.",
|
||||
);
|
||||
if (
|
||||
request.background !== undefined &&
|
||||
(request.workspaceId || (request.callerAgentId && !request.worktree))
|
||||
) {
|
||||
throw new Error(
|
||||
"background is workspace creation intent; configure the selected workspace at creation.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createSessionAgent(
|
||||
@@ -4585,8 +4583,7 @@ export class Session {
|
||||
cwd: config.cwd,
|
||||
target: worktree,
|
||||
firstAgentContext,
|
||||
background: msg.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId),
|
||||
...this.workspaceBackgroundIntent(msg.background, msg.callerAgentId),
|
||||
});
|
||||
createdWorktreeForCleanup = createdWorktree;
|
||||
const resolvedIntent = await this.resolveSessionCreateAgentIntent({
|
||||
@@ -4617,6 +4614,7 @@ export class Session {
|
||||
agentId,
|
||||
config: resolvedIntent.config,
|
||||
workspaceId: resolvedIntent.intent.workspaceId,
|
||||
background: resolvedIntent.background,
|
||||
initialPrompt,
|
||||
source: msg.callerAgentId
|
||||
? { kind: "agent-message", agentId: msg.callerAgentId }
|
||||
@@ -4701,7 +4699,7 @@ export class Session {
|
||||
createdWorktree: null,
|
||||
cwd: config.cwd,
|
||||
initialTitle: input.workspacePromptTitle,
|
||||
background: request.background,
|
||||
background: inheritWorkspaceBackground(request.background, callerAgent),
|
||||
callerWorkspaceId: callerAgent?.workspaceId,
|
||||
}),
|
||||
cwd: config.cwd,
|
||||
@@ -4712,6 +4710,7 @@ export class Session {
|
||||
return {
|
||||
config,
|
||||
intent,
|
||||
background: inheritAgentBackground(request.background, callerAgent),
|
||||
createdDirectoryWorkspace: !createdWorktree && !request.workspaceId && !callerAgent,
|
||||
};
|
||||
}
|
||||
@@ -5663,7 +5662,22 @@ export class Session {
|
||||
return matchedEntries;
|
||||
}
|
||||
|
||||
private async listFetchAgentsEntries(request: AgentDirectoryRequestMessage): Promise<{
|
||||
/**
|
||||
* Inclusive discovery for the requesting source. Exact-ID reads never pass through this.
|
||||
* COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor
|
||||
* >= v0.15.0. Sources without the capability see background workspaces' agents only.
|
||||
*/
|
||||
private resolveBackgroundVisibility(
|
||||
includeBackground: boolean | undefined,
|
||||
): BackgroundVisibility {
|
||||
if (!includeBackground) return false;
|
||||
return this.supports(CLIENT_CAPS.backgroundAgents) ? true : "workspaces";
|
||||
}
|
||||
|
||||
private async listFetchAgentsEntries(
|
||||
request: AgentDirectoryRequestMessage,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
): Promise<{
|
||||
entries: FetchAgentsResponseEntry[];
|
||||
pageInfo: FetchAgentsResponsePageInfo;
|
||||
searchTruncated?: boolean;
|
||||
@@ -5679,9 +5693,7 @@ export class Session {
|
||||
let agents = await this.listAgentPayloads({
|
||||
labels: filter?.labels,
|
||||
includeArchived: filter?.includeArchived,
|
||||
includeBackground:
|
||||
filter?.includeBackground ||
|
||||
(request.type === "fetch_agents_request" && request.includeBackground),
|
||||
includeBackground: backgroundVisibility,
|
||||
includeUnavailablePersisted: request.type === "fetch_agent_history_request",
|
||||
});
|
||||
const activePlacementsByWorkspaceId =
|
||||
@@ -6415,16 +6427,17 @@ export class Session {
|
||||
})
|
||||
: null;
|
||||
const subscriptionId = owner?.responseId;
|
||||
const backgroundVisibility = this.resolveBackgroundVisibility(
|
||||
request.includeBackground || request.filter?.includeBackground,
|
||||
);
|
||||
try {
|
||||
if (owner) {
|
||||
this.agentUpdates.beginSubscription({
|
||||
subscriptionId: owner.id,
|
||||
isProviderVisible: (provider) =>
|
||||
this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)),
|
||||
filter: {
|
||||
...request.filter,
|
||||
...(request.includeBackground ? { includeBackground: true } : {}),
|
||||
},
|
||||
filter: request.filter,
|
||||
backgroundVisibility,
|
||||
syncEnabled: Boolean(request.sync),
|
||||
emit: (message) => {
|
||||
if (message.type === "agent_update") owner.emit(message);
|
||||
@@ -6434,8 +6447,8 @@ export class Session {
|
||||
}
|
||||
|
||||
const payload = request.sync
|
||||
? await this.readAgentDirectorySync(request)
|
||||
: await this.listFetchAgentsEntries(request);
|
||||
? await this.readAgentDirectorySync(request, backgroundVisibility)
|
||||
: await this.listFetchAgentsEntries(request, backgroundVisibility);
|
||||
const snapshotUpdatedAtByAgentId = new Map<string, number>();
|
||||
for (const entry of payload.entries) {
|
||||
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
|
||||
@@ -6479,7 +6492,10 @@ export class Session {
|
||||
request: Extract<SessionInboundMessage, { type: "fetch_agent_history_request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const payload = await this.listFetchAgentsEntries(request);
|
||||
const payload = await this.listFetchAgentsEntries(
|
||||
request,
|
||||
this.resolveBackgroundVisibility(request.filter?.includeBackground),
|
||||
);
|
||||
this.emit({
|
||||
type: "fetch_agent_history_response",
|
||||
payload: {
|
||||
@@ -6844,6 +6860,7 @@ export class Session {
|
||||
|
||||
private async readAgentDirectorySync(
|
||||
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
) {
|
||||
if (request.scope !== "active" || request.filter) {
|
||||
throw new SessionRequestError(
|
||||
@@ -6851,19 +6868,19 @@ export class Session {
|
||||
"Sequenced agent directory reads require scope=active and no filter.",
|
||||
);
|
||||
}
|
||||
const snapshot = await this.listFetchAgentsEntries({
|
||||
...request,
|
||||
includeBackground: true,
|
||||
page: { limit: Number.MAX_SAFE_INTEGER },
|
||||
});
|
||||
// The canonical collection is complete; only the read below is scoped.
|
||||
const snapshot = await this.listFetchAgentsEntries(
|
||||
{ ...request, page: { limit: Number.MAX_SAFE_INTEGER } },
|
||||
true,
|
||||
);
|
||||
const result = this.directorySync.synchronizeAgents(
|
||||
snapshot.entries,
|
||||
request.sync ?? {},
|
||||
request.includeBackground,
|
||||
backgroundVisibility,
|
||||
);
|
||||
const entries = await Promise.all(
|
||||
result.entries.map(async (entry) =>
|
||||
(await this.agentDirectory.includes(entry.agent, request.includeBackground)) ? entry : null,
|
||||
(await this.agentDirectory.includes(entry.agent, backgroundVisibility)) ? entry : null,
|
||||
),
|
||||
);
|
||||
return { ...result, entries: entries.filter((entry) => entry !== null) };
|
||||
@@ -6964,11 +6981,22 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private getCallerWorkspaceId(callerAgentId?: string): string | undefined {
|
||||
private getCallerAgent(callerAgentId?: string): ManagedAgent | undefined {
|
||||
if (!callerAgentId) return undefined;
|
||||
const caller = this.agentManager.getAgent(callerAgentId);
|
||||
if (!caller) throw new Error(`Caller agent ${callerAgentId} not found`);
|
||||
return caller.workspaceId;
|
||||
return caller;
|
||||
}
|
||||
|
||||
private workspaceBackgroundIntent(
|
||||
background: boolean | undefined,
|
||||
callerAgentId: string | undefined,
|
||||
): { background?: boolean; callerWorkspaceId?: string } {
|
||||
const caller = this.getCallerAgent(callerAgentId);
|
||||
return {
|
||||
background: inheritWorkspaceBackground(background, caller),
|
||||
callerWorkspaceId: caller?.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
private async createRequestedWorkspace(
|
||||
@@ -7017,8 +7045,7 @@ export class Session {
|
||||
{
|
||||
expectsInitialAgent: Boolean(request.firstAgentContext),
|
||||
workspaceId,
|
||||
background: request.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
|
||||
...this.workspaceBackgroundIntent(request.background, request.callerAgentId),
|
||||
},
|
||||
);
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
@@ -7081,8 +7108,7 @@ export class Session {
|
||||
githubPrNumber: source.githubPrNumber,
|
||||
firstAgentContext: request.firstAgentContext,
|
||||
title: request.title,
|
||||
background: request.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
|
||||
...this.workspaceBackgroundIntent(request.background, request.callerAgentId),
|
||||
},
|
||||
source.baseBranch
|
||||
? { resolveDefaultBranch: async () => source.baseBranch as string }
|
||||
@@ -7900,7 +7926,8 @@ export class Session {
|
||||
);
|
||||
const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const));
|
||||
const candidates = agents
|
||||
.filter((agent) => !agent.archivedAt && agent.workspaceId === workspace.workspaceId)
|
||||
.filter((agent) => !agent.archivedAt && !agent.background)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId)
|
||||
.filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id)
|
||||
.filter((agent) => agent.status === "idle" || agent.status === "closed")
|
||||
.filter((agent) => agent.requiresAttention !== true)
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "../../messages.js";
|
||||
import type { ManagedAgent } from "../../agent/agent-manager.js";
|
||||
import type { StoredAgentRecord } from "../../agent/agent-storage.js";
|
||||
import type { BackgroundVisibility } from "../../agent/directory.js";
|
||||
import { resolveEffectiveThinkingOptionId, toAgentPayload } from "../../agent/agent-projections.js";
|
||||
|
||||
type AgentUpdatePayload = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||
@@ -19,6 +20,7 @@ interface AgentUpdatesSubscriptionState {
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
syncEnabled: boolean;
|
||||
filter?: AgentUpdatesFilter;
|
||||
backgroundVisibility: BackgroundVisibility;
|
||||
isProviderVisible: (provider: string) => boolean;
|
||||
isBootstrapping: boolean;
|
||||
pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>;
|
||||
@@ -31,6 +33,7 @@ export interface AgentUpdatesService {
|
||||
isProviderVisible?: (provider: string) => boolean;
|
||||
emit?: (message: SessionOutboundMessage) => void;
|
||||
filter?: AgentUpdatesFilter;
|
||||
backgroundVisibility?: BackgroundVisibility;
|
||||
syncEnabled?: boolean;
|
||||
}): void;
|
||||
flushBootstrapped(
|
||||
@@ -47,7 +50,10 @@ export interface AgentUpdatesService {
|
||||
}
|
||||
|
||||
export interface AgentUpdatesServiceDeps {
|
||||
includesWorkspace?(agent: AgentSnapshotPayload, includeBackground?: boolean): Promise<boolean>;
|
||||
includesWorkspace?(
|
||||
agent: AgentSnapshotPayload,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
): Promise<boolean>;
|
||||
emit(message: SessionOutboundMessage): void;
|
||||
enrichAgentPayload(payload: AgentSnapshotPayload): Promise<AgentSnapshotPayload>;
|
||||
buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload;
|
||||
@@ -60,7 +66,7 @@ export interface AgentUpdatesServiceDeps {
|
||||
project: ProjectPlacementPayload | null,
|
||||
agentId: string,
|
||||
includeSequence: boolean,
|
||||
includeBackground?: boolean,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
): T;
|
||||
logger: pino.Logger;
|
||||
}
|
||||
@@ -173,7 +179,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
project,
|
||||
agentId,
|
||||
sub.syncEnabled === true,
|
||||
sub.filter?.includeBackground,
|
||||
sub.backgroundVisibility,
|
||||
);
|
||||
|
||||
function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void {
|
||||
@@ -194,10 +200,12 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
isProviderVisible?: (provider: string) => boolean;
|
||||
emit?: (message: SessionOutboundMessage) => void;
|
||||
filter?: AgentUpdatesFilter;
|
||||
backgroundVisibility?: BackgroundVisibility;
|
||||
syncEnabled?: boolean;
|
||||
}): void {
|
||||
subscriptions.set(input.subscriptionId, {
|
||||
...input,
|
||||
backgroundVisibility: input.backgroundVisibility ?? false,
|
||||
emit: input.emit ?? deps.emit,
|
||||
syncEnabled: input.syncEnabled ?? false,
|
||||
isProviderVisible: input.isProviderVisible ?? deps.isProviderVisibleToClient,
|
||||
@@ -249,9 +257,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
|
||||
: null;
|
||||
const backgroundVisible = await Promise.all(
|
||||
observers.map(
|
||||
(sub) => deps.includesWorkspace?.(payload, sub.filter?.includeBackground) ?? true,
|
||||
),
|
||||
observers.map((sub) => deps.includesWorkspace?.(payload, sub.backgroundVisibility) ?? true),
|
||||
);
|
||||
return (
|
||||
project !== null &&
|
||||
@@ -273,7 +279,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
for (const sub of observers) {
|
||||
const matches =
|
||||
project &&
|
||||
((await deps.includesWorkspace?.(payload, sub.filter?.includeBackground)) ?? true) &&
|
||||
((await deps.includesWorkspace?.(payload, sub.backgroundVisibility)) ?? true) &&
|
||||
matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter });
|
||||
bufferOrEmit(
|
||||
sub,
|
||||
|
||||
@@ -2013,6 +2013,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
creationLifecycle: true,
|
||||
hubAgentRpc: true,
|
||||
backgroundWorkspaces: true,
|
||||
// COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03.
|
||||
backgroundAgents: true,
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
directorySync: true,
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
|
||||
@@ -262,6 +262,7 @@ interface AgentState {
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: AgentSnapshotPayload["attentionReason"];
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
function createAgent(
|
||||
@@ -273,6 +274,7 @@ function createAgent(
|
||||
provider: "codex",
|
||||
cwd: input.cwd,
|
||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||
...(input.background ? { background: true } : {}),
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
@@ -330,6 +332,15 @@ describe("WorkspaceDirectory", () => {
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||
});
|
||||
|
||||
test("a running background agent does not make its workspace running", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasRootAgent({ id: "root-agent", status: "idle" });
|
||||
workspace.hasRootAgent({ id: "classifier", status: "running", background: true });
|
||||
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("done");
|
||||
});
|
||||
|
||||
test("same-cwd workspaces attribute agent status only to the owner", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
|
||||
@@ -267,8 +267,12 @@ export class WorkspaceDirectory {
|
||||
});
|
||||
}
|
||||
|
||||
// Background agents report to their creator; they never move the workspace's status.
|
||||
const activeAgents = agents.filter(
|
||||
(agent) => !agent.archivedAt && this.deps.isProviderVisibleToClient(agent.provider),
|
||||
(agent) =>
|
||||
!agent.archivedAt &&
|
||||
!agent.background &&
|
||||
this.deps.isProviderVisibleToClient(agent.provider),
|
||||
);
|
||||
this.applyAgentBucketContributions({
|
||||
activeAgents,
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
+12
-12
@@ -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.
|
||||
|
||||
|
||||
+12
-12
@@ -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` 查看结果。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)` 对其中一台主机
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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` 接受:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user