feat(workspaces): add background visibility (#5039)

* feat(server): let clients create internal agents

The daemon's own branch-name and commit-message generators run as
internal agents: never persisted, listed, flagged, announced, or seen by
plugin lifecycle hooks. Nothing outside the daemon could ask for one,
because the wire config schema strips `internal` and the agent.create
transform re-pins it. A plugin that runs a one-turn helper per event
therefore left an archived session in History every time.

Add an optional `internal` flag to `create_agent_request` (inherited by
`agent.create.request`), copy it onto the config in the session's create
path, and skip provider session persistence for it like the generators
do. The client SDK forwards the flag on both creation paths and gates it
once on `server_info.features.internalAgents`.

Two daemon paths assumed every agent is public and had to follow:
`resolveAgentIdentifier` now accepts a live agent by exact id, since the
id set it matched against came from listings that exclude internal
agents and left `wait_for_finish` answering "Agent not found"; and
archiving an internal agent now closes it without writing the record
that `registry.applySnapshot` used to leave behind, so `autoArchive`
does not accumulate hidden files on disk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(server): keep internal agents whole through failure, archive, and wait

Review follow-ups for internal agents:

- A create that fails after the runtime is registered now closes the
  internal agent before rethrowing, so a hidden runtime cannot outlive
  the failed request. Public agents keep today's behaviour.
- Archiving an internal agent cascades to attached children like any
  parent. cascadeArchiveChildren takes the parent record instead of
  reading it back from storage, where an internal agent never is.
- The archived snapshot and last message of an internal agent stay
  readable by exact id for ten minutes, so create-then-waitForFinish
  returns the finished turn whether the wait lands before or after an
  auto-archive. Storage plays that role for public agents.
- The internal-agent e2e scenarios live in the existing
  create_agent_request lifecycle suite rather than a new file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(server): cascade to live internal children and keep a failed internal create's worktree until it closes

Internal children never reach storage, so the parent archive cascade scans
live agents for them as well. A failed internal create now clears the
created id only after the runtime is archived, so worktree cleanup cannot
delete the directory under a runtime that would not close. Stored agent
payloads carry lastError, so a retained failed helper reports its error
through wait_for_finish instead of the generic message.

* feat(cli,mcp): expose internal agents and document --no-wait

paseo run gains --internal and create_agent gains internal, both forwarded
to the create request. --no-wait becomes the documented spelling for
returning immediately, matching paseo send; --background and --detach stay
as hidden aliases under COMPAT tags.

* test: follow the --no-wait rename and stub getRetiredInternalAgent

* feat: list internal agents on request and add internal workspaces

Internal agents stay hidden by default, but a caller can now ask for
them: `fetch_agents_request` takes `filter.includeInternal`, snapshots
carry `internal: true`, and `paseo ls --internal` shows them in an
INTERNAL column. The app never passes the flag.

Workspaces get the same flag. `workspace.create.request` with
`internal: true` persists a hidden workspace: listings, update
subscriptions, sync reads, empty-project counting, open-by-path, MCP
`list_workspaces`, and plugin workspace hooks all skip it unless the
caller opts in with `filter.includeInternal`. Every agent created inside
an internal workspace is internal and unpersisted; AgentManager applies
that from the workspace id before the plugin hook so creators cannot
opt out. Archive and shutdown enumerate internal agents so nothing
inside an internal workspace outlives it.

Surfaces: `paseo workspace create --internal`, `paseo workspace ls
--internal`, `paseo run --internal --new-workspace`, MCP
`create_workspace({ internal })`, SDK `workspaces.create({ internal })`
and `list({ filter: { includeInternal } })`. Clients gate on
`features.internalWorkspaces`.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(workspaces): add background visibility with inherited creation defaults

* fix(cli): resolve workspace callers on the selected daemon

* test(workspaces): keep caller integration coverage in server suite

* test(cli): exercise workspace callers through CLI integration harness

* test(mcp): remove obsolete execution flags from browser fixtures

---------

Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Gustavo Ambrozio
2026-10-09 09:31:50 +01:00
committed by GitHub
co-authored by Claude marvin-ambrozio Mohamed Boudra
parent 29db2b7980
commit 0009a7e717
92 changed files with 2795 additions and 775 deletions
+28 -2
View File
@@ -69,11 +69,11 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
@@ -84,6 +84,32 @@ 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
`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.
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.
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`.
### Private daemon helpers
Branch-name and commit-message generators still use private disposable internal runtimes.
`AgentManager` does not persist them or their provider sessions, excludes them from public
discovery and plugin hooks, and drops their timelines on archive. An archived helper retains
its exact-ID result briefly to cover completion before a waiter attaches. This internal
lifecycle is separate from public workspace visibility and is not a public creation option.
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
@@ -1,6 +1,7 @@
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
function workspaceRowTestId(workspaceId: string): string {
@@ -73,3 +74,75 @@ test.describe("Sidebar workspace rename", () => {
}
});
});
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: "Show background", exact: true }).click();
await page.keyboard.press("Escape");
}
async function expectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
// Rows have no single accessible role: their nested title and actions share this container.
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toBeVisible({ timeout: 30_000 });
}
async function expectWorkspaceHidden(page: Page, workspaceId: string): Promise<void> {
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toHaveCount(0);
}
async function reconnectApp(page: Page): Promise<void> {
await page.reload();
await expect(page.getByLabel("Display preferences", { exact: true })).toBeVisible();
}
async function expectWorkerOpen(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
await expect(page.getByText("Background reviewer", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Background review prompt", { exact: true }).first()).toBeVisible();
}
test("background workspace stays directly accessible across sidebar toggles and reconnect", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-background-",
title: "Background review",
background: true,
});
try {
const worker = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Background reviewer",
initialPrompt: "Background review prompt",
model: "e2e-fast-stream",
modeId: "load-test",
});
await test.step("Default sidebar hides background work", async () => {
await gotoAppShell(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
});
await test.step("Show background exposes the fully replicated workspace", async () => {
await toggleBackgroundWorkspaces(page);
await expectWorkspaceInSidebar(page, workspace.workspaceId);
await reconnectApp(page);
await expectWorkspaceInSidebar(page, workspace.workspaceId);
});
await test.step("Direct agent navigation survives hiding and reconnect", async () => {
await toggleBackgroundWorkspaces(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: worker.id });
await expectWorkerOpen(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
await reconnectApp(page);
await expectWorkerOpen(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
await page.screenshot({ path: testInfo.outputPath("background-direct-open.png") });
});
} finally {
await workspace.cleanup();
}
});
@@ -98,7 +98,6 @@ async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, i
settings: { modeId: "full-access", thinkingOptionId: "low" },
title: "Codex worktree restore reproduction",
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
background: true,
}),
);
agentId = agent.agentId;
@@ -66,6 +66,7 @@ export interface SeedDaemonClient {
worktreeSlug?: string;
};
title?: string;
background?: boolean;
}): Promise<{
workspace: SeedWorkspaceDescriptor | null;
error: string | null;
@@ -223,6 +224,7 @@ export interface SeededWorkspace {
export async function seedWorkspace(options: {
repoPrefix: string;
title?: string;
background?: boolean;
port?: number;
/** Repo fixture options; only applies to git projects (the default). */
repo?: Parameters<typeof createTempGitRepo>[1];
@@ -238,6 +240,7 @@ export async function seedWorkspace(options: {
const created = await client.createWorkspace({
source: { kind: "directory", path: project.path },
title: options.title,
background: options.background,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
@@ -63,11 +63,7 @@ import {
type SidebarWorkspacePlacement,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import {
hasActiveSidebarLabelFilter,
useSidebarViewStore,
type SidebarGroupMode,
} from "@/stores/sidebar-view-store";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
import {
ContextMenu,
@@ -1919,9 +1915,6 @@ export function SidebarWorkspaceList({
const onToggleWorkspacePin = useSidebarWorkspacePinController();
const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder);
const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder);
const hasActiveLabelFilter = useSidebarViewStore((state) =>
hasActiveSidebarLabelFilter(state.labelFilter),
);
const handlePinnedWorkspaceReorder = useCallback(
(reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
@@ -1953,11 +1946,10 @@ export function SidebarWorkspaceList({
// this whole subtree, which unmounted the header — and the header is where the display menu's
// trigger lives, so filtering the last row away closed the menu you were filtering from.
//
// Only the label filter can get here. The project filter resolves against the projects it can
// see and falls back to "all projects" when nothing matches, so it either keeps at least one
// project or is not applied at all — it can narrow this list but never empty it.
const sidebarFilterEmpty =
hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0;
// Background visibility can also hide the last project. Keep the header mounted
// whenever a local filter empties an otherwise populated directory, so users can
// reach the display menu to reveal their work again.
const sidebarFilterEmpty = hasProjectsBeforeFilter && projects.length === 0;
// Project mode is the one that keeps its project headers; every other grouping mode is a flat
// list of grouped rows, so a new mode lands in the grouped branch rather than silently in this
@@ -301,6 +301,13 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
sheetTitle={t("sidebar.display.heading")}
testID="sidebar-display-preferences-content"
>
<MenuItem
selected={preferences.showBackground}
closeOnSelect={false}
onSelect={preferences.toggleBackground}
>
{t("sidebar.display.showBackground")}
</MenuItem>
<MenuSubTrigger
id="grouping"
value={t(GROUPING_LABEL_KEYS[preferences.grouping])}
@@ -16,6 +16,8 @@ import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems }
export type SidebarTrailingChoice = Exclude<SidebarWorkspaceTrailing, "none">;
export interface SidebarDisplayPreferences {
showBackground: boolean;
toggleBackground: () => void;
grouping: SidebarGroupMode;
setGrouping: (mode: SidebarGroupMode) => void;
titleSource: WorkspaceTitleSource;
@@ -48,6 +50,8 @@ export interface SidebarDisplayPreferences {
* this for a value and set it; where it lands is this module's problem.
*/
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
const showBackground = useSidebarViewStore((state) => state.showBackground);
const toggleBackground = useSidebarViewStore((state) => state.toggleBackground);
const grouping = useSidebarViewStore((state) => state.groupMode);
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
@@ -104,6 +108,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
return useMemo(
() => ({
showBackground,
toggleBackground,
grouping,
setGrouping,
titleSource: workspaceTitleSource,
@@ -125,6 +131,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
clearLabelFilter,
}),
[
showBackground,
toggleBackground,
grouping,
setGrouping,
workspaceTitleSource,
@@ -57,6 +57,7 @@ export function SidebarModelProvider({
}) {
const { t } = useTranslation();
const list = useSidebarWorkspacesList({ enabled: active });
const showBackground = useSidebarViewStore((state) => state.showBackground);
const groupMode = useSidebarViewStore((state) => state.groupMode);
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
const projectFilters = useSidebarViewStore((state) => state.projectFilters);
@@ -107,9 +108,12 @@ export function SidebarModelProvider({
workspaces: [...workspaceEntriesByKey.values()],
projectFilters: resolvedProjectFilters,
});
const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter });
const filtered = filterWorkspacesByLabels({
workspaces: byProject.filter((workspace) => showBackground || !workspace.background),
...labelFilter,
});
return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace]));
}, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
}, [showBackground, labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
const visibleWorkspaceKeys = useMemo(
() => new Set(filteredWorkspaceEntriesByKey.keys()),
[filteredWorkspaceEntriesByKey],
@@ -124,16 +128,19 @@ export function SidebarModelProvider({
const included = new Set(resolvedProjectFilters);
projects = projects.filter((project) => included.has(project.viewKey));
}
if (hasActiveLabelFilter) {
if (hasActiveLabelFilter || !showBackground) {
projects = projects.flatMap((project) => {
const workspaces = project.workspaces.filter((workspace) =>
visibleWorkspaceKeys.has(workspace.workspaceKey),
);
return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0)
? [{ ...project, workspaces }]
: [];
});
}
return projects;
}, [
showBackground,
hasActiveLabelFilter,
hasActiveProjectFilter,
resolvedProjectFilters,
@@ -27,6 +27,7 @@ export interface SidebarWorkspacePlacement {
workspaceDirectory?: string;
projectKind: WorkspaceStructureProject["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
background?: boolean;
name: string;
}
@@ -164,6 +165,7 @@ export function createSidebarWorkspaceEntry(input: {
input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory),
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
background: input.workspace.background,
name: input.workspace.name,
title: input.workspace.title ?? null,
pinnedAt: input.workspace.pinnedAt,
+1
View File
@@ -1147,6 +1147,7 @@ export const ar: TranslationResources = {
done: "تم",
},
display: {
showBackground: "إظهار مساحات العمل في الخلفية",
trigger: "تفضيلات العرض",
heading: "العرض",
grouping: {
+1
View File
@@ -1155,6 +1155,7 @@ export const en = {
done: "Done",
},
display: {
showBackground: "Show background",
trigger: "Display preferences",
heading: "Display",
grouping: {
+1
View File
@@ -1183,6 +1183,7 @@ export const es: TranslationResources = {
done: "Terminado",
},
display: {
showBackground: "Mostrar espacios en segundo plano",
trigger: "Preferencias de visualización",
heading: "Visualización",
grouping: {
+1
View File
@@ -1174,6 +1174,7 @@ export const fr: TranslationResources = {
done: "Terminé",
},
display: {
showBackground: "Afficher les espaces en arrière-plan",
trigger: "Préférences d’affichage",
heading: "Affichage",
grouping: {
+1
View File
@@ -1160,6 +1160,7 @@ export const ja: TranslationResources = {
done: "完了",
},
display: {
showBackground: "バックグラウンドを表示",
trigger: "表示設定",
heading: "表示",
grouping: {
+1
View File
@@ -1154,6 +1154,7 @@ export const ko: TranslationResources = {
done: "완료",
},
display: {
showBackground: "백그라운드 표시",
trigger: "표시 설정",
heading: "표시",
grouping: {
+1
View File
@@ -1173,6 +1173,7 @@ export const ptBR: TranslationResources = {
done: "Concluído",
},
display: {
showBackground: "Mostrar espaços em segundo plano",
trigger: "Preferências de exibição",
heading: "Exibição",
grouping: {
+1
View File
@@ -1164,6 +1164,7 @@ export const ru: TranslationResources = {
done: "Готово",
},
display: {
showBackground: "Показать фоновые пространства",
trigger: "Настройки отображения",
heading: "Отображение",
grouping: {
+1
View File
@@ -1139,6 +1139,7 @@ export const zhCN: TranslationResources = {
done: "已完成",
},
display: {
showBackground: "显示后台工作区",
trigger: "显示偏好",
heading: "显示",
grouping: {
@@ -33,6 +33,10 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
class FakeDirectoryClient {
supportsBackgroundWorkspaces(): boolean {
return false;
}
supportsWorkspaceLabels = false;
listWorkspaceLabelsCalls = 0;
fetchAgentsCalls = 0;
@@ -535,6 +535,7 @@ export class DirectorySync {
let subscribe = initialSubscribe;
while (true) {
const query: Parameters<DaemonClient["observeWorkspaces"]>[0] = {
...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}),
sort: [{ key: "activity_at", direction: "desc" }],
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
...(supportsDirectorySync
@@ -634,6 +635,7 @@ export class DirectorySync {
const limit = input.page?.limit ?? PAGE_LIMIT;
const query: Omit<FetchAgentsOptions, "subscribe"> = {
...(input.filter ? { filter: input.filter } : { scope: "active" as const }),
...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}),
sort: AGENT_SORT,
page: cursor ? { limit, cursor } : { limit },
...(!input.filter && cursor === null && this.supportsDirectorySync()
@@ -52,6 +52,9 @@ it("requests the managed connection credential through desktop main without a we
});
class FakeDaemonClient {
supportsBackgroundWorkspaces(): boolean {
return false;
}
private state: ConnectionState = { status: "idle" };
private listeners = new Set<(status: ConnectionState) => void>();
private error: string | null = null;
+2
View File
@@ -113,6 +113,7 @@ export interface WorkspaceDescriptor {
worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"];
projectKind: WorkspaceDescriptorPayload["projectKind"];
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
background?: boolean;
name: string;
title?: string | null;
pinnedAt?: string | null;
@@ -150,6 +151,7 @@ export function normalizeWorkspaceDescriptor(
worktreeSlug: payload.worktreeSlug,
projectKind: payload.projectKind,
workspaceKind: payload.workspaceKind,
background: payload.background ?? false,
name: payload.name,
title: payload.title ?? null,
pinnedAt: payload.pinnedAt ?? null,
@@ -32,6 +32,7 @@ function createMemoryStorage(entries: Record<string, string | null>): MemoryStor
describe("sidebar view store", () => {
beforeEach(() => {
useSidebarViewStore.setState({
showBackground: false,
groupMode: "project",
hostFilters: [],
projectFilters: [],
@@ -84,6 +85,7 @@ describe("sidebar view store", () => {
},
}),
).toEqual({
showBackground: false,
groupMode: "status",
hostFilters: [],
projectFilters: [],
@@ -98,6 +100,7 @@ describe("sidebar view store", () => {
hostFilter: "host-a",
}),
).toEqual({
showBackground: false,
groupMode: "status",
hostFilters: ["host-a"],
projectFilters: [],
@@ -112,6 +115,7 @@ describe("sidebar view store", () => {
hostFilters: ["host-a", "host-b"],
}),
).toEqual({
showBackground: false,
groupMode: "status",
hostFilters: ["host-a", "host-b"],
projectFilters: [],
@@ -222,6 +226,7 @@ describe("sidebar view store", () => {
projectFilters: ["project-a", "project-b"],
}),
).toEqual({
showBackground: false,
groupMode: "project",
hostFilters: ["host-a"],
projectFilters: ["project-a", "project-b"],
@@ -231,6 +236,7 @@ describe("sidebar view store", () => {
it("never keeps project filters from state the schema rejects", () => {
expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({
showBackground: false,
groupMode: "project",
hostFilters: [],
projectFilters: [],
+11 -1
View File
@@ -9,7 +9,7 @@ export type SidebarGroupMode = "project" | "status";
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
const SIDEBAR_VIEW_STORE_VERSION = 6;
const SIDEBAR_VIEW_STORE_VERSION = 7;
/**
* The key standing for "this workspace carries no labels at all".
@@ -47,6 +47,7 @@ function toggleFilterEntry(list: readonly string[], key: string): string[] {
interface SidebarViewStoreState {
groupMode: SidebarGroupMode;
showBackground: boolean;
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
hostFilters: string[];
/**
@@ -61,6 +62,7 @@ interface SidebarViewStoreState {
*/
projectFilters: string[];
labelFilter: SidebarLabelFilter;
toggleBackground: () => void;
setGroupMode: (mode: SidebarGroupMode) => void;
toggleHostFilter: (serverId: string) => void;
clearHostFilters: () => void;
@@ -74,6 +76,7 @@ interface SidebarViewStoreState {
interface SidebarViewPersistedState {
groupMode: SidebarGroupMode;
showBackground: boolean;
hostFilters: string[];
projectFilters: string[];
labelFilter: SidebarLabelFilter;
@@ -84,6 +87,7 @@ const SidebarLabelFilterSchema = z.object({
labels: z.array(z.string()),
});
const SidebarViewPersistedStateSchema = z.strictObject({
showBackground: z.boolean().optional(),
groupMode: PersistedSidebarGroupModeSchema.optional(),
hostFilters: z.array(z.string()).optional(),
hostFilter: z.string().nullable().optional(),
@@ -122,6 +126,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
const result = SidebarViewPersistedStateSchema.safeParse(persistedState);
if (!result.success) {
return {
showBackground: false,
groupMode: "project",
hostFilters: [],
projectFilters: [],
@@ -133,6 +138,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
const legacyGroupMode = readLegacyGroupMode(state);
if (legacyGroupMode) {
return {
showBackground: false,
groupMode: legacyGroupMode,
hostFilters: [],
projectFilters: [],
@@ -141,6 +147,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
}
return {
showBackground: state.showBackground ?? false,
groupMode: state.groupMode === "status" ? "status" : "project",
hostFilters: readHostFilters(state),
projectFilters: state.projectFilters ?? [],
@@ -178,10 +185,12 @@ export function createSidebarViewStorage(
export const useSidebarViewStore = create<SidebarViewStoreState>()(
persist(
(set) => ({
showBackground: false,
groupMode: "project",
hostFilters: [],
projectFilters: [],
labelFilter: emptyLabelFilter(),
toggleBackground: () => set((state) => ({ showBackground: !state.showBackground })),
setGroupMode: (mode) => set({ groupMode: mode }),
toggleHostFilter: (serverId) =>
set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })),
@@ -228,6 +237,7 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
SidebarViewPersistedStateSchema,
),
partialize: (state) => ({
showBackground: state.showBackground ?? false,
groupMode: state.groupMode,
hostFilters: state.hostFilters,
projectFilters: state.projectFilters,
+30 -3
View File
@@ -46,10 +46,25 @@ describe("canonical CLI surface", () => {
expect(help).toContain("--forge <forge>");
});
it("uses background for execution and reserves detach for ownership", () => {
it("uses --no-wait for execution, --internal for visibility, and reserves detach for ownership", () => {
const run = createCli().commands.find((command) => command.name() === "run");
expect(run?.helpInformation()).toContain("--background");
expect(run?.helpInformation()).not.toContain("--detach");
const help = run?.helpInformation();
expect(help).toContain("--no-wait");
expect(help).not.toContain("--background");
expect(help).not.toContain("--background");
expect(help).not.toContain("--detach");
});
it("hides internal agents and workspaces from listings unless --internal is passed", () => {
const cli = createCli();
const ls = cli.commands.find((command) => command.name() === "ls");
const workspace = cli.commands.find((command) => command.name() === "workspace");
const workspaceLs = workspace?.commands.find((command) => command.name() === "ls");
const workspaceCreate = workspace?.commands.find((command) => command.name() === "create");
expect(ls?.helpInformation()).toContain("--background");
expect(workspaceLs?.helpInformation()).toContain("--background");
expect(workspaceCreate?.helpInformation()).toContain("--background");
});
it("offers thinking configuration when running, updating, and scheduling agents", () => {
@@ -96,3 +111,15 @@ describe("canonical CLI surface", () => {
).toContain("--id <id>");
});
});
it("preserves omitted and explicit false workspace background intent", () => {
const readOptions = (args: string[]) => {
const workspace = createCli().commands.find((command) => command.name() === "workspace")!;
const create = workspace.commands.find((command) => command.name() === "create")!;
create.parseOptions(args);
return create.opts();
};
expect(readOptions([]).background).toBeUndefined();
expect(readOptions(["--background"]).background).toBe(true);
expect(readOptions(["--no-background"]).background).toBe(false);
});
+9 -2
View File
@@ -23,15 +23,21 @@ describe("buildAgentLsFetchOptions", () => {
});
});
it("asks the daemon for internal agents only with --internal", () => {
expect(buildAgentLsFetchOptions({ background: true, global: true })).toEqual({
filter: { includeBackground: true },
});
});
it("fetches global non-archived agents for -g", () => {
expect(buildAgentLsFetchOptions({ global: true })).toEqual({});
});
it("keeps -a within the active scope", () => {
it("includes archived and background agents with -a", () => {
expect(buildAgentLsFetchOptions({ all: true })).toEqual({
scope: "active",
filter: {
includeArchived: true,
includeBackground: true,
},
});
});
@@ -40,6 +46,7 @@ describe("buildAgentLsFetchOptions", () => {
expect(buildAgentLsFetchOptions({ all: true, global: true })).toEqual({
filter: {
includeArchived: true,
includeBackground: true,
},
});
});
+10 -4
View File
@@ -12,8 +12,9 @@ type FetchAgentsOptions = NonNullable<
export function addLsOptions(cmd: Command): Command {
return cmd
.description("List agents. By default excludes archived agents.")
.option("-a, --all", "Include 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(
"--label <key=value>",
"Filter by label (can be used multiple times)",
@@ -109,6 +110,8 @@ export interface AgentLsOptions extends CommandOptions {
all?: boolean;
/** -g: List agents across all directories */
global?: boolean;
/** --background: Include background workspace agents */
background?: boolean;
/** Filter by specific status */
status?: string;
/** Filter by specific cwd */
@@ -133,7 +136,7 @@ function parseLabelFilters(labels: string[] | undefined): Record<string, string>
}
export function buildAgentLsFetchOptions(
options: Pick<AgentLsOptions, "all" | "global" | "label" | "thinking">,
options: Pick<AgentLsOptions, "all" | "global" | "background" | "label" | "thinking">,
): FetchAgentsOptions {
const labelFilters = parseLabelFilters(options.label);
const normalizedThinkingOptionId = options.thinking?.trim();
@@ -142,6 +145,9 @@ export function buildAgentLsFetchOptions(
if (options.all) {
daemonFilter.includeArchived = true;
}
if (options.background || options.all) {
daemonFilter.includeBackground = true;
}
if (Object.keys(labelFilters).length > 0) {
daemonFilter.labels = labelFilters;
}
@@ -150,7 +156,7 @@ export function buildAgentLsFetchOptions(
}
const fetchOptions: FetchAgentsOptions = {};
if (!options.global) {
if (!options.global && !options.all) {
fetchOptions.scope = "active";
}
if (Object.keys(daemonFilter).length > 0) {
@@ -163,7 +169,7 @@ export function buildAgentLsFetchOptions(
* Agent ls command semantics:
* - `paseo agent ls` → active non-archived agents
* - `paseo agent ls -g` → global non-archived agents
* - `paseo agent ls -a` → active agents, including archived
* - `paseo agent ls -a` → all agents, including archived and background
* - `paseo agent ls -ag` → global agents, including archived
*/
export async function runLsCommand(
+24 -11
View File
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { resolveCallerAgentId as resolveRunCallerAgentId } from "../../utils/caller-agent.js";
import {
resolveExistingRunWorkspace,
resolveRunCallerAgentId,
runRunCommand,
waitsForFinish,
type AgentRunOptions,
} from "./run";
@@ -21,6 +22,15 @@ function daemonWithAgents(...agentIds: string[]) {
};
}
describe("run wait policy", () => {
it("waits by default and stops waiting on --no-wait or a legacy alias", () => {
expect(waitsForFinish({})).toBe(true);
expect(waitsForFinish({ wait: false })).toBe(false);
expect(waitsForFinish({ background: true })).toBe(false);
expect(waitsForFinish({ detach: true })).toBe(false);
});
});
describe("managed agent caller context", () => {
it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => {
await expect(
@@ -38,17 +48,20 @@ describe("managed agent caller context", () => {
).resolves.toBeUndefined();
});
it("fails instead of dropping the caller when the lookup loses its connection", async () => {
const disconnectedDaemon = {
async fetchAgent(): Promise<never> {
throw new DaemonConnectionError("Connection lost before message could be sent");
},
};
it.each(["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"] as const)(
"preserves %s instead of dropping the caller",
async (code) => {
const disconnectedDaemon = {
async fetchAgent(): Promise<never> {
throw new DaemonConnectionError("Caller lookup transport failed", code);
},
};
await expect(
resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
).rejects.toBeInstanceOf(DaemonConnectionError);
});
await expect(
resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
).rejects.toBeInstanceOf(DaemonConnectionError);
},
);
it("omits blank caller ids", async () => {
await expect(
+24 -37
View File
@@ -4,7 +4,7 @@ import {
StructuredAgentResponseError,
} from "@getpaseo/server/agent-response";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { resolveCallerAgentId } from "../../utils/caller-agent.js";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
@@ -27,10 +27,13 @@ export function addRunOptions(cmd: Command): Command {
cmd
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --background", "Run in background")
.option("--no-wait", "Return immediately without waiting for completion")
// COMPAT(backgroundRunFlag): --background was the documented spelling of
// --no-wait. Added in v0.2.0; remove after 2027-04-01.
.addOption(new Option("-d, --background", "Legacy alias for --no-wait").hideHelp())
// COMPAT(detachRunFlag): --detach used to mean background execution, not
// ownership transfer. Added in v0.2.0; remove after 2027-01-17.
.addOption(new Option("--detach", "Legacy alias for --background").hideHelp())
.addOption(new Option("--detach", "Legacy alias for --no-wait").hideHelp())
.option("--title <title>", "Assign a title to the agent")
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
.option(
@@ -111,6 +114,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = {
};
export interface AgentRunOptions extends CommandOptions {
wait?: boolean;
background?: boolean;
detach?: boolean;
title?: string;
@@ -389,19 +393,25 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche
} satisfies CommandError;
}
if (options.background) {
console.error("Warning: --background (-d) is deprecated. Use --no-wait instead.");
}
validateRunWorkspaceOptions(options);
if (outputSchema && runsInBackground(options)) {
if (outputSchema && !waitsForFinish(options)) {
throw {
code: "INVALID_OPTIONS",
message: "--output-schema cannot be used with --background",
message: "--output-schema cannot be used with --no-wait",
details: "Structured output requires waiting for the agent to finish",
} satisfies CommandError;
}
}
function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean {
return Boolean(options.background || options.detach);
export function waitsForFinish(
options: Pick<AgentRunOptions, "wait" | "background" | "detach">,
): boolean {
return options.wait !== false && !options.background && !options.detach;
}
function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
@@ -557,7 +567,10 @@ async function resolveRunWorkspace(
// TODO: thread the run `prompt` as firstAgentContext so workspace-level
// title/branch generation picks up the task description (U8/U6 deferred).
const source = buildRunWorkspaceSource(options, cwd);
const result = await client.createWorkspace({ source });
const result = await client.createWorkspace({
source,
...(callerAgentId ? { callerAgentId } : {}),
});
if (!result.workspace) {
throw {
@@ -611,7 +624,7 @@ export async function runRunCommand(
const env = parseRunEnv(options.env);
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
const callerAgentId = await resolveRunCallerAgentId(client);
const callerAgentId = await resolveCallerAgentId(client);
const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId);
const workspaceId = workspace.id;
const runCwd = workspace.cwd;
@@ -707,8 +720,8 @@ export async function runRunCommand(
labels: Object.keys(labels).length > 0 ? labels : undefined,
});
// Default run behavior is foreground: wait for completion unless background execution is set.
if (!runsInBackground(options)) {
// Default run behavior is foreground: wait for completion unless --no-wait is set.
if (waitsForFinish(options)) {
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
await client.close();
@@ -744,29 +757,3 @@ export async function runRunCommand(
throw error;
}
}
export interface RunCallerLookupClient {
fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>;
}
// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run
// sent to another daemon (--host or --home) has no caller there, so it runs as
// a top-level agent instead of failing on an unknown caller.
export async function resolveRunCallerAgentId(
client: RunCallerLookupClient,
env: { PASEO_AGENT_ID?: string } = process.env,
): Promise<string | undefined> {
const agentId = env.PASEO_AGENT_ID?.trim();
if (!agentId) {
return undefined;
}
const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => {
// A daemon without this agent answers with an error. A lost or timed-out
// connection is not an answer, so it must not drop the caller.
if (error instanceof DaemonConnectionError) {
throw error;
}
return null;
});
return caller?.agent.id === agentId ? agentId : undefined;
}
@@ -1,5 +1,6 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { resolveCallerAgentId } from "../../utils/caller-agent.js";
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
@@ -15,6 +16,7 @@ export interface WorkspaceCreateOptions extends CommandOptions {
branch?: string;
prNumber?: string;
forge?: string;
background?: boolean;
}
interface WorktreeSourceBase {
@@ -144,9 +146,12 @@ export async function runCreateCommand(
});
try {
const callerAgentId = await resolveCallerAgentId(client);
const payload = await client.createWorkspace({
source: buildWorkspaceSource(options),
...(options.title ? { title: options.title } : {}),
...(options.background !== undefined ? { background: options.background } : {}),
...(callerAgentId ? { callerAgentId } : {}),
});
if (!payload.workspace) {
throw new Error(payload.error ?? "Workspace creation failed");
+9 -4
View File
@@ -34,12 +34,17 @@ export function createWorkspaceCommand(): Command {
.option("--base <ref>", "Base ref (--mode branch-off)")
.option("--branch <name>", "Existing branch (--mode checkout-branch)")
.option("--pr-number <n>", "Pull request or change request number (--mode checkout-pr)")
.option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)"),
.option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)")
.option("--background", "Create a background workspace")
.option("--no-background", "Create a visible workspace instead of inheriting background"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(workspace.command("ls").description("List active workspaces")).action(
withOutput(runLsCommand),
);
addJsonAndDaemonHostOptions(
workspace
.command("ls")
.description("List active workspaces")
.option("--background", "Include background workspaces"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
workspace
+20 -3
View File
@@ -1,10 +1,22 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, ListResult } from "../../output/index.js";
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
import {
toWorkspaceRow,
workspaceSchema,
workspaceWithBackgroundSchema,
type WorkspaceRow,
} from "./shared.js";
export interface WorkspaceLsOptions {
host?: string;
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
/** --background: Include background workspaces */
background?: boolean;
}
export async function runLsCommand(
options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget },
options: WorkspaceLsOptions,
_command: Command,
): Promise<ListResult<WorkspaceRow>> {
const host = getDaemonHost({ target: options.daemonTarget });
@@ -20,12 +32,17 @@ export async function runLsCommand(
let cursor: string | undefined;
do {
const payload = await client.fetchWorkspaces({
...(options.background ? { filter: { includeBackground: true } } : {}),
page: { limit: 200, ...(cursor ? { cursor } : {}) },
});
workspaces.push(...payload.entries.map(toWorkspaceRow));
cursor = payload.pageInfo.nextCursor ?? undefined;
} while (cursor);
return { type: "list", data: workspaces, schema: workspaceSchema };
return {
type: "list",
data: workspaces,
schema: options.background ? workspaceWithBackgroundSchema : workspaceSchema,
};
} finally {
await client.close().catch(() => undefined);
}
@@ -7,6 +7,7 @@ export interface WorkspaceRow {
name: string;
isolation: "local" | "worktree";
cwd: string;
background: boolean;
}
export const workspaceSchema: OutputSchema<WorkspaceRow> = {
@@ -20,6 +21,15 @@ export const workspaceSchema: OutputSchema<WorkspaceRow> = {
],
};
/** `--background` listings add a column so hidden workspaces stand out. */
export const workspaceWithBackgroundSchema: OutputSchema<WorkspaceRow> = {
...workspaceSchema,
columns: [
...workspaceSchema.columns,
{ header: "BACKGROUND", field: (row) => (row.background ? "yes" : ""), width: 8 },
],
};
export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): WorkspaceRow {
return {
workspaceId: workspace.id,
@@ -27,5 +37,6 @@ export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): Workspace
name: workspace.name,
isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local",
cwd: workspace.workspaceDirectory,
background: workspace.background === true,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
interface CallerLookupClient {
fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>;
}
// PASEO_AGENT_ID belongs to the daemon that launched this shell. Commands
// targeting another daemon (--host or --home) have no caller on that daemon.
export async function resolveCallerAgentId(
client: CallerLookupClient,
env: { PASEO_AGENT_ID?: string } = process.env,
): Promise<string | undefined> {
const agentId = env.PASEO_AGENT_ID?.trim();
if (!agentId) return undefined;
const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => {
// A missing agent is a daemon answer; a lost or timed-out connection is not.
if (error instanceof DaemonConnectionError) throw error;
return null;
});
return caller?.agent.id === agentId ? agentId : undefined;
}
+12 -8
View File
@@ -53,8 +53,12 @@ try {
console.log("Test 1: run --help shows options");
const result = await $`npx paseo run --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "run --help should exit 0");
assert(result.stdout.includes("-d"), "help should mention -d flag");
assert(result.stdout.includes("--background"), "help should mention --background flag");
assert(result.stdout.includes("--no-wait"), "help should mention --no-wait flag");
assert(
!result.stdout.includes("--internal"),
"help should not mention removed --internal flag",
);
assert(!result.stdout.includes("--background"), "help should hide legacy --background syntax");
assert(!result.stdout.includes("--detach"), "help should hide legacy --detach syntax");
assert(result.stdout.includes("--title"), "help should mention --title option");
assert(result.stdout.includes("--provider"), "help should mention --provider option");
@@ -178,18 +182,18 @@ try {
console.log("✓ run --output-schema flag is accepted\n");
}
// Test 10: run --output-schema cannot be used with --background
// Test 10: run --output-schema cannot be used with --no-wait
{
console.log("Test 10: run --output-schema cannot be used with --background");
console.log("Test 10: run --output-schema cannot be used with --no-wait");
const result =
await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --background --output-schema ${schemaPath} "test prompt"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema");
await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --no-wait --output-schema ${schemaPath} "test prompt"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail with --no-wait and --output-schema");
const output = result.stdout + result.stderr;
assert(
output.includes("--output-schema cannot be used with --background"),
output.includes("--output-schema cannot be used with --no-wait"),
"error should explain background incompatibility",
);
console.log("✓ run --output-schema cannot be used with --background\n");
console.log("✓ run --output-schema cannot be used with --no-wait\n");
}
// Test 11: -q (quiet) flag is accepted with run
@@ -2,6 +2,7 @@
import assert from "node:assert";
import { basename } from "node:path";
import { createPaseoClient } from "@getpaseo/client";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
console.log("=== Workspace Rename Command ===\n");
@@ -51,8 +52,61 @@ try {
)?.name,
basename(ctx.workDir),
);
await verifyWorkspaceCallerContext();
} finally {
await ctx.stop();
}
console.log("=== Workspace Rename Command Tests Passed ===");
async function verifyWorkspaceCallerContext() {
const sdk = createPaseoClient({ url: `${ctx.wsUrl}/ws`, reconnect: { enabled: false } });
try {
await sdk.connect();
const parentWorkspace = await sdk.workspaces.create({
source: { kind: "directory", path: ctx.workDir },
background: true,
});
// Creation without a prompt stays idle; this visibility test runs no provider turn.
const parent = await parentWorkspace.agents.create({ config: { provider: "claude/haiku" } });
const cases = [
{ caller: parent.id, background: undefined, expected: true },
{ caller: parent.id, background: false, expected: false },
{ caller: parent.id, background: true, expected: true },
{ caller: "foreign-or-missing-agent", background: undefined, expected: false },
{ caller: "foreign-or-missing-agent", background: true, expected: true },
{ caller: "foreign-or-missing-agent", background: false, expected: false },
{ caller: "", background: undefined, expected: false },
{ caller: "", background: true, expected: true },
];
for (const entry of cases) {
const result = await ctx.paseo(
[
"workspace",
"create",
"--host",
`127.0.0.1:${ctx.port}`,
"--isolation",
"local",
"--path",
ctx.workDir,
...(entry.background === undefined
? []
: [entry.background ? "--background" : "--no-background"]),
"--json",
],
{ env: { PASEO_AGENT_ID: entry.caller } },
);
assert.strictEqual(result.exitCode, 0, result.stderr);
assert.strictEqual(
JSON.parse(result.stdout).background,
entry.expected,
JSON.stringify(entry),
);
}
console.log("✓ workspace create caller inheritance, foreign/blank defaults, and overrides");
} finally {
await sdk.close();
}
}
+156
View File
@@ -3100,6 +3100,162 @@ test("sends worktree target and autoArchive in create_agent_request", async () =
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
});
test("sends internal in create_agent_request when the host advertises internal agents", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { backgroundWorkspaces: true } });
await connectPromise;
const createPromise = client.createAgent({
provider: "codex",
cwd: "/tmp/project",
background: true,
});
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
config: { provider: "codex", cwd: "/tmp/project" },
background: true,
}),
);
mock.triggerMessage(
wrapSessionMessage({
type: "status",
payload: {
status: "agent_create_failed",
requestId: request.requestId,
error: "internal agent sentinel",
},
}),
);
await expect(createPromise).rejects.toThrow("internal agent sentinel");
});
test("rejects an internal create before sending when the host predates internal agents", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: {} });
await connectPromise;
await expect(
client.createAgent({ provider: "codex", cwd: "/tmp/project", background: true }),
).rejects.toThrow("Update the host to use background workspaces.");
expect(mock.sent).toHaveLength(0);
});
test("sends internal workspace creation and internal listings only to hosts that support them", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
// workspaceMultiplicity keeps the fetch on the real workspace directory RPC.
mock.triggerOpen({
features: { workspaceMultiplicity: true, backgroundWorkspaces: true },
});
await connectPromise;
void client
.createWorkspace({ source: { kind: "directory", path: "/tmp/project" }, background: true })
.catch(() => undefined);
void client.fetchWorkspaces({ filter: { includeBackground: true } }).catch(() => undefined);
void client.fetchAgents({ filter: { includeBackground: true } }).catch(() => undefined);
expect(mock.sent.map((frame) => parseSentFrame(frame))).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "workspace.create.request", background: true }),
expect.objectContaining({
type: "fetch_workspaces_request",
filter: { includeBackground: true },
}),
expect.objectContaining({
type: "fetch_agents_request",
filter: { includeBackground: true },
}),
]),
);
});
test("rejects internal workspace creation and internal listings before sending on older hosts", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: {} });
await connectPromise;
await expect(
client.createWorkspace({
source: { kind: "directory", path: "/tmp/project" },
background: true,
}),
).rejects.toThrow("Update the host to use background workspaces.");
await expect(client.fetchWorkspaces({ filter: { includeBackground: true } })).rejects.toThrow(
"Update the host to use background workspaces.",
);
expect(mock.sent).toHaveLength(0);
// Agent listings gate on the older internalAgents feature.
const olderMock = createMockTransport();
const olderClient = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => olderMock.transport,
});
clients.push(olderClient);
const olderConnect = olderClient.connect();
olderMock.triggerOpen({ features: {} });
await olderConnect;
await expect(olderClient.fetchAgents({ filter: { includeBackground: true } })).rejects.toThrow(
"Update the host to use background workspaces.",
);
expect(olderMock.sent).toHaveLength(0);
});
test("sends structured attachments with create_agent_request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+53 -15
View File
@@ -460,6 +460,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
git?: GitSetupOptions;
worktree?: CreateAgentRequestMessage["worktree"];
autoArchive?: CreateAgentRequestMessage["autoArchive"];
background?: CreateAgentRequestMessage["background"];
// COMPAT(createAgentWorktree): low-level old callers may still send the
// create-agent worktree field. Added in v0.2.0; remove after 2027-01-17.
worktreeName?: string;
@@ -470,6 +471,9 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
export interface CreateWorkspaceRequestOptions {
source: WorkspaceCreateRequest["source"];
title?: string;
/** Workspace discovery visibility; contents retain normal durability. */
background?: boolean;
callerAgentId?: string;
idempotencyKey?: string;
workspaceId?: string;
agent?: Omit<
@@ -1196,6 +1200,25 @@ export function supportsUsageReports(features: ServerInfoStatusPayload["features
return features?.usageSources === true || features?.providerUsageList === true;
}
function buildFetchAgentsRequest(
options: FetchAgentsOptions | undefined,
requestId: string,
): SessionInboundMessage {
return SessionInboundMessageSchema.parse({
type: "fetch_agents_request",
requestId,
...(options?.scope ? { scope: options.scope } : {}),
...(options?.filter ? { filter: options.filter } : {}),
...(options?.sort ? { sort: options.sort } : {}),
...(options?.page ? { page: options.page } : {}),
...(options?.subscribe ? { subscribe: options.subscribe } : {}),
...(options?.sync ? { sync: options.sync } : {}),
...(options?.includeBackground !== undefined
? { includeBackground: options.includeBackground }
: {}),
});
}
export class DaemonClient {
private readonly providerSnapshotUpdates = new ProviderSnapshotUpdates({
active: (message) => this.owned.owns(message),
@@ -2342,6 +2365,8 @@ export class DaemonClient {
observeAgents(
options: Omit<FetchAgentsOptions, "subscribe"> = {},
): OwnedSubscription<FetchAgentsPayload> {
if (options.includeBackground || options.filter?.includeBackground)
this.requireBackgroundWorkspaces();
const { signal, requestId, timeout, ...query } = options;
return this.observe(
"fetch_agents_response",
@@ -2353,6 +2378,8 @@ export class DaemonClient {
observeWorkspaces(
options: Omit<FetchWorkspacesOptions, "subscribe"> = {},
): OwnedSubscription<FetchWorkspacesPayload> {
if (options.includeBackground || options.filter?.includeBackground)
this.requireBackgroundWorkspaces();
const { signal, requestId, ...query } = options;
return this.observe(
"fetch_workspaces_response",
@@ -2368,6 +2395,8 @@ export class DaemonClient {
async fetchAgents(
options?: FetchAgentsOptions,
): Promise<FetchAgentsPayload & { subscription?: OwnedSubscription<FetchAgentsPayload> }> {
if (options?.includeBackground || options?.filter?.includeBackground)
this.requireBackgroundWorkspaces();
if (options?.subscribe) {
if (options.subscribe.subscriptionId !== undefined)
throw new Error("Subscription IDs are assigned by the host");
@@ -2375,16 +2404,7 @@ export class DaemonClient {
return { ...(await subscription.ready), subscription };
}
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "fetch_agents_request",
requestId: resolvedRequestId,
...(options?.scope ? { scope: options.scope } : {}),
...(options?.filter ? { filter: options.filter } : {}),
...(options?.sort ? { sort: options.sort } : {}),
...(options?.page ? { page: options.page } : {}),
...(options?.subscribe ? { subscribe: options.subscribe } : {}),
...(options?.sync ? { sync: options.sync } : {}),
});
const message = buildFetchAgentsRequest(options, resolvedRequestId);
return this.sendRequest({
requestId: resolvedRequestId,
message,
@@ -2403,6 +2423,7 @@ export class DaemonClient {
}
async fetchAgentHistory(options?: FetchAgentHistoryOptions): Promise<FetchAgentHistoryPayload> {
if (options?.filter?.includeBackground) this.requireBackgroundWorkspaces();
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "fetch_agent_history_request",
@@ -2466,6 +2487,8 @@ export class DaemonClient {
): Promise<
FetchWorkspacesPayload & { subscription?: OwnedSubscription<FetchWorkspacesPayload> }
> {
if (options?.includeBackground || options?.filter?.includeBackground)
this.requireBackgroundWorkspaces();
if (options?.subscribe) {
if (options.subscribe.subscriptionId !== undefined)
throw new Error("Subscription IDs are assigned by the host");
@@ -2475,12 +2498,8 @@ export class DaemonClient {
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "fetch_workspaces_request",
...options,
requestId: resolvedRequestId,
...(options?.filter ? { filter: options.filter } : {}),
...(options?.sort ? { sort: options.sort } : {}),
...(options?.page ? { page: options.page } : {}),
...(options?.subscribe ? { subscribe: options.subscribe } : {}),
...(options?.sync ? { sync: options.sync } : {}),
});
return this.sendRequest({
requestId: resolvedRequestId,
@@ -2853,7 +2872,19 @@ export class DaemonClient {
legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
});
// Background workspaces are gated once at the transport boundary.
private requireBackgroundWorkspaces(): void {
if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) {
throw new Error("Update the host to use background workspaces.");
}
}
supportsBackgroundWorkspaces(): boolean {
return this.lastServerInfoMessage?.features?.backgroundWorkspaces === true;
}
async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> {
if (options.background !== undefined) this.requireBackgroundWorkspaces();
const result = await this.creations.createAgent({
...options,
config: resolveAgentConfig(options),
@@ -2886,6 +2917,7 @@ export class DaemonClient {
...(options.git ? { git: options.git } : {}),
...(options.worktree ? { worktree: options.worktree } : {}),
...(options.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}),
...(options.background !== undefined ? { background: options.background } : {}),
...(options.worktreeName ? { worktreeName: options.worktreeName } : {}),
...(options.labels && Object.keys(options.labels).length > 0
? { labels: options.labels }
@@ -4560,6 +4592,9 @@ export class DaemonClient {
requestId?: string,
): Promise<WorkspaceCreatePayload> {
const resolvedRequestId = this.createRequestId(requestId ?? input.requestId);
if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces();
if (input.agent?.background !== undefined)
throw new Error("Configure background on the workspace, not its initial agent.");
const result = await this.creations.createWorkspace({
...input,
requestId: resolvedRequestId,
@@ -4591,6 +4626,8 @@ export class DaemonClient {
? { idempotencyKey: input.idempotencyKey }
: {}),
...(input.title !== undefined ? { title: input.title } : {}),
...(input.background !== undefined ? { background: input.background } : {}),
...(input.callerAgentId ? { callerAgentId: input.callerAgentId } : {}),
...(input.firstAgentContext !== undefined
? { firstAgentContext: input.firstAgentContext }
: {}),
@@ -6940,6 +6977,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
attachments: _attachments,
worktree: _worktree,
autoArchive: _autoArchive,
background: _background,
env: _env,
workspaceId: _workspaceId,
initialPrompt: _initialPrompt,
+55
View File
@@ -691,6 +691,61 @@ test("plugin-shaped PR workspace create and agent create use the existing daemon
await client.close();
});
test("agents.create forwards internal on the request, outside the agent config", async () => {
const { client, ws } = await connectClient({
providerUsageList: true,
providersSnapshotCwd: true,
ownedSubscriptions: true,
backgroundWorkspaces: true,
});
const createPromise = client.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/repo/sdk",
title: "Summary helper",
background: true,
autoArchive: true,
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "create_agent_request",
config: { provider: "codex", model: "gpt-5.4", cwd: "/repo/sdk", title: "Summary helper" },
background: true,
autoArchive: true,
});
expect(request.config).not.toHaveProperty("internal");
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: request.requestId,
agentId: "agent_helper",
agent: createAgent({ id: "agent_helper" }),
},
}),
);
const agent = await createPromise;
expect(agent.id).toBe("agent_helper");
await client.close();
});
test("agents.create with internal fails before sending on a host without internal agents", async () => {
const { client, ws } = await connectClient();
const sentBefore = ws.sent.length;
await expect(
client.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/repo/sdk",
background: true,
}),
).rejects.toThrow("Update the host to use background workspaces.");
expect(ws.sent).toHaveLength(sentBefore);
await client.close();
});
test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => {
const { client, ws } = await connectClient();
const createdAgent = createAgent();
+5
View File
@@ -257,6 +257,8 @@ export interface PaseoAgentCreateOptions {
git?: CreateAgentRequestMessage["git"];
worktree?: CreateAgentRequestMessage["worktree"];
autoArchive?: CreateAgentRequestMessage["autoArchive"];
/** Visibility intent for a NEW workspace. Existing targets reject this option. */
background?: CreateAgentRequestMessage["background"];
requestId?: string;
labels?: Record<string, string>;
}
@@ -780,9 +782,12 @@ function createWorkspaceHandleFactory(
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);
@@ -287,7 +287,6 @@ async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) {
provider: "mock/ten-second-stream",
settings: { modeId: "load-test" },
initialPrompt: "Remain available while the browser bridge regression runs.",
background: true,
},
});
const result = response.structuredContent;
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema } from "./messages.js";
describe("create request internal field", () => {
test("accepts internal on create_agent_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "create-agent-internal",
config: {
provider: "codex",
cwd: "/repo/app",
},
internal: true,
});
expect(parsed).toEqual({
type: "create_agent_request",
requestId: "create-agent-internal",
config: {
provider: "codex",
cwd: "/repo/app",
},
internal: true,
labels: {},
});
});
test("accepts internal on agent.create.request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "agent.create.request",
requestId: "agent-create-internal",
config: {
provider: "codex",
cwd: "/repo/app",
},
internal: true,
});
expect(parsed).toEqual({
type: "agent.create.request",
requestId: "agent-create-internal",
config: {
provider: "codex",
cwd: "/repo/app",
},
internal: true,
labels: {},
});
});
});
+29
View File
@@ -839,6 +839,8 @@ export const AgentSnapshotPayloadSchema = z.object({
attentionTimestamp: z.string().nullable().optional(),
archivedAt: z.string().nullable().optional(),
providerUnavailable: z.boolean().optional(),
// Private helper marker, retained for exact-ID snapshots and older wire messages.
internal: z.boolean().optional(),
});
export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>;
@@ -910,6 +912,9 @@ const AgentDirectoryFilterSchema = z.object({
includeArchived: z.boolean().optional(),
requiresAttention: z.boolean().optional(),
thinkingOptionId: z.string().nullable().optional(),
// Retained for wire parsing; private helpers are excluded from public discovery.
includeInternal: z.boolean().optional(),
includeBackground: z.boolean().optional(),
});
export const DeleteAgentRequestMessageSchema = z.object({
@@ -1237,6 +1242,7 @@ export const FetchAgentsRequestMessageSchema = z.object({
type: z.literal("fetch_agents_request"),
requestId: z.string(),
scope: z.enum(["active"]).optional(),
includeBackground: z.boolean().optional(),
filter: AgentDirectoryFilterSchema.optional(),
sort: z
.array(
@@ -1270,6 +1276,7 @@ const WorkspaceStateBucketSchema = z.enum([
]);
export const FetchWorkspacesRequestMessageSchema = z.object({
includeBackground: z.boolean().optional(),
type: z.literal("fetch_workspaces_request"),
requestId: z.string(),
filter: z
@@ -1278,6 +1285,9 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
projectId: z.string().optional(),
// Unused: accepted so older clients still parse, but the server does not filter on it.
idPrefix: z.string().optional(),
// Retained for wire parsing of older requests.
includeInternal: z.boolean().optional(),
includeBackground: z.boolean().optional(),
})
.optional(),
sort: z
@@ -1714,6 +1724,13 @@ export const CreateAgentRequestMessageSchema = z.object({
git: GitSetupOptionsSchema.optional(),
worktree: CreateAgentWorktreeTargetSchema.optional(),
autoArchive: z.boolean().optional(),
// An ephemeral helper: the daemon never persists, lists, flags or announces
// it, and plugin lifecycle hooks skip it. Lives on the request rather than
// 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.
background: z.boolean().optional(),
labels: z.record(z.string(), z.string()).default({}),
requestId: z.string(),
});
@@ -2633,6 +2650,10 @@ export const WorkspaceCreateRequestSchema = z.object({
title: z.string().optional(),
// Optional prompt context for workspace-level name/branch generation.
firstAgentContext: FirstAgentContextSchema.optional(),
// Retained for wire parsing; public internal creation is no longer supported.
internal: z.boolean().optional(),
background: z.boolean().optional(),
callerAgentId: z.string().optional(),
source: z.discriminatedUnion("kind", [
z.object({
kind: z.literal("directory"),
@@ -3558,6 +3579,11 @@ export const ServerInfoStatusPayloadSchema = z
creationLifecycle: z.boolean().optional(),
// COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05.
hubAgentRpc: z.boolean().optional(),
// COMPAT(internalAgents): added in v0.9.0; remove gate after 2027-03-17.
internalAgents: z.boolean().optional(),
// COMPAT(internalWorkspaces): added in v0.9.0; remove gate after 2027-03-17.
internalWorkspaces: z.boolean().optional(),
backgroundWorkspaces: z.boolean().optional(),
providersSnapshot: z.boolean().optional(),
usageSources: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
@@ -4024,6 +4050,9 @@ export const WorkspaceDescriptorPayloadSchema = z
pinnedAt: z.string().nullable().optional(),
// COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14.
labels: z.array(z.string()).optional(),
// Retained for wire parsing of older daemon descriptors.
internal: z.boolean().optional(),
background: z.boolean().optional(),
archivingAt: z.string().nullable().optional().default(null),
status: WorkspaceStateBucketSchema,
// Best-effort workspace status entry timestamp. Old daemons omit the
@@ -47,6 +47,7 @@ const AGENT_FIELDS = [
"modeId",
"background",
"notifyOnFinish",
"internal",
"settings",
"labels",
] as const;
@@ -196,6 +197,7 @@ const FIELD_LABELS: Readonly<Record<string, string>> = {
newMode: "New mode",
nextRunAt: "Next run",
notifyOnFinish: "Notify on finish",
internal: "Internal",
prNumber: "Change request",
projectId: "Project",
removedDirectory: "Removed directory",
@@ -7710,6 +7710,81 @@ test("listAgents excludes internal agents", async () => {
const agents = manager.listAgents();
expect(agents).toHaveLength(1);
expect(agents[0]?.config.title).toBe("Normal Agent");
// Opting in lists both, and the internal one is marked.
const everyAgent = manager.listAgents({ includeInternal: true });
expect(everyAgent.map((agent) => [agent.config.title, agent.internal])).toEqual([
["Normal Agent", false],
["Internal Agent", true],
]);
});
test("workspace ownership does not turn an ordinary agent into a disposable helper", async () => {
const agentId = "00000000-0000-4000-8000-000000000115";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const beforeHookCalls: string[] = [];
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
idFactory: () => agentId,
pluginLifecycle: {
emit: () => {},
before: async (name, request) => {
beforeHookCalls.push(name);
return request;
},
},
});
await manager.createAgent({ provider: "codex", cwd: workdir, title: "Helper" }, undefined, {
workspaceId: "wks_internal",
});
expect(manager.getAgent(agentId)?.internal).toBe(false);
expect(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]);
expect(await storage.get(agentId)).not.toBeNull();
// Workspace ownership preserves ordinary agent lifecycle hooks.
expect(beforeHookCalls).toContain("agent.create");
});
test("global subscribers receive internal agent events only when they opt in", async () => {
const internalAgentId = "00000000-0000-4000-8000-000000000116";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
idFactory: () => internalAgentId,
});
await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Internal Agent", internal: true },
undefined,
{ workspaceId: undefined },
);
const hidden: string[] = [];
const optedIn: string[] = [];
const unsubscribeHidden = manager.subscribe((event) => {
if (event.type === "agent_state") hidden.push(event.agent.id);
});
const unsubscribeOptedIn = manager.subscribe(
(event) => {
if (event.type === "agent_state") optedIn.push(event.agent.id);
},
{ includeInternal: true },
);
expect(hidden).toEqual([]);
expect(optedIn).toEqual([internalAgentId]);
await manager.archiveAgent(internalAgentId);
expect(hidden).toEqual([]);
expect(optedIn.length).toBeGreaterThan(1);
unsubscribeHidden();
unsubscribeOptedIn();
});
test("getAgent returns internal agents by ID", async () => {
@@ -7742,6 +7817,124 @@ test("getAgent returns internal agents by ID", async () => {
expect(agent?.internal).toBe(true);
});
test("archiveAgent closes an internal agent without writing a record", async () => {
const internalAgentId = "00000000-0000-4000-8000-000000000108";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => internalAgentId,
});
await manager.createAgent(
{
provider: "codex",
cwd: workdir,
title: "Internal Agent",
internal: true,
},
undefined,
{ workspaceId: undefined },
);
const { archivedAt } = await manager.archiveAgent(internalAgentId);
expect(Number.isNaN(Date.parse(archivedAt))).toBe(false);
expect(manager.getAgent(internalAgentId)).toBeNull();
expect(await storage.get(internalAgentId)).toBeNull();
expect(await storage.list()).toEqual([]);
expect(manager.getRetiredInternalAgent(internalAgentId)).toEqual({
record: expect.objectContaining({
id: internalAgentId,
title: "Internal Agent",
internal: true,
archivedAt,
lastStatus: "idle",
}),
lastMessage: null,
});
expect(manager.getRetiredInternalAgent("00000000-0000-4000-8000-000000000999")).toBeNull();
});
test("archiveAgent on an internal parent still archives its attached children", async () => {
const parentAgentId = "00000000-0000-4000-8000-000000000109";
const childAgentId = "00000000-0000-4000-8000-000000000110";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const ids = [parentAgentId, childAgentId];
let agentCounter = 0;
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => ids[agentCounter++] ?? randomUUID(),
});
await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Internal Parent", internal: true },
undefined,
{ workspaceId: undefined },
);
await manager.createAgent({ provider: "codex", cwd: workdir, title: "Child" }, undefined, {
workspaceId: undefined,
labels: { "paseo.parent-agent-id": parentAgentId },
});
await manager.archiveAgent(parentAgentId);
expect(manager.getAgent(parentAgentId)).toBeNull();
expect(manager.getAgent(childAgentId)).toBeNull();
const child = await storage.get(childAgentId);
expect(child?.archivedAt).toEqual(expect.any(String));
expect(await storage.get(parentAgentId)).toBeNull();
});
test("archiveAgent on a parent archives its live internal children", async () => {
const parentAgentId = "00000000-0000-4000-8000-000000000111";
const childAgentId = "00000000-0000-4000-8000-000000000112";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const ids = [parentAgentId, childAgentId];
let agentCounter = 0;
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => ids[agentCounter++] ?? randomUUID(),
});
await manager.createAgent({ provider: "codex", cwd: workdir, title: "Parent" }, undefined, {
workspaceId: undefined,
});
// An internal child is never in storage, so only the live scan can reach it.
await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Internal Child", internal: true },
undefined,
{ workspaceId: undefined, labels: { "paseo.parent-agent-id": parentAgentId } },
);
expect(manager.getAgent(childAgentId)).not.toBeNull();
await manager.archiveAgent(parentAgentId);
expect(manager.getAgent(parentAgentId)).toBeNull();
expect(manager.getAgent(childAgentId)).toBeNull();
expect(manager.getRetiredInternalAgent(childAgentId)?.record.archivedAt).toEqual(
expect.any(String),
);
expect(await storage.get(childAgentId)).toBeNull();
});
test("subscribe does not emit state events for internal agents to global subscribers", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
+122 -14
View File
@@ -55,6 +55,7 @@ import {
type ListImportableSessionsOptions,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AgentOwner } from "./agent-owner.js";
import {
@@ -243,6 +244,8 @@ export type AgentSubscriber = (event: AgentManagerEvent) => void;
export interface SubscribeOptions {
agentId?: string;
replayState?: boolean;
/** Receive events for internal agents too. Off by default for global subscribers. */
includeInternal?: boolean;
}
interface HydrateTimelineOptions {
@@ -316,6 +319,7 @@ export interface CreateAgentOptions {
export interface AgentManagerOptions {
pluginLifecycle?: PluginLifecycle;
/** An internal workspace makes every agent created inside it internal. */
clients?: ProviderClientMap;
providerDefinitions?: ProviderEnabledMap;
idFactory?: () => string;
@@ -568,6 +572,7 @@ function attachPersistenceCwd(
interface SubscriptionRecord {
callback: AgentSubscriber;
agentId: string | null;
includeInternal: boolean;
}
interface SteerEventBarrier {
@@ -693,7 +698,7 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str
}
function shouldDetachFromArchivedParent(
parent: StoredAgentRecord,
parent: Pick<StoredAgentRecord, "workspaceId">,
child: StoredAgentRecord,
): boolean {
const isCrossWorkspace =
@@ -713,8 +718,25 @@ function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatc
return patch;
}
/**
* How long an archived internal agent stays readable by id. Storage plays this
* role for public agents; internal agents never reach it, and a one-shot helper
* can finish and auto-archive before its creator's `waitForFinish` arrives.
*/
const RETIRED_INTERNAL_AGENT_TTL_MS = 10 * 60 * 1000;
const RETIRED_INTERNAL_AGENT_LIMIT = 500;
export interface RetiredInternalAgent {
record: ArchivedStoredAgentRecord;
lastMessage: string | null;
}
export class AgentManager {
private readonly pluginLifecycle: PluginLifecycle | undefined;
private readonly retiredInternalAgents = new Map<
string,
RetiredInternalAgent & { expiresAt: number }
>();
private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly providerDefinitions = new Map<AgentProvider, ProviderEnabledFlag>();
@@ -948,6 +970,7 @@ export class AgentManager {
const record: SubscriptionRecord = {
callback,
agentId: targetAgentId,
includeInternal: options?.includeInternal === true,
};
this.subscribers.add(record);
@@ -961,9 +984,9 @@ export class AgentManager {
});
}
} else {
// For global subscribers, skip internal agents during replay
// Global subscribers skip internal agents unless they opted in.
for (const agent of this.agents.values()) {
if (agent.internal) {
if (agent.internal && !record.includeInternal) {
continue;
}
callback({
@@ -983,12 +1006,26 @@ export class AgentManager {
return this.subscribers.size;
}
listAgents(): ManagedAgent[] {
listAgents(options?: { includeInternal?: boolean }): ManagedAgent[] {
return Array.from(this.agents.values())
.filter((agent) => !agent.internal)
.filter((agent) => options?.includeInternal === true || !agent.internal)
.map((agent) => Object.assign({}, agent));
}
/** Archived internal agents still readable by id; see getRetiredInternalAgent. */
listRetiredInternalAgents(): RetiredInternalAgent[] {
const now = Date.now();
const retired: RetiredInternalAgent[] = [];
for (const [agentId, entry] of this.retiredInternalAgents) {
if (entry.expiresAt <= now) {
this.retiredInternalAgents.delete(agentId);
continue;
}
retired.push({ record: entry.record, lastMessage: entry.lastMessage });
}
return retired;
}
async listImportableSessions(
options?: ImportablePersistedAgentQueryOptions,
): Promise<ManagedImportableSessionsResult> {
@@ -1672,6 +1709,42 @@ export class AgentManager {
}
}
/**
* The archived snapshot of an internal agent, for a while after archive.
* Null once it has expired or was never an internal agent.
*/
getRetiredInternalAgent(agentId: string): RetiredInternalAgent | null {
const entry = this.retiredInternalAgents.get(agentId);
if (!entry) {
return null;
}
if (entry.expiresAt <= Date.now()) {
this.retiredInternalAgents.delete(agentId);
return null;
}
return { record: entry.record, lastMessage: entry.lastMessage };
}
private retireInternalAgent(entry: RetiredInternalAgent): void {
const now = Date.now();
for (const [agentId, retired] of this.retiredInternalAgents) {
if (retired.expiresAt <= now) {
this.retiredInternalAgents.delete(agentId);
}
}
while (this.retiredInternalAgents.size >= RETIRED_INTERNAL_AGENT_LIMIT) {
const oldest = this.retiredInternalAgents.keys().next().value;
if (oldest === undefined) {
break;
}
this.retiredInternalAgents.delete(oldest);
}
this.retiredInternalAgents.set(entry.record.id, {
...entry,
expiresAt: now + RETIRED_INTERNAL_AGENT_TTL_MS,
});
}
closeAgent(agentId: string): Promise<void> {
const existing = this.inFlightAgentCloses.get(agentId);
if (existing) {
@@ -1757,6 +1830,23 @@ export class AgentManager {
requestedArchivedAt?: string,
): Promise<{ archivedAt: string }> {
const agent = this.requireAgent(agentId);
if (agent.internal) {
// Nothing about an internal agent is on disk, and archiving must not put
// it there. Its final snapshot is kept in memory for a while (see
// getRetiredInternalAgent); the runtime is closed and the committed
// timeline dropped, so `getAgent` returns null from here on.
const archivedAt = requestedArchivedAt ?? new Date().toISOString();
const lastMessage = await this.getLastAssistantMessage(agentId);
const record = buildArchivedAgentRecord(
toStoredAgentRecord(agent, { title: agent.config.title ?? null, internal: true }),
{ archivedAt, updatedAt: archivedAt },
);
await this.closeAgentRuntime(agentId);
await this.deleteAgentState(agentId);
this.retireInternalAgent({ record, lastMessage });
await this.cascadeArchiveChildren(record);
return { archivedAt };
}
if (!this.registry) {
throw new Error("Agent storage is not configured");
}
@@ -1775,7 +1865,7 @@ export class AgentManager {
await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive");
this.discardRetainedAgentState(agentId);
await this.cascadeArchiveChildren(agentId);
await this.cascadeArchiveChildren(stored);
return { archivedAt };
}
@@ -1784,16 +1874,15 @@ export class AgentManager {
// label pointing back at the caller. Archiving the parent cascades to those
// children so subagent fleets don't outlive their orchestrator. Detached
// handoff agents omit this label, so they stand outside the cascade.
private async cascadeArchiveChildren(parentAgentId: string): Promise<void> {
private async cascadeArchiveChildren(
parent: Pick<StoredAgentRecord, "id" | "workspaceId">,
): Promise<void> {
const registry = this.registry;
if (!registry) {
return;
}
const parentAgentId = parent.id;
const records = await registry.list();
const parent = records.find((record) => record.id === parentAgentId);
if (!parent) {
throw new Error(`Archived parent ${parentAgentId} not found in storage`);
}
for (const record of records) {
if (record.archivedAt) {
continue;
@@ -1823,6 +1912,21 @@ export class AgentManager {
}
});
}
// Internal children never reach storage, so the scan above cannot see them.
// They have no tab to keep open and no listing to detach into, so they
// always archive with the parent.
for (const child of Array.from(this.agents.values())) {
if (!child.internal || getParentAgentIdFromLabels(child.labels) !== parentAgentId) {
continue;
}
await this.runLifecycleMutation(child.id, async () => {
const current = this.agents.get(child.id);
if (!current || getParentAgentIdFromLabels(current.labels) !== parentAgentId) {
return;
}
await this.archiveAgentUnlocked(current.id);
});
}
}
private async markRecordArchived(
@@ -2198,7 +2302,7 @@ export class AgentManager {
if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord);
await this.fireAgentArchived(agentId);
await this.cascadeArchiveChildren(agentId);
await this.cascadeArchiveChildren(record);
return nextRecord;
}
@@ -5084,8 +5188,12 @@ export class AgentManager {
) {
continue;
}
// Skip internal agents for global subscribers (those without a specific agentId)
if (!subscriber.agentId && this.eventBelongsToInternalAgent(event)) {
// Global subscribers (no agentId) skip internal agents unless they opted in.
if (
!subscriber.agentId &&
!subscriber.includeInternal &&
this.eventBelongsToInternalAgent(event)
) {
continue;
}
subscriber.callback(event);
@@ -215,22 +215,19 @@ function createMcpRecordingAgentClients(recorder: LaunchRecorder) {
};
}
async function assertAgentNotRunning(options: {
client: McpClient;
agentId: string;
}): Promise<void> {
const statusResult = await options.client.callTool({
name: "get_agent_status",
args: { agentId: options.agentId },
});
const payload = getStructuredContent(statusResult);
if (!payload) {
throw new Error("get_agent_status returned no structured payload");
}
const status = payload.status;
if (status === "running" || status === "initializing") {
throw new Error(`Agent still running after blocking create_agent (status=${status})`);
}
async function waitForAgentFinish(options: { client: McpClient; agentId: string }): Promise<void> {
await expect
.poll(
async () => {
const result = await options.client.callTool({
name: "get_agent_status",
args: { agentId: options.agentId },
});
return getStructuredContent(result)?.status;
},
{ timeout: 10000 },
)
.toBe("idle");
}
describe("agent MCP end-to-end (offline)", () => {
@@ -276,7 +273,6 @@ describe("agent MCP end-to-end (offline)", () => {
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt,
background: false,
},
});
@@ -284,7 +280,7 @@ describe("agent MCP end-to-end (offline)", () => {
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
expect(agentId).toBeTruthy();
await assertAgentNotRunning({ client, agentId: agentId! });
await waitForAgentFinish({ client, agentId: agentId! });
if (existsSync(filePath)) {
const contents = await readFile(filePath, "utf8");
@@ -377,7 +373,6 @@ describe("agent MCP end-to-end (offline)", () => {
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt: "reply with done and stop",
background: true,
},
});
const payload = getStructuredContent(result);
@@ -452,7 +447,6 @@ describe("agent MCP end-to-end (offline)", () => {
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt: "reply with done and stop",
background: true,
},
});
const payload = getStructuredContent(result);
@@ -476,7 +470,6 @@ describe("agent MCP end-to-end (offline)", () => {
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt: "reply with done and stop",
background: true,
},
});
const disabledPayload = getStructuredContent(disabledResult);
@@ -541,7 +534,6 @@ describe("agent MCP end-to-end (offline)", () => {
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt: "reply with done and stop",
background: true,
},
});
const payload = getStructuredContent(result);
@@ -601,7 +593,6 @@ describe("agent MCP end-to-end (offline)", () => {
provider: "codex/gpt-5.4-mini",
mode: "full-access",
initialPrompt: "Run exactly: sleep 30",
background: true,
},
});
@@ -772,7 +763,6 @@ describe("agent MCP end-to-end (offline)", () => {
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
let agentId: string | null = null;
try {
const result = await client.callTool({
name: "create_agent",
@@ -782,29 +772,13 @@ describe("agent MCP end-to-end (offline)", () => {
provider: "codex/gpt-5.4-mini",
mode: "full-access",
initialPrompt: "Run exactly: sleep 30",
background: true,
},
});
const payload = getStructuredContent(result);
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
expect(agentId).toBeTruthy();
await assertAgentNotRunning({ client, agentId: agentId! });
const statusResult = await client.callTool({
name: "get_agent_status",
args: { agentId },
});
const statusPayload = getStructuredContent(statusResult);
expect(statusPayload?.status).toBe("error");
const snapshot = statusPayload?.snapshot;
const lastError =
snapshot && typeof snapshot === "object" ? Reflect.get(snapshot, "lastError") : undefined;
expect(lastError).toContain("Initial turn failed to start");
expect(result.isError).toBe(true);
expect(JSON.stringify(result)).toContain("Initial turn failed to start");
expect(getStructuredContent(result)?.agentId).toBeUndefined();
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
}
await client.close();
await daemon.stop();
await rm(paseoHome, { recursive: true, force: true });
@@ -880,7 +854,6 @@ describe("agent MCP end-to-end (offline)", () => {
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
baseBranch: "main",
background: true,
},
}),
timeoutMs: 2500,
@@ -407,6 +407,17 @@ describe("toAgentPayload", () => {
});
});
it("carries a stored lastError onto the payload", () => {
const record = toStoredAgentRecord(
createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }),
);
expect(buildStoredAgentPayload(record, ["codex"]).lastError).toBe("boom");
expect(buildStoredAgentPayload({ ...record, lastError: null }, ["codex"])).not.toHaveProperty(
"lastError",
);
});
it("omits lastUsage when not available", () => {
const agent = createManagedAgent({ lastUsage: undefined });
const payload = toAgentPayload(agent);
@@ -139,6 +139,7 @@ export function toAgentPayload(
persistence: projectPersistenceHandleForWire(agent.persistence),
title: options?.title ?? null,
labels: agent.labels,
...(agent.internal ? { internal: true } : {}),
};
const usage = sanitizeUsage(agent.lastUsage);
@@ -247,7 +248,9 @@ export function buildStoredAgentPayload(
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
archivedAt: record.archivedAt ?? null,
...(record.internal ? { internal: true } : {}),
labels: normalizeLabels(record.labels),
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
...(providerAvailable ? {} : { providerUnavailable: true }),
};
}
@@ -28,6 +28,8 @@ export type AgentRunController = Pick<
};
export interface StartAgentRunOptions {
/** Called after selecting the run, before consuming its events. */
onDispatch?: (disposition: PromptDispatchDisposition) => void;
replaceRunning?: boolean;
activeTurnBehavior?: ActiveTurnBehavior;
runOptions?: AgentRunOptions;
@@ -114,6 +116,7 @@ export async function startAgentRun(
// in-flight turn — replaceAgentRun would interrupt the running turn. The
// intercept lives at this layer so it covers every prompt entrypoint.
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
options?.onDispatch?.("out_of_band");
return { disposition: "out_of_band" };
}
try {
@@ -138,6 +141,7 @@ async function startAgentRunInner(
const snapshot = agentManager.getAgent(agentId);
const steered = await steerOrReplaceActiveRun(agentManager, agentId, prompt, options);
if (steered?.disposition === "steered") {
options?.onDispatch?.("steered");
return steered;
}
const { iterator, replaced } = steered
@@ -152,6 +156,7 @@ async function startAgentRunInner(
},
"agent.session.start_stream.iterator_returned",
);
options?.onDispatch?.("turn_started");
void (async () => {
try {
try {
@@ -208,6 +213,7 @@ export async function unarchiveAgentState(
}
export interface SendPromptToAgentParams {
onDispatch?: StartAgentRunOptions["onDispatch"];
agentManager: AgentManager;
agentStorage: AgentStorage;
agentId: string;
@@ -347,6 +353,7 @@ export async function sendPromptToAgent(
return await startAgentRun(params.agentManager, params.agentId, delivery.prompt, params.logger, {
replaceRunning: true,
onDispatch: params.onDispatch,
activeTurnBehavior: params.activeTurnBehavior,
clearPendingPermissions: params.clearPendingPermissions,
runOptions,
@@ -395,6 +402,8 @@ export interface SetupFinishNotificationParams {
childAgentId: string;
callerAgentId: string;
requireParentOwnership?: boolean;
/** A replacement must observe its new start, not the displaced running snapshot. */
waitForTurnStart?: boolean;
logger: Logger;
}
@@ -455,16 +464,18 @@ interface NotifySafelyOptions {
// next finish reaches the caller once.
const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>();
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
export function setupFinishNotification(params: SetupFinishNotificationParams): () => void {
const {
agentManager,
agentStorage,
childAgentId,
callerAgentId,
requireParentOwnership = false,
waitForTurnStart = false,
logger,
} = params;
let hasSeenRunning = false;
let hasSeenTurn = false;
let stopped = false;
const notifiedPermissionRequestIds = new Set<string>();
let unsubscribe: (() => void) | null = null;
@@ -537,6 +548,37 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
});
}
function observeState(agent: ManagedAgent): void {
for (const requestId of notifiedPermissionRequestIds) {
if (!agent.pendingPermissions.has(requestId)) {
notifiedPermissionRequestIds.delete(requestId);
}
}
if (agent.lifecycle === "running") {
if (waitForTurnStart && !hasSeenTurn) return;
hasSeenTurn = true;
if (agent.pendingPermissions.size === 0) {
hasSeenRunning = true;
}
return;
}
// Prior terminal state is not this task's outcome. A failure before turn start
// is reported by the dispatch acknowledgement instead of a finish callback.
if (agent.lifecycle === "error" && hasSeenTurn) {
notifySafely("errored");
return;
}
if (agent.lifecycle === "idle" && hasSeenRunning) {
notifySafely("finished");
return;
}
if (agent.lifecycle === "closed") {
notifySafely("was closed");
return;
}
return;
}
unsubscribe = agentManager.subscribe(
(event) => {
if (stopped) {
@@ -544,29 +586,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
}
if (event.type === "agent_state") {
for (const requestId of notifiedPermissionRequestIds) {
if (!event.agent.pendingPermissions.has(requestId)) {
notifiedPermissionRequestIds.delete(requestId);
}
}
if (event.agent.lifecycle === "running") {
if (event.agent.pendingPermissions.size === 0) {
hasSeenRunning = true;
}
return;
}
if (event.agent.lifecycle === "error") {
notifySafely("errored");
return;
}
if (event.agent.lifecycle === "idle" && hasSeenRunning) {
notifySafely("finished");
return;
}
if (event.agent.lifecycle === "closed") {
notifySafely("was closed");
return;
}
observeState(event.agent);
return;
}
@@ -574,6 +594,12 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return;
}
if (event.event.type === "turn_started") {
hasSeenTurn = true;
hasSeenRunning = true;
return;
}
if (event.event.type === "permission_requested") {
// A permission pause is an intermediate checkpoint. Forget the run
// observed before it so an idle state during follow-up startup cannot
@@ -608,11 +634,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
const childSnapshot = agentManager.getAgent(childAgentId);
if (!childSnapshot || childSnapshot.lifecycle === "closed") {
stop();
return;
return stop;
}
if (childSnapshot.lifecycle === "running") {
if (childSnapshot.lifecycle === "running" && !waitForTurnStart) {
hasSeenTurn = true;
hasSeenRunning = true;
} else if (childSnapshot.lifecycle === "error") {
notifySafely("errored");
}
return stop;
}
@@ -7,13 +7,11 @@ import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-se
import type {
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
CreatePaseoWorktreeSetupContinuationInput,
NormalizedGitOptions,
} from "../worktree-session.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type {
CreateAgentWorktreeTarget,
FirstAgentContext,
SessionOutboundMessage,
} from "../messages.js";
import type { FirstAgentContext, SessionOutboundMessage } from "../messages.js";
import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
@@ -59,18 +57,34 @@ export class CreateAgentLifecycleDispatch {
async createWorktreeForRequest(input: {
cwd: string;
target: CreateAgentWorktreeTarget | undefined;
target: NormalizedGitOptions | null;
firstAgentContext: FirstAgentContext;
hasLegacyGitOptions: boolean;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
background?: boolean;
callerWorkspaceId?: string;
}): Promise<CreatePaseoWorktreeWorkflowResult | null> {
if (input.target && input.hasLegacyGitOptions) {
throw new Error("create_agent_request worktree cannot be combined with git options");
}
if (!input.target) {
return null;
}
return this.createWorktreeForTarget(input.cwd, input.target, input.firstAgentContext);
const target = input.target;
if (!target?.createWorktree) return null;
return this.dependencies.createPaseoWorktreeWorkflow(
{
cwd: input.cwd,
background: input.background,
callerWorkspaceId: input.callerWorkspaceId,
firstAgentContext: input.firstAgentContext,
worktreeSlug: target.worktreeSlug,
refName: target.refName,
action: target.action,
checkoutSource: target.checkoutSource,
githubPrNumber: target.githubPrNumber,
runSetup: false,
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
},
{
...(target.baseBranch ? { resolveDefaultBranch: async () => target.baseBranch! } : {}),
setupContinuation: input.setupContinuation,
},
);
}
registerAutoArchiveIfRequested(input: {
@@ -111,47 +125,6 @@ export class CreateAgentLifecycleDispatch {
});
}
private async createWorktreeForTarget(
cwd: string,
target: CreateAgentWorktreeTarget,
firstAgentContext: FirstAgentContext,
): Promise<CreatePaseoWorktreeWorkflowResult> {
const baseInput = {
cwd,
firstAgentContext,
runSetup: false,
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
} as const;
switch (target.mode) {
case "branch-off":
return this.dependencies.createPaseoWorktreeWorkflow(
{
...baseInput,
worktreeSlug: target.newBranch,
action: "branch-off",
...(target.base ? { refName: target.base } : {}),
},
target.base ? { resolveDefaultBranch: async () => target.base! } : undefined,
);
case "checkout-branch":
return this.dependencies.createPaseoWorktreeWorkflow({
...baseInput,
action: "checkout",
refName: target.branch,
});
case "checkout-pr":
return this.dependencies.createPaseoWorktreeWorkflow({
...baseInput,
action: "checkout",
githubPrNumber: target.prNumber,
});
default:
throw new Error("Unsupported create_agent_request worktree target");
}
}
private registerAutoArchiveOnTerminalState(
agentId: string,
target: AutoArchiveTarget,
@@ -11,6 +11,7 @@ import { AgentStorage } from "../agent-storage.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import { createAgentCommand } from "./create.js";
import type { ManagedAgent } from "../agent-manager.js";
import type { AgentSessionConfig } from "../agent-sdk-types.js";
const logger = createTestLogger();
@@ -211,7 +212,6 @@ test("mcp create accepts provider-only internal input and leaves model undefined
workspaceId: "ws-create-test",
title: "provider default",
initialPrompt: "hello",
background: true,
notifyOnFinish: false,
});
@@ -329,7 +329,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
provider: "codex/gpt-5.4",
title: "child",
initialPrompt: "do the thing",
background: true,
notifyOnFinish: false,
callerAgentId: parent.id,
worktree: { worktreeName: "feature", baseBranch: "main" },
@@ -378,7 +377,6 @@ test("mcp create exposes the created worktree before dispatching the initial pro
cwd: workdir,
title: "worktree callback",
initialPrompt: "Say done.",
background: true,
notifyOnFinish: false,
worktree: { worktreeName: "feature", baseBranch: "main" },
onCreated: ({ agentId, createdWorktree: callbackWorktree }) => {
@@ -471,3 +469,47 @@ test("session create keeps an explicit title after the initial prompt settles",
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
}
});
test("session create with an internal config skips provider session persistence", async () => {
const snapshot = {
id: "agent-1",
provider: "codex",
cwd: "/tmp/paseo-create-test",
runtimeInfo: null,
} as ManagedAgent;
const createAgent = vi.fn(async () => snapshot);
const dependencies: Parameters<typeof createAgentCommand>[0] = {
agentManager: {
createAgent,
} as unknown as Parameters<typeof createAgentCommand>[0]["agentManager"],
agentStorage: {} as Parameters<typeof createAgentCommand>[0]["agentStorage"],
logger: createTestLogger(),
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
};
const input = {
kind: "session" as const,
workspaceId: "ws-create-test",
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config: AgentSessionConfig) => ({ sessionConfig: config }),
};
await createAgentCommand(dependencies, {
...input,
config: { provider: "codex", cwd: "/tmp/paseo-create-test", internal: true },
});
await createAgentCommand(dependencies, {
...input,
config: { provider: "codex", cwd: "/tmp/paseo-create-test" },
});
expect(createAgent).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ provider: "codex", internal: true }),
undefined,
expect.objectContaining({ persistSession: false }),
);
expect(createAgent.mock.calls[1]?.[0]).not.toHaveProperty("internal");
expect(createAgent.mock.calls[1]?.[2]).not.toHaveProperty("persistSession");
});
@@ -52,6 +52,7 @@ export interface CreateAgentCommandDependencies {
export type EnsureWorkspaceForCreate = (
cwd: string,
firstAgentContext?: FirstAgentContext,
context?: { callerWorkspaceId?: string },
) => Promise<string>;
export interface CreateAgentFromSessionInput {
@@ -94,7 +95,6 @@ export interface CreateAgentFromMcpInput {
mode?: string;
unattended?: boolean;
promptFailure?: CreateAgentPromptFailureMode;
background: boolean;
notifyOnFinish: boolean;
internal?: boolean;
detached?: boolean;
@@ -127,7 +127,6 @@ export type CreateAgentPromptFailureMode = "throw" | "log" | "return-error";
export interface CreateAgentCommandResult {
snapshot: ManagedAgent;
liveSnapshot: ManagedAgent;
background: boolean;
initialPromptStarted: boolean;
initialPromptError: unknown | null;
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
@@ -169,7 +168,6 @@ interface ResolvedCreateAgent {
source?: AgentPromptSource;
runOptions?: AgentRunOptions;
setupContinuation?: AgentWorktreeSetupContinuation;
background: boolean;
promptFailure: CreateAgentPromptFailureMode;
promptLogger?: Logger;
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
@@ -190,10 +188,6 @@ export async function createAgentCommand(
resolved.createOptions,
);
resolved.setupContinuation?.startAfterAgentCreate({
agentId: snapshot.id,
});
if (input.kind === "session") await input.onAgentReady?.(snapshot);
let liveSnapshot = snapshot;
@@ -202,28 +196,37 @@ export async function createAgentCommand(
if (input.kind === "mcp") {
input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null });
}
const stopNotification =
input.kind === "mcp" &&
input.notifyOnFinish &&
input.callerAgentId &&
(resolved.prompt !== undefined || resolved.setupContinuation !== undefined)
? setupFinishNotification({
agentManager: dependencies.agentManager,
agentStorage: dependencies.agentStorage,
childAgentId: snapshot.id,
callerAgentId: input.callerAgentId,
requireParentOwnership: true,
logger: dependencies.logger,
})
: undefined;
resolved.setupContinuation?.startAfterAgentCreate({
agentId: snapshot.id,
});
if (resolved.prompt !== undefined) {
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot);
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => {
stopNotification?.();
throw error;
});
initialPromptStarted = sendResult.started;
liveSnapshot = sendResult.liveSnapshot;
initialPromptError = sendResult.error ?? null;
}
if (input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && initialPromptStarted) {
setupFinishNotification({
agentManager: dependencies.agentManager,
agentStorage: dependencies.agentStorage,
childAgentId: snapshot.id,
callerAgentId: input.callerAgentId,
requireParentOwnership: true,
logger: dependencies.logger,
});
}
return {
snapshot,
liveSnapshot,
background: resolved.background,
initialPromptStarted,
initialPromptError,
...(resolved.createdWorktree ? { createdWorktree: resolved.createdWorktree } : {}),
@@ -290,6 +293,9 @@ async function resolveSessionCreateAgent(
initialPrompt: trimmedPrompt,
env: input.env,
initialTitle: input.provisionalTitle,
// Paseo never persists an internal agent, so nothing could resume its
// provider session either. Same as the daemon's own ephemeral helpers.
...(sessionConfig.internal ? { persistSession: false } : {}),
// A legacy git/worktreeName worktree creates a fresh workspace, so the
// agent belongs to that workspace, not the source one. createdWorkspaceId
// is the freshly created worktree's workspace.
@@ -299,7 +305,6 @@ async function resolveSessionCreateAgent(
source: input.source,
runOptions,
setupContinuation,
background: true,
// Registration commits creation. First-turn failures belong to the created agent.
promptFailure: "return-error",
promptLogger: dependencies.logger.child({
@@ -324,6 +329,7 @@ async function resolveMcpCreateAgent(
cwd,
worktree: input.worktree,
initialPrompt: input.initialPrompt ?? "",
callerWorkspaceId: parentAgent?.workspaceId,
});
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
@@ -338,7 +344,12 @@ async function resolveMcpCreateAgent(
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: resolvedCwd }),
createWorkspace: async () => ({
workspaceId: requireResolvedWorkspaceId(
await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt ?? ""),
await ensureWorkspaceForMcpCreate(
dependencies,
resolvedCwd,
input.initialPrompt ?? "",
parentAgent?.workspaceId,
),
),
cwd: resolvedCwd,
}),
@@ -374,7 +385,6 @@ async function resolveMcpCreateAgent(
: undefined,
setupContinuation,
createdWorktree,
background: input.background,
promptFailure: input.promptFailure ?? "log",
};
}
@@ -449,11 +459,16 @@ async function ensureWorkspaceForMcpCreate(
dependencies: CreateAgentCommandDependencies,
cwd: string,
initialPrompt: string,
callerWorkspaceId?: string,
): Promise<string | undefined> {
if (!dependencies.ensureWorkspaceForCreate) {
return undefined;
}
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt });
return dependencies.ensureWorkspaceForCreate(
cwd,
{ prompt: initialPrompt },
{ callerWorkspaceId },
);
}
async function sendInitialPrompt(
@@ -524,6 +539,7 @@ async function resolveMcpCwd(params: {
dependencies: CreateAgentCommandDependencies;
cwd: string;
initialPrompt: string;
callerWorkspaceId?: string;
worktree: CreateAgentFromMcpInput["worktree"];
}): Promise<{
resolvedCwd: string;
@@ -554,6 +570,7 @@ async function resolveMcpCwd(params: {
const createdWorktree = await createMcpWorktree({
input: {
cwd: params.cwd,
callerWorkspaceId: params.callerWorkspaceId,
worktreeSlug: worktree.worktreeName,
branchName: worktree.branchName,
refName: worktree.refName,
@@ -0,0 +1,65 @@
import type { AgentSnapshotPayload } from "../messages.js";
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. */
export class AgentDirectory {
constructor(
private readonly deps: {
manager: AgentManager;
storage: AgentStorage;
workspaces: Pick<WorkspaceRegistry, "get" | "list">;
projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>;
projectStored(record: StoredAgentRecord): AgentSnapshotPayload;
isProviderVisible(provider: string): boolean;
isStoredProviderAvailable(record: StoredAgentRecord): boolean;
},
) {}
async includes(
agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">,
includeBackground = false,
): Promise<boolean> {
if (agent.internal) return false;
if (includeBackground || !agent.workspaceId) return true;
return !(await this.deps.workspaces.get(agent.workspaceId))?.background;
}
async list(filter?: {
labels?: Record<string, string>;
includeArchived?: boolean;
includeBackground?: boolean;
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
? []
: (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>;
}) =>
(filter?.includeArchived || !agent.archivedAt) &&
(filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) &&
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) =>
filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record),
)
.map((record) => this.deps.projectStored(record));
const active = await Promise.all(
live.filter(matches).map((agent) => this.deps.projectLive(agent)),
);
return [...active, ...stored].filter((agent) => this.deps.isProviderVisible(agent.provider));
}
}
@@ -219,6 +219,25 @@ describe("agent lifecycle commands", () => {
expect(manager.archivedAgentIds).toEqual(["agent-1"]);
});
test("archives a live internal agent without touching storage", async () => {
const storage = new FakeLifecycleAgentStorage();
const manager = new FakeLifecycleAgentManager(storage);
manager.liveAgents.set("agent-1", { ...managedAgent("agent-1", "idle"), internal: true });
const result = await archiveAgentCommand(
{ agentManager: manager, agentStorage: storage, logger },
"agent-1",
);
expect(result).toEqual({
agentId: "agent-1",
archivedAt: "2026-05-10T10:00:00.000Z",
record: null,
});
expect(manager.archivedAgentIds).toEqual(["agent-1"]);
expect(storage.upserts).toEqual([]);
});
test("archives a live agent when its graceful cancellation is rejected", async () => {
const storage = new FakeLifecycleAgentStorage();
const manager = new FakeLifecycleAgentManager(storage);
@@ -8,7 +8,7 @@ import {
import type { StoredAgentRecord } from "./agent-storage.js";
import type { AgentProviderNotice } from "./agent-sdk-types.js";
export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle">;
export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle" | "internal">;
export interface LifecycleAgentManager {
getAgent(agentId: string): LifecycleAgentSnapshot | null;
@@ -112,7 +112,8 @@ export async function cancelAgentRunCommand(
export interface ArchiveAgentResult {
agentId: string;
archivedAt: string;
record: StoredAgentRecord;
/** Null for an internal agent, which is closed and leaves no record behind. */
record: StoredAgentRecord | null;
}
export async function archiveAgentCommand(
@@ -124,7 +125,10 @@ export async function archiveAgentCommand(
if (liveAgent) {
await requestAgentRunCancellation(dependencies, agentId);
await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined);
await dependencies.agentManager.archiveAgent(agentId);
const archived = await dependencies.agentManager.archiveAgent(agentId);
if (liveAgent.internal) {
return { agentId, archivedAt: archived.archivedAt, record: null };
}
record = await dependencies.agentStorage.get(agentId);
} else {
record = await archiveStoredAgent(dependencies, agentId);
@@ -215,7 +215,6 @@ async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<s
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
settings: { modeId: "bypassPermissions" },
background: true,
...rest,
});
return str(payload.agentId);
@@ -267,28 +266,6 @@ async function killTerminalIfPresent(terminalId: string | null | undefined): Pro
}
}
async function archiveWorktreeIfPresent(params: {
cwd: string;
worktreePath?: string | null;
worktreeSlug?: string | null;
}): Promise<void> {
if (!params.worktreePath && !params.worktreeSlug) {
return;
}
try {
await topLevelClient.callTool({
name: "archive_worktree",
args: {
cwd: params.cwd,
...(params.worktreePath ? { worktreePath: params.worktreePath } : {}),
...(params.worktreeSlug ? { worktreeSlug: params.worktreeSlug } : {}),
},
});
} catch {
// ignore cleanup errors
}
}
beforeAll(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), "mcp-parity-e2e-"));
parentAgentCwd = await makeCwd("parent-agent-cwd");
@@ -307,7 +284,6 @@ beforeAll(async () => {
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
settings: { modeId: "bypassPermissions" },
background: true,
});
parentAgentId = str(parentPayload.agentId);
@@ -845,117 +821,107 @@ describe("Suite D: Provider Tools", () => {
});
});
describe("Suite E: Worktree Tools", () => {
test("list_worktrees on empty repo", async () => {
const payload = await callToolStructured(topLevelClient, "list_worktrees", {
cwd: worktreeRepoCwd,
describe("Suite E: Workspace Tools", () => {
test("background worktree creation, discovery and archive use ordinary workspace ownership", async () => {
const created = await callToolStructured(topLevelClient, "create_workspace", {
isolation: "worktree",
path: worktreeRepoCwd,
baseBranch: "main",
branchName: `parity-background-${Date.now()}`,
background: true,
});
expect(payload.worktrees).toEqual([]);
});
test("create_worktree and list_worktrees", async () => {
let worktreePath: string | null = null;
const branchName = `parity-create-${Date.now()}`;
const workspaceId = str(created.workspaceId);
try {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
target: {
kind: "branch-off",
worktreeSlug: branchName,
baseBranch: "main",
},
expect(created.background).toBe(true);
const ordinary = await callToolStructured(topLevelClient, "list_workspaces", {});
expect(
recordArr(ordinary.workspaces).map((workspace) => workspace.workspaceId),
).not.toContain(workspaceId);
const inclusive = await callToolStructured(topLevelClient, "list_workspaces", {
includeBackground: true,
});
worktreePath = str(created.worktreePath);
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
cwd: worktreeRepoCwd,
});
const worktrees = recordArr(listed.worktrees);
expect(worktrees).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: worktreePath,
branchName,
}),
]),
expect(recordArr(inclusive.workspaces)).toContainEqual(
expect.objectContaining({ workspaceId, background: true }),
);
const child = await callToolStructured(topLevelClient, "create_agent", {
workspaceId,
title: "Background worker",
provider: "claude/claude-test-model",
initialPrompt: "Say done.",
});
const agentId = str(child.agentId);
const workerClient = await createMcpClient(
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${agentId}`,
);
let terminalId: string;
try {
const terminal = await callToolStructured(workerClient, "create_terminal", {
name: "Background terminal",
});
terminalId = str(terminal.id);
expect(terminal.cwd).toBe(created.cwd);
} finally {
await workerClient.close();
}
const status = await callToolStructured(topLevelClient, "get_agent_status", { agentId });
expect((status.snapshot as StructuredContent).workspaceId).toBe(workspaceId);
const archived = await callToolStructured(topLevelClient, "archive_workspace", {
workspaceId,
});
expect(archived.archivedAgentIds).toContain(agentId);
const terminals = await callToolStructured(topLevelClient, "list_terminals", { all: true });
expect(recordArr(terminals.terminals).map((terminal) => terminal.id)).not.toContain(
terminalId,
);
const remaining = await callToolStructured(topLevelClient, "list_workspaces", {
includeBackground: true,
});
expect(
recordArr(remaining.workspaces).map((workspace) => workspace.workspaceId),
).not.toContain(workspaceId);
} finally {
await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
await topLevelClient
.callTool({ name: "archive_workspace", args: { workspaceId } })
.catch(() => undefined);
}
});
test("archive_worktree removes worktree", async () => {
let worktreePath: string | null = null;
const branchName = `parity-archive-${Date.now()}`;
test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => {
const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", {
isolation: "local",
path: worktreeRepoCwd,
background: true,
});
const parentWorkspaceId = str(parentWorkspace.workspaceId);
let scoped: McpClient | null = null;
const ownedWorkspaces: string[] = [parentWorkspaceId];
try {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
target: {
kind: "branch-off",
worktreeSlug: branchName,
baseBranch: "main",
},
const parent = await callToolStructured(topLevelClient, "create_agent", {
workspaceId: parentWorkspaceId,
title: "Background parent",
provider: "claude/claude-test-model",
initialPrompt: "Say done.",
});
worktreePath = str(created.worktreePath);
await callToolStructured(topLevelClient, "archive_worktree", {
cwd: worktreeRepoCwd,
worktreePath,
});
worktreePath = null;
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
cwd: worktreeRepoCwd,
});
const worktrees = recordArr(listed.worktrees);
expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
} finally {
await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
}
});
test("archive_worktree succeeds when caller cwd is inside the archived worktree", async () => {
let worktreePath: string | null = null;
let worktreeAgentId: string | null = null;
let worktreeScopedClient: McpClient | null = null;
const branchName = `parity-archive-self-cwd-${Date.now()}`;
try {
const created = await callToolStructured(topLevelClient, "create_worktree", {
cwd: worktreeRepoCwd,
target: {
kind: "branch-off",
worktreeSlug: branchName,
baseBranch: "main",
},
});
worktreePath = str(created.worktreePath);
worktreeAgentId = await createTopLevelAgent({
cwd: worktreePath,
title: "Worktree scoped parity agent",
});
worktreeScopedClient = await createMcpClient(
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${encodeURIComponent(
worktreeAgentId,
)}`,
scoped = await createMcpClient(
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${str(parent.agentId)}`,
);
const archived = await callToolStructured(worktreeScopedClient, "archive_worktree", {
worktreePath,
const inherited = await callToolStructured(scoped, "create_workspace", {
isolation: "local",
});
expect(archived).toEqual({ success: true });
worktreePath = null;
worktreeAgentId = null;
const listed = await callToolStructured(topLevelClient, "list_worktrees", {
cwd: worktreeRepoCwd,
ownedWorkspaces.push(str(inherited.workspaceId));
expect(inherited.background).toBe(true);
const visible = await callToolStructured(scoped, "create_workspace", {
isolation: "local",
background: false,
});
const worktrees = recordArr(listed.worktrees);
expect(worktrees.map((worktree) => worktree.path)).not.toContain(created.worktreePath);
ownedWorkspaces.push(str(visible.workspaceId));
expect(visible.background).toBe(false);
} finally {
await worktreeScopedClient?.close();
await archiveAgentIfPresent(worktreeAgentId);
await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath });
await scoped?.close();
for (const workspaceId of ownedWorkspaces.toReversed())
await topLevelClient
.callTool({ name: "archive_workspace", args: { workspaceId } })
.catch(() => undefined);
}
});
});
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
import { z } from "zod";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js";
import { createAgentMcpServer as createAgentMcpServerWithDependencies } from "./mcp-server.js";
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
@@ -833,6 +833,14 @@ function createPaseoWorktreeForMcpTest(options: {
};
}
// Typed in-memory workspace port for catalogue tests without workspace setup.
function createAgentMcpServer(options: Parameters<typeof createAgentMcpServerWithDependencies>[0]) {
return createAgentMcpServerWithDependencies({
workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} },
...options,
});
}
describe("browser MCP tools", () => {
const logger = createTestLogger();
@@ -1327,10 +1335,13 @@ describe("create_agent MCP tool", () => {
title: "Top-level agent",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expect(ensureWorkspace).toHaveBeenCalledWith(existingCwd, { prompt: "Do work" });
expect(ensureWorkspace).toHaveBeenCalledWith(
existingCwd,
{ prompt: "Do work" },
{ callerWorkspaceId: undefined },
);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ cwd: existingCwd }),
undefined,
@@ -1338,6 +1349,32 @@ describe("create_agent MCP tool", () => {
);
});
it("rejects public internal helpers and the old top-level execution background option", async () => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const agentManager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
});
try {
const server = await createAgentMcpServer({
agentManager,
agentStorage: storage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
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);
}
});
it("rejects partial explicit workspace shape", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
@@ -1446,7 +1483,6 @@ describe("create_agent MCP tool", () => {
title: "Existing workspace",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expect(response.structuredContent.workspaceId).toBe("wks_existing");
@@ -1483,7 +1519,7 @@ describe("create_agent MCP tool", () => {
title: "Feature test",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
settings: { features: { fast_mode: true } },
};
@@ -1536,7 +1572,6 @@ describe("create_agent MCP tool", () => {
title: "Mode test",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expect(response.structuredContent).toEqual(
@@ -1862,7 +1897,6 @@ describe("create_agent MCP tool", () => {
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expect(broadcasts).toHaveLength(1);
@@ -1950,7 +1984,6 @@ describe("create_agent MCP tool", () => {
title: "Worktree agent",
provider: "codex/gpt-5.4",
initialPrompt: "Fix workspace creation naming",
background: true,
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
@@ -2031,7 +2064,6 @@ describe("create_agent MCP tool", () => {
title: "Agent title",
provider: "codex/gpt-5.4",
initialPrompt: "Build a workspace auto title flow",
background: true,
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow");
@@ -2133,7 +2165,6 @@ describe("create_agent MCP tool", () => {
title: "Agent title",
provider: "codex/gpt-5.4",
initialPrompt: "Keep the manually renamed workspace title",
background: true,
});
const renameWorkspaceTool = registeredTool(server, "rename_workspace");
await renameWorkspaceTool.handler({
@@ -2223,7 +2254,6 @@ describe("create_agent MCP tool", () => {
title: "Explicit Agent Title",
provider: "codex/gpt-5.4",
initialPrompt: "Generate the workspace title anyway",
background: true,
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title");
@@ -2330,7 +2360,6 @@ describe("create_agent MCP tool", () => {
title: "Directory agent",
provider: "codex/gpt-5.4",
initialPrompt: "Name a directory workspace from the prompt",
background: true,
});
await waitForWorkspaceTitle(
workspaceRecords,
@@ -2437,7 +2466,6 @@ describe("create_agent MCP tool", () => {
title: "Checkout agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this checkout from the prompt",
background: true,
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
@@ -2544,7 +2572,6 @@ describe("create_agent MCP tool", () => {
title: "PR agent",
provider: "codex/gpt-5.4",
initialPrompt: "Rename this PR branch from prompt",
background: true,
});
expect(createPaseoWorktree).toHaveBeenCalledWith(
@@ -3056,6 +3083,47 @@ describe("create_agent MCP tool", () => {
]);
});
it("creates an background local workspace and leaves it out of list_workspaces", async () => {
const { agentManager, agentStorage } = createTestDeps();
const createCalls: Array<{ cwd: string; context: { background?: boolean } | undefined }> = [];
const backgroundWorkspace = createPersistedWorkspaceRecord({
workspaceId: "ws-background",
projectId: "project-1",
cwd: process.cwd(),
kind: "directory",
displayName: "helper",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z",
background: true,
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: vi.fn(async () => backgroundWorkspace),
list: vi.fn(async () => [backgroundWorkspace]),
upsert: vi.fn(async () => undefined),
},
createDirectoryWorkspace: async (cwd, _title, _projectId, context) => {
createCalls.push({ cwd, context });
return backgroundWorkspace;
},
logger,
});
const created = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), {
isolation: "local",
path: process.cwd(),
background: true,
});
expect(created.structuredContent.workspaceId).toBe("ws-background");
expect(createCalls).toEqual([{ cwd: process.cwd(), context: { background: true } }]);
const listed = await registeredTool(server, "list_workspaces").handler({});
expect(listed.structuredContent.workspaces).toEqual([]);
});
it("accepts custom provider IDs in create_agent input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
@@ -3084,6 +3152,7 @@ describe("create_agent MCP tool", () => {
await mkdir(subdir, { recursive: true });
spies.agentManager.getAgent.mockReturnValue({
id: "voice-agent",
config: { title: "Voice" },
cwd: baseDir,
workspaceId: "wks_voice",
provider: "codex",
@@ -3712,6 +3781,7 @@ class HeldTurnAgentSession implements AgentSession {
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private activeTurnId: string | null = null;
private turnStartGate: Promise<void> | null = null;
private nextStartError: Error | null = null;
constructor(
readonly provider: AgentProvider,
@@ -3731,12 +3801,37 @@ class HeldTurnAgentSession implements AgentSession {
return { sessionId: this.id, finalText: "", timeline: [] };
}
rejectNextTurnStart(error: Error): void {
this.nextStartError = error;
}
tryHandleOutOfBand(prompt: AgentPromptInput) {
const text = typeof prompt === "string" ? (parseAgentMessage(prompt)?.text ?? prompt) : null;
return text === "/side-command" ? { run: async () => {} } : null;
}
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
await this.turnStartGate;
if (this.nextStartError) {
const error = this.nextStartError;
this.nextStartError = null;
throw error;
}
const turnId = randomUUID();
this.activeTurnId = turnId;
this.pushEvent({
type: "timeline",
provider: this.provider,
turnId,
item: {
type: "user_message",
text: typeof prompt === "string" ? prompt : JSON.stringify(prompt),
messageId: randomUUID(),
},
});
setTimeout(() => {
if (this.activeTurnId !== turnId) return;
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
if (!this.holdTurns && this.activeTurnId === turnId) {
this.finishTurn();
@@ -3745,15 +3840,29 @@ class HeldTurnAgentSession implements AgentSession {
return { turnId };
}
finishTurn(): void {
finishTurn(finalText?: string): void {
const turnId = this.activeTurnId;
if (!turnId) {
throw new Error("No held turn to finish");
}
this.activeTurnId = null;
if (finalText)
this.pushEvent({
type: "timeline",
provider: this.provider,
turnId,
item: { type: "assistant_message", text: finalText },
});
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}
failTurn(error: string): void {
const turnId = this.activeTurnId;
if (!turnId) throw new Error("No held turn to fail");
this.activeTurnId = null;
this.pushEvent({ type: "turn_failed", provider: this.provider, error, turnId });
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
@@ -3884,7 +3993,6 @@ describe("send_agent_prompt MCP tool", () => {
throw new Error("Expected caller send_agent_prompt input to parse");
}
expect(parsed.data).toMatchObject({
background: true,
notifyOnFinish: true,
});
@@ -3897,7 +4005,7 @@ describe("send_agent_prompt MCP tool", () => {
);
});
it("keeps top-level prompts blocking by default", async () => {
it("acknowledges top-level prompts without a completion wait", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "child-agent",
@@ -3925,20 +4033,16 @@ describe("send_agent_prompt MCP tool", () => {
throw new Error("Expected top-level send_agent_prompt input to parse");
}
expect(parsed.data).toMatchObject({
background: false,
notifyOnFinish: false,
});
await tool.handler(parsed.data as Record<string, unknown>);
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith(
"child-agent",
expect.objectContaining({ waitForActive: true }),
);
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
});
it("does not arm a finish notification for blocking agent-scoped prompts", async () => {
it("allows agent-scoped callers to opt out of finish notifications", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentAgent = {
id: "parent-agent",
@@ -3974,17 +4078,175 @@ describe("send_agent_prompt MCP tool", () => {
await invokeToolWithParsedInput(tool, {
agentId: "child-agent",
prompt: "Follow up",
background: false,
notifyOnFinish: false,
});
expect(spies.agentManager.subscribe).not.toHaveBeenCalled();
expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith(
"child-agent",
expect.objectContaining({ waitForActive: true }),
);
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
});
it("notifies the caller when a blocking agent-scoped prompt outlasts the wait", async () => {
it.each([
{ immediateFinish: false, failure: false },
{ immediateFinish: true, failure: false },
{ immediateFinish: false, failure: true },
])(
"notifies the retry outcome rather than the prior failed turn (immediate: $immediateFinish, failure: $failure)",
async ({ immediateFinish, failure }) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-retry-notification-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const parentClient = new HeldTurnAgentClient("claude", false);
const childClient = new HeldTurnAgentClient("codex", true);
const agentManager = new AgentManager({
clients: { claude: parentClient, codex: childClient },
registry: storage,
logger,
});
try {
const parent = await agentManager.createAgent(
{ provider: "claude", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await agentManager.createAgent(
{ provider: "codex", cwd: existingCwd, title: "Retry child" },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "Old task",
notifyOnFinish: false,
});
childClient.sessions[0]!.failTurn("Old task failed");
await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("error"));
const unsubscribe = agentManager.subscribe(
(event) => {
if (
immediateFinish &&
event.type === "agent_state" &&
event.agent.lifecycle === "running"
) {
childClient.sessions[0]!.finishTurn("Retry done");
}
},
{ agentId: child.id, replayState: false },
);
const response = await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "Retry now",
});
unsubscribe();
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "turn_started",
});
if (!immediateFinish) {
expect(response.structuredContent.status).toBe("running");
if (failure) childClient.sessions[0]!.failTurn("Retry failed");
else childClient.sessions[0]!.finishTurn("Retry done");
}
await vi.waitFor(() => {
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
expect(parentClient.sessions[0]!.prompts[0]).toContain(
`Agent ${child.id} (Retry child) ${failure ? "errored" : "finished"}.`,
);
expect(parentClient.sessions[0]!.prompts[0]).not.toContain("Old task failed");
});
await vi.waitFor(() => expect(agentManager.getAgent(parent.id)?.lifecycle).toBe("idle"));
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
},
);
it.each(["out_of_band", "startup_failure"] as const)(
"preserves %s dispatch notification semantics",
async (scenario) => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const parentClient = new HeldTurnAgentClient("claude", false);
const childClient = new HeldTurnAgentClient("codex", true);
const agentManager = new AgentManager({
clients: { claude: parentClient, codex: childClient },
registry: storage,
logger,
});
try {
const parent = await agentManager.createAgent(
{ provider: "claude", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await agentManager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "Ongoing task",
notifyOnFinish: false,
});
if (scenario === "out_of_band") {
const response = await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "/side-command",
});
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "out_of_band",
status: "running",
});
expect(childClient.sessions[0]!.prompts).toHaveLength(1);
expect(parseAgentMessage(childClient.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-message", agentId: parent.id },
text: "Ongoing task",
});
expect(parentClient.sessions[0]!.prompts).toHaveLength(0);
childClient.sessions[0]!.finishTurn("Ongoing task done");
await vi.waitFor(() => {
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
expect(parentClient.sessions[0]!.prompts[0]).toContain("finished.");
});
} else {
childClient.sessions[0]!.rejectNextTurnStart(new Error("Requested turn could not start"));
await expect(
invokeToolWithParsedInput(tool, { agentId: child.id, prompt: "Replacement task" }),
).rejects.toThrow("Requested turn could not start");
// A later real turn must not inherit a subscription from the rejected dispatch.
await invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "Recovery",
notifyOnFinish: false,
});
childClient.sessions[0]!.finishTurn("Recovered");
await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle"));
expect(parentClient.sessions[0]!.prompts).toHaveLength(0);
}
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
},
);
it("acknowledges a pending agent-scoped turn and notifies its completion", async () => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-blocking-send-timeout-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const parentClient = new HeldTurnAgentClient("claude", false);
@@ -4015,24 +4277,19 @@ describe("send_agent_prompt MCP tool", () => {
});
const tool = registeredTool(server, "send_agent_prompt");
vi.useFakeTimers();
const pending = invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "Follow up",
background: false,
});
await vi.advanceTimersByTimeAsync(31_000);
const response = await pending;
expect(response.structuredContent).toMatchObject({
status: "running",
guidance:
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
});
expect(response.structuredContent.lastMessage).toContain("timed out after 30s");
expect(response.structuredContent).not.toHaveProperty("lastMessage");
childClient.sessions[0]!.finishTurn();
await vi.advanceTimersByTimeAsync(1_000);
vi.useRealTimers();
await vi.waitFor(() => {
const parentPrompts = parentClient.sessions[0]!.prompts;
@@ -4091,6 +4348,7 @@ describe("send_agent_prompt MCP tool", () => {
const childSession = childClient.sessions[0]!;
await vi.waitFor(() => expect(childSession.prompts).toHaveLength(2));
expect(parentClient.sessions[0]!.prompts).toHaveLength(0);
childSession.finishTurn();
function finishNotifications() {
@@ -4220,7 +4478,11 @@ describe("send_agent_prompt MCP tool", () => {
},
);
unsubscribe();
expect(response.structuredContent).toMatchObject({ success: true, status: "idle" });
expect(response.structuredContent).toMatchObject({
success: true,
disposition: "turn_started",
});
await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle"));
await vi.waitFor(() => {
const parentPrompts = parentClient.sessions[0]!.prompts;
expect(parentPrompts).toHaveLength(1);
@@ -1,3 +1,4 @@
import { AgentDirectory } from "../directory.js";
import { stat } from "node:fs/promises";
import { z } from "zod";
import { ensureValidJson } from "../../json-utils.js";
@@ -59,9 +60,9 @@ import {
sanitizePermissionRequest,
serializeSnapshotWithMetadata,
toScheduleSummary,
waitForAgentWithTimeout,
} from "../mcp-shared.js";
import {
type PromptDispatchDisposition,
sendPromptToAgent,
setupFinishNotification,
waitForAgentRunStartWithTimeout,
@@ -123,6 +124,7 @@ export interface PaseoToolHostDependencies {
cwd: string,
title?: string | null,
projectId?: string,
context?: { background?: boolean; callerWorkspaceId?: string },
) => Promise<PersistedWorkspaceRecord>;
workspaceScripts?: Pick<WorkspaceScriptsService, "list" | "launch" | "stop">;
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
@@ -132,6 +134,7 @@ export interface PaseoToolHostDependencies {
ensureWorkspaceForCreate?: (
cwd: string,
firstAgentContext?: FirstAgentContext,
context?: { callerWorkspaceId?: string },
) => Promise<string>;
browserToolsEnabled?: boolean;
browserToolsBroker?: BrowserToolsBroker | null;
@@ -189,6 +192,7 @@ const WorkspaceAutomationSummarySchema = z.object({
isolation: z.enum(["local", "worktree"]),
kind: z.enum(["directory", "local_checkout", "worktree"]),
title: z.string().nullable(),
background: z.boolean().optional(),
});
function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) {
@@ -199,6 +203,7 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) {
isolation: workspace.kind === "worktree" ? ("worktree" as const) : ("local" as const),
kind: workspace.kind,
title: workspace.title,
background: workspace.background,
};
}
@@ -1047,13 +1052,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
};
const canonicalTopLevelInputSchema = {
...canonicalCreateAgentFields,
background: z
.boolean()
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
notifyOnFinish: z
.boolean()
.optional()
@@ -1071,7 +1069,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
...commonCreateAgentFields,
relationship: legacyCreateAgentPlacementFields.relationship.optional(),
workspace: legacyCreateAgentPlacementFields.workspace.optional(),
background: canonicalTopLevelInputSchema.background,
notifyOnFinish: canonicalTopLevelInputSchema.notifyOnFinish,
cwd: z
.string()
@@ -1129,13 +1126,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
};
const agentToAgentSendAgentPromptInputSchema = {
...commonSendAgentPromptInputSchema,
background: z
.boolean()
.optional()
.default(true)
.describe(
"Run agent in background. Agent-scoped default is true so you can continue until the finish notification arrives. Set false only when you need a blocking response.",
),
notifyOnFinish: z
.boolean()
.optional()
@@ -1146,13 +1136,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
};
const topLevelSendAgentPromptInputSchema = {
...commonSendAgentPromptInputSchema,
background: z
.boolean()
.optional()
.default(false)
.describe(
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.",
),
notifyOnFinish: z
.boolean()
.optional()
@@ -1249,6 +1232,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
),
projectId: z.string().optional().describe("Existing project id to own the workspace."),
title: z.string().trim().min(1).optional(),
background: z
.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.",
),
mode: z
.enum(["branch-off", "checkout-branch", "checkout-pr"])
.optional()
@@ -1287,6 +1276,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
path,
projectId,
title,
background,
mode,
worktreeSlug,
branchName,
@@ -1316,7 +1306,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
if (!options.createDirectoryWorkspace) {
throw new Error("Workspace provisioning is not configured");
}
workspace = await options.createDirectoryWorkspace(cwd, title, projectId);
workspace = await options.createDirectoryWorkspace(cwd, title, projectId, {
background,
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
});
} else {
let cwd =
path !== undefined || !projectId ? resolveScopedCwd(path, { required: true }) : null;
@@ -1347,6 +1340,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
...(worktreeSlug ? { worktreeSlug } : {}),
...worktreeTarget,
...(title ? { title } : {}),
background,
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
},
);
if (!result.ok) {
@@ -1367,15 +1362,17 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
{
title: "List workspaces",
description: "List active workspaces.",
inputSchema: {},
inputSchema: { includeBackground: z.boolean().optional().default(false) },
outputSchema: { workspaces: z.array(WorkspaceAutomationSummarySchema) },
},
async () => {
async ({ includeBackground = false }) => {
if (!options.workspaceRegistry) {
throw new Error("Workspace registry is not configured");
}
const workspaces = (await options.workspaceRegistry.list())
.filter((workspace) => !workspace.archivedAt)
.filter(
(workspace) => !workspace.archivedAt && (includeBackground || !workspace.background),
)
.map(toWorkspaceAutomationSummary);
return {
content: [],
@@ -1442,30 +1439,21 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
workspaceId: z.string().optional(),
currentModeId: z.string().nullable(),
availableModes: z.array(ProviderModeSchema),
lastMessage: z.string().nullable().optional(),
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
guidance: z.string().optional(),
},
},
async (args: unknown) => {
const resolvedArgs = await resolveCreateAgentToolArgs(args);
const { parsedArgs, worktree } = resolvedArgs;
let requestedBackground: boolean;
let notifyOnFinish: boolean;
if (resolvedArgs.kind === "agent-scoped") {
requestedBackground = true;
notifyOnFinish = parsedArgs.notifyOnFinish;
} else {
requestedBackground = resolvedArgs.parsedArgs.background;
notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false;
}
const selectedProvider = resolveRequiredProviderModel(parsedArgs.provider).provider;
const inheritedConfig = resolveInheritedProviderConfig(selectedProvider);
const {
snapshot,
background: createdInBackground,
initialPromptStarted,
} = await createAgentCommand(
const { snapshot, initialPromptStarted } = await createAgentCommand(
{
agentManager,
agentStorage,
@@ -1491,7 +1479,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
features: parsedArgs.settings?.features,
labels: parsedArgs.labels,
mode: parsedArgs.settings?.modeId,
background: requestedBackground,
promptFailure: "throw",
notifyOnFinish,
detached: resolvedArgs.detached,
callerAgentId,
@@ -1500,43 +1488,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
);
try {
if (!createdInBackground && initialPromptStarted) {
const result = await waitForAgentWithTimeout(agentManager, snapshot.id, {
waitForActive: true,
});
const liveSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot;
const responseData = {
agentId: snapshot.id,
type: snapshot.provider,
status: result.status,
cwd: liveSnapshot.cwd,
...(liveSnapshot.workspaceId ? { workspaceId: liveSnapshot.workspaceId } : {}),
currentModeId: liveSnapshot.currentModeId,
availableModes: liveSnapshot.availableModes,
lastMessage: result.lastMessage,
permission: sanitizePermissionRequest(result.permission),
};
const validJson = ensureValidJson(responseData);
const response = {
content: [],
structuredContent: validJson,
};
return response;
}
} catch (error) {
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt");
throw error;
}
// Return immediately for async creation.
const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot;
const guidance =
callerAgentId && notifyOnFinish && initialPromptStarted
? "You will get notified when the created agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives."
: undefined;
: "Creation acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.";
const response = {
content: [],
structuredContent: ensureValidJson({
@@ -1547,8 +1504,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
...(currentSnapshot.workspaceId ? { workspaceId: currentSnapshot.workspaceId } : {}),
currentModeId: currentSnapshot.currentModeId,
availableModes: currentSnapshot.availableModes,
lastMessage: null,
permission: null,
...(guidance ? { guidance } : {}),
}),
};
@@ -1682,7 +1637,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
const cwd = process.cwd();
return {
cwd,
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext),
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, {
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
}),
};
}
const caller = resolveCallerAgent();
@@ -1850,7 +1807,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
return {
cwd,
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext),
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, {
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
}),
worktree: undefined,
};
}
@@ -1897,37 +1856,32 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
{
title: "Send agent prompt",
description:
"Send a task to a running agent. Agent-scoped callers run in background by default; top-level callers wait by default.",
"Send a task and return after dispatch acknowledgement. Calling agents receive finish notifications by default.",
inputSchema: sendAgentPromptInputSchema,
outputSchema: {
success: z.boolean(),
disposition: z.enum(["turn_started", "out_of_band"]).optional(),
status: AgentStatusEnum,
lastMessage: z.string().nullable().optional(),
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
guidance: z.string().optional(),
},
},
async ({
agentId,
prompt,
sessionMode,
background = Boolean(callerAgentId),
notifyOnFinish = Boolean(callerAgentId),
}) => {
function armFinishNotification(): boolean {
async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => {
let stopNotification: (() => void) | undefined;
function armFinishNotification(disposition: PromptDispatchDisposition): void {
if (!callerAgentId || !notifyOnFinish) {
return false;
return;
}
setupFinishNotification({
stopNotification = setupFinishNotification({
agentManager,
agentStorage,
childAgentId: agentId,
callerAgentId,
waitForTurnStart: disposition === "turn_started",
logger: childLogger,
});
return true;
}
const notifying = Boolean(callerAgentId && notifyOnFinish);
const { disposition } = await sendPromptToAgent({
agentManager,
agentStorage,
@@ -1935,51 +1889,29 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
prompt,
source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined,
sessionMode,
onDispatch: armFinishNotification,
logger: childLogger,
}).catch((error) => {
stopNotification?.();
throw error;
});
// If not running in background, wait for completion
if (!background) {
const result = await waitForAgentWithTimeout(agentManager, agentId, {
waitForActive: true,
});
// The wait ran out while the agent keeps working, so its result arrives as a
// finish notification instead of in this response.
const notifying =
result.timedOut &&
agentManager.getAgent(agentId)?.lifecycle === "running" &&
armFinishNotification();
const responseData = {
success: true,
status: result.status,
lastMessage: result.lastMessage,
permission: sanitizePermissionRequest(result.permission),
...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}),
};
const validJson = ensureValidJson(responseData);
const response = {
content: [],
structuredContent: validJson,
};
return response;
}
const notifying = armFinishNotification();
// Return once the provider has accepted the turn, so the status reports it running.
if (disposition === "turn_started") {
await waitForAgentRunStartWithTimeout(agentManager, agentId);
await waitForAgentRunStartWithTimeout(agentManager, agentId).catch((error) => {
stopNotification?.();
throw error;
});
}
const currentSnapshot = agentManager.getAgent(agentId);
const responseData = {
success: true,
disposition,
status: currentSnapshot?.lifecycle ?? "idle",
lastMessage: null,
permission: null,
...(notifying ? { guidance: PROMPTED_AGENT_NOTIFICATION_GUIDANCE } : {}),
guidance: notifying
? PROMPTED_AGENT_NOTIFICATION_GUIDANCE
: "Dispatch acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.",
};
const validJson = ensureValidJson(responseData);
@@ -2047,6 +1979,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
title: "List agents",
description: "List recent agents as compact metadata.",
inputSchema: {
includeBackground: z.boolean().optional().default(false),
includeArchived: z.boolean().optional().default(false),
cwd: z.string().optional(),
sinceHours: z
@@ -2063,29 +1996,38 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
agents: z.array(AgentListItemPayloadSchema),
},
},
async ({ includeArchived = false, cwd, sinceHours = 48, statuses, limit = 50 }) => {
async ({
includeBackground = false,
includeArchived = false,
cwd,
sinceHours = 48,
statuses,
limit = 50,
}) => {
const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined;
const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd;
const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null;
const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000;
const liveSnapshots = agentManager.listAgents();
const liveAgents = await Promise.all(
liveSnapshots.map((snapshot) =>
if (!options.workspaceRegistry) throw new Error("Workspace registry is not configured");
const directory = new AgentDirectory({
manager: agentManager,
storage: agentStorage,
workspaces: options.workspaceRegistry,
projectLive: (snapshot) =>
serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger),
),
);
const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id));
const storedRecords = await agentStorage.list();
const registeredProviderIds = new Set(providerSnapshotManager.listRegisteredProviderIds());
const storedAgents = storedRecords
.filter((record) => !record.internal && !liveIds.has(record.id))
.filter((record) => includeArchived || !record.archivedAt)
.filter(
(record) =>
includeArchived || isStoredAgentProviderAvailable(record, registeredProviderIds),
)
.map((record) => buildStoredAgentPayload(record, registeredProviderIds));
const agents = [...liveAgents, ...storedAgents]
projectStored: (record) =>
buildStoredAgentPayload(
record,
new Set(providerSnapshotManager.listRegisteredProviderIds()),
),
isProviderVisible: () => true,
isStoredProviderAvailable: (record) =>
isStoredAgentProviderAvailable(
record,
new Set(providerSnapshotManager.listRegisteredProviderIds()),
),
});
const agents = (await directory.list({ includeBackground, includeArchived }))
.map(toAgentListItemPayload)
.filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd))
.filter((agent) => !statusFilter || statusFilter.has(agent.status))
+8 -3
View File
@@ -1031,10 +1031,13 @@ export async function createPaseoDaemon(
const ensureWorkspaceForCreateExternal = async (
cwd: string,
firstAgentContext?: FirstAgentContext,
context?: { callerWorkspaceId?: string },
): Promise<string> => {
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
cwd,
resolveFirstAgentPromptTitle(firstAgentContext),
undefined,
context,
);
if (firstAgentContext) {
workspaceAutoName.scheduleForDirectory({
@@ -1081,8 +1084,9 @@ export async function createPaseoDaemon(
const ensureWorkspaceForCreateAndBroadcastExternal = async (
cwd: string,
firstAgentContext?: FirstAgentContext,
context?: { callerWorkspaceId?: string },
): Promise<string> => {
const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext);
const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext, context);
await emitWorkspaceUpdatesExternal([workspaceId]);
return workspaceId;
};
@@ -1399,11 +1403,12 @@ export async function createPaseoDaemon(
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
workspaceRegistry,
projectRegistry,
createDirectoryWorkspace: async (cwd, title, projectId) => {
createDirectoryWorkspace: async (cwd, title, projectId, context) => {
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
cwd,
title,
projectId,
context,
);
await emitWorkspaceUpdatesExternal([workspace.workspaceId]);
return workspace;
@@ -1874,7 +1879,7 @@ export async function createPaseoDaemon(
const AGENT_CLOSE_TIMEOUT_MS = 5_000;
async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promise<void> {
const agents = agentManager.listAgents();
const agents = agentManager.listAgents({ includeInternal: true });
await Promise.all(
agents.map(async (agent) => {
try {
@@ -69,27 +69,29 @@ export class DirectorySyncService {
synchronizeWorkspaces(
snapshot: Iterable<WorkspaceDescriptorPayload>,
cursor: DirectorySyncCursor,
includeBackground = false,
): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> {
this.workspaces.replaceAll(snapshot);
const read = this.read(this.workspaces, cursor);
const read = this.read(this.workspaces, cursor, includeBackground);
return {
entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
emptyProjects: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
sync: this.metadata(read),
sync: this.metadata(read, includeBackground),
};
}
synchronizeAgents(
snapshot: Iterable<AgentDirectoryEntry>,
cursor: DirectorySyncCursor,
includeBackground = false,
): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> {
this.agents.replaceAll(snapshot);
const read = this.read(this.agents, cursor);
const read = this.read(this.agents, cursor, includeBackground);
return {
entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
sync: this.metadata(read),
sync: this.metadata(read, includeBackground),
};
}
@@ -111,13 +113,19 @@ export class DirectorySyncService {
value: WorkspaceDescriptorPayload | null,
id: string,
includeSequence: boolean,
includeBackground = false,
): T & Partial<DirectoryVersion> {
if (value) {
this.workspaces.replace(value);
} else {
this.workspaces.remove(id);
}
return this.withVersion(payload, includeSequence, this.version(this.workspaces, id));
return this.withVersion(
payload,
includeSequence,
this.version(this.workspaces, id),
includeBackground,
);
}
sequenceAgentUpdate<T extends object>(
@@ -125,18 +133,24 @@ export class DirectorySyncService {
value: AgentDirectoryEntry | null,
id: string,
includeSequence: boolean,
includeBackground = false,
): T & Partial<DirectoryVersion> {
if (value) {
this.agents.replace(value);
} else {
this.agents.remove(id);
}
return this.withVersion(payload, includeSequence, this.version(this.agents, id));
return this.withVersion(
payload,
includeSequence,
this.version(this.agents, id),
includeBackground,
);
}
private metadata(read: CollectionRead<unknown>) {
private metadata(read: CollectionRead<unknown>, includeBackground = false) {
return {
generation: this.generation,
generation: this.generationFor(includeBackground),
headSeq: read.headSeq,
mode: read.mode,
...(read.reason ? { reason: read.reason } : {}),
@@ -148,15 +162,19 @@ export class DirectorySyncService {
payload: T,
includeSequence: boolean,
version: DirectoryVersion | null,
includeBackground = false,
): T & Partial<DirectoryVersion> {
return version && includeSequence ? { ...payload, ...version } : payload;
return version && includeSequence
? { ...payload, ...version, generation: this.generationFor(includeBackground) }
: payload;
}
private read<T>(
collection: VersionedCollection<T>,
cursor: DirectorySyncCursor,
includeBackground = false,
): CollectionRead<T> {
if (cursor.generation !== this.generation) {
if (cursor.generation !== this.generationFor(includeBackground)) {
return collection.readSnapshot(
cursor.generation === undefined ? "no_cursor" : "generation_changed",
);
@@ -167,6 +185,10 @@ export class DirectorySyncService {
return collection.readAfter(cursor.afterSeq);
}
private generationFor(includeBackground: boolean): string {
return includeBackground ? `${this.generation}:background` : this.generation;
}
private version<T>(collection: VersionedCollection<T>, id: string): DirectoryVersion | null {
const seq = collection.sequenceFor(id);
return seq === null ? null : { generation: this.generation, seq };
@@ -205,7 +205,6 @@ export class DaemonExecutions implements HubExecutionAgents {
}
: {}),
worktree: toCreateAgentWorktree(input.worktree),
background: true,
notifyOnFinish: false,
owner,
onWorktreeCreated: (worktree) => {
@@ -33,6 +33,8 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
workspaceId?: string;
projectId?: string;
title?: string;
background?: boolean;
callerWorkspaceId?: string;
}
export interface CreatePaseoWorktreeResult {
@@ -101,6 +103,8 @@ async function createPaseoWorktreeWithPriority(
baseBranch: createdWorktree.worktree.comparisonBaseRef,
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
expectsInitialAgent: Boolean(input.firstAgentContext),
background: input.background,
callerWorkspaceId: input.callerWorkspaceId,
...(createdWorktree.intent.kind === "checkout-change-request" &&
createdWorktree.intent.headRepository
? {
@@ -235,6 +235,10 @@ test("archiveOnFinish=false local scheduled run emits upserts and remains active
await waitForAgentUpsert(events.agentUpdates, agentId);
expect(workspaceWasRemoved(events.workspaceUpdates, workspaceId!)).toBe(false);
expect(await activeAgentIds()).toContain(agentId);
expect(
(await ctx.client.fetchWorkspaces()).entries.find((entry) => entry.id === workspaceId)
?.background,
).toBe(false);
events.stop();
});
@@ -904,7 +904,6 @@ describe("ScheduleService", () => {
liveSnapshot: snapshot as Awaited<
ReturnType<ScheduleServiceOptions["createAgent"]>
>["liveSnapshot"],
background: true,
initialPromptStarted: false,
initialPromptError: null,
};
@@ -982,7 +981,6 @@ describe("ScheduleService", () => {
liveSnapshot: snapshot as Awaited<
ReturnType<ScheduleServiceOptions["createAgent"]>
>["liveSnapshot"],
background: true,
initialPromptStarted: false,
initialPromptError: null,
};
@@ -1052,7 +1050,6 @@ describe("ScheduleService", () => {
liveSnapshot: snapshot as Awaited<
ReturnType<ScheduleServiceOptions["createAgent"]>
>["liveSnapshot"],
background: true,
initialPromptStarted: false,
initialPromptError: null,
};
@@ -1112,7 +1109,6 @@ describe("ScheduleService", () => {
liveSnapshot: snapshot as Awaited<
ReturnType<ScheduleServiceOptions["createAgent"]>
>["liveSnapshot"],
background: true,
initialPromptStarted: false,
initialPromptError: null,
};
@@ -909,7 +909,6 @@ export class ScheduleService {
features: config.featureValues,
unattended: true,
promptFailure: "return-error",
background: true,
notifyOnFinish: false,
});
const agent = created.snapshot;
@@ -1,11 +1,16 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
import {
createDaemonTestContext,
DaemonClient,
type DaemonTestContext,
} from "./test-utils/index.js";
import type { CreateAgentOptions } from "./test-utils/index.js";
import type { CreateAgentWorktreeTarget } from "./messages.js";
import { createRealpathAwarePathMatcher } from "../utils/path.js";
@@ -96,6 +101,7 @@ async function expectWorktreeListEmpty(repoDir: string): Promise<void> {
async function createAgentInBranchOffWorktree(options?: {
autoArchive?: boolean;
initialPrompt?: string;
branchName?: string;
repoDir?: string;
}): Promise<{ repoDir: string; agentId: string; worktreePath: string }> {
@@ -112,7 +118,7 @@ async function createAgentInBranchOffWorktree(options?: {
base: "main",
},
...(options?.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}),
initialPrompt: "Say done.",
initialPrompt: options?.initialPrompt ?? "Say done.",
});
return { repoDir, agentId: created.id, worktreePath: created.cwd };
}
@@ -350,24 +356,39 @@ test("archiving a created worktree removes the directory on last reference", asy
});
test("auto-archiving a created worktree keeps the directory when a sibling workspace references it", async () => {
const created = await createAgentInBranchOffWorktree({ autoArchive: true });
// Create a sibling workspace that shares the same backing directory.
const sibling = await ctx.client.createWorkspace({
source: { kind: "directory", path: created.worktreePath },
title: "sibling",
// Hold turn completion while establishing the sibling reference. The original
// ordering raced a fast completed turn against sibling workspace creation.
await ctx.cleanup();
let finishTurn!: () => void;
const completion = new Promise<void>((resolve) => {
finishTurn = resolve;
});
if (!sibling.workspace) {
throw new Error(sibling.error ?? "Failed to create sibling workspace");
ctx = await createDaemonTestContext({
agentClients: createTestAgentClients({
beforeTurnComplete: async (prompt) => {
if (prompt === "Hold for sibling workspace") await completion;
},
}),
});
try {
const created = await createAgentInBranchOffWorktree({
autoArchive: true,
initialPrompt: "Hold for sibling workspace",
});
const sibling = await ctx.client.createWorkspace({
source: { kind: "directory", path: created.worktreePath },
title: "sibling",
});
if (!sibling.workspace) throw new Error(sibling.error ?? "Failed to create sibling workspace");
finishTurn();
await ctx.client.waitForFinish(created.agentId, 10000);
await expectAgentAbsentFromActiveList(created.agentId);
await expectWorktreePresentInList(created.repoDir, created.worktreePath);
expect(existsSync(created.worktreePath)).toBe(true);
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
} finally {
finishTurn();
}
await ctx.client.waitForFinish(created.agentId, 10000);
await expectAgentAbsentFromActiveList(created.agentId);
await expectWorktreePresentInList(created.repoDir, created.worktreePath);
expect(existsSync(created.worktreePath)).toBe(true);
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
});
test("create_agent_request rejects legacy git options before creating a worktree", async () => {
@@ -416,3 +437,248 @@ test("create_agent_request fails cleanly when worktree creation cannot resolve t
await expectActiveAgentListEmpty();
await expectWorktreeListEmpty(repoDir);
});
/** Every agent id with a record under `$PASEO_HOME/agents`, whatever its cwd bucket. */
function storedAgentIds(): string[] {
const root = path.join(ctx.daemon.paseoHome, "agents");
if (!existsSync(root)) return [];
return readdirSync(root, { recursive: true, encoding: "utf8" })
.filter((entry) => entry.endsWith(".json"))
.map((entry) => path.basename(entry, ".json"));
}
test.each(["git", "worktreeName"] as const)(
"legacy %s worktree creation resolves final workspace visibility without intermediate records",
async (placement) => {
const cwd = createGitRepo();
const config = { ...getFullAccessConfig("codex"), cwd };
const backgroundCaller = await ctx.client.createAgent({ config, background: true });
const visibleCaller = await ctx.client.createAgent({ config });
const cases = [
{ name: "review", background: true, expected: true },
{ name: "review-child", callerAgentId: backgroundCaller.id, expected: true },
{
name: "visible-child",
callerAgentId: backgroundCaller.id,
background: false,
expected: false,
},
{ name: "hidden-child", callerAgentId: visibleCaller.id, background: true, expected: true },
{ name: "human-default", expected: false },
];
const orderedCases =
placement === "worktreeName" ? [cases[1]!, cases[0]!, ...cases.slice(2)] : cases;
for (const entry of orderedCases) {
const before = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } });
const child = await ctx.client.createAgent({
config,
...(entry.callerAgentId ? { callerAgentId: entry.callerAgentId } : {}),
...(entry.background !== undefined ? { background: entry.background } : {}),
...(placement === "git"
? {
git: {
createWorktree: true,
createNewBranch: true,
newBranchName: entry.name,
baseBranch: "main",
},
}
: { worktreeName: entry.name }),
});
const inclusive = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } });
const created = inclusive.entries.filter(
(workspace) => !before.entries.some((existing) => existing.id === workspace.id),
);
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({
id: child.workspaceId,
workspaceDirectory: child.cwd,
background: entry.expected,
workspaceKind: "worktree",
});
expect(child.cwd).not.toBe(cwd);
expect(child.labels?.["paseo.parent-agent-id"]).toBe(entry.callerAgentId);
const defaultAgents = await ctx.client.fetchAgents();
const allAgents = await ctx.client.fetchAgents({ filter: { includeBackground: true } });
expect(defaultAgents.entries.some((item) => item.agent.id === child.id)).toBe(
!entry.expected,
);
expect(allAgents.entries.some((item) => item.agent.id === child.id)).toBe(true);
const defaultWorkspaces = await ctx.client.fetchWorkspaces();
expect(
defaultWorkspaces.entries.some((workspace) => workspace.id === child.workspaceId),
).toBe(!entry.expected);
}
},
30_000,
);
test("background workspaces keep durable agents addressable through restart and archive", async () => {
const cwd = createGitRepo();
const config = { ...getFullAccessConfig("codex"), cwd };
const hidden = await ctx.client.createAgent({
config,
background: true,
idempotencyKey: "background-durable",
});
expect(hidden.internal).not.toBe(true);
expect(
(await ctx.client.fetchWorkspaces({ filter: { query: hidden.workspaceId } })).entries.map(
(entry) => entry.id,
),
).toContain(hidden.workspaceId);
expect(storedAgentIds()).toContain(hidden.id);
expect((await ctx.client.fetchWorkspaces()).entries).not.toContainEqual(
expect.objectContaining({ id: hidden.workspaceId }),
);
expect(
(await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } })).entries,
).toContainEqual(expect.objectContaining({ id: hidden.workspaceId, background: true }));
expect((await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain(
hidden.id,
);
expect(
(await ctx.client.fetchAgents({ filter: { includeBackground: true } })).entries.map(
(entry) => entry.agent.id,
),
).toContain(hidden.id);
const child = await ctx.client.createAgent({ config, callerAgentId: hidden.id });
expect(child.workspaceId).toBe(hidden.workspaceId);
const inherited = await ctx.client.createWorkspace({
source: { kind: "directory", path: cwd },
callerAgentId: hidden.id,
});
expect(inherited.workspace?.background).toBe(true);
const visible = await ctx.client.createWorkspace({
source: { kind: "directory", path: cwd },
callerAgentId: hidden.id,
background: false,
});
expect(visible.workspace?.background).toBe(false);
const inheritedWorktree = await ctx.client.createAgent({
config,
callerAgentId: hidden.id,
worktree: { mode: "branch-off", newBranch: "background-child", base: "main" },
});
const overriddenWorktree = await ctx.client.createAgent({
config,
callerAgentId: hidden.id,
background: false,
worktree: { mode: "branch-off", newBranch: "visible-child", base: "main" },
});
const workspaces = (await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }))
.entries;
expect(workspaces.find((entry) => entry.id === inheritedWorktree.workspaceId)?.background).toBe(
true,
);
expect(workspaces.find((entry) => entry.id === overriddenWorktree.workspaceId)?.background).toBe(
false,
);
await expect(
ctx.client.createAgent({ config, workspaceId: hidden.workspaceId, background: false }),
).rejects.toThrow("workspace creation intent");
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
await ctx.client.waitForFinish(hidden.id, 10_000);
expect(
(await ctx.client.fetchAgentTimeline(hidden.id, { limit: 20 })).entries.length,
).toBeGreaterThan(0);
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 });
const replay = await ctx.client.createAgent({
config,
background: true,
idempotencyKey: "background-durable",
});
expect(replay.id).toBe(hidden.id);
expect((await ctx.client.fetchAgent(hidden.id))?.agent.workspaceId).toBe(hidden.workspaceId);
await ctx.client.sendAgentMessage(hidden.id, "Say done again.");
await ctx.client.waitForFinish(hidden.id, 10_000);
await ctx.client.archiveAgent(hidden.id);
expect((await ctx.client.fetchAgent(hidden.id))?.agent.archivedAt).toEqual(expect.any(String));
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("default and inclusive observers retain independent snapshots, live updates and catch-up", async () => {
const otherClient = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
await otherClient.connect();
try {
const cwd = createGitRepo();
const config = { ...getFullAccessConfig("codex"), cwd };
const hidden = await ctx.client.createAgent({ config, background: true });
const visible = await ctx.client.createAgent({ config });
const inclusive = ctx.client.observeAgents({
scope: "active",
sync: {},
includeBackground: true,
});
const ordinary = otherClient.observeAgents({ scope: "active", sync: {} });
const inclusiveSnapshot = await inclusive.ready;
const defaultSnapshot = await ordinary.ready;
expect(inclusiveSnapshot.entries.map((entry) => entry.agent.id)).toContain(hidden.id);
expect(defaultSnapshot.entries.map((entry) => entry.agent.id)).toEqual([visible.id]);
const inclusiveWorkspaces = ctx.client.observeWorkspaces({ sync: {}, includeBackground: true });
const ordinaryWorkspaces = otherClient.observeWorkspaces({ sync: {} });
const full = await inclusiveWorkspaces.ready;
const partial = await ordinaryWorkspaces.ready;
expect(full.entries.map((entry) => entry.id)).toContain(hidden.workspaceId);
expect(partial.entries.map((entry) => entry.id)).not.toContain(hidden.workspaceId);
const received: string[] = [];
const defaultReceived: string[] = [];
inclusive.subscribe({
snapshot: () => {},
update: (message) => {
if (message.type === "agent_update" && message.payload.kind === "upsert")
received.push(message.payload.agent.id);
},
});
ordinary.subscribe({
snapshot: () => {},
update: (message) => {
if (message.type === "agent_update" && message.payload.kind === "upsert")
defaultReceived.push(message.payload.agent.id);
},
});
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
await ctx.client.waitForFinish(hidden.id, 10_000);
await expect.poll(() => received).toContain(hidden.id);
expect(defaultReceived).not.toContain(hidden.id);
const catchup = await ctx.client.fetchAgents({
scope: "active",
includeBackground: true,
sync: {
generation: inclusiveSnapshot.sync?.generation,
afterSeq: inclusiveSnapshot.sync?.headSeq,
},
});
expect(catchup.sync?.mode).toBe("changes");
expect(catchup.sync?.removals.map((item) => item.id)).not.toContain(hidden.id);
const scopeChanged = await ctx.client.fetchAgents({
scope: "active",
includeBackground: true,
sync: {
generation: defaultSnapshot.sync?.generation,
afterSeq: defaultSnapshot.sync?.headSeq,
},
});
expect(scopeChanged.sync?.mode).toBe("snapshot");
expect(scopeChanged.entries.map((entry) => entry.agent.id)).toContain(hidden.id);
await inclusive.release();
await ordinary.release();
await inclusiveWorkspaces.release();
await ordinaryWorkspaces.release();
} finally {
await otherClient.close();
}
}, 30_000);
@@ -5247,6 +5247,7 @@ test("unions viewed timelines across socket sources and removes detached sources
const session = createSessionForTest({
messages,
agentManager: {
getAgent: (id: string) => ({ id, internal: false }),
subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => {
agentEventListeners.push(listener);
return () => {};
@@ -5354,6 +5355,7 @@ test("keeps selective delivery scoped per socket when a retained session also ha
messages,
targetedMessages,
agentManager: {
getAgent: (id: string) => ({ id, internal: false }),
subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => {
agentEventListeners.push(listener);
return () => {};
+195 -88
View File
@@ -1,3 +1,4 @@
import { AgentDirectory } from "./agent/directory.js";
import { searchTimeline } from "./agent/chat-search/index.js";
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities";
@@ -248,6 +249,7 @@ import {
import { WorkspaceAutoName } from "./workspace-auto-name.js";
import {
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
normalizeAgentCreationGitOptions,
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
type CreatePaseoWorktreeSetupContinuationInput,
type CreatePaseoWorktreeWorkflowResult,
@@ -724,6 +726,7 @@ export class Session {
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly agentDirectory: AgentDirectory;
private readonly directorySync: DirectorySyncService;
private readonly filesystem: SessionFileSystem;
private readonly github: ForgeService;
@@ -1087,7 +1090,22 @@ export class Session {
this.supportsForSource(CLIENT_CAPS.terminalReflowableSnapshot, source),
getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source),
});
this.agentDirectory = new AgentDirectory({
manager: this.agentManager,
storage: this.agentStorage,
workspaces: this.workspaceRegistry,
projectLive: (agent) => this.buildAgentPayload(agent),
projectStored: (record) => this.buildStoredAgentPayload(record),
isProviderVisible: (provider) => this.isProviderVisibleToClient(provider),
isStoredProviderAvailable: (record) =>
isStoredAgentProviderAvailable(
record,
new Set(this.providerSnapshotManager.listRegisteredProviderIds()),
),
});
this.agentUpdates = createAgentUpdatesService({
includesWorkspace: (agent, includeBackground) =>
this.agentDirectory.includes(agent, includeBackground),
emit: (message) => this.emit(message),
enrichAgentPayload: (payload) => this.enrichAgentPayload(payload),
buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record),
@@ -1096,12 +1114,13 @@ export class Session {
this.buildProjectPlacementForWorkspaceId(workspaceId),
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) =>
sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence, includeBackground) =>
this.directorySync.sequenceAgentUpdate(
payload,
agent && project ? { agent, project } : null,
agentId,
includeSequence,
includeBackground,
),
logger: this.sessionLogger,
});
@@ -1164,7 +1183,7 @@ export class Session {
logger: this.sessionLogger,
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
listAgentPayloads: () => this.listAgentPayloads(),
listAgentPayloads: () => this.listAgentPayloads({ includeBackground: true }),
listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(),
listTerminalActivityContributions: () => this.listTerminalActivityContributions(),
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
@@ -1410,6 +1429,7 @@ export class Session {
}
// COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12.
// Older clients create before subscribing and require the causal update beside the response.
if (workspace.background) return;
this.emit({
type: "workspace_update",
payload: {
@@ -1929,6 +1949,12 @@ export class Session {
}
if (event.type === "provider_subagent") {
// The subagents track of an internal parent stays hidden.
const parentAgentId =
event.event.type === "upsert"
? event.event.subagent.parentAgentId
: event.event.parentAgentId;
if (this.agentManager.getAgent(parentAgentId)?.internal) return;
this.emitProviderSubagentWorkspaceUpdate(event.event);
this.forwardProviderSubagentUpdate(event.event);
return;
@@ -1974,7 +2000,10 @@ export class Session {
this.forwardAgentStream(event, serializedEvent);
if (event.event.type === "permission_requested") {
// An internal agent streams only to clients subscribed to it by id;
// session-wide prompts would surface it everywhere.
const internal = this.agentManager.getAgent(event.agentId)?.internal === true;
if (event.event.type === "permission_requested" && !internal) {
this.emit({
type: "agent_permission_request",
payload: {
@@ -1996,7 +2025,9 @@ export class Session {
// Title updates may be applied asynchronously after agent creation.
},
{ replayState: false },
// Internal agents flow through so exact-id subscribers and opted-in
// listings see them; everything else above filters them out.
{ replayState: false, includeInternal: true },
);
}
@@ -3231,7 +3262,7 @@ export class Session {
agentId,
);
if (this.agentUpdates.hasSubscription()) {
if (archivedRecord && this.agentUpdates.hasSubscription()) {
const payload = await this.agentUpdates.emitStoredRecord(archivedRecord);
if (payload.workspaceId) {
await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
@@ -4208,6 +4239,24 @@ export class Session {
}
}
private validatePublicAgentWorkspaceIntent(
request: CreateAgentRequestMessage,
createsWorktree: boolean,
): void {
if (request.internal)
throw new Error(
"Public internal agents were removed; create a background workspace instead.",
);
if (
request.background !== undefined &&
(request.workspaceId || (request.callerAgentId && !createsWorktree))
) {
throw new Error(
"background is workspace creation intent; configure the selected workspace at creation.",
);
}
}
private async createSessionAgent(
msg: CreateAgentRequestMessage,
agentId?: string,
@@ -4242,6 +4291,9 @@ export class Session {
if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) {
throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`);
}
// Resolve all supported worktree input forms before provisioning workspace ownership.
const gitIntent = normalizeAgentCreationGitOptions(git, worktreeName, worktree);
this.validatePublicAgentWorkspaceIntent(msg, gitIntent?.createWorktree === true);
const trimmedPrompt = initialPrompt?.trim();
const { provisionalTitle } = resolveCreateAgentTitles({
configTitle: config.title,
@@ -4255,9 +4307,11 @@ export class Session {
const workspacePromptTitle = resolveFirstAgentPromptTitle(firstAgentContext);
const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({
cwd: config.cwd,
target: worktree,
target: gitIntent,
firstAgentContext,
hasLegacyGitOptions: Boolean(git),
setupContinuation: worktree ? undefined : this.agentWorktreeSetupContinuation(),
background: msg.background,
callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId),
});
createdWorktreeForCleanup = createdWorktree;
const resolvedIntent = await this.resolveSessionCreateAgentIntent({
@@ -4302,8 +4356,14 @@ export class Session {
env,
provisionalTitle,
firstAgentContext,
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
buildSessionConfig: async (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
createdWorktree
? {
sessionConfig,
setupContinuation: createdWorktree.setupContinuation,
createdWorkspaceId: createdWorktree.workspace.workspaceId,
}
: this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
},
);
createdAgentId = snapshot.id;
@@ -4373,6 +4433,8 @@ export class Session {
createdWorktree: null,
cwd: config.cwd,
initialTitle: input.workspacePromptTitle,
background: request.background,
callerWorkspaceId: callerAgent?.workspaceId,
}),
cwd: config.cwd,
}),
@@ -4771,6 +4833,26 @@ export class Session {
}
}
private agentWorktreeSetupContinuation(): CreatePaseoWorktreeSetupContinuationInput {
return {
kind: "agent",
terminalManager: this.terminalManager,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId,
item,
}),
logger: this.sessionLogger,
};
}
private async buildAgentSessionConfig(
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
@@ -4790,23 +4872,7 @@ export class Session {
createPaseoWorktree: (input, serviceOptions) =>
this.createPaseoWorktreeWorkflow(input, {
...serviceOptions,
setupContinuation: {
kind: "agent",
terminalManager: this.terminalManager,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId,
item,
}),
logger: this.sessionLogger,
},
setupContinuation: this.agentWorktreeSetupContinuation(),
}),
checkoutExistingBranch: (cwd, branch) =>
this.gitMutation.checkoutExistingBranch(cwd, branch),
@@ -5175,52 +5241,10 @@ export class Session {
/**
* Build the current agent list payload (live + persisted), optionally filtered by labels.
*/
private async listAgentPayloads(filter?: {
labels?: Record<string, string>;
includeArchived?: boolean;
includeUnavailablePersisted?: boolean;
}): Promise<AgentSnapshotPayload[]> {
const includeArchived = filter?.includeArchived === true;
const labelEntries = filter?.labels ? Object.entries(filter.labels) : [];
// Get live agents with session modes
const agentSnapshots = this.agentManager.listAgents();
const liveAgents = await Promise.all(
agentSnapshots.map((agent) => this.buildAgentPayload(agent)),
);
// Add persisted agents that have not been lazily initialized yet
// (excluding internal agents which are for ephemeral system tasks)
const registryRecords = await this.agentStorage.list();
const liveIds = new Set(agentSnapshots.map((a) => a.id));
const registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds());
const persistedAgents = registryRecords
.filter((record) => !liveIds.has(record.id) && !record.internal)
// Keep raw-record filters ahead of projection; seeded homes can carry thousands of archived agents.
.filter((record) => includeArchived || !record.archivedAt)
.filter((record) => labelEntries.every(([key, value]) => record.labels?.[key] === value))
.filter(
(record) =>
filter?.includeUnavailablePersisted === true ||
isStoredAgentProviderAvailable(record, registeredProviderIds),
)
.map((record) => this.buildStoredAgentPayload(record, registeredProviderIds));
let agents = [...liveAgents, ...persistedAgents];
agents = agents.filter((agent) => this.isProviderVisibleToClient(agent.provider));
if (!includeArchived) {
agents = agents.filter((agent) => !agent.archivedAt);
}
// Filter by labels if filter provided
if (labelEntries.length > 0) {
agents = agents.filter((agent) =>
labelEntries.every(([key, value]) => agent.labels[key] === value),
);
}
return agents;
private listAgentPayloads(
filter?: Parameters<AgentDirectory["list"]>[0],
): Promise<AgentSnapshotPayload[]> {
return this.agentDirectory.list(filter);
}
private async resolveAgentIdentifier(
@@ -5231,6 +5255,13 @@ export class Session {
return { ok: false, notFound: false, error: "Agent identifier cannot be empty" };
}
// An internal agent, live or recently archived, is addressable by its exact
// id and nothing else: the caller that created it holds the id, and no
// listing ever includes it.
if (this.agentManager.getAgent(trimmed) || this.agentManager.getRetiredInternalAgent(trimmed)) {
return { ok: true, agentId: trimmed };
}
const stored = await this.agentStorage.list();
const storedRecords = stored.filter((record) => !record.internal);
const knownIds = new Set<string>();
@@ -5278,6 +5309,25 @@ export class Session {
return { ok: false, notFound: true, error: `Agent not found: ${trimmed}` };
}
/**
* An agent that is no longer live, in its settled form: the retained snapshot
* of a recently archived internal agent, else its stored record. Null when
* neither exists, and for a stored internal record, which is never served.
*/
private async readSettledAgent(
agentId: string,
): Promise<{ record: StoredAgentRecord; lastMessage: string | null } | null> {
const retired = this.agentManager.getRetiredInternalAgent(agentId);
if (retired) {
return { record: retired.record, lastMessage: retired.lastMessage };
}
const record = await this.agentStorage.get(agentId);
if (!record || record.internal) {
return null;
}
return { record, lastMessage: null };
}
private async getAgentPayloadById(agentId: string): Promise<AgentSnapshotPayload | null> {
const live = this.agentManager.getAgent(agentId);
if (live) {
@@ -5285,11 +5335,11 @@ export class Session {
return this.isProviderVisibleToClient(payload.provider) ? payload : null;
}
const record = await this.agentStorage.get(agentId);
if (!record || record.internal) {
const settled = await this.readSettledAgent(agentId);
if (!settled) {
return null;
}
const payload = this.buildStoredAgentPayload(record);
const payload = this.buildStoredAgentPayload(settled.record);
return this.isProviderVisibleToClient(payload.provider) ? payload : null;
}
@@ -5413,6 +5463,9 @@ export class Session {
let agents = await this.listAgentPayloads({
labels: filter?.labels,
includeArchived: filter?.includeArchived,
includeBackground:
filter?.includeBackground ||
(request.type === "fetch_agents_request" && request.includeBackground),
includeUnavailablePersisted: request.type === "fetch_agent_history_request",
});
const activePlacementsByWorkspaceId =
@@ -5556,6 +5609,7 @@ export class Session {
title: workspace.title,
pinnedAt: workspace.pinnedAt,
...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}),
background: workspace.background,
archivingAt: null,
status: "done",
statusEnteredAt: null,
@@ -5641,6 +5695,7 @@ export class Session {
worktreeSlug: basename(result.worktree.worktreePath),
projectKind: projectRecord?.kind ?? "git",
workspaceKind: result.workspace.kind,
background: result.workspace.background,
name: resolveWorkspaceName({
title: result.workspace.title,
derivedDisplayName: result.worktree.branchName || result.workspace.displayName,
@@ -5719,7 +5774,13 @@ export class Session {
pageInfo: FetchWorkspacesResponsePageInfo;
}> {
try {
return await this.workspaceDirectory.listFetchEntries(request);
return await this.workspaceDirectory.listFetchEntries({
...request,
filter: {
...request.filter,
...(request.includeBackground ? { includeBackground: true } : {}),
},
});
} catch (error) {
if (error instanceof CursorError) {
throw new SessionRequestError("invalid_cursor", error.message);
@@ -6021,6 +6082,7 @@ export class Session {
workspace ?? null,
workspaceId,
subscription.syncEnabled === true,
subscription.filter?.includeBackground,
),
);
continue;
@@ -6031,6 +6093,7 @@ export class Session {
workspace ?? null,
workspaceId,
subscription.syncEnabled === true,
subscription.filter?.includeBackground,
);
if (
@@ -6136,7 +6199,10 @@ export class Session {
subscriptionId: owner.id,
isProviderVisible: (provider) =>
this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)),
filter: request.filter,
filter: {
...request.filter,
...(request.includeBackground ? { includeBackground: true } : {}),
},
syncEnabled: Boolean(request.sync),
emit: (message) => {
if (message.type === "agent_update") owner.emit(message);
@@ -6275,7 +6341,10 @@ export class Session {
subscriptionId: owner.id,
owner,
syncEnabled: Boolean(request.sync),
filter: request.filter,
filter: {
...request.filter,
...(request.includeBackground ? { includeBackground: true } : {}),
},
isBootstrapping: true,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
@@ -6531,24 +6600,41 @@ export class Session {
}
const snapshot = await this.listFetchAgentsEntries({
...request,
includeBackground: true,
page: { limit: Number.MAX_SAFE_INTEGER },
});
return this.directorySync.synchronizeAgents(snapshot.entries, request.sync ?? {});
const result = this.directorySync.synchronizeAgents(
snapshot.entries,
request.sync ?? {},
request.includeBackground,
);
const entries = await Promise.all(
result.entries.map(async (entry) =>
(await this.agentDirectory.includes(entry.agent, request.includeBackground)) ? entry : null,
),
);
return { ...result, entries: entries.filter((entry) => entry !== null) };
}
private async readWorkspaceDirectorySync(
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
) {
if (request.filter) {
if (request.filter)
throw new SessionRequestError(
"invalid_request",
"Sequenced workspace directory reads do not support filters.",
);
}
return this.directorySync.synchronizeWorkspaces(
const result = this.directorySync.synchronizeWorkspaces(
await this.workspaceDirectory.listDescriptors(),
request.sync ?? {},
request.includeBackground,
);
return {
...result,
entries: result.entries.filter(
(workspace) => request.includeBackground || !workspace.background,
),
};
}
// Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates`
@@ -6625,10 +6711,23 @@ export class Session {
}
}
private getCallerWorkspaceId(callerAgentId?: string): string | undefined {
if (!callerAgentId) return undefined;
const caller = this.agentManager.getAgent(callerAgentId);
if (!caller) throw new Error(`Caller agent ${callerAgentId} not found`);
return caller.workspaceId;
}
private async createRequestedWorkspace(
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
workspaceId?: string,
): Promise<WorkspaceDescriptorPayload> {
if (request.internal)
throw new Error(
"Public internal workspaces were removed; use background workspace creation.",
);
if (request.agent?.background !== undefined)
throw new Error("Configure background on the workspace, not its initial agent.");
let creationRequest = request;
// Hooks belong to the operation: retries fingerprint the caller's input
// and must not rerun hooks or compare their potentially changing output.
@@ -6662,7 +6761,12 @@ export class Session {
cwd,
explicitTitle ?? promptTitle,
request.source.projectId,
{ expectsInitialAgent: Boolean(request.firstAgentContext), workspaceId },
{
expectsInitialAgent: Boolean(request.firstAgentContext),
workspaceId,
background: request.background,
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
},
);
await this.syncWorkspaceGitObserverForWorkspace(workspace);
const descriptor = await this.describeWorkspaceRecord(workspace);
@@ -6724,6 +6828,8 @@ export class Session {
githubPrNumber: source.githubPrNumber,
firstAgentContext: request.firstAgentContext,
title: request.title,
background: request.background,
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
},
source.baseBranch
? { resolveDefaultBranch: async () => source.baseBranch as string }
@@ -7397,7 +7503,7 @@ export class Session {
const requestedWorkspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId];
let agents: AgentSnapshotPayload[];
try {
agents = await this.listAgentPayloads();
agents = await this.listAgentPayloads({ includeBackground: true });
} catch (error) {
const message = getErrorMessage(error);
const results = requestedWorkspaceIds.map((requestedWorkspaceId) => ({
@@ -7537,7 +7643,7 @@ export class Session {
throw new Error(`Workspace not found: ${workspaceId}`);
}
const agents = (await this.listAgentPayloads()).filter((agent) =>
const agents = (await this.listAgentPayloads({ includeBackground: true })).filter((agent) =>
this.isProviderVisibleToClient(agent.provider),
);
const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const));
@@ -8180,8 +8286,8 @@ export class Session {
const agentId = resolved.agentId;
const live = this.agentManager.getAgent(agentId);
if (!live) {
const record = await this.agentStorage.get(agentId);
if (!record || record.internal) {
const settled = await this.readSettledAgent(agentId);
if (!settled) {
this.emit({
type: "wait_for_finish_response",
payload: {
@@ -8194,6 +8300,7 @@ export class Session {
});
return;
}
const { record, lastMessage } = settled;
const final = this.buildStoredAgentPayload(record);
let status: "permission" | "error" | "idle";
if (record.attentionReason === "permission") {
@@ -8206,7 +8313,7 @@ export class Session {
const error = resolveWaitForFinishError({ status, final });
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error, lastMessage: null },
payload: { requestId, status, final, error, lastMessage },
});
return;
}
@@ -9514,6 +9514,50 @@ test("workspace.create.request attaches a directory workspace to its explicit ac
});
});
test("workspace.create.request with background persists a hidden workspace that only opted-in listings show", async () => {
const emitted: SessionOutboundMessage[] = [];
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
session.workspaceRegistry.upsert = async (record: unknown) => {
const workspace = record as PersistedWorkspaceRecord;
workspaces.set(workspace.workspaceId, workspace);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
await session.handleMessage({
type: "workspace.create.request",
requestId: "req-background-workspace",
background: true,
source: { kind: "directory", path: REPO_CWD },
});
const response = findByType(emitted, "workspace.create.response");
expect(response?.payload).toMatchObject({
requestId: "req-background-workspace",
error: null,
workspace: { background: true },
});
const workspaceId = response?.payload.workspace?.id as string;
expect(workspaces.get(workspaceId)?.background).toBe(true);
// No subscription exists, and the legacy causal update stays quiet for a hidden workspace.
expect(filterByType(emitted, "workspace_update")).toEqual([]);
const hidden = await session.listFetchWorkspacesEntries({
type: "fetch_workspaces_request",
requestId: "req-list-hidden",
});
expect(hidden.entries).toEqual([]);
const shown = await session.listFetchWorkspacesEntries({
type: "fetch_workspaces_request",
requestId: "req-list-shown",
filter: { includeBackground: true },
});
expect(shown.entries.map((entry) => [entry.id, entry.background])).toEqual([[workspaceId, true]]);
});
test("workspace.create.request reports an unknown explicit project", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
@@ -36,6 +36,7 @@ function makeAgentPayload(input: {
requiresAttention?: boolean;
effectiveThinkingOptionId?: string | null;
thinkingOptionId?: string | null;
internal?: boolean;
}): AgentSnapshotPayload {
const updatedAt = input.updatedAt ?? "2026-03-01T12:00:00.000Z";
const provider = input.provider ?? "codex";
@@ -44,6 +45,7 @@ function makeAgentPayload(input: {
provider,
cwd: "/tmp/repo",
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
...(input.internal ? { internal: true } : {}),
model: null,
thinkingOptionId: input.thinkingOptionId ?? null,
effectiveThinkingOptionId: input.effectiveThinkingOptionId ?? null,
@@ -235,6 +237,15 @@ describe("matchesAgentUpdatesFilter", () => {
);
});
test("internal agents are excluded unless includeInternal", () => {
const agent = makeAgentPayload({ id: "a", internal: true });
expect(matchesAgentUpdatesFilter({ agent, project })).toBe(false);
expect(matchesAgentUpdatesFilter({ agent, project, filter: {} })).toBe(false);
expect(matchesAgentUpdatesFilter({ agent, project, filter: { includeInternal: true } })).toBe(
true,
);
});
test("thinking-option filter compares the resolved option", () => {
const high = makeAgentPayload({ id: "a", effectiveThinkingOptionId: "high" });
expect(
@@ -47,6 +47,7 @@ export interface AgentUpdatesService {
}
export interface AgentUpdatesServiceDeps {
includesWorkspace?(agent: AgentSnapshotPayload, includeBackground?: boolean): Promise<boolean>;
emit(message: SessionOutboundMessage): void;
enrichAgentPayload(payload: AgentSnapshotPayload): Promise<AgentSnapshotPayload>;
buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload;
@@ -59,6 +60,7 @@ export interface AgentUpdatesServiceDeps {
project: ProjectPlacementPayload | null,
agentId: string,
includeSequence: boolean,
includeBackground?: boolean,
): T;
logger: pino.Logger;
}
@@ -136,6 +138,10 @@ export function matchesAgentUpdatesFilter(input: {
return false;
}
if (agent.internal && filter?.includeInternal !== true) {
return false;
}
if (filter && !agentThinkingOptionMatchesFilter(agent, filter)) {
return false;
}
@@ -160,7 +166,15 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
agent: AgentSnapshotPayload | null,
project: ProjectPlacementPayload | null,
agentId: string,
) => deps.sequenceAgentUpdate(payload, agent, project, agentId, sub.syncEnabled === true);
) =>
deps.sequenceAgentUpdate(
payload,
agent,
project,
agentId,
sub.syncEnabled === true,
sub.filter?.includeBackground,
);
function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void {
if (subscriptions.get(sub.subscriptionId) !== sub) return;
@@ -234,10 +248,16 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
const project = payload.workspaceId
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
const backgroundVisible = await Promise.all(
observers.map(
(sub) => deps.includesWorkspace?.(payload, sub.filter?.includeBackground) ?? true,
),
);
return (
project !== null &&
observers.some(
(sub) =>
(sub, index) =>
backgroundVisible[index] &&
sub.isProviderVisible(payload.provider) &&
matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }),
)
@@ -252,7 +272,9 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
: null;
for (const sub of observers) {
const matches =
project && matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter });
project &&
((await deps.includesWorkspace?.(payload, sub.filter?.includeBackground)) ?? true) &&
matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter });
bufferOrEmit(
sub,
sequence(
@@ -145,6 +145,46 @@ test("fresh non-git directory creates a directory workspace at the exact path",
expect(workspace.cwd).toBe(dir);
});
test("background workspace creation preserves hooks, inheritance and durable visibility", async () => {
const dir = path.join(tmpDir, "plain");
const events: string[] = [];
provisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
workspaceGitService: gitService(),
isDirectory,
logger,
lifecycle: {
emit: (name) => {
events.push(name);
},
before: async (_name, request) => request,
},
});
const parent = await provisioning.createWorkspaceForDirectory(dir, null, undefined, {
background: true,
});
const child = await provisioning.createWorkspaceForDirectory(dir, null, undefined, {
callerWorkspaceId: parent.workspaceId,
});
const visible = await provisioning.createWorkspaceForDirectory(dir, null, undefined, {
callerWorkspaceId: parent.workspaceId,
background: false,
});
const human = await provisioning.createWorkspaceForDirectory(dir);
expect(parent.background).toBe(true);
expect(child.background).toBe(true);
expect(visible.background).toBe(false);
expect(human.background).toBe(false);
expect(events).toEqual(Array(4).fill("workspace.created"));
const reloaded = new FileBackedWorkspaceRegistry(
path.join(tmpDir, "projects", "workspaces.json"),
logger,
);
await reloaded.initialize();
expect((await reloaded.get(child.workspaceId))?.background).toBe(true);
});
test("re-opening an active workspace by exact path returns the same record without duplicating", async () => {
const repo = path.join(tmpDir, "repo");
gitRoots.add(repo);
@@ -236,6 +276,30 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps
expect(reopened.archivedAt).toBeNull();
});
test("default directory open skips archived background workspaces while explicit recovery preserves their visibility", async () => {
const cwd = path.join(tmpDir, "repo");
mkdirSync(cwd, { recursive: true });
const background = await provisioning.createWorkspaceForDirectory(cwd, undefined, undefined, {
background: true,
});
await workspaceRegistry.archive(background.workspaceId, ARCHIVED_AT);
const opened = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
expect(opened).toMatchObject({ background: false, archivedAt: null });
expect(opened.workspaceId).not.toBe(background.workspaceId);
const archived = await workspaceRegistry.get(background.workspaceId);
expect(archived).toMatchObject({ background: true, archivedAt: ARCHIVED_AT });
const recovered = await provisioning.ensureWorkspaceRecordUnarchived(archived!);
expect(recovered).toMatchObject({
workspaceId: background.workspaceId,
background: true,
archivedAt: null,
});
expect((await provisioning.findOrCreateWorkspaceForDirectory(cwd)).workspaceId).toBe(
opened.workspaceId,
);
});
test("reopening archived exact-root records restores the fresh Git project", async () => {
const cwd = path.join(tmpDir, "repo");
mkdirSync(cwd, { recursive: true });
@@ -26,6 +26,8 @@ export interface ResolveOrCreateWorkspaceIdInput {
requestedWorkspaceId?: string;
cwd: string;
initialTitle: string | null;
background?: boolean;
callerWorkspaceId?: string;
}
export interface ImportWorkspaceInput {
@@ -50,6 +52,8 @@ export interface CreateWorktreeWorkspaceInput {
title: string | null;
expectsInitialAgent?: boolean;
untrustedSource?: UntrustedWorkspaceSource;
background?: boolean;
callerWorkspaceId?: string;
}
export interface WorkspaceProvisioningService {
@@ -63,7 +67,12 @@ export interface WorkspaceProvisioningService {
cwd: string,
title?: string | null,
projectId?: string,
context?: { expectsInitialAgent?: boolean; workspaceId?: string },
context?: {
expectsInitialAgent?: boolean;
workspaceId?: string;
background?: boolean;
callerWorkspaceId?: string;
},
): Promise<PersistedWorkspaceRecord>;
createWorkspaceForWorktree(
input: CreateWorktreeWorkspaceInput,
@@ -215,11 +224,27 @@ export function createWorkspaceProvisioningService(deps: {
return project;
}
async function resolveBackground(input?: {
background?: boolean;
callerWorkspaceId?: string;
}): Promise<boolean> {
if (input?.background !== undefined) return input.background;
if (!input?.callerWorkspaceId) return false;
const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId);
if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`);
return callerWorkspace.background;
}
async function createWorkspaceForDirectory(
cwd: string,
title?: string | null,
projectId?: string,
context?: { expectsInitialAgent?: boolean; workspaceId?: string },
context?: {
expectsInitialAgent?: boolean;
workspaceId?: string;
background?: boolean;
callerWorkspaceId?: string;
},
): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = resolve(cwd);
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
@@ -235,9 +260,12 @@ export function createWorkspaceProvisioningService(deps: {
title: title?.trim() || null,
createdAt: timestamp,
updatedAt: timestamp,
background: await resolveBackground(context),
});
await workspaceRegistry.upsert(workspace, context);
deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) });
await workspaceRegistry.upsert(workspace, {
expectsInitialAgent: context?.expectsInitialAgent,
});
emitWorkspaceCreated(workspace);
return workspace;
}
@@ -269,14 +297,19 @@ export function createWorkspaceProvisioningService(deps: {
createdAt: timestamp,
updatedAt: timestamp,
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
background: await resolveBackground(input),
});
await workspaceRegistry.upsert(workspace, {
expectsInitialAgent: input.expectsInitialAgent,
});
deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) });
emitWorkspaceCreated(workspace);
return workspace;
}
function emitWorkspaceCreated(workspace: PersistedWorkspaceRecord): void {
deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) });
}
async function resolveSourceProjectForWorktree(input: {
sourceCwd: string;
projectId?: string;
@@ -320,26 +353,19 @@ export function createWorkspaceProvisioningService(deps: {
async function findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = resolve(cwd);
const workspaces = await workspaceRegistry.list();
const active = workspaces
// Path-based discovery selects public work; explicit-ID recovery bypasses this selector.
const workspaces = (await workspaceRegistry.list())
.filter(
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd),
(workspace) => !workspace.background && areEquivalentPaths(workspace.cwd, normalizedCwd),
)
.sort(
(left, right) =>
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
left.workspaceId.localeCompare(right.workspaceId),
)[0];
);
const active = workspaces.find((workspace) => !workspace.archivedAt);
if (active) return refreshWorkspaceRecord(active);
const archived = workspaces
.filter(
(workspace) => workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd),
)
.sort(
(left, right) =>
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
left.workspaceId.localeCompare(right.workspaceId),
)[0];
const archived = workspaces.find((workspace) => workspace.archivedAt);
if (archived) {
const project = await projectRegistry.get(archived.projectId);
if (project && !project.archivedAt) return ensureWorkspaceRecordUnarchived(archived);
@@ -355,6 +381,8 @@ export function createWorkspaceProvisioningService(deps: {
return (
await createWorkspaceForDirectory(input.cwd, input.initialTitle, undefined, {
expectsInitialAgent: true,
background: input.background,
callerWorkspaceId: input.callerWorkspaceId,
})
).workspaceId;
}
@@ -59,12 +59,14 @@ interface FakeAgentSessionOptions {
memoryMarker?: string | null;
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
}
export interface TestAgentClientOptions {
beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>;
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
supportsMcpServers?: boolean;
}
@@ -338,6 +340,7 @@ class FakeAgentSession implements AgentSession {
private readonly closeSession: (() => Promise<void>) | undefined;
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
constructor(options: FakeAgentSessionOptions) {
this.capabilities = {
@@ -350,6 +353,7 @@ class FakeAgentSession implements AgentSession {
this.memoryMarker = options.memoryMarker ?? null;
this.closeSession = options.closeSession;
this.onStartTurn = options.onStartTurn;
this.beforeTurnComplete = options.beforeTurnComplete;
this.historyPath = path.join(
tmpdir(),
"paseo-fake-provider-history",
@@ -799,6 +803,7 @@ class FakeAgentSession implements AgentSession {
this.notifySubscribers(assistantChunkB);
}
await this.beforeTurnComplete?.(prompt);
const completed: AgentStreamEvent = {
type: "turn_completed",
provider: this.providerName,
@@ -1220,6 +1225,7 @@ class FakeAgentClient implements AgentClient {
supportsMcpServers: this.options.supportsMcpServers,
closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
});
}
@@ -1245,6 +1251,7 @@ class FakeAgentClient implements AgentClient {
memoryMarker: typeof marker === "string" ? marker : null,
closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
});
}
@@ -35,6 +35,7 @@ export function asAgentManager(stub: {
}): SessionOptions["agentManager"] {
return createStub<SessionOptions["agentManager"]>({
listProviderSubagentActivity: () => [],
getRetiredInternalAgent: () => null,
...stub,
});
}
@@ -1793,6 +1793,7 @@ export class VoiceAssistantWebSocketServer {
workspaceRequestReceipts: true,
creationLifecycle: true,
hubAgentRpc: true,
backgroundWorkspaces: 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.
@@ -466,7 +466,7 @@ export async function archiveWorkspaceContents(
const archivedAgents = new Set<string>();
const liveAgents = dependencies.agentManager
.listAgents()
.listAgents({ includeInternal: true })
.filter((agent) => agent.workspaceId === workspaceId);
for (const agent of liveAgents) {
archivedAgents.add(agent.id);
@@ -82,6 +82,7 @@ class WorkspaceStatus {
workspaceDirectory: workspace.cwd,
projectKind: "git",
workspaceKind: workspace.kind,
background: workspace.background,
name: workspace.displayName,
archivingAt: null,
status: "done",
@@ -571,7 +572,9 @@ describe("WorkspaceDirectory empty projects", () => {
workspaceDirectory: workspace.cwd,
projectKind: "non_git",
workspaceKind: workspace.kind,
background: workspace.background,
name: workspace.displayName,
...(workspace.internal ? { background: true } : {}),
archivingAt: null,
status: "done",
activityAt: null,
@@ -644,6 +647,41 @@ describe("WorkspaceDirectory empty projects", () => {
expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]);
});
test("hides background workspaces and counts their project as empty unless includeBackground", async () => {
const directory = makeDirectory({
projects: [project({ projectId: "hidden-only" })],
workspaces: [
{
workspaceId: "ws-internal",
projectId: "hidden-only",
cwd: "/workspace/hidden-only",
kind: "directory",
displayName: "helper",
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
background: true,
},
],
});
const hidden = await directory.listFetchEntries({
type: "fetch_workspaces_request",
requestId: "r1",
filter: { query: "helper" },
});
expect(hidden.entries).toEqual([]);
expect(hidden.emptyProjects.map((p) => p.projectId)).toEqual(["hidden-only"]);
const shown = await directory.listFetchEntries({
type: "fetch_workspaces_request",
requestId: "r2",
filter: { includeBackground: true },
});
expect(shown.entries.map((entry) => entry.id)).toEqual(["ws-internal"]);
expect(shown.emptyProjects).toEqual([]);
});
});
test("Git observation targets exclude archived records without hydrating app descriptors", async () => {
@@ -170,7 +170,7 @@ export class WorkspaceDirectory {
/**
* Per-workspace last-seen winning bucket + entered-at. Persists across
* `buildDescriptorMap` calls inside the daemon process; reset on cold start.
* Server-internal; never crosses the wire.
* Server-private; never crosses the wire.
*/
private readonly bucketHistoryByWorkspaceId = new Map<string, WorkspaceBucketHistoryEntry>();
@@ -557,14 +557,20 @@ export class WorkspaceDirectory {
// Project parents that have no active workspaces. The wire field is the
// sidebar projection bucket for projects whose workspace list is currently
// empty; it is not a separate domain record.
async listEmptyProjects(): Promise<WorkspaceProjectDescriptor[]> {
async listEmptyProjects(options?: {
includeBackground?: boolean;
}): Promise<WorkspaceProjectDescriptor[]> {
const [persistedWorkspaces, persistedProjects] = await Promise.all([
this.deps.workspaceRegistry.list(),
this.deps.projectRegistry.list(),
]);
// A project whose only workspaces are hidden reads as empty to that caller.
const projectIdsWithActiveWorkspaces = new Set(
persistedWorkspaces
.filter((workspace) => !workspace.archivedAt)
.filter(
(workspace) =>
!workspace.archivedAt && (options?.includeBackground === true || !workspace.background),
)
.map((workspace) => workspace.projectId),
);
return persistedProjects
@@ -596,6 +602,8 @@ export class WorkspaceDirectory {
}));
}
// Every active workspace, background ones included. Callers that answer a
// listing go through matchesFilter so background workspaces stay opt-in.
async listDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
return Array.from(
(
@@ -611,6 +619,13 @@ export class WorkspaceDirectory {
filter: FetchWorkspacesRequestFilter | undefined;
}): boolean {
const { workspace, filter } = input;
if (
workspace.background &&
filter?.includeBackground !== true &&
filter?.query !== workspace.id
) {
return false;
}
if (!filter) {
return true;
}
@@ -666,7 +681,7 @@ export class WorkspaceDirectory {
const projectIdFilter = filter?.projectId?.trim();
const emptyProjects = cursorToken
? []
: (await this.listEmptyProjects()).filter(
: (await this.listEmptyProjects({ includeBackground: filter?.includeBackground })).filter(
(project) => !projectIdFilter || project.projectId === projectIdFilter,
);
@@ -748,6 +748,7 @@ describe("WorkspaceReconciliationService", () => {
cwd: missingWorkspace,
kind: "directory",
displayName: "orphan",
background: false,
title: null,
pinnedAt: null,
branch: null,
@@ -28,6 +28,21 @@ describe("resolveWorkspaceName", () => {
);
});
test("createPersistedWorkspaceRecord defaults background to false and preserves explicit intent", () => {
const base = {
workspaceId: "ws-1",
projectId: "proj-1",
cwd: "/tmp/repo",
kind: "local_checkout" as const,
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
};
expect(createPersistedWorkspaceRecord(base).background).toBe(false);
expect(createPersistedWorkspaceRecord({ ...base, background: false }).background).toBe(false);
expect(createPersistedWorkspaceRecord({ ...base, background: true }).background).toBe(true);
});
test("resolveWorkspaceDisplayName applies the same rule over the persisted record", () => {
const record = createPersistedWorkspaceRecord({
workspaceId: "ws-1",
@@ -102,6 +102,8 @@ const PersistedWorkspaceRecordSchema = z.object({
.transform((value) => value ?? null),
labels: z.array(z.string()).optional(),
untrustedSource: UntrustedWorkspaceSourceSchema.optional(),
// Public discovery visibility. Contents retain their ordinary lifecycle.
background: z.boolean().optional().default(false),
});
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
@@ -684,9 +686,12 @@ export function createPersistedWorkspaceRecord(input: {
pinnedAt?: string | null;
labels?: string[];
untrustedSource?: UntrustedWorkspaceSource;
background?: boolean;
}): PersistedWorkspaceRecord {
const { background, ...rest } = input;
return PersistedWorkspaceRecordSchema.parse({
...input,
...rest,
background: background ?? false,
title: input.title ?? null,
branch: input.branch ?? null,
worktreeRoot: input.worktreeRoot ?? null,
+37 -1
View File
@@ -4,6 +4,7 @@ import { basename } from "node:path";
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import {
type GitSetupOptions,
type CreateAgentWorktreeTarget,
type FirstAgentContext,
type ChangeRequestCheckoutSource,
type SessionInboundMessage,
@@ -320,7 +321,42 @@ function validateNormalizedGitOptions(input: ValidateNormalizedGitOptionsInput):
}
}
export function normalizeGitOptions(
export function normalizeAgentCreationGitOptions(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
worktree?: CreateAgentWorktreeTarget,
): NormalizedGitOptions | null {
if (worktree && gitOptions) {
throw new Error("create_agent_request worktree cannot be combined with git options");
}
if (worktree) {
switch (worktree.mode) {
case "branch-off":
gitOptions = {
createWorktree: true,
createNewBranch: true,
newBranchName: worktree.newBranch,
baseBranch: worktree.base,
action: "branch-off",
refName: worktree.base,
};
break;
case "checkout-branch":
gitOptions = { createWorktree: true, action: "checkout", refName: worktree.branch };
break;
case "checkout-pr":
gitOptions = {
createWorktree: true,
action: "checkout",
githubPrNumber: worktree.prNumber,
};
break;
}
}
return normalizeGitOptions(gitOptions, legacyWorktreeName);
}
function normalizeGitOptions(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
): NormalizedGitOptions | null {
+24 -4
View File
@@ -42,7 +42,8 @@ Use `paseo run` to start a new agent with a task:
```bash
paseo run "implement user authentication"
paseo run --provider codex "refactor the API layer"
paseo run --background "run the focused test suite"
paseo run --no-wait "run the focused test suite"
paseo run --output-schema schema.json "name this branch"
paseo run --new-workspace worktree --worktree-mode branch-off --new-branch feature/x --base origin/main "implement feature X"
paseo run --workspace <workspace-id> "review the current diff"
paseo run --output-schema schema.json "extract release notes"
@@ -55,9 +56,14 @@ Worktree creation accepts `--worktree-mode branch-off|checkout-branch|checkout-p
When an existing Paseo agent runs the same command, Paseo recognizes it through `PASEO_AGENT_ID`. Without explicit placement, the new agent becomes its subagent in the same workspace. `--workspace` can place that subagent elsewhere without changing its parent.
Use `--output-schema` to return only matching JSON output. You can pass a schema file path or an inline JSON schema object. This mode cannot be used with `--background`.
Use `--output-schema` to return only matching JSON output. You can pass a schema file path or an inline JSON schema object. This mode cannot be used with `--no-wait`.
By default, `paseo run` waits for completion. Use `--background` to return immediately while the agent keeps running.
By default, `paseo run` waits for completion. Use `--no-wait` to return immediately while the agent keeps running, the same flag `paseo send` takes.
The old `run --background` and `-d` aliases still mean `--no-wait`. They are deprecated, hidden
from help, and print a warning to stderr. Use `--no-wait` in scripts. Workspace visibility is
selected separately with `workspace create --background`; this release does not repurpose
`run --background`.
## Projects
@@ -116,10 +122,23 @@ paseo workspace create \
--pr-number 2186
```
Add `--background` to hide a workspace from default discovery. Its agents and terminals keep
ordinary history, persistence, hooks, and restart recovery. `--no-background` explicitly overrides
caller-workspace inheritance; omission inherits when run by a Paseo agent and defaults to false
from a human shell. Visibility is creation-only.
```bash
paseo workspace create --isolation local --background --path /path/to/repo
paseo run --workspace <workspace-id> --no-wait "review the checkout flow"
```
Exact workspace and agent IDs remain accessible regardless of listing visibility.
Then list, use, rename, or archive it:
```bash
paseo workspace ls
paseo workspace ls --background # Also include background workspaces
paseo run --workspace <workspace-id> "implement authentication"
paseo workspace rename <workspace-id> "Auth rework"
paseo workspace rename <workspace-id> --reset # back to the branch or directory name
@@ -197,6 +216,7 @@ paseo ls # Non-archived agents in active workspaces
paseo ls -a # Also include archived agents
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
```
## Streaming output
@@ -391,7 +411,7 @@ The CLI is designed to be used by agents themselves. You can instruct an agent t
```bash
# Agent A spawns Agent B and waits for it
agent_id=$(paseo run --background --quiet --title api-agent "implement the API")
agent_id=$(paseo run --no-wait --quiet --title api-agent "implement the API")
paseo wait "$agent_id"
paseo logs "$agent_id" --tail 5
```
+20 -18
View File
@@ -87,27 +87,29 @@ 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`. |
| `send_agent_prompt` | Send a prompt to an existing agent using its `agentId` and a `prompt`. |
| `get_agent_status` | Return the latest snapshot for an agent. |
| `list_agents` | List recent agents as compact metadata. |
| `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. |
| `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.
### Workspaces
| Tool | Function |
| ------------------- | ----------------------------------------------------------------------------------------------------- |
| `create_workspace` | Create a local or worktree-isolated workspace. Worktrees can branch off, check out a branch, or a PR. |
| `list_workspaces` | List active workspaces and their directories and isolation. |
| `rename_workspace` | Change the user-visible name of the current or specified workspace. |
| `archive_workspace` | Archive a workspace and the sessions it owns. |
| Tool | Function |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `create_workspace` | Create a local or worktree-isolated workspace. Worktrees can branch off, check out a branch, or a PR. `background: true` hides it from default lists and the sidebar while preserving history and access by ID. Omission inherits the calling agent’s workspace setting, or defaults to false without a caller. |
| `list_workspaces` | List active workspaces and their directories and isolation. Use `includeBackground: true` to include background workspaces. |
| `rename_workspace` | Change the user-visible name of the current or specified workspace. |
| `archive_workspace` | Archive a workspace and the sessions it owns. |
For worktree isolation, `create_workspace` accepts the same useful choices as the app: branch off from a base, check out an existing branch, or check out a pull request. The worktree remains an implementation detail of the workspace lifecycle.
+2 -2
View File
@@ -48,12 +48,12 @@ See the [MCP reference](/docs/mcp) for tool configuration and the full catalog.
Agents with shell access can also use the Paseo CLI. This route does not require enabling tool injection. With Paseo installed, a running host, and Codex configured:
```bash
paseo run --provider codex --background \
paseo run --provider codex --no-wait \
"Review this branch without changing files"
paseo ls -a
```
The first command starts a worker and returns immediately; the second lists agents from active workspaces, including archived agents. When a Paseo agent runs the command, the worker becomes its subagent in the same workspace. From your own terminal, it starts in a new local workspace.
The first command starts a worker and returns immediately; the second lists archived and background agents as well. When a Paseo agent runs the command, the worker becomes its subagent in the same workspace. From your own terminal, it starts in a new local workspace.
See the [CLI reference](/docs/cli) for follow-ups, output, worktrees, and remote hosts.
+3 -5
View File
@@ -730,10 +730,9 @@ plans, and mode changes; requesting permission does not end the turn.
| `workspace.created` | `workspace` | Record created; directory available |
| `workspace.archived` | `workspace` | Archive state is saved |
Agent events exclude internal utility agents. Archive events can precede runtime/worktree cleanup;
closing an agent that is already closed does not emit another `agent.closed` event.
During daemon shutdown, pending event hooks have up to five seconds to finish before plugins stop.
`workspace.created` is not a setup barrier before agent startup.
Agent events exclude the daemon's private disposable helpers. Public background workspaces and
their ordinary agents run the same lifecycle hooks as other workspaces. Archive events can precede
runtime/worktree cleanup; observe lifecycle completion separately when cleanup matters.
**Shared payload shapes** (`@getpaseo/plugin/server`):
@@ -787,7 +786,6 @@ type PluginTurnOutcome =
| `providerOptions` | Opaque provider-specific options |
| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals |
| `cwd` | Cannot change |
| `internal` | Daemon-owned; cannot change through this hook |
**`agent.session_open` request example:**
+25
View File
@@ -105,6 +105,31 @@ const child = await workspace.agents.create({
`parent` establishes parentage. Archiving a parent cascade-archives its children. Call `detach()` first when a child should continue independently.
## Run work in a background workspace
Create a background workspace and its first agent in one call. Put `background` on the
workspace options and the agent's configuration under `agent`:
```ts
const cwd = "/Users/me/dev/storefront";
const workspace = await client.workspaces.create({
source: { kind: "directory", path: cwd },
background: true,
agent: {
cwd,
config: { provider: "codex/gpt-5.5" },
prompt: "Review the checkout flow.",
},
});
```
The call returns a workspace handle and starts its first agent with the supplied prompt.
Background agents keep their history and survive daemon restarts. Default discovery hides them;
use `client.agents.list({ filter: { includeBackground: true } })` to include them.
Exact-ID handles work normally. See [workspace creation](./workspaces.md#create-a-fresh-workspace)
for defaults and caller inheritance.
## Request structured output
```ts
+17 -9
View File
@@ -57,7 +57,10 @@ Create a new client after `close()`.
`list({ subscribe: {} })` also returns a `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Capable daemons assign the ID and keep observations independent. Older daemons use local IDs and their established shared delivery behavior. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md).
Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, and `labels`.
Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, `background`, and `labels`.
`background` is workspace creation intent. It cannot accompany an existing `workspaceId`.
See [background workspaces](./workspaces.md) for caller inheritance and inclusive discovery.
`config` accepts:
@@ -123,14 +126,19 @@ See [events](./events.md#follow-provider-catalog-changes) for explicit event obs
## `client.workspaces`
| Method | Result | Behavior |
| ------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------- |
| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. |
| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. |
| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. |
| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. |
| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. |
| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. |
| Method | Result | Behavior |
| ------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. |
| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. |
| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. `background: true` hides it from default discovery. |
| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. |
| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. |
| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. |
`create({ source, agent, background })` creates a workspace and its initial agent together.
`agent` accepts agent creation options except `worktree`, `git`, `onEvent`, `idempotencyKey`,
and `requestId`. Set `background` on the workspace options; `agent.background` is rejected.
See the [combined creation example](./agents.md#run-work-in-a-background-workspace).
A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory.
+22
View File
@@ -50,8 +50,25 @@ const workspace = await client.workspaces.create({
});
```
Pass `agent` to create the workspace and its first agent in one call. See the
[combined background workspace example](./agents.md#run-work-in-a-background-workspace).
Set visibility on the workspace options; `agent.background` is rejected.
You can pass `projectId` in either source when you already have one. Most integrations should omit it; the daemon finds or creates the project from the directory.
Pass `background: true` to hide a workspace from default discovery. Its agents and terminals
remain durable, with ordinary history, lifecycle hooks, archive, and exact-ID access. The app's
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.
Creation and inclusive discovery require `features.backgroundWorkspaces`; the client rejects an
unsupported host with an update-host message.
## Start an agent in a workspace
Create through the workspace handle:
@@ -116,6 +133,11 @@ do {
} while (cursor);
```
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.
## Refresh and archive a handle
```ts
+9 -5
View File
@@ -23,9 +23,11 @@ For a local daemon, `project create` defaults to the current directory and resol
**`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`.
`background: true` hides a 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.
Choose `baseBranch` explicitly: `origin/main` selects the remote-tracking branch; `refs/heads/main` selects local main. Bare `main` prefers local main when it exists, otherwise origin/main. Paseo retains the resolved ref for workspace comparisons, even after rebasing the branch or changing its PR target.
**`list_workspaces`** — list active workspaces.
**`list_workspaces`** — list active workspaces, excluding background workspaces unless `includeBackground: true`.
**`archive_workspace`** — `{ workspaceId }`. Archives the workspace, its agents, and its terminals. Local directories remain; Paseo removes an owned worktree only after its final active workspace reference is archived.
@@ -61,11 +63,11 @@ Detach is an explicit user action in the subagents track, not an agent tool. A c
Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents.
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Agent-scoped prompt calls default to `background: true` and `notifyOnFinish: true`; top-level calls default to blocking with no callback. For a synchronous follow-up, pass `background: false` and use the returned result.
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback.
**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.
**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`.
**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`; set `includeBackground: true` to include agents in background workspaces.
**`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list.
@@ -106,9 +108,11 @@ Schedules have the full list/inspect/update/pause/resume/run-once/log/delete sur
Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows.
For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. Move on to other work. The notification arrives on its own.
`create_agent` and `send_agent_prompt` return after startup or dispatch acknowledgement in both MCP scopes. Top-level callers have no parent callback; observe completion through status/activity tools or SDK/RPC subscriptions and wait APIs.
Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you.
For agent-scoped `create_agent` and `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. Move on to other work. The notification arrives on its own.
With finish notifications enabled, don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you.
## CLI semantics