mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965ee97e4a | ||
|
|
02168446fb | ||
|
|
0ac8175015 | ||
|
|
66676d49e6 | ||
|
|
064243288b | ||
|
|
a880c9619d | ||
|
|
6740d41fab | ||
|
|
ca6b54fc2e | ||
|
|
4cd23a8f17 | ||
|
|
3084f32852 |
+30
-11
@@ -183,23 +183,42 @@ Some providers can create their own child sessions inside one provider runtime.
|
||||
|
||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
||||
|
||||
## Background workspaces
|
||||
## Background visibility
|
||||
|
||||
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
|
||||
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
|
||||
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
|
||||
change it. New workspaces default to false, inherit the caller workspace's value when an agent
|
||||
creates one without an override, and accept explicit true or false.
|
||||
An agent is effectively background when it or its workspace is background:
|
||||
`agent.background || workspace.background`. Background controls discovery only. Background agents
|
||||
and the agents in background workspaces are ordinary durable agents: provider history, plugin
|
||||
hooks, parentage, finish notifications to a caller, archive, and restart recovery keep their
|
||||
normal behavior. Both flags are fixed at creation.
|
||||
|
||||
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
|
||||
including History requests, exclude background work; `filter.includeBackground` includes it.
|
||||
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
|
||||
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
|
||||
- **Agent.** `create_agent_request.background` marks the agent. Use it for helpers the user does
|
||||
not watch, such as a plugin's classifier in the user's open workspace. A background agent never
|
||||
raises attention, adds nothing to its workspace's status bucket or Last prompt time, and stays
|
||||
out of tabs, the parent's subagent track and agent lists in the app. A tab opened by id still
|
||||
works.
|
||||
- **Workspace.** `workspace.background` hides the workspace from the sidebar unless **Show
|
||||
background** is on.
|
||||
- **Inheritance.** When `background` is omitted, an agent created by a caller agent takes the
|
||||
caller's own `background`. A new workspace takes the caller's effective background. A request
|
||||
that creates its workspace applies `background` to both the agent and the workspace. Explicit
|
||||
true or false always wins.
|
||||
|
||||
`AgentDirectory` (`packages/server/src/server/agent/directory.ts`) owns visibility for lists.
|
||||
Default workspace and agent lists, including History requests, exclude background work;
|
||||
`includeBackground` includes it. Exact-ID operations bypass discovery filtering. The app
|
||||
replicates inclusively and filters at each surface.
|
||||
|
||||
Sources without the `background_agents` hello capability get workspace-only visibility on
|
||||
inclusive reads and subscriptions: agents in background workspaces, but no agent-level background
|
||||
agents. Older apps replicate inclusively and auto-open a tab for every root agent, so a classifier
|
||||
run would otherwise open a tab each turn. That visibility has its own directory sync generation.
|
||||
|
||||
Sequenced directories keep complete canonical collections. A filtered read must never replace
|
||||
those collections with its subset. Inclusive and default observations have distinct cursor
|
||||
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
|
||||
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
Clients gate background workspaces and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
They gate a background agent placed in an existing workspace or under a caller on
|
||||
`features.backgroundAgents`.
|
||||
|
||||
### Private daemon helpers
|
||||
|
||||
|
||||
@@ -777,6 +777,8 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||
|
||||
+2
-1
@@ -96,7 +96,8 @@ hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
|
||||
Back returns to that host's Plugins page.
|
||||
Back returns to that host's Plugins page. Its screen tabs change `screenId` with `router.setParams`,
|
||||
so switching tabs adds no stack entries.
|
||||
|
||||
Settings detail routes are separate siblings on purpose. Keep
|
||||
`settings/[section]`, the host routes, the projects index, and project detail as
|
||||
|
||||
@@ -28,6 +28,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Background agent** — An agent hidden from default discovery and from app surfaces that announce agents: tabs, the subagent track, agent lists, workspace status and attention. It is otherwise an ordinary durable agent, addressable by id. An agent is effectively background when it or its workspace is background. Fixed at creation. Code: `background` on `AgentSnapshotPayload` and `create_agent_request`. See [agent-lifecycle.md](agent-lifecycle.md#background-visibility). Don't confuse with background work (provider shells and monitors) or the daemon's private internal helpers, which are never public ([agent-lifecycle.md](agent-lifecycle.md#private-daemon-helpers)).
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectWorkspaceShownIdle,
|
||||
finishAndArchiveAgent,
|
||||
startBackgroundAgent,
|
||||
} from "../support/helpers/background-agents";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
openSubagentsTrack,
|
||||
seedParentWithSubagent,
|
||||
seedSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
import { expectOnlyWorkspaceAgentTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Background agents", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "background-agents-",
|
||||
title: "Classifier workspace",
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("a background agent runs in an open workspace without announcing itself", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Feature agent",
|
||||
childTitle: "Visible helper",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
const classifier = await test.step("a plugin starts a background agent", () =>
|
||||
startBackgroundAgent(workspace, { parentId: agents.parent.id, title: "Classifier" }));
|
||||
// A visible child created after the classifier reaches the app on the same stream, so once
|
||||
// its row shows, the app has processed the classifier too.
|
||||
const marker = await seedSubagent(workspace, {
|
||||
parentId: agents.parent.id,
|
||||
title: "Ordering marker",
|
||||
});
|
||||
|
||||
await test.step("it stays out of the track, the tabs and the sidebar status", async () => {
|
||||
await expectSubagentRowVisible(page, marker.id);
|
||||
await expectSubagentRowGone(page, classifier.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||
await expectWorkspaceShownIdle(page, "Classifier workspace");
|
||||
});
|
||||
|
||||
await test.step("the plugin reads its finish and archives it", async () => {
|
||||
await finishAndArchiveAgent(workspace, classifier.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||
await expectSubagentRowGone(page, classifier.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ test("plugin header and composer buttons share actions, menus, content, and upda
|
||||
await buttons.verifyObservationLifetime();
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.keepTrackSpaceWithoutPills();
|
||||
await buttons.openCompactSheets();
|
||||
await buttons.useCompactWorkspaceMenu();
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
returnAndReopenSettings,
|
||||
returnToPluginsPage,
|
||||
expectSettingsUnavailable,
|
||||
openPluginsPage,
|
||||
expectActionsMenuWithoutSettings,
|
||||
expectNoSettingsCogWithoutScreens,
|
||||
openTabbedSettings,
|
||||
selectSettingsTab,
|
||||
openTabbedSettingsLink,
|
||||
expectSettingsTooltip,
|
||||
expectNamedPluginRow,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
test("display preferences survive a reload", async ({ page }) => {
|
||||
@@ -49,6 +57,32 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) =
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
|
||||
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
|
||||
await openPluginsPage(page);
|
||||
await test.step("plugin rows show the display name", async () => {
|
||||
await expectNamedPluginRow(page);
|
||||
});
|
||||
await test.step("the cog explains itself", async () => {
|
||||
await expectSettingsTooltip(page);
|
||||
});
|
||||
await test.step("the actions menu keeps only plugin actions", async () => {
|
||||
await expectActionsMenuWithoutSettings(page);
|
||||
});
|
||||
await test.step("tabs switch between the plugin's settings screens", async () => {
|
||||
await openTabbedSettings(page);
|
||||
await selectSettingsTab(page, "Advanced");
|
||||
await selectSettingsTab(page, "General");
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
await test.step("a plugin without settings screens shows no cog", async () => {
|
||||
await expectNoSettingsCogWithoutScreens(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("a settings screen link opens its tab", async ({ page }) => {
|
||||
await openTabbedSettingsLink(page, "Advanced");
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
/**
|
||||
* Starts a background agent the way a plugin helper does: in the workspace, under a parent,
|
||||
* streaming for about ten seconds. Resolves once the daemon reports it running.
|
||||
*/
|
||||
export async function startBackgroundAgent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentId: string; title: string },
|
||||
): Promise<{ id: string }> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "classify the last turn",
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: input.parentId },
|
||||
background: true,
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running");
|
||||
return { id: agent.id };
|
||||
}
|
||||
|
||||
/** Waits for the agent's turn to end and archives it, as the plugin does with a helper's reply. */
|
||||
export async function finishAndArchiveAgent(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const finished = await workspace.client.waitForFinish(agentId, 60_000);
|
||||
expect(finished.status).toBe("idle");
|
||||
await workspace.client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceShownIdle(page: Page, workspaceName: string): Promise<void> {
|
||||
const row = page.getByRole("button", {
|
||||
name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`),
|
||||
});
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { scrollAgentChatToBottom, waitForScrollableChat } from "./agent-bottom-anchor";
|
||||
import { expectTurnCopyButton } from "./agent-stream";
|
||||
import { submitMessage } from "./composer";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import {
|
||||
@@ -88,7 +91,7 @@ export default function contribute(client) {
|
||||
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
|
||||
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
||||
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const usage = pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
||||
command("finish", "Finish deployment", () => finish());
|
||||
command("finish-review", "Finish review", () => finishReview());
|
||||
@@ -102,10 +105,16 @@ export default function contribute(client) {
|
||||
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
|
||||
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
|
||||
const lifetime = new AbortController();
|
||||
let pillsHidden = false;
|
||||
const applyWorkspace = (workspace) => {
|
||||
if (workspace.id !== workspaceId) return;
|
||||
const hidePills = workspace.name === "No composer pills";
|
||||
deploy.update({ visible: workspace.name !== "Compact checks" });
|
||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" });
|
||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" && !hidePills });
|
||||
if (hidePills === pillsHidden) return;
|
||||
pillsHidden = hidePills;
|
||||
review.update({ visible: !hidePills });
|
||||
usage.update({ visible: !hidePills });
|
||||
};
|
||||
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
|
||||
subscription.subscribe({
|
||||
@@ -244,6 +253,38 @@ async function captureSchemes(page: Page, info: TestInfo, name: string, subject:
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
}
|
||||
|
||||
const COMPOSER_PILLS = ["Run review", "Composer checks", "Composer status"] as const;
|
||||
|
||||
function chevronOf(page: Page, title: string): Locator {
|
||||
return page
|
||||
.getByRole("button", { name: title, exact: true })
|
||||
.getByTestId("plugin-button-chevron");
|
||||
}
|
||||
|
||||
/** Composer pills that open a menu or popover carry a chevron; a one-tap action does not. */
|
||||
async function expectComposerPillChevrons(page: Page) {
|
||||
await expect(chevronOf(page, "Composer checks")).toHaveCount(1);
|
||||
await expect(chevronOf(page, "Composer status")).toHaveCount(1);
|
||||
await expect(chevronOf(page, "Run review")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function setComposerPillsShown(
|
||||
page: Page,
|
||||
showcase: { setTitle(title: string): Promise<unknown> },
|
||||
shown: boolean,
|
||||
) {
|
||||
await showcase.setTitle(shown ? "Button showcase" : "No composer pills");
|
||||
for (const title of COMPOSER_PILLS) {
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).toHaveCount(shown ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function lastTurnTop(page: Page): Promise<number> {
|
||||
const box = await page.getByRole("button", { name: "Copy turn" }).last().boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
return box!.y;
|
||||
}
|
||||
|
||||
async function expectHeaderTextTruncated(page: Page) {
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const subtitle = page.getByTestId("workspace-header-subtitle");
|
||||
@@ -276,6 +317,7 @@ export async function withButtonShowcase(
|
||||
run: (buttons: {
|
||||
openWideMenusAndPopovers(): Promise<void>;
|
||||
runAndUpdateActions(): Promise<void>;
|
||||
keepTrackSpaceWithoutPills(): Promise<void>;
|
||||
openCompactSheets(): Promise<void>;
|
||||
useCompactWorkspaceMenu(): Promise<void>;
|
||||
hideAndUnloadOpenButtons(): Promise<void>;
|
||||
@@ -348,11 +390,7 @@ export async function withButtonShowcase(
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Composer checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await expectComposerPillChevrons(page);
|
||||
await expectSurfaceVisible(page);
|
||||
await captureSchemes(
|
||||
page,
|
||||
@@ -476,10 +514,31 @@ export async function withButtonShowcase(
|
||||
"Review ready",
|
||||
);
|
||||
}),
|
||||
keepTrackSpaceWithoutPills: () =>
|
||||
test.step("the composer track keeps its space when every pill leaves and returns", async () => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await submitMessage(page, "Stream a turn under the composer pills");
|
||||
await expectTurnCopyButton(page);
|
||||
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
|
||||
for (const viewport of [WIDE, COMPACT]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await scrollAgentChatToBottom(page);
|
||||
const top = await lastTurnTop(page);
|
||||
await setComposerPillsShown(page, showcase, false);
|
||||
expect(await lastTurnTop(page)).toBe(top);
|
||||
await setComposerPillsShown(page, showcase, true);
|
||||
expect(await lastTurnTop(page)).toBe(top);
|
||||
}
|
||||
await page.setViewportSize(WIDE);
|
||||
}),
|
||||
openCompactSheets: () =>
|
||||
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectComposerPillChevrons(page);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toContainText("Checks");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
@@ -9,6 +12,54 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
const TABBED_PLUGIN_ID = "settings-tabs";
|
||||
const TABBED_PLUGIN_NAME = "Tabbed settings";
|
||||
/** Two settings screens, so the settings page shows a tab per screen. */
|
||||
const TABBED_SOURCE = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { defineTranslations, useTranslation } from "@getpaseo/plugin/client";
|
||||
|
||||
const messages = defineTranslations({ resources: { en: { icon: "advanced-tab-icon" } } });
|
||||
|
||||
function General({ theme }) {
|
||||
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
|
||||
}
|
||||
|
||||
function Advanced({ theme }) {
|
||||
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
|
||||
}
|
||||
|
||||
// Component icons run with the plugin's runtime; one that throws costs its tab only the icon.
|
||||
function TranslatedIcon({ color, size }) {
|
||||
const { t } = useTranslation(messages);
|
||||
return <View testID={t("icon")} aria-hidden style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
function BrokenIcon() {
|
||||
throw new Error("Settings tab icon exploded");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General });
|
||||
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, Component: Advanced });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
async function writeTabbedPlugin() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: TABBED_PLUGIN_ID,
|
||||
name: TABBED_PLUGIN_NAME,
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE);
|
||||
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
settingsPlugin: [
|
||||
async ({ e2eWorker, page }, provide, testInfo) => {
|
||||
@@ -16,16 +67,20 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("settings");
|
||||
const tabbed = await writeTabbedPlugin();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await client.installDirectoryPlugin(tabbed.directory);
|
||||
await provide(client);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings.png") });
|
||||
} finally {
|
||||
await client.removePlugin("settings-example");
|
||||
await client.removePlugin(TABBED_PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await tabbed.cleanup();
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
@@ -44,9 +99,88 @@ export async function openCompactDisplaySettings(page: Page) {
|
||||
}
|
||||
async function openPluginScreen(page: Page) {
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await openSettingsFromCog(page);
|
||||
}
|
||||
/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */
|
||||
function settingsButton(page: Page, pluginId: string) {
|
||||
return page
|
||||
.getByTestId(`plugin-row-${pluginId}`)
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Settings", exact: true });
|
||||
}
|
||||
async function openSettingsFromCog(page: Page) {
|
||||
await settingsButton(page, "settings-example").click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
// A plugin with one settings screen shows no tabs, and the header names only the plugin.
|
||||
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
|
||||
await expectSettingsHeader(page, "settings-example");
|
||||
}
|
||||
export async function openPluginsPage(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
}
|
||||
export async function expectActionsMenuWithoutSettings(page: Page) {
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
export async function expectNoSettingsCogWithoutScreens(page: Page) {
|
||||
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
|
||||
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
|
||||
await expect(settingsButton(page, "settings-example")).toHaveCount(0);
|
||||
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
|
||||
await expect(settingsButton(page, "settings-example")).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsTooltip(page: Page) {
|
||||
await settingsButton(page, "settings-example").hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Settings");
|
||||
await page.mouse.move(0, 0);
|
||||
}
|
||||
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
|
||||
export async function expectNamedPluginRow(page: Page) {
|
||||
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
|
||||
await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible();
|
||||
await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
export async function openTabbedSettings(page: Page) {
|
||||
await settingsButton(page, TABBED_PLUGIN_ID).click();
|
||||
await expectSettingsTab(page, "General");
|
||||
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
|
||||
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
|
||||
}
|
||||
function settingsTab(page: Page, title: string) {
|
||||
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
|
||||
}
|
||||
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
await settingsTab(page, title).click();
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
const other = title === "General" ? "Advanced" : "General";
|
||||
await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0);
|
||||
// The header names the plugin by its manifest name; the tab names the screen.
|
||||
await expectSettingsHeader(page, TABBED_PLUGIN_NAME);
|
||||
await expectAppRoute(
|
||||
page,
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
|
||||
await gotoAppShell(page);
|
||||
await page.goto(
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
||||
@@ -101,11 +235,7 @@ export async function discardTitle(page: Page) {
|
||||
}
|
||||
export async function returnAndReopenSettings(page: Page) {
|
||||
await goBackInSettings(page);
|
||||
const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
|
||||
await expect(actions).toBeVisible();
|
||||
await actions.click();
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
await openSettingsFromCog(page);
|
||||
}
|
||||
export async function returnToPluginsPage(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
@@ -114,6 +244,10 @@ export async function returnToPluginsPage(page: Page) {
|
||||
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function expectSettingsHeader(page: Page, title: string) {
|
||||
// Settings keeps the Plugins page mounted beneath, so read the visible title only.
|
||||
await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1);
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface SeedDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
labels?: Record<string, string>;
|
||||
background?: boolean;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
|
||||
@@ -29,8 +29,9 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
|
||||
* on every platform.
|
||||
*
|
||||
* The bar floats over the transcript with no background, so content remains visible underneath.
|
||||
* Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps
|
||||
* the final footer clear without turning the overlay into a layout band.
|
||||
* Its host gives the scroll viewport a small bottom inset for as long as the bar is mounted, with
|
||||
* or without pills: that keeps the final footer clear, and pills that come and go every turn do not
|
||||
* move the transcript.
|
||||
*/
|
||||
export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
|
||||
return (
|
||||
|
||||
@@ -76,7 +76,7 @@ export function useAggregatedAgents(options?: {
|
||||
}
|
||||
const serverLabel = serverLabelById.get(serverId) ?? serverId;
|
||||
for (const agent of agents.values()) {
|
||||
if (!includeArchived && agent.archivedAt) {
|
||||
if (agent.background || (!includeArchived && agent.archivedAt)) {
|
||||
continue;
|
||||
}
|
||||
const nextAgent: AggregatedAgent = {
|
||||
|
||||
@@ -2384,7 +2384,7 @@ export const en = {
|
||||
installing: "Installing…",
|
||||
logs: {
|
||||
action: "Logs",
|
||||
title: "Logs: {{id}}",
|
||||
title: "Logs: {{name}}",
|
||||
loading: "Loading logs…",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing…",
|
||||
@@ -2393,7 +2393,8 @@ export const en = {
|
||||
},
|
||||
status: { running: "running", disabled: "disabled", failed: "failed" },
|
||||
actions: {
|
||||
menu: "Actions for {{id}}",
|
||||
menu: "Actions for {{name}}",
|
||||
settings: "Settings",
|
||||
reload: "Reload",
|
||||
reloading: "Reloading…",
|
||||
enable: "Enable",
|
||||
@@ -2404,16 +2405,16 @@ export const en = {
|
||||
removing: "Removing…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "Installed {{id}}",
|
||||
reload: "Reloaded {{id}}",
|
||||
enable: "Enabled {{id}}",
|
||||
disable: "Disabled {{id}}",
|
||||
remove: "Removed {{id}}",
|
||||
kept: "Kept {{id}}",
|
||||
installed: "Installed {{name}}",
|
||||
reload: "Reloaded {{name}}",
|
||||
enable: "Enabled {{name}}",
|
||||
disable: "Disabled {{name}}",
|
||||
remove: "Removed {{name}}",
|
||||
kept: "Kept {{name}}",
|
||||
globalEnabled: "Plugins enabled",
|
||||
globalDisabled: "Plugins disabled",
|
||||
},
|
||||
removeConfirmTitle: "Remove {{id}}?",
|
||||
removeConfirmTitle: "Remove {{name}}?",
|
||||
removeConfirmMessage: "This removes the configuration. The source directory is not deleted.",
|
||||
states: {
|
||||
offlineTitle: "Plugin host is offline",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const shared = {
|
||||
logs: {
|
||||
action: "Logs",
|
||||
title: "Logs: {{id}}",
|
||||
title: "Logs: {{name}}",
|
||||
loading: "Loading logs…",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing…",
|
||||
@@ -33,7 +33,8 @@ export const pluginSettings = {
|
||||
installing: "جارٍ التثبيت…",
|
||||
status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" },
|
||||
actions: {
|
||||
menu: "إجراءات {{id}}",
|
||||
menu: "إجراءات {{name}}",
|
||||
settings: "الإعدادات",
|
||||
reload: "إعادة تحميل",
|
||||
reloading: "جارٍ إعادة التحميل…",
|
||||
enable: "تفعيل",
|
||||
@@ -44,16 +45,16 @@ export const pluginSettings = {
|
||||
removing: "جارٍ الإزالة…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "تم تثبيت {{id}}",
|
||||
reload: "تمت إعادة تحميل {{id}}",
|
||||
enable: "تم تفعيل {{id}}",
|
||||
disable: "تم تعطيل {{id}}",
|
||||
remove: "تمت إزالة {{id}}",
|
||||
kept: "تم الإبقاء على {{id}}",
|
||||
installed: "تم تثبيت {{name}}",
|
||||
reload: "تمت إعادة تحميل {{name}}",
|
||||
enable: "تم تفعيل {{name}}",
|
||||
disable: "تم تعطيل {{name}}",
|
||||
remove: "تمت إزالة {{name}}",
|
||||
kept: "تم الإبقاء على {{name}}",
|
||||
globalEnabled: "تم تفعيل الإضافات",
|
||||
globalDisabled: "تم تعطيل الإضافات",
|
||||
},
|
||||
removeConfirmTitle: "إزالة {{id}}؟",
|
||||
removeConfirmTitle: "إزالة {{name}}؟",
|
||||
removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.",
|
||||
states: {
|
||||
offlineTitle: "مضيف الإضافات غير متصل",
|
||||
@@ -88,7 +89,8 @@ export const pluginSettings = {
|
||||
installing: "Instalando…",
|
||||
status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" },
|
||||
actions: {
|
||||
menu: "Acciones para {{id}}",
|
||||
menu: "Acciones para {{name}}",
|
||||
settings: "Configuración",
|
||||
reload: "Recargar",
|
||||
reloading: "Recargando…",
|
||||
enable: "Activar",
|
||||
@@ -99,16 +101,16 @@ export const pluginSettings = {
|
||||
removing: "Eliminando…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} instalado",
|
||||
reload: "{{id}} recargado",
|
||||
enable: "{{id}} activado",
|
||||
disable: "{{id}} desactivado",
|
||||
remove: "{{id}} eliminado",
|
||||
kept: "Se conservó {{id}}",
|
||||
installed: "{{name}} instalado",
|
||||
reload: "{{name}} recargado",
|
||||
enable: "{{name}} activado",
|
||||
disable: "{{name}} desactivado",
|
||||
remove: "{{name}} eliminado",
|
||||
kept: "Se conservó {{name}}",
|
||||
globalEnabled: "Plugins activados",
|
||||
globalDisabled: "Plugins desactivados",
|
||||
},
|
||||
removeConfirmTitle: "¿Eliminar {{id}}?",
|
||||
removeConfirmTitle: "¿Eliminar {{name}}?",
|
||||
removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.",
|
||||
states: {
|
||||
offlineTitle: "El host de plugins está sin conexión",
|
||||
@@ -143,7 +145,8 @@ export const pluginSettings = {
|
||||
installing: "Installation…",
|
||||
status: { running: "actif", disabled: "désactivé", failed: "échec" },
|
||||
actions: {
|
||||
menu: "Actions pour {{id}}",
|
||||
menu: "Actions pour {{name}}",
|
||||
settings: "Paramètres",
|
||||
reload: "Recharger",
|
||||
reloading: "Rechargement…",
|
||||
enable: "Activer",
|
||||
@@ -154,16 +157,16 @@ export const pluginSettings = {
|
||||
removing: "Suppression…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} installé",
|
||||
reload: "{{id}} rechargé",
|
||||
enable: "{{id}} activé",
|
||||
disable: "{{id}} désactivé",
|
||||
remove: "{{id}} supprimé",
|
||||
kept: "{{id}} conservé",
|
||||
installed: "{{name}} installé",
|
||||
reload: "{{name}} rechargé",
|
||||
enable: "{{name}} activé",
|
||||
disable: "{{name}} désactivé",
|
||||
remove: "{{name}} supprimé",
|
||||
kept: "{{name}} conservé",
|
||||
globalEnabled: "Plugins activés",
|
||||
globalDisabled: "Plugins désactivés",
|
||||
},
|
||||
removeConfirmTitle: "Supprimer {{id}} ?",
|
||||
removeConfirmTitle: "Supprimer {{name}} ?",
|
||||
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
|
||||
states: {
|
||||
offlineTitle: "L’hôte des plugins est hors ligne",
|
||||
@@ -198,7 +201,8 @@ export const pluginSettings = {
|
||||
installing: "インストール中…",
|
||||
status: { running: "実行中", disabled: "無効", failed: "失敗" },
|
||||
actions: {
|
||||
menu: "{{id}} のアクション",
|
||||
menu: "{{name}} のアクション",
|
||||
settings: "設定",
|
||||
reload: "再読み込み",
|
||||
reloading: "再読み込み中…",
|
||||
enable: "有効化",
|
||||
@@ -209,16 +213,16 @@ export const pluginSettings = {
|
||||
removing: "削除中…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} をインストールしました",
|
||||
reload: "{{id}} を再読み込みしました",
|
||||
enable: "{{id}} を有効にしました",
|
||||
disable: "{{id}} を無効にしました",
|
||||
remove: "{{id}} を削除しました",
|
||||
kept: "{{id}} を保持しました",
|
||||
installed: "{{name}} をインストールしました",
|
||||
reload: "{{name}} を再読み込みしました",
|
||||
enable: "{{name}} を有効にしました",
|
||||
disable: "{{name}} を無効にしました",
|
||||
remove: "{{name}} を削除しました",
|
||||
kept: "{{name}} を保持しました",
|
||||
globalEnabled: "プラグインを有効にしました",
|
||||
globalDisabled: "プラグインを無効にしました",
|
||||
},
|
||||
removeConfirmTitle: "{{id}} を削除しますか?",
|
||||
removeConfirmTitle: "{{name}} を削除しますか?",
|
||||
removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。",
|
||||
states: {
|
||||
offlineTitle: "プラグインホストはオフラインです",
|
||||
@@ -252,7 +256,8 @@ export const pluginSettings = {
|
||||
installing: "설치 중…",
|
||||
status: { running: "실행 중", disabled: "비활성화", failed: "실패" },
|
||||
actions: {
|
||||
menu: "{{id}} 작업",
|
||||
menu: "{{name}} 작업",
|
||||
settings: "설정",
|
||||
reload: "다시 로드",
|
||||
reloading: "다시 로드 중…",
|
||||
enable: "활성화",
|
||||
@@ -263,16 +268,16 @@ export const pluginSettings = {
|
||||
removing: "제거 중…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} 설치됨",
|
||||
reload: "{{id}} 다시 로드됨",
|
||||
enable: "{{id}} 활성화됨",
|
||||
disable: "{{id}} 비활성화됨",
|
||||
remove: "{{id}} 제거됨",
|
||||
kept: "{{id}} 유지됨",
|
||||
installed: "{{name}} 설치됨",
|
||||
reload: "{{name}} 다시 로드됨",
|
||||
enable: "{{name}} 활성화됨",
|
||||
disable: "{{name}} 비활성화됨",
|
||||
remove: "{{name}} 제거됨",
|
||||
kept: "{{name}} 유지됨",
|
||||
globalEnabled: "플러그인 활성화됨",
|
||||
globalDisabled: "플러그인 비활성화됨",
|
||||
},
|
||||
removeConfirmTitle: "{{id}}을(를) 제거할까요?",
|
||||
removeConfirmTitle: "{{name}}을(를) 제거할까요?",
|
||||
removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.",
|
||||
states: {
|
||||
offlineTitle: "플러그인 호스트가 오프라인입니다",
|
||||
@@ -307,7 +312,8 @@ export const pluginSettings = {
|
||||
installing: "Instalando…",
|
||||
status: { running: "em execução", disabled: "desativado", failed: "falhou" },
|
||||
actions: {
|
||||
menu: "Ações para {{id}}",
|
||||
menu: "Ações para {{name}}",
|
||||
settings: "Configurações",
|
||||
reload: "Recarregar",
|
||||
reloading: "Recarregando…",
|
||||
enable: "Ativar",
|
||||
@@ -318,16 +324,16 @@ export const pluginSettings = {
|
||||
removing: "Removendo…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} instalado",
|
||||
reload: "{{id}} recarregado",
|
||||
enable: "{{id}} ativado",
|
||||
disable: "{{id}} desativado",
|
||||
remove: "{{id}} removido",
|
||||
kept: "{{id}} mantido",
|
||||
installed: "{{name}} instalado",
|
||||
reload: "{{name}} recarregado",
|
||||
enable: "{{name}} ativado",
|
||||
disable: "{{name}} desativado",
|
||||
remove: "{{name}} removido",
|
||||
kept: "{{name}} mantido",
|
||||
globalEnabled: "Plugins ativados",
|
||||
globalDisabled: "Plugins desativados",
|
||||
},
|
||||
removeConfirmTitle: "Remover {{id}}?",
|
||||
removeConfirmTitle: "Remover {{name}}?",
|
||||
removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.",
|
||||
states: {
|
||||
offlineTitle: "O host de plugins está offline",
|
||||
@@ -362,7 +368,8 @@ export const pluginSettings = {
|
||||
installing: "Установка…",
|
||||
status: { running: "работает", disabled: "отключён", failed: "ошибка" },
|
||||
actions: {
|
||||
menu: "Действия для {{id}}",
|
||||
menu: "Действия для {{name}}",
|
||||
settings: "Настройки",
|
||||
reload: "Перезагрузить",
|
||||
reloading: "Перезагрузка…",
|
||||
enable: "Включить",
|
||||
@@ -373,16 +380,16 @@ export const pluginSettings = {
|
||||
removing: "Удаление…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{id}} установлен",
|
||||
reload: "{{id}} перезагружен",
|
||||
enable: "{{id}} включён",
|
||||
disable: "{{id}} отключён",
|
||||
remove: "{{id}} удалён",
|
||||
kept: "{{id}} сохранён",
|
||||
installed: "{{name}} установлен",
|
||||
reload: "{{name}} перезагружен",
|
||||
enable: "{{name}} включён",
|
||||
disable: "{{name}} отключён",
|
||||
remove: "{{name}} удалён",
|
||||
kept: "{{name}} сохранён",
|
||||
globalEnabled: "Плагины включены",
|
||||
globalDisabled: "Плагины отключены",
|
||||
},
|
||||
removeConfirmTitle: "Удалить {{id}}?",
|
||||
removeConfirmTitle: "Удалить {{name}}?",
|
||||
removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.",
|
||||
states: {
|
||||
offlineTitle: "Хост плагинов не в сети",
|
||||
@@ -416,7 +423,8 @@ export const pluginSettings = {
|
||||
installing: "正在安装…",
|
||||
status: { running: "运行中", disabled: "已禁用", failed: "失败" },
|
||||
actions: {
|
||||
menu: "{{id}} 的操作",
|
||||
menu: "{{name}} 的操作",
|
||||
settings: "设置",
|
||||
reload: "重新加载",
|
||||
reloading: "正在重新加载…",
|
||||
enable: "启用",
|
||||
@@ -427,16 +435,16 @@ export const pluginSettings = {
|
||||
removing: "正在移除…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "已安装 {{id}}",
|
||||
reload: "已重新加载 {{id}}",
|
||||
enable: "已启用 {{id}}",
|
||||
disable: "已禁用 {{id}}",
|
||||
remove: "已移除 {{id}}",
|
||||
kept: "已保留 {{id}}",
|
||||
installed: "已安装 {{name}}",
|
||||
reload: "已重新加载 {{name}}",
|
||||
enable: "已启用 {{name}}",
|
||||
disable: "已禁用 {{name}}",
|
||||
remove: "已移除 {{name}}",
|
||||
kept: "已保留 {{name}}",
|
||||
globalEnabled: "插件已启用",
|
||||
globalDisabled: "插件已禁用",
|
||||
},
|
||||
removeConfirmTitle: "移除 {{id}}?",
|
||||
removeConfirmTitle: "移除 {{name}}?",
|
||||
removeConfirmMessage: "这会移除配置,但不会删除源目录。",
|
||||
states: {
|
||||
offlineTitle: "插件主机离线",
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
useComposerFullscreen,
|
||||
type ComposerEditingSession,
|
||||
} from "@/composer/editing-session";
|
||||
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||
import {
|
||||
resolveComposerTrackControlClearance,
|
||||
resolveComposerTrackTailClearance,
|
||||
@@ -66,7 +65,6 @@ import { useAgentFactsLine } from "@/panels/agent-facts-line";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { definePanel } from "@/panels/panel-registry";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useHasPluginComposerPills } from "@/plugins";
|
||||
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
||||
import {
|
||||
type HostRuntimeConnectionStatus,
|
||||
@@ -81,7 +79,7 @@ import {
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
|
||||
import { AgentTracks } from "@/panels/agent-tracks";
|
||||
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
@@ -1110,18 +1108,10 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
const parentPillAgentId = showsComposer ? parentAgentId : null;
|
||||
const hasVisibleAgentTracks = hasAgentTracks({
|
||||
parentAgentId: parentPillAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||
hasPluginComposerPills,
|
||||
});
|
||||
const rawAgentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
@@ -1199,13 +1189,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
<AgentStreamSection
|
||||
streamViewRef={streamViewRef}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
agent={effectiveAgent}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
hasActiveComposer={hasActiveComposer}
|
||||
hasVisibleAgentTracks={hasVisibleAgentTracks}
|
||||
toast={toastApi}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
@@ -1220,7 +1208,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
hasPluginComposerPills={hasPluginComposerPills}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -1366,36 +1353,31 @@ function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: str
|
||||
const AgentStreamSection = memo(function AgentStreamSection({
|
||||
streamViewRef,
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
agent,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
hasActiveComposer,
|
||||
hasVisibleAgentTracks,
|
||||
toast,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId?: string;
|
||||
agent: AgentScreenAgent;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
hasActiveComposer: boolean;
|
||||
hasVisibleAgentTracks: boolean;
|
||||
toast: ReturnType<typeof useToastHost>["api"];
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||
const hasVisibleComposerTracks =
|
||||
hasActiveComposer && (hasVisibleAgentTracks || hasWorkspaceDiffStat);
|
||||
const bottomOverlayTailClearance = hasVisibleComposerTracks
|
||||
// The track bar's space is reserved whenever the bar is mounted, pills or not: pills come and go
|
||||
// every turn, and the transcript must not jump with them.
|
||||
const bottomOverlayTailClearance = hasActiveComposer
|
||||
? resolveComposerTrackTailClearance(isCompactFormFactor)
|
||||
: 0;
|
||||
const bottomOverlayControlClearance = hasVisibleComposerTracks
|
||||
const bottomOverlayControlClearance = hasActiveComposer
|
||||
? resolveComposerTrackControlClearance(isCompactFormFactor)
|
||||
: 0;
|
||||
const streamItemsRaw = useAgentStreamTail(serverId, agentId);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||
import { memo, useCallback, type ReactElement } from "react";
|
||||
import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
|
||||
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||
import { AgentTaskList } from "@/composer/task-list";
|
||||
import { ComposerTrackBar } from "@/composer/tracks";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -40,7 +39,6 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
onArchiveFinished,
|
||||
hasPluginComposerPills,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -50,10 +48,8 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
onArchiveFinished: () => void;
|
||||
hasPluginComposerPills: boolean;
|
||||
}): ReactElement | null {
|
||||
}): ReactElement {
|
||||
const { tabId } = usePaneContext();
|
||||
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
@@ -112,19 +108,6 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
});
|
||||
}, [isCompact, openInSidePane, workspaceKey]);
|
||||
|
||||
if (
|
||||
!hasWorkspaceDiffStat &&
|
||||
!hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposerTrackBar>
|
||||
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
|
||||
@@ -154,25 +137,3 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
</ComposerTrackBar>
|
||||
);
|
||||
});
|
||||
|
||||
export function hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills = false,
|
||||
}: {
|
||||
parentAgentId: string | null;
|
||||
subagentRows: readonly SubagentRow[];
|
||||
tasks: readonly TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
hasPluginComposerPills?: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
parentAgentId !== null ||
|
||||
subagentRows.length > 0 ||
|
||||
Boolean(tasks?.length) ||
|
||||
archiveFinishedStatus.kind !== "idle" ||
|
||||
hasPluginComposerPills
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "react";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native";
|
||||
import { AlertCircle, ChevronDown, ChevronUp, MoreHorizontal } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
MenuRoot,
|
||||
@@ -247,7 +247,11 @@ function ButtonControl({ view }: { view: ButtonView }) {
|
||||
const composer = entry.placement === "composer";
|
||||
const disabled = button.disabled || entry.pending;
|
||||
const expanded = button.behavior.kind !== "action";
|
||||
const chevron = !composer && !props.layout.compact && expanded;
|
||||
// A chevron marks a button that opens something. Composer pills keep their label on compact and
|
||||
// open their surface above, so theirs points up and shows at every width; compact headers drop
|
||||
// the label and the chevron with it.
|
||||
const chevron = expanded && (composer || !props.layout.compact);
|
||||
const Chevron = composer ? ChevronUp : ChevronDown;
|
||||
let label = button.label;
|
||||
if (composer) label = button.label ?? button.title;
|
||||
else if (props.layout.compact) label = undefined;
|
||||
@@ -284,7 +288,7 @@ function ButtonControl({ view }: { view: ButtonView }) {
|
||||
) : null}
|
||||
{chevron ? (
|
||||
<View testID="plugin-button-chevron">
|
||||
<ChevronDown size={12} color={props.theme.colors.foregroundMuted} />
|
||||
<Chevron size={12} color={props.theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
@@ -559,14 +563,6 @@ function useButtons(serverId: string, workspaceId: string, agentId: string | nul
|
||||
);
|
||||
}
|
||||
|
||||
export function useHasPluginComposerPills(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
): boolean {
|
||||
return useButtons(serverId, workspaceId, agentId).length > 0;
|
||||
}
|
||||
|
||||
export function PluginComposerPills({
|
||||
serverId,
|
||||
workspaceId,
|
||||
|
||||
@@ -3,9 +3,4 @@ export { buildLegacyPluginSurfaceRedirectRoute } from "./routes";
|
||||
export { PluginSurfaceScreen } from "./surface-screen";
|
||||
export { usePluginAttachmentPicker } from "./attachments/picker";
|
||||
export { PluginResourceAttachmentPill } from "./attachments/pill";
|
||||
export {
|
||||
PluginComposerPills,
|
||||
PluginHeaderButtons,
|
||||
useHasPluginComposerPills,
|
||||
usePluginWorkspaceMenu,
|
||||
} from "./buttons/view";
|
||||
export { PluginComposerPills, PluginHeaderButtons, usePluginWorkspaceMenu } from "./buttons/view";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PluginListItem } from "@getpaseo/protocol/messages";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
/** A host's configured plugins, as listed on Settings → Plugins. One cache entry per host. */
|
||||
export function usePluginList(serverId: string, options: { enabled?: boolean } = {}) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "pluginManagement");
|
||||
return useFetchQuery({
|
||||
queryKey: ["plugins", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) throw new Error("Plugin host is offline");
|
||||
return client.listPlugins();
|
||||
},
|
||||
enabled: (options.enabled ?? true) && Boolean(client && connected && supported),
|
||||
dataShape: "list",
|
||||
staleTimeMs: 1_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** The manifest's display name, or the plugin id when it has none. */
|
||||
export function pluginDisplayName(plugin: Pick<PluginListItem, "id" | "name">): string {
|
||||
return plugin.name ?? plugin.id;
|
||||
}
|
||||
|
||||
/** The display name of one configured plugin; its id until the list loads. */
|
||||
export function usePluginDisplayName(
|
||||
target: { serverId: string; pluginId: string } | null,
|
||||
): string | undefined {
|
||||
const plugins = usePluginList(target?.serverId ?? "", { enabled: target !== null });
|
||||
if (!target) return undefined;
|
||||
const plugin = plugins.data?.find((candidate) => candidate.id === target.pluginId);
|
||||
return plugin ? pluginDisplayName(plugin) : target.pluginId;
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, Text, View } from "react-native";
|
||||
import { Platform, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { router } from "expo-router";
|
||||
import { ArrowLeft } from "lucide-react-native";
|
||||
import { ArrowLeft, Settings } from "lucide-react-native";
|
||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { PluginIconView } from "../icons";
|
||||
import { useInstalledPlugin } from "../registry";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { PluginInstallationProvider } from "../installation-provider";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
@@ -23,45 +26,84 @@ interface SettingsIdentity {
|
||||
screenId: string;
|
||||
}
|
||||
|
||||
function PluginSettingsMenuItem({
|
||||
serverId,
|
||||
pluginId,
|
||||
screenId,
|
||||
title,
|
||||
disabled,
|
||||
}: SettingsIdentity & { title: string; disabled?: boolean }) {
|
||||
const open = useCallback(
|
||||
() => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)),
|
||||
[serverId, pluginId, screenId],
|
||||
);
|
||||
const ThemedSettingsIcon = withUnistyles(Settings);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/** Opens the plugin's settings at its first screen. Renders nothing when it has none. */
|
||||
export function PluginSettingsButton({ serverId, pluginId }: Omit<SettingsIdentity, "screenId">) {
|
||||
const { t } = useTranslation();
|
||||
const plugin = useInstalledPlugin(serverId, pluginId);
|
||||
const supported = useHostFeature(serverId, "pluginSettings");
|
||||
const firstScreenId = plugin?.settingsScreens[0]?.id;
|
||||
const open = useCallback(() => {
|
||||
if (firstScreenId) router.push(buildPluginSettingsRoute(serverId, pluginId, firstScreenId));
|
||||
}, [serverId, pluginId, firstScreenId]);
|
||||
const compact = useIsCompactFormFactor();
|
||||
if (!supported || !firstScreenId) return null;
|
||||
return (
|
||||
<DropdownMenuItem onSelect={open} disabled={disabled}>
|
||||
{title}
|
||||
</DropdownMenuItem>
|
||||
<ToolbarButton
|
||||
label={t("settings.plugins.actions.settings")}
|
||||
compact={compact}
|
||||
onPress={open}
|
||||
testID={`plugin-settings-${pluginId}`}
|
||||
>
|
||||
<ThemedSettingsIcon size={paneContentToolbarIconSize(compact)} uniProps={mutedIconMapping} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginSettingsMenuItems({
|
||||
serverId,
|
||||
pluginId,
|
||||
disabled,
|
||||
}: Omit<SettingsIdentity, "screenId"> & { disabled?: boolean }) {
|
||||
const plugin = useInstalledPlugin(serverId, pluginId);
|
||||
const supported = useHostFeature(serverId, "pluginSettings");
|
||||
if (!supported || !plugin) return null;
|
||||
function renderNoIcon() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A plugin-supplied icon component may throw; its tab then shows the label alone. */
|
||||
function SettingsTabIcon({
|
||||
plugin,
|
||||
icon,
|
||||
size,
|
||||
color,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
icon: InstalledPlugin["settingsScreens"][number]["icon"];
|
||||
size: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{plugin.settingsScreens.map((screen) => (
|
||||
<PluginSettingsMenuItem
|
||||
key={screen.id}
|
||||
serverId={serverId}
|
||||
pluginId={pluginId}
|
||||
screenId={screen.id}
|
||||
title={plugin.i18n.resolve(screen.title, plugin.locale)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={icon} renderError={renderNoIcon}>
|
||||
<PluginIconView icon={icon} size={size} color={color} />
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One tab per registered settings screen. A plugin with a single screen shows no tabs.
|
||||
* Render inside the installation's provider: tab icons may be plugin components.
|
||||
*/
|
||||
function SettingsTabs({ plugin, screenId }: { plugin: InstalledPlugin; screenId: string }) {
|
||||
const options = useMemo<SegmentedControlOption<string>[]>(
|
||||
() =>
|
||||
plugin.settingsScreens.map((screen) => ({
|
||||
value: screen.id,
|
||||
label: plugin.i18n.resolve(screen.title, plugin.locale),
|
||||
icon: ({ color, size }) => (
|
||||
<SettingsTabIcon plugin={plugin} icon={screen.icon} size={size} color={color} />
|
||||
),
|
||||
testID: `plugin-settings-tab-${screen.id}`,
|
||||
})),
|
||||
[plugin],
|
||||
);
|
||||
// Tabs swap the screen in place, so Back still returns to the Plugins page.
|
||||
const select = useCallback((id: string) => router.setParams({ screenId: id }), []);
|
||||
if (options.length < 2) return null;
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tabs}
|
||||
testID="plugin-settings-tabs"
|
||||
>
|
||||
<SegmentedControl options={options} value={screenId} onValueChange={select} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,18 +154,19 @@ function SettingsContent({
|
||||
return <Text style={styles.message}>{t("settings.plugins.screens.unavailable")}</Text>;
|
||||
const Component = screen.Component;
|
||||
return (
|
||||
<View>
|
||||
<SurfaceErrorBoundary
|
||||
installation={plugin}
|
||||
Surface={Component}
|
||||
resetKey={attempt}
|
||||
renderError={renderError}
|
||||
>
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<View>
|
||||
<SettingsTabs plugin={plugin} screenId={screen.id} />
|
||||
<SurfaceErrorBoundary
|
||||
installation={plugin}
|
||||
Surface={Component}
|
||||
resetKey={attempt}
|
||||
renderError={renderError}
|
||||
>
|
||||
<Component theme={theme} layout={layout} host={host} />
|
||||
</PluginInstallationProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
</View>
|
||||
</SurfaceErrorBoundary>
|
||||
</View>
|
||||
</PluginInstallationProvider>
|
||||
);
|
||||
}
|
||||
const ThemedSettingsContent = withUnistyles(SettingsContent);
|
||||
@@ -153,5 +196,6 @@ export function PluginSettingsContent({
|
||||
}
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
message: { color: theme.colors.foregroundMuted },
|
||||
tabs: { flexGrow: 0, marginBottom: theme.spacing[4] },
|
||||
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
|
||||
}));
|
||||
|
||||
@@ -513,6 +513,23 @@ describe("ReplicaCache", () => {
|
||||
expect(storage.changes[0]?.deletes).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a background agent background across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const helper = { ...agent("helper"), background: true };
|
||||
const visible = agent("visible");
|
||||
writer.commitDirectoryMutations(SERVER_ID, [
|
||||
{ kind: "agent", type: "upsert", id: helper.id, value: helper },
|
||||
{ kind: "agent", type: "upsert", id: visible.id, value: visible },
|
||||
]);
|
||||
await writer.flush();
|
||||
|
||||
const restored = await createCache(storage).readDirectory(SERVER_ID);
|
||||
|
||||
expect(restored.agents.get("helper")?.background).toBe(true);
|
||||
expect(restored.agents.get("visible")?.background).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips identified and anonymous open turns without changing protocol status", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -247,6 +247,8 @@ const StoredAgentSnapshotSchema = z.strictObject({
|
||||
lastError: z.string().optional(),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string(), z.string()),
|
||||
// Optional: clients without background_agents never received a background agent to cache.
|
||||
background: z.boolean().optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: IsoDateSchema.nullable().optional(),
|
||||
@@ -612,6 +614,31 @@ function serializeAgentTurn(agent: Agent): NonNullable<StoredAgent["turn"]> {
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAgentCapabilities(
|
||||
capabilities: Agent["capabilities"],
|
||||
): StoredAgent["snapshot"]["capabilities"] {
|
||||
return {
|
||||
supportsStreaming: capabilities.supportsStreaming,
|
||||
supportsSessionPersistence: capabilities.supportsSessionPersistence,
|
||||
...(capabilities.supportsSessionListing !== undefined
|
||||
? { supportsSessionListing: capabilities.supportsSessionListing }
|
||||
: {}),
|
||||
supportsDynamicModes: capabilities.supportsDynamicModes,
|
||||
supportsMcpServers: capabilities.supportsMcpServers,
|
||||
supportsReasoningStream: capabilities.supportsReasoningStream,
|
||||
supportsToolInvocations: capabilities.supportsToolInvocations,
|
||||
...(capabilities.supportsRewindConversation !== undefined
|
||||
? { supportsRewindConversation: capabilities.supportsRewindConversation }
|
||||
: {}),
|
||||
...(capabilities.supportsRewindFiles !== undefined
|
||||
? { supportsRewindFiles: capabilities.supportsRewindFiles }
|
||||
: {}),
|
||||
...(capabilities.supportsRewindBoth !== undefined
|
||||
? { supportsRewindBoth: capabilities.supportsRewindBoth }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAgent(agent: Agent): StoredAgent {
|
||||
const snapshot = {
|
||||
id: agent.id,
|
||||
@@ -632,26 +659,7 @@ function serializeAgent(agent: Agent): StoredAgent {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
capabilities: {
|
||||
supportsStreaming: agent.capabilities.supportsStreaming,
|
||||
supportsSessionPersistence: agent.capabilities.supportsSessionPersistence,
|
||||
...(agent.capabilities.supportsSessionListing !== undefined
|
||||
? { supportsSessionListing: agent.capabilities.supportsSessionListing }
|
||||
: {}),
|
||||
supportsDynamicModes: agent.capabilities.supportsDynamicModes,
|
||||
supportsMcpServers: agent.capabilities.supportsMcpServers,
|
||||
supportsReasoningStream: agent.capabilities.supportsReasoningStream,
|
||||
supportsToolInvocations: agent.capabilities.supportsToolInvocations,
|
||||
...(agent.capabilities.supportsRewindConversation !== undefined
|
||||
? { supportsRewindConversation: agent.capabilities.supportsRewindConversation }
|
||||
: {}),
|
||||
...(agent.capabilities.supportsRewindFiles !== undefined
|
||||
? { supportsRewindFiles: agent.capabilities.supportsRewindFiles }
|
||||
: {}),
|
||||
...(agent.capabilities.supportsRewindBoth !== undefined
|
||||
? { supportsRewindBoth: agent.capabilities.supportsRewindBoth }
|
||||
: {}),
|
||||
},
|
||||
capabilities: serializeAgentCapabilities(agent.capabilities),
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
@@ -659,6 +667,7 @@ function serializeAgent(agent: Agent): StoredAgent {
|
||||
...(agent.lastError ? { lastError: agent.lastError } : {}),
|
||||
title: agent.title,
|
||||
labels: agent.labels,
|
||||
...(agent.background ? { background: true } : {}),
|
||||
requiresAttention: agent.requiresAttention ?? false,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
||||
|
||||
@@ -114,7 +114,7 @@ import {
|
||||
HostTerminalsPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
import { PluginSettingsContent } from "@/plugins/settings";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { usePluginDisplayName } from "@/plugins/plugin-list";
|
||||
import { HostPluginsPage } from "@/screens/settings/plugins-page";
|
||||
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
|
||||
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
|
||||
@@ -1267,15 +1267,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
returnFromSettings({ kind: "root" });
|
||||
}, []);
|
||||
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
// The header names the plugin; its tabs name the open settings screen.
|
||||
const pluginDisplayName = usePluginDisplayName(view.kind === "plugin" ? view : null);
|
||||
const detailTitle = ((): string | undefined => {
|
||||
if (view.kind === "plugin") {
|
||||
const plugin = installedPlugins.find(
|
||||
(candidate) => candidate.serverId === view.serverId && candidate.id === view.pluginId,
|
||||
);
|
||||
const screen = plugin?.settingsScreens.find((candidate) => candidate.id === view.screenId);
|
||||
return `${view.pluginId} · ${plugin && screen ? plugin.i18n.resolve(screen.title, plugin.locale) : t("settings.title")}`;
|
||||
}
|
||||
if (view.kind === "plugin") return pluginDisplayName;
|
||||
if (view.kind === "host") {
|
||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
return item ? t(item.labelKey) : undefined;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import { PluginSettingsMenuItems } from "@/plugins/settings";
|
||||
import { PluginSettingsButton } from "@/plugins/settings";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
@@ -29,10 +29,10 @@ import { SettingsSection } from "@/components/settings/headings/settings-section
|
||||
import { resolvePluginPageState } from "@/screens/settings/plugins-page-state";
|
||||
import { openPluginInstallForm } from "@/screens/settings/plugin-install-form-model";
|
||||
import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry";
|
||||
import { pluginDisplayName, usePluginList } from "@/plugins/plugin-list";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const;
|
||||
const PLUGIN_SOURCE_DOCS_URL = "https://paseo.sh/docs/plugins/reference#plugin-sources";
|
||||
type PluginRowAction = "reload" | "enable" | "disable" | "remove";
|
||||
|
||||
@@ -49,7 +49,6 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine
|
||||
|
||||
function PluginActionsMenu({
|
||||
plugin,
|
||||
serverId,
|
||||
pending,
|
||||
pendingAction,
|
||||
onAction,
|
||||
@@ -57,19 +56,18 @@ function PluginActionsMenu({
|
||||
supportsLogs,
|
||||
}: {
|
||||
plugin: PluginListItem;
|
||||
serverId: string;
|
||||
pending: boolean;
|
||||
pendingAction?: PluginRowAction;
|
||||
onAction(action: PluginRowAction, plugin: PluginListItem): void;
|
||||
onOpenLogs(pluginId: string): void;
|
||||
onOpenLogs(plugin: PluginListItem): void;
|
||||
supportsLogs: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]);
|
||||
const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]);
|
||||
const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]);
|
||||
const openLogs = useCallback(() => onOpenLogs(plugin), [onOpenLogs, plugin]);
|
||||
const removeIcon = useMemo(() => <Trash2 size={16} color={styles.dangerIcon.color} />, []);
|
||||
const menuLabel = t("settings.plugins.actions.menu", { id: plugin.id });
|
||||
const menuLabel = t("settings.plugins.actions.menu", { name: pluginDisplayName(plugin) });
|
||||
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
@@ -83,7 +81,6 @@ function PluginActionsMenu({
|
||||
<MoreHorizontal size={18} color={styles.menuIcon.color} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220} sheetTitle={menuLabel}>
|
||||
<PluginSettingsMenuItems serverId={serverId} pluginId={plugin.id} disabled={pending} />
|
||||
{supportsLogs ? (
|
||||
<DropdownMenuItem onSelect={openLogs} disabled={pending}>
|
||||
{t("settings.plugins.logs.action")}
|
||||
@@ -128,7 +125,7 @@ function PluginRow({
|
||||
pending: boolean;
|
||||
pendingAction?: PluginRowAction;
|
||||
onAction(action: PluginRowAction, plugin: PluginListItem): void;
|
||||
onOpenLogs(pluginId: string): void;
|
||||
onOpenLogs(plugin: PluginListItem): void;
|
||||
supportsLogs: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -145,10 +142,14 @@ function PluginRow({
|
||||
() => <StatusBadge label={statusLabel} variant={badgeVariant} />,
|
||||
[badgeVariant, statusLabel],
|
||||
);
|
||||
const name = pluginDisplayName(plugin);
|
||||
// A display name hides the id that the CLI and plugin routes use, so show it beneath.
|
||||
const shownId = name === plugin.id ? null : plugin.id;
|
||||
const hint = useMemo(
|
||||
() =>
|
||||
plugin.description || plugin.installation ? (
|
||||
shownId || plugin.description || plugin.installation ? (
|
||||
<View>
|
||||
{shownId ? <Text style={styles.pluginSource}>{shownId}</Text> : null}
|
||||
{plugin.description ? (
|
||||
<Text style={styles.pluginDescription}>{plugin.description}</Text>
|
||||
) : null}
|
||||
@@ -157,20 +158,21 @@ function PluginRow({
|
||||
) : null}
|
||||
</View>
|
||||
) : undefined,
|
||||
[plugin.description, plugin.installation],
|
||||
[shownId, plugin.description, plugin.installation],
|
||||
);
|
||||
const toggleLabel = `${plugin.id}: ${t(
|
||||
const toggleLabel = `${name}: ${t(
|
||||
plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable",
|
||||
)}`;
|
||||
return (
|
||||
<SettingsRow
|
||||
label={plugin.id}
|
||||
label={name}
|
||||
labelAccessory={statusBadge}
|
||||
hint={hint}
|
||||
error={clientError ?? plugin.error}
|
||||
testID={`plugin-row-${plugin.id}`}
|
||||
>
|
||||
<View style={styles.pluginControls} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
|
||||
<View style={styles.pluginControls} accessibilityLabel={`${name} ${statusLabel}`}>
|
||||
<PluginSettingsButton serverId={serverId} pluginId={plugin.id} />
|
||||
<Switch
|
||||
value={plugin.enabled}
|
||||
onValueChange={toggle}
|
||||
@@ -179,7 +181,6 @@ function PluginRow({
|
||||
/>
|
||||
<PluginActionsMenu
|
||||
plugin={plugin}
|
||||
serverId={serverId}
|
||||
pending={pending}
|
||||
pendingAction={pendingAction}
|
||||
onAction={onAction}
|
||||
@@ -193,15 +194,16 @@ function PluginRow({
|
||||
|
||||
function PluginLogsSheet({
|
||||
client,
|
||||
pluginId,
|
||||
plugin,
|
||||
serverId,
|
||||
onClose,
|
||||
}: {
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
pluginId: string;
|
||||
plugin: PluginListItem;
|
||||
serverId: string;
|
||||
onClose(): void;
|
||||
}) {
|
||||
const pluginId = plugin.id;
|
||||
const { t } = useTranslation();
|
||||
const logs = useFetchQuery({
|
||||
queryKey: ["plugin-logs", serverId, pluginId],
|
||||
@@ -214,7 +216,7 @@ function PluginLogsSheet({
|
||||
}, [logs]);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("settings.plugins.logs.title", { id: pluginId }),
|
||||
title: t("settings.plugins.logs.title", { name: pluginDisplayName(plugin) }),
|
||||
actions: (
|
||||
<Button variant="outline" onPress={refresh} disabled={logs.isFetching}>
|
||||
{logs.isFetching
|
||||
@@ -223,7 +225,7 @@ function PluginLogsSheet({
|
||||
</Button>
|
||||
),
|
||||
}),
|
||||
[logs.isFetching, pluginId, refresh, t],
|
||||
[logs.isFetching, plugin, refresh, t],
|
||||
);
|
||||
|
||||
let content = <Text style={styles.logsState}>{t("settings.plugins.logs.loading")}</Text>;
|
||||
@@ -276,7 +278,6 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const refreshQueue = useRef(Promise.resolve());
|
||||
useInstalledPlugins();
|
||||
const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]);
|
||||
const [installForm] = useState(openPluginInstallForm);
|
||||
const installState = useSyncExternalStore(
|
||||
installForm.subscribe,
|
||||
@@ -293,20 +294,11 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
),
|
||||
[t],
|
||||
);
|
||||
const [logsPluginId, setLogsPluginId] = useState<string | null>(null);
|
||||
const [logsPlugin, setLogsPlugin] = useState<PluginListItem | null>(null);
|
||||
const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>(
|
||||
null,
|
||||
);
|
||||
const plugins = useFetchQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (!client) throw new Error("Plugin host is offline");
|
||||
return client.listPlugins();
|
||||
},
|
||||
enabled: Boolean(client && connected && supported),
|
||||
dataShape: "list",
|
||||
staleTimeMs: 1_000,
|
||||
});
|
||||
const plugins = usePluginList(serverId);
|
||||
const refetchPlugins = plugins.refetch;
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client) return;
|
||||
@@ -356,27 +348,28 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
run: async () => {
|
||||
const installed = await client.installPluginSource(input);
|
||||
installForm.reset();
|
||||
return t("settings.plugins.feedback.installed", { id: installed.id });
|
||||
return t("settings.plugins.feedback.installed", { name: pluginDisplayName(installed) });
|
||||
},
|
||||
});
|
||||
}, [client, installForm, installState.canSubmit, mutation, sourceInstallSupported, t]);
|
||||
const action = useCallback(
|
||||
(name: PluginRowAction, plugin: PluginListItem) => {
|
||||
if (!client) return;
|
||||
const pluginName = pluginDisplayName(plugin);
|
||||
const run = async () => {
|
||||
if (name === "remove") {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.plugins.removeConfirmTitle", { id: plugin.id }),
|
||||
title: t("settings.plugins.removeConfirmTitle", { name: pluginName }),
|
||||
message: t("settings.plugins.removeConfirmMessage"),
|
||||
confirmLabel: t("settings.plugins.actions.remove"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return t("settings.plugins.feedback.kept", { id: plugin.id });
|
||||
if (!confirmed) return t("settings.plugins.feedback.kept", { name: pluginName });
|
||||
await client.removePlugin(plugin.id);
|
||||
} else {
|
||||
await client[`${name}Plugin`](plugin.id);
|
||||
}
|
||||
return t(`settings.plugins.feedback.${name}`, { id: plugin.id });
|
||||
return t(`settings.plugins.feedback.${name}`, { name: pluginName });
|
||||
};
|
||||
setFeedback(null);
|
||||
mutation.mutate({ action: name, pluginId: plugin.id, run });
|
||||
@@ -408,7 +401,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
const retry = useCallback(() => {
|
||||
void refetchPlugins();
|
||||
}, [refetchPlugins]);
|
||||
const closeLogs = useCallback(() => setLogsPluginId(null), []);
|
||||
const closeLogs = useCallback(() => setLogsPlugin(null), []);
|
||||
|
||||
if (pageState === "offline") {
|
||||
return (
|
||||
@@ -454,7 +447,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
pending={mutation.isPending}
|
||||
pendingAction={pending ? pluginRowAction(mutation.variables?.action) : undefined}
|
||||
onAction={action}
|
||||
onOpenLogs={setLogsPluginId}
|
||||
onOpenLogs={setLogsPlugin}
|
||||
supportsLogs={logsSupported}
|
||||
/>
|
||||
);
|
||||
@@ -528,10 +521,10 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
) : null}
|
||||
{catalogContent}
|
||||
</SettingsSection>
|
||||
{client && logsPluginId ? (
|
||||
{client && logsPlugin ? (
|
||||
<PluginLogsSheet
|
||||
client={client}
|
||||
pluginId={logsPluginId}
|
||||
plugin={logsPlugin}
|
||||
serverId={serverId}
|
||||
onClose={closeLogs}
|
||||
/>
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface Agent {
|
||||
attentionReason?: "finished" | "error" | "permission" | null;
|
||||
attentionTimestamp?: Date | null;
|
||||
archivedAt?: Date | null;
|
||||
/** Background agents stay out of tabs, the subagent track and agent lists. Open them by id. */
|
||||
background?: boolean;
|
||||
parentAgentId: string | null;
|
||||
labels: Record<string, string>;
|
||||
projectPlacement?: ProjectPlacementPayload | null;
|
||||
|
||||
@@ -246,6 +246,22 @@ describe("selectSubagentsForParent", () => {
|
||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
||||
});
|
||||
|
||||
it("excludes background children from the track", () => {
|
||||
setAgents([
|
||||
makeAgent({ id: "parent-a" }),
|
||||
makeAgent({ id: "child-a", parentAgentId: "parent-a" }),
|
||||
makeAgent({ id: "background-child", parentAgentId: "parent-a", background: true }),
|
||||
]);
|
||||
|
||||
const rows = selectSubagentsForParent(
|
||||
useSessionStore.getState(),
|
||||
{ serverId: SERVER_ID, parentAgentId: "parent-a" },
|
||||
EMPTY_PENDING_ARCHIVE_IDS,
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
||||
});
|
||||
|
||||
it("excludes siblings, unrelated agents, and grandchildren", () => {
|
||||
setAgents([
|
||||
makeAgent({ id: "parent-a" }),
|
||||
|
||||
@@ -82,6 +82,7 @@ export function selectSubagentsForParent(
|
||||
const parent = getParentAddressFromLabels(agent.labels, serverId);
|
||||
if (
|
||||
agent.archivedAt ||
|
||||
agent.background ||
|
||||
pendingArchiveIds.has(`${serverId}:${agent.id}`) ||
|
||||
parent?.serverId !== params.serverId ||
|
||||
parent.agentId !== params.parentAgentId
|
||||
|
||||
@@ -85,6 +85,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
||||
archivedAt: agent.archivedAt?.toISOString() ?? null,
|
||||
...(agent.background ? { background: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,6 +134,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
attentionReason: snapshot.attentionReason ?? null,
|
||||
attentionTimestamp,
|
||||
archivedAt,
|
||||
background: snapshot.background === true,
|
||||
parentAgentId,
|
||||
labels: snapshot.labels,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ function agent(input: {
|
||||
pendingPermissionCount?: number;
|
||||
archivedAt?: string | null;
|
||||
parentAgentId?: string | null;
|
||||
background?: boolean;
|
||||
}): Agent {
|
||||
return {
|
||||
serverId: "host-a",
|
||||
@@ -61,6 +62,7 @@ function agent(input: {
|
||||
attentionTimestamp: input.attentionTimestamp ? new Date(input.attentionTimestamp) : null,
|
||||
archivedAt: input.archivedAt ? new Date(input.archivedAt) : null,
|
||||
parentAgentId: input.parentAgentId ?? null,
|
||||
background: input.background ?? false,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
@@ -158,6 +160,33 @@ describe("workspace agent activity index", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a running background agent change root workspace activity", () => {
|
||||
const index = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({ id: "root", workspaceId: "workspace-a", updatedAt: "2026-06-01T10:00:00.000Z" }),
|
||||
],
|
||||
[
|
||||
"classifier",
|
||||
agent({
|
||||
id: "classifier",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:05:00.000Z",
|
||||
background: true,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(index.get("workspace-a")).toEqual({
|
||||
agentId: "root",
|
||||
status: "done",
|
||||
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let archived or child agents change root workspace activity", () => {
|
||||
const index = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
|
||||
@@ -22,7 +22,12 @@ export function buildWorkspaceAgentActivityIndex(
|
||||
|
||||
for (const agent of agents.values()) {
|
||||
const parentAgent = agent.parentAgentId ? agents.get(agent.parentAgentId) : undefined;
|
||||
if (agent.archivedAt || !agent.workspaceId || !isWorkspaceRootAgent(agent, parentAgent)) {
|
||||
if (
|
||||
agent.archivedAt ||
|
||||
agent.background ||
|
||||
!agent.workspaceId ||
|
||||
!isWorkspaceRootAgent(agent, parentAgent)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ function makeAgent(input: {
|
||||
archivedAt?: Date | null;
|
||||
createdAt?: Date;
|
||||
lastActivityAt?: Date;
|
||||
background?: boolean;
|
||||
}): Agent {
|
||||
const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z");
|
||||
const lastActivityAt = input.lastActivityAt ?? createdAt;
|
||||
@@ -58,6 +59,7 @@ function makeAgent(input: {
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
background: input.background ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +133,33 @@ describe("workspace agent visibility", () => {
|
||||
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a background agent active without auto-opening its tab", () => {
|
||||
const visible = makeAgent({ id: "visible", cwd: "/repo", workspaceId: WORKSPACE_ID });
|
||||
const classifier = makeAgent({
|
||||
id: "classifier",
|
||||
cwd: "/repo",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
background: true,
|
||||
});
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sessionAgents: new Map([
|
||||
[visible.id, visible],
|
||||
[classifier.id, classifier],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.autoOpenAgentIds).toEqual(new Set([visible.id]));
|
||||
expect(result.activeAgentIds).toEqual(new Set([visible.id, classifier.id]));
|
||||
expect(
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: classifier.id,
|
||||
agentsHydrated: true,
|
||||
activeAgentIds: result.activeAgentIds,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses directory ownership before historical details when identifying foreign agents", () => {
|
||||
const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID });
|
||||
const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" });
|
||||
|
||||
@@ -36,11 +36,9 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
continue;
|
||||
}
|
||||
if (!agent.archivedAt) {
|
||||
// A background agent stays active so a tab opened by id isn't pruned, but never auto-opens.
|
||||
activeAgentIds.add(agent.id);
|
||||
const parentAgent = agent.parentAgentId
|
||||
? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId))
|
||||
: undefined;
|
||||
if (isWorkspaceRootAgent(agent, parentAgent)) {
|
||||
if (shouldAutoOpenAgent({ agent, sessionAgents, agentDetails })) {
|
||||
autoOpenAgentIds.add(agent.id);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +55,21 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
return { activeAgentIds, autoOpenAgentIds, foreignAgentIds };
|
||||
}
|
||||
|
||||
function shouldAutoOpenAgent(input: {
|
||||
agent: Agent;
|
||||
sessionAgents: Map<string, Agent> | undefined;
|
||||
agentDetails: Map<string, Agent> | undefined;
|
||||
}): boolean {
|
||||
const { agent, sessionAgents, agentDetails } = input;
|
||||
if (agent.background) {
|
||||
return false;
|
||||
}
|
||||
const parentAgent = agent.parentAgentId
|
||||
? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId))
|
||||
: undefined;
|
||||
return isWorkspaceRootAgent(agent, parentAgent);
|
||||
}
|
||||
|
||||
interface AgentMapsSnapshot {
|
||||
sessions: Record<
|
||||
string,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { render } from "../../output/index.js";
|
||||
import { buildAgentLsFetchOptions, listAgents } from "./ls.js";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
@@ -130,4 +131,26 @@ describe("runLsCommand", () => {
|
||||
|
||||
expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]);
|
||||
});
|
||||
|
||||
it("marks background agents in a BACKGROUND column only for inclusive listings", async () => {
|
||||
const client = createPagingClient();
|
||||
const fetchAgents: typeof client.fetchAgents = async (options) => {
|
||||
const page = await client.fetchAgents(options);
|
||||
return {
|
||||
...page,
|
||||
entries: page.entries.map(({ agent }) => ({
|
||||
agent: agent.title === "agent 2" ? { ...agent, background: true } : agent,
|
||||
})),
|
||||
};
|
||||
};
|
||||
const connect = async () => ({ ...client, fetchAgents });
|
||||
|
||||
const inclusive = await listAgents({ daemonTarget, all: true, global: true }, connect);
|
||||
const rows = render(inclusive, { noColor: true }).split("\n");
|
||||
expect(rows[0]).toMatch(/BACKGROUND\s*$/);
|
||||
expect(rows.slice(1).map((row) => row.trim().split(/\s+/).at(-1))).toEqual(["-", "yes", "-"]);
|
||||
|
||||
const ordinary = await listAgents({ daemonTarget, global: true }, connect);
|
||||
expect(render(ordinary, { noColor: true })).not.toContain("BACKGROUND");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export function addLsOptions(cmd: Command): Command {
|
||||
.description("List agents. By default excludes archived agents.")
|
||||
.option("-a, --all", "Include archived and background agents")
|
||||
.option("-g, --global", "List agents across all directories")
|
||||
.option("--background", "Include background workspace agents")
|
||||
.option("--background", "Include background agents and agents in background workspaces")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Filter by label (can be used multiple times)",
|
||||
@@ -35,6 +35,7 @@ export interface AgentListItem {
|
||||
status: string;
|
||||
cwd: string;
|
||||
created: string;
|
||||
background: boolean;
|
||||
}
|
||||
|
||||
/** Helper to get relative time string */
|
||||
@@ -89,6 +90,15 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Inclusive listings mark background agents so they read apart from visible ones. */
|
||||
const agentLsInclusiveSchema: OutputSchema<AgentListItem> = {
|
||||
...agentLsSchema,
|
||||
columns: [
|
||||
...agentLsSchema.columns,
|
||||
{ header: "BACKGROUND", field: (item) => (item.background ? "yes" : "-"), width: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
/** Transform agent snapshot to AgentListItem */
|
||||
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model);
|
||||
@@ -101,6 +111,7 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
status: agent.status,
|
||||
cwd: shortenPath(agent.cwd),
|
||||
created: relativeTime(agent.createdAt),
|
||||
background: agent.background === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,7 +270,7 @@ export async function listAgents(
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: agentLsSchema,
|
||||
schema: options.all || options.background ? agentLsInclusiveSchema : agentLsSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
@@ -145,6 +145,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.workspaceSetupBlocked]: true,
|
||||
[CLIENT_CAPS.workspaceSetupObservation]: true,
|
||||
[CLIENT_CAPS.pluginBinaryPayloads]: true,
|
||||
[CLIENT_CAPS.backgroundAgents]: true,
|
||||
[CLIENT_CAPS.explicitEventSubscriptions]: true,
|
||||
} satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
|
||||
|
||||
|
||||
@@ -1110,6 +1110,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
workspace_setup_blocked: true,
|
||||
workspace_setup_observation: true,
|
||||
hello_rejection: true,
|
||||
background_agents: true,
|
||||
browser_host: {
|
||||
supportedCommands: ["list_tabs"],
|
||||
hostKind: "desktop app",
|
||||
|
||||
@@ -504,6 +504,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
||||
git?: GitSetupOptions;
|
||||
worktree?: CreateAgentRequestMessage["worktree"];
|
||||
autoArchive?: CreateAgentRequestMessage["autoArchive"];
|
||||
/** Creates a background agent. A workspace this request creates inherits it. */
|
||||
background?: CreateAgentRequestMessage["background"];
|
||||
/** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */
|
||||
worktreeName?: string;
|
||||
@@ -2891,6 +2892,28 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Finds one workspace by id across pages, background ones included when the host has them. */
|
||||
async findWorkspace(
|
||||
workspaceId: string,
|
||||
options?: { requestId?: string },
|
||||
): Promise<FetchWorkspacesEntry | null> {
|
||||
const filter = this.supportsBackgroundWorkspaces() ? { includeBackground: true } : {};
|
||||
let cursor: string | undefined;
|
||||
let requestId = options?.requestId;
|
||||
do {
|
||||
const result = await this.fetchWorkspaces({
|
||||
requestId,
|
||||
filter,
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
const match = result.entries.find((entry) => entry.id === workspaceId);
|
||||
if (match) return match;
|
||||
cursor = result.pageInfo.nextCursor ?? undefined;
|
||||
requestId = undefined;
|
||||
} while (cursor);
|
||||
return null;
|
||||
}
|
||||
|
||||
listWorkspaceLabels(
|
||||
options: {
|
||||
sync?: { generation: string; afterSeq: number };
|
||||
@@ -3293,13 +3316,24 @@ export class DaemonClient {
|
||||
return this.lastServerInfoMessage?.features?.backgroundWorkspaces === true;
|
||||
}
|
||||
|
||||
// Background on a request that creates its workspace predates background agents: older hosts
|
||||
// apply it to the new workspace. Placing a background agent in an existing workspace, or
|
||||
// under a caller, needs a host that knows background agents.
|
||||
private requireBackgroundCreation(options: CreateAgentRequestOptions): void {
|
||||
if (options.workspaceId === undefined && options.callerAgentId === undefined) {
|
||||
this.requireBackgroundWorkspaces();
|
||||
} else if (this.lastServerInfoMessage?.features?.backgroundAgents !== true) {
|
||||
throw new Error("Update the host to use background agents.");
|
||||
}
|
||||
}
|
||||
|
||||
async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> {
|
||||
if (options.git !== undefined || options.worktreeName !== undefined) {
|
||||
throw new Error(
|
||||
"Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) or create a workspace first and pass workspaceId.",
|
||||
);
|
||||
}
|
||||
if (options.background !== undefined) this.requireBackgroundWorkspaces();
|
||||
if (options.background !== undefined) this.requireBackgroundCreation(options);
|
||||
const result = await this.creations.createAgent({
|
||||
...options,
|
||||
config: resolveAgentConfig(options),
|
||||
|
||||
@@ -747,6 +747,88 @@ test("agents.create with internal fails before sending on a host without interna
|
||||
await client.close();
|
||||
});
|
||||
|
||||
async function createWorkspaceHandle(features: Record<string, boolean>) {
|
||||
const { client, ws } = await connectClient(features);
|
||||
const creating = client.workspaces.create({
|
||||
source: { kind: "directory", path: "/repo/sdk" },
|
||||
});
|
||||
const request = parseSentSessionMessage(ws.sent.at(-1));
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: createWorkspace({ id: "workspace_open" }),
|
||||
setupTerminalId: null,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { client, ws, workspace: await creating };
|
||||
}
|
||||
|
||||
test("a background agent in an existing workspace fails before sending on a host without background agents", async () => {
|
||||
const { client, ws, workspace } = await createWorkspaceHandle({
|
||||
providerUsageList: true,
|
||||
providersSnapshotCwd: true,
|
||||
ownedSubscriptions: true,
|
||||
backgroundWorkspaces: true,
|
||||
});
|
||||
const sentBefore = ws.sent.length;
|
||||
|
||||
await expect(
|
||||
workspace.agents.create({ config: { provider: "codex/gpt-5.4" }, background: true }),
|
||||
).rejects.toThrow("Update the host to use background agents.");
|
||||
expect(ws.sent).toHaveLength(sentBefore);
|
||||
|
||||
const creating = client.agents.create({
|
||||
config: { provider: "codex/gpt-5.4" },
|
||||
cwd: "/repo/sdk",
|
||||
background: true,
|
||||
});
|
||||
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
|
||||
type: "create_agent_request",
|
||||
background: true,
|
||||
});
|
||||
expect(parseSentSessionMessage(ws.sent.at(-1))).not.toHaveProperty("workspaceId");
|
||||
creating.catch(() => {});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("a background agent in an existing workspace is sent to a host with background agents", async () => {
|
||||
const { client, ws, workspace } = await createWorkspaceHandle({
|
||||
providerUsageList: true,
|
||||
providersSnapshotCwd: true,
|
||||
ownedSubscriptions: true,
|
||||
backgroundWorkspaces: true,
|
||||
backgroundAgents: true,
|
||||
});
|
||||
|
||||
const creating = workspace.agents.create({
|
||||
config: { provider: "codex/gpt-5.4" },
|
||||
background: true,
|
||||
});
|
||||
const request = parseSentSessionMessage(ws.sent.at(-1));
|
||||
expect(request).toMatchObject({
|
||||
type: "create_agent_request",
|
||||
workspaceId: "workspace_open",
|
||||
background: true,
|
||||
});
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_created",
|
||||
requestId: request.requestId,
|
||||
agentId: "agent_background",
|
||||
agent: createAgent({ id: "agent_background", workspaceId: "workspace_open" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect((await creating).id).toBe("agent_background");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
const createdAgent = createAgent();
|
||||
|
||||
@@ -264,7 +264,11 @@ export interface PaseoAgentCreateOptions {
|
||||
git?: CreateAgentRequestMessage["git"];
|
||||
worktree?: CreateAgentRequestMessage["worktree"];
|
||||
autoArchive?: CreateAgentRequestMessage["autoArchive"];
|
||||
/** Visibility intent for a NEW workspace. Existing targets reject this option. */
|
||||
/**
|
||||
* Creates a background agent: hidden from default lists, tabs, the parent's subagent track
|
||||
* and notifications, and still addressable by id. A workspace this call creates is
|
||||
* background too. With a parent and no value, the parent's own background applies.
|
||||
*/
|
||||
background?: CreateAgentRequestMessage["background"];
|
||||
requestId?: string;
|
||||
labels?: Record<string, string>;
|
||||
@@ -609,12 +613,14 @@ export function createPaseoApi(
|
||||
options: PaseoAgentCreateOptions,
|
||||
placement?: { workspaceId: string; cwd: string },
|
||||
) => {
|
||||
const serverId = daemonClient.getLastServerInfoMessage()?.serverId;
|
||||
const foreignParent =
|
||||
options.background === undefined ? foreignParentHandle(options.parent, serverId) : null;
|
||||
const resolved = foreignParent
|
||||
? await inheritParentBackground(options, foreignParent)
|
||||
: options;
|
||||
const agent = await daemonClient.createAgent(
|
||||
toDaemonAgentCreateOptions(
|
||||
options,
|
||||
placement,
|
||||
daemonClient.getLastServerInfoMessage()?.serverId,
|
||||
),
|
||||
toDaemonAgentCreateOptions(resolved, placement, serverId),
|
||||
);
|
||||
return createAgentHandle(agent);
|
||||
};
|
||||
@@ -819,25 +825,7 @@ function createWorkspaceHandleFactory(
|
||||
let current = typeof workspace === "string" ? null : workspace;
|
||||
|
||||
const refresh = async (options?: { requestId?: string }) => {
|
||||
let cursor: string | undefined;
|
||||
let requestId = options?.requestId;
|
||||
// A ref addresses one workspace by id, so a background one resolves too.
|
||||
const filter = daemonClient.supportsBackgroundWorkspaces() ? { includeBackground: true } : {};
|
||||
do {
|
||||
const result = await daemonClient.fetchWorkspaces({
|
||||
requestId,
|
||||
filter,
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
const match = result.entries.find((entry) => entry.id === id);
|
||||
if (match) {
|
||||
current = match;
|
||||
return current;
|
||||
}
|
||||
cursor = result.pageInfo.nextCursor ?? undefined;
|
||||
requestId = undefined;
|
||||
} while (cursor);
|
||||
current = null;
|
||||
current = await daemonClient.findWorkspace(id, options);
|
||||
return current;
|
||||
};
|
||||
|
||||
@@ -1066,6 +1054,24 @@ function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string {
|
||||
return typeof workspace === "string" ? workspace : workspace.id;
|
||||
}
|
||||
|
||||
function foreignParentHandle(
|
||||
parent: PaseoAgentCreateOptions["parent"],
|
||||
serverId: string | undefined,
|
||||
): PaseoAgentHandle | null {
|
||||
if (!parent || typeof parent === "string" || !("address" in parent)) return null;
|
||||
return parent.address.serverId === serverId ? null : parent;
|
||||
}
|
||||
|
||||
// A host inherits background from a caller it can see. A parent on another host is invisible to
|
||||
// it, so a handle passes its own value on. A bare foreign address can't; pass background then.
|
||||
async function inheritParentBackground(
|
||||
options: PaseoAgentCreateOptions,
|
||||
parent: PaseoAgentHandle,
|
||||
): Promise<PaseoAgentCreateOptions> {
|
||||
const snapshot = parent.current() ?? (await parent.refresh())?.agent;
|
||||
return snapshot?.background ? { ...options, background: true } : options;
|
||||
}
|
||||
|
||||
function encodeParent(
|
||||
parent: string | AgentAddress | PaseoAgentHandle,
|
||||
serverId?: string,
|
||||
|
||||
@@ -51,6 +51,10 @@ export const CLIENT_CAPS = {
|
||||
pluginBinaryPayloads: "plugin_binary_payloads",
|
||||
// COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
|
||||
workspaceSetupBlocked: "workspace_setup_blocked",
|
||||
// COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor >= v0.15.0.
|
||||
// Sources without it never receive agent-level background agents in inclusive lists or
|
||||
// update subscriptions, because older apps auto-open a tab for every replicated root agent.
|
||||
backgroundAgents: "background_agents",
|
||||
browserHost: "browser_host",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -891,6 +891,9 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
providerUnavailable: z.boolean().optional(),
|
||||
// Private helper marker, retained for exact-ID snapshots and older wire messages.
|
||||
internal: z.boolean().optional(),
|
||||
// Background agent: hidden from default discovery, tabs, tracks and notifications.
|
||||
// Fixed at creation. Effective background also includes the workspace's flag.
|
||||
background: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>;
|
||||
@@ -1802,7 +1805,8 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
// in the config schema, which is reused for updates. Retained for wire parsing;
|
||||
// public internal creation is no longer supported.
|
||||
internal: z.boolean().optional(),
|
||||
// Workspace creation intent, rejected when selecting an existing workspace.
|
||||
// The created agent is a background agent. A workspace this request creates inherits it.
|
||||
// Omitted on agent-scoped creation, the caller agent's own value applies.
|
||||
background: z.boolean().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
requestId: z.string(),
|
||||
@@ -3694,6 +3698,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
// COMPAT(internalWorkspaces): added in v0.9.0; remove gate after 2027-03-17.
|
||||
internalWorkspaces: z.boolean().optional(),
|
||||
backgroundWorkspaces: z.boolean().optional(),
|
||||
// COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03.
|
||||
backgroundAgents: z.boolean().optional(),
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
connectedHosts: z.boolean().optional(),
|
||||
hostMultiplexing: z.boolean().optional(),
|
||||
@@ -7802,6 +7808,7 @@ export const WSHelloMessageSchema = z.object({
|
||||
[CLIENT_CAPS.providerSnapshotReferences]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.timelineNotifications]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.backgroundAgents]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/persisted-creation-caller.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -135,6 +135,7 @@ export async function ensureAgentLoaded(
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
background: record.background,
|
||||
env: record.launchEnv,
|
||||
});
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
|
||||
@@ -324,6 +324,8 @@ export interface CreateAgentOptions {
|
||||
// undefined is an explicit decision: the agent never appears in the sidebar.
|
||||
workspaceId: string | undefined;
|
||||
owner?: AgentOwner;
|
||||
/** Background agents stay out of discovery, tabs, tracks and attention. Fixed at creation. */
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentManagerOptions {
|
||||
@@ -387,6 +389,11 @@ export type AttentionState =
|
||||
attentionTimestamp: Date;
|
||||
};
|
||||
|
||||
/** Private helpers and background agents report to their creator, never to the user. */
|
||||
function raisesAttention(agent: Pick<ManagedAgent, "internal" | "background">): boolean {
|
||||
return !agent.internal && !agent.background;
|
||||
}
|
||||
|
||||
function resolveInitialAttention(input: AttentionState | undefined): AttentionState {
|
||||
if (input == null || !input.requiresAttention) {
|
||||
return { requiresAttention: false };
|
||||
@@ -452,6 +459,11 @@ interface ManagedAgentBase {
|
||||
* Internal agents are hidden from listings and don't trigger notifications.
|
||||
*/
|
||||
internal?: boolean;
|
||||
/**
|
||||
* Background agents are durable but hidden from default discovery and never raise attention.
|
||||
* Fixed at creation.
|
||||
*/
|
||||
background?: boolean;
|
||||
/**
|
||||
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
||||
*/
|
||||
@@ -1402,6 +1414,7 @@ export class AgentManager {
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
background: options.background,
|
||||
launchEnv: options.env ? { ...options.env } : undefined,
|
||||
historyPrimed: true,
|
||||
});
|
||||
@@ -1434,6 +1447,7 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
@@ -1466,6 +1480,7 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
@@ -1727,6 +1742,7 @@ export class AgentManager {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
background: existing.background,
|
||||
launchEnv: existing.launchEnv,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
@@ -2181,6 +2197,7 @@ export class AgentManager {
|
||||
lastError: record.lastError ?? undefined,
|
||||
attention,
|
||||
internal: record.internal,
|
||||
background: record.background,
|
||||
labels: record.labels,
|
||||
},
|
||||
});
|
||||
@@ -3807,6 +3824,7 @@ export class AgentManager {
|
||||
publishWhenReady?: boolean;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
launchEnv?: Record<string, string>;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
@@ -3836,6 +3854,7 @@ export class AgentManager {
|
||||
config,
|
||||
now,
|
||||
durableTimelineHasRows,
|
||||
background: options?.background === true,
|
||||
options,
|
||||
});
|
||||
|
||||
@@ -3980,6 +3999,7 @@ export class AgentManager {
|
||||
config: AgentSessionConfig;
|
||||
now: Date;
|
||||
durableTimelineHasRows: boolean;
|
||||
background: boolean;
|
||||
options:
|
||||
| {
|
||||
createdAt?: Date;
|
||||
@@ -4034,6 +4054,7 @@ export class AgentManager {
|
||||
lastError: options?.lastError,
|
||||
attention: resolveInitialAttention(options?.attention),
|
||||
internal: config.internal ?? false,
|
||||
background: params.background,
|
||||
labels: options?.labels ?? {},
|
||||
} as ActiveManagedAgent;
|
||||
}
|
||||
@@ -4967,7 +4988,7 @@ export class AgentManager {
|
||||
const hadPendingPermissions = agent.pendingPermissions.size > 0;
|
||||
agent.pendingPermissions.set(event.request.id, event.request);
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (!hadPendingPermissions && !agent.internal) {
|
||||
if (!hadPendingPermissions && raisesAttention(agent)) {
|
||||
this.broadcastAgentAttention(agent, "permission");
|
||||
}
|
||||
this.emitState(agent);
|
||||
@@ -5222,8 +5243,7 @@ export class AgentManager {
|
||||
// Track the new status
|
||||
this.previousStatuses.set(agent.id, currentStatus);
|
||||
|
||||
// Skip attention tracking for internal agents
|
||||
if (agent.internal) {
|
||||
if (!raisesAttention(agent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export function toStoredAgentRecord(
|
||||
? agent.attention.attentionTimestamp.toISOString()
|
||||
: null,
|
||||
internal: options?.internal,
|
||||
...(agent.background ? { background: true } : {}),
|
||||
owner: agent.owner,
|
||||
} satisfies StoredAgentRecord;
|
||||
}
|
||||
@@ -149,7 +150,7 @@ export function toAgentPayload(
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
archivedAt: options?.archivedAt ?? null,
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
...visibilityFlags(agent),
|
||||
};
|
||||
|
||||
const usage = sanitizeUsage(agent.lastUsage);
|
||||
@@ -206,6 +207,17 @@ function buildStoredPersistenceHandle(
|
||||
return toAgentPersistenceHandle(validProviders, record.persistence);
|
||||
}
|
||||
|
||||
/** The wire carries `internal` and `background` only when set. */
|
||||
function visibilityFlags(agent: {
|
||||
internal?: boolean;
|
||||
background?: boolean;
|
||||
}): Pick<AgentSnapshotPayload, "internal" | "background"> {
|
||||
return {
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
...(agent.background ? { background: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStoredAgentPayload(
|
||||
record: StoredAgentRecord,
|
||||
validProviders: Iterable<AgentProvider>,
|
||||
@@ -258,7 +270,7 @@ export function buildStoredAgentPayload(
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
attentionTimestamp: record.attentionTimestamp ?? null,
|
||||
archivedAt: record.archivedAt ?? null,
|
||||
...(record.internal ? { internal: true } : {}),
|
||||
...visibilityFlags(record),
|
||||
labels: normalizeLabels(record.labels),
|
||||
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
|
||||
...(providerAvailable ? {} : { providerUnavailable: true }),
|
||||
|
||||
@@ -75,6 +75,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
internal: z.boolean().optional(),
|
||||
background: z.boolean().optional(),
|
||||
archivedAt: z.string().nullable().optional(),
|
||||
owner: AgentOwnerSchema.optional(),
|
||||
});
|
||||
@@ -261,6 +262,8 @@ export class AgentStorage {
|
||||
if (existing && existing.archivedAt !== undefined) {
|
||||
record.archivedAt = existing.archivedAt;
|
||||
}
|
||||
// Background is fixed at creation; a runtime opened without it cannot clear it.
|
||||
if (existing?.background) record.background = true;
|
||||
record.resumeOnStartup =
|
||||
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
|
||||
return record;
|
||||
|
||||
@@ -21,7 +21,17 @@ import { resolveCreateAgentTitles } from "../create-agent-title.js";
|
||||
import { buildAgentPrompt } from "../prompt-attachments.js";
|
||||
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
||||
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
import {
|
||||
inheritAgentBackground,
|
||||
inheritWorkspaceBackground,
|
||||
resolveCreateAgentIntent,
|
||||
} from "./intent.js";
|
||||
|
||||
/** How a workspace this creation makes inherits its creator's visibility. */
|
||||
interface McpWorkspaceIntent {
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentCommandDependencies {
|
||||
finishSubscriptions?: FinishSubscriptions;
|
||||
@@ -40,7 +50,7 @@ export interface CreateAgentCommandDependencies {
|
||||
export type EnsureWorkspaceForCreate = (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
) => Promise<string>;
|
||||
|
||||
export interface CreateAgentFromSessionInput {
|
||||
@@ -49,6 +59,7 @@ export interface CreateAgentFromSessionInput {
|
||||
agentId?: string;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
background: boolean;
|
||||
initialPrompt?: string;
|
||||
source?: AgentPromptSource;
|
||||
clientMessageId?: string;
|
||||
@@ -77,6 +88,8 @@ export interface CreateAgentFromMcpInput {
|
||||
promptFailure?: CreateAgentPromptFailureMode;
|
||||
notifyOnFinish: boolean;
|
||||
internal?: boolean;
|
||||
/** Omitted with a caller, the caller agent's own background applies. */
|
||||
background?: boolean;
|
||||
detached?: boolean;
|
||||
owner?: AgentOwner;
|
||||
env?: Record<string, string>;
|
||||
@@ -255,6 +268,7 @@ async function resolveSessionCreateAgent(
|
||||
// provider session either. Same as the daemon's own ephemeral helpers.
|
||||
...(sessionConfig.internal ? { persistSession: false } : {}),
|
||||
workspaceId: requireResolvedWorkspaceId(workspaceId),
|
||||
background: input.background,
|
||||
},
|
||||
prompt: hasPromptContent ? prompt : undefined,
|
||||
source: input.source,
|
||||
@@ -277,12 +291,16 @@ async function resolveMcpCreateAgent(
|
||||
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
|
||||
: null;
|
||||
const cwd = resolveMcpInitialCwd(input, parentAgent);
|
||||
const workspaceIntent = {
|
||||
background: inheritWorkspaceBackground(input.background, parentAgent),
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
};
|
||||
const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt ?? "",
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
workspaceIntent,
|
||||
});
|
||||
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
@@ -300,7 +318,7 @@ async function resolveMcpCreateAgent(
|
||||
dependencies,
|
||||
resolvedCwd,
|
||||
input.initialPrompt ?? "",
|
||||
parentAgent?.workspaceId,
|
||||
workspaceIntent,
|
||||
),
|
||||
),
|
||||
cwd: resolvedCwd,
|
||||
@@ -329,6 +347,7 @@ async function resolveMcpCreateAgent(
|
||||
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
||||
workspaceId: intent.workspaceId,
|
||||
owner: input.owner,
|
||||
background: inheritAgentBackground(input.background, parentAgent),
|
||||
env: input.env,
|
||||
},
|
||||
prompt: trimmedPrompt ? trimmedPrompt : undefined,
|
||||
@@ -410,16 +429,12 @@ async function ensureWorkspaceForMcpCreate(
|
||||
dependencies: CreateAgentCommandDependencies,
|
||||
cwd: string,
|
||||
initialPrompt: string,
|
||||
callerWorkspaceId?: string,
|
||||
workspaceIntent: McpWorkspaceIntent,
|
||||
): Promise<string | undefined> {
|
||||
if (!dependencies.ensureWorkspaceForCreate) {
|
||||
return undefined;
|
||||
}
|
||||
return dependencies.ensureWorkspaceForCreate(
|
||||
cwd,
|
||||
{ prompt: initialPrompt },
|
||||
{ callerWorkspaceId },
|
||||
);
|
||||
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }, workspaceIntent);
|
||||
}
|
||||
|
||||
async function sendInitialPrompt(
|
||||
@@ -512,7 +527,7 @@ async function resolveMcpCwd(params: {
|
||||
dependencies: CreateAgentCommandDependencies;
|
||||
cwd: string;
|
||||
initialPrompt: string;
|
||||
callerWorkspaceId?: string;
|
||||
workspaceIntent: McpWorkspaceIntent;
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
}): Promise<{
|
||||
resolvedCwd: string;
|
||||
@@ -542,7 +557,7 @@ async function resolveMcpCwd(params: {
|
||||
const createdWorktree = await createMcpWorktree({
|
||||
input: {
|
||||
cwd: params.cwd,
|
||||
callerWorkspaceId: params.callerWorkspaceId,
|
||||
...params.workspaceIntent,
|
||||
worktreeSlug: worktree.worktreeName,
|
||||
branchName: worktree.branchName,
|
||||
refName: worktree.refName,
|
||||
|
||||
@@ -6,6 +6,30 @@ export interface CreateAgentCaller {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/** The agent on whose behalf something is created, as far as background inheritance goes. */
|
||||
export interface BackgroundCaller {
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
/** Omitted, an agent inherits its caller agent's own background; without a caller it is visible. */
|
||||
export function inheritAgentBackground(
|
||||
requested: boolean | undefined,
|
||||
caller: BackgroundCaller | null | undefined,
|
||||
): boolean {
|
||||
return requested ?? caller?.background === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Omitted, a new workspace inherits its caller's effective background. A background caller
|
||||
* agent decides it here; otherwise `undefined` lets provisioning apply the caller workspace's value.
|
||||
*/
|
||||
export function inheritWorkspaceBackground(
|
||||
requested: boolean | undefined,
|
||||
caller: BackgroundCaller | null | undefined,
|
||||
): boolean | undefined {
|
||||
return requested ?? (caller?.background === true ? true : undefined);
|
||||
}
|
||||
|
||||
export interface CreateAgentPlacement {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { buildStoredAgentPayload } from "./agent-projections.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { AgentDirectory, type BackgroundVisibility } from "./directory.js";
|
||||
import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
|
||||
const NOW = "2026-03-01T12:00:00.000Z";
|
||||
|
||||
let home: string;
|
||||
let storage: AgentStorage;
|
||||
let directory: AgentDirectory;
|
||||
|
||||
function workspace(workspaceId: string, background: boolean): PersistedWorkspaceRecord {
|
||||
return {
|
||||
workspaceId,
|
||||
projectId: "project",
|
||||
cwd: home,
|
||||
kind: "local_checkout",
|
||||
displayName: workspaceId,
|
||||
background,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), "agent-directory-"));
|
||||
storage = new AgentStorage(home, createTestLogger());
|
||||
const base = {
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
labels: {},
|
||||
lastStatus: "idle" as const,
|
||||
};
|
||||
await storage.upsert({ ...base, id: "visible", workspaceId: "open" });
|
||||
await storage.upsert({ ...base, id: "background", workspaceId: "open", background: true });
|
||||
await storage.upsert({ ...base, id: "in-background-workspace", workspaceId: "hidden" });
|
||||
await storage.upsert({ ...base, id: "internal", workspaceId: "open", internal: true });
|
||||
const workspaces = [workspace("open", false), workspace("hidden", true)];
|
||||
directory = new AgentDirectory({
|
||||
storage,
|
||||
manager: { listAgents: () => [] },
|
||||
workspaces: {
|
||||
get: async (id) => workspaces.find((entry) => entry.workspaceId === id) ?? null,
|
||||
list: async () => workspaces,
|
||||
},
|
||||
projectLive: () => {
|
||||
throw new Error("No live agents in this suite");
|
||||
},
|
||||
projectStored: (record) => buildStoredAgentPayload(record, ["codex"]),
|
||||
isProviderVisible: () => true,
|
||||
isStoredProviderAvailable: () => true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function listedIds(includeBackground: BackgroundVisibility): Promise<string[]> {
|
||||
return (await directory.list({ includeBackground })).map((agent) => agent.id).toSorted();
|
||||
}
|
||||
|
||||
test("default lists hide agents that are background or live in a background workspace", async () => {
|
||||
expect(await listedIds(false)).toEqual(["visible"]);
|
||||
});
|
||||
|
||||
test("inclusive lists show every background agent", async () => {
|
||||
expect(await listedIds(true)).toEqual(["background", "in-background-workspace", "visible"]);
|
||||
});
|
||||
|
||||
test("workspace-only visibility hides agent-level background agents from older clients", async () => {
|
||||
expect(await listedIds("workspaces")).toEqual(["in-background-workspace", "visible"]);
|
||||
});
|
||||
|
||||
test("includes applies the same visibility to a single agent", async () => {
|
||||
const background = { workspaceId: "open", background: true };
|
||||
const inBackgroundWorkspace = { workspaceId: "hidden" };
|
||||
expect(await directory.includes(background)).toBe(false);
|
||||
expect(await directory.includes(background, "workspaces")).toBe(false);
|
||||
expect(await directory.includes(background, true)).toBe(true);
|
||||
expect(await directory.includes(inBackgroundWorkspace)).toBe(false);
|
||||
expect(await directory.includes(inBackgroundWorkspace, "workspaces")).toBe(true);
|
||||
});
|
||||
|
||||
test("prompt recency ignores background agents", async () => {
|
||||
const base = {
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
workspaceId: "open",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
labels: {},
|
||||
lastStatus: "idle" as const,
|
||||
};
|
||||
await storage.upsert({ ...base, id: "visible", lastUserMessageAt: "2026-03-02T00:00:00.000Z" });
|
||||
await storage.upsert({
|
||||
...base,
|
||||
id: "background",
|
||||
background: true,
|
||||
lastUserMessageAt: "2026-03-03T00:00:00.000Z",
|
||||
});
|
||||
expect(await directory.workspaceLastUserMessageTimes()).toEqual(
|
||||
new Map([["open", "2026-03-02T00:00:00.000Z"]]),
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,25 @@ import type { WorkspaceRegistry } from "../workspace-registry.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
|
||||
/** Public agent discovery owns workspace visibility for live and durable records. */
|
||||
/**
|
||||
* Which background agents a discovery read admits. An agent is effectively background when
|
||||
* it or its workspace is background.
|
||||
* - `false`: none, the default for lists.
|
||||
* - `true`: all of them.
|
||||
* - `"workspaces"`: only agents in background workspaces.
|
||||
* COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor
|
||||
* >= v0.15.0. Inclusive reads from sources without `background_agents` use it: older apps
|
||||
* replicate inclusively and auto-open a tab for every root agent.
|
||||
*/
|
||||
export type BackgroundVisibility = boolean | "workspaces";
|
||||
|
||||
interface DiscoverableAgent {
|
||||
workspaceId?: string;
|
||||
internal?: boolean;
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
/** Public agent discovery owns background visibility for live and durable records. */
|
||||
export class AgentDirectory {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
@@ -28,50 +46,53 @@ export class AgentDirectory {
|
||||
if (!previous || at > previous) times.set(workspaceId, at);
|
||||
};
|
||||
for (const agent of await this.deps.storage.list()) {
|
||||
if (!agent.internal && !liveIds.has(agent.id))
|
||||
if (!agent.internal && !agent.background && !liveIds.has(agent.id))
|
||||
record(agent.workspaceId, agent.lastUserMessageAt);
|
||||
}
|
||||
for (const agent of live) {
|
||||
if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
|
||||
if (!agent.internal && !agent.background)
|
||||
record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
async includes(
|
||||
agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">,
|
||||
includeBackground = false,
|
||||
agent: DiscoverableAgent,
|
||||
visibility: BackgroundVisibility = false,
|
||||
): Promise<boolean> {
|
||||
if (agent.internal) return false;
|
||||
if (includeBackground || !agent.workspaceId) return true;
|
||||
return !(await this.deps.workspaces.get(agent.workspaceId))?.background;
|
||||
const workspace = agent.workspaceId ? await this.deps.workspaces.get(agent.workspaceId) : null;
|
||||
return admits(agent, workspace?.background === true, visibility);
|
||||
}
|
||||
|
||||
async list(filter?: {
|
||||
labels?: Record<string, string>;
|
||||
includeArchived?: boolean;
|
||||
includeBackground?: boolean;
|
||||
includeBackground?: BackgroundVisibility;
|
||||
includeUnavailablePersisted?: boolean;
|
||||
}): Promise<AgentSnapshotPayload[]> {
|
||||
const live = this.deps.manager.listAgents();
|
||||
const liveIds = new Set(live.map((agent) => agent.id));
|
||||
const labels = Object.entries(filter?.labels ?? {});
|
||||
const backgroundIds = new Set(
|
||||
filter?.includeBackground
|
||||
const visibility = filter?.includeBackground ?? false;
|
||||
const backgroundWorkspaceIds = new Set(
|
||||
visibility === true
|
||||
? []
|
||||
: (await this.deps.workspaces.list())
|
||||
.filter((workspace) => workspace.background)
|
||||
.map((workspace) => workspace.workspaceId),
|
||||
);
|
||||
const matches = (agent: {
|
||||
archivedAt?: string | null;
|
||||
workspaceId?: string;
|
||||
labels?: Record<string, string>;
|
||||
}) =>
|
||||
const matches = (
|
||||
agent: DiscoverableAgent & { archivedAt?: string | null; labels?: Record<string, string> },
|
||||
) =>
|
||||
(filter?.includeArchived || !agent.archivedAt) &&
|
||||
(filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) &&
|
||||
admits(
|
||||
agent,
|
||||
agent.workspaceId !== undefined && backgroundWorkspaceIds.has(agent.workspaceId),
|
||||
visibility,
|
||||
) &&
|
||||
labels.every(([key, value]) => agent.labels?.[key] === value);
|
||||
const stored = (await this.deps.storage.list())
|
||||
.filter((record) => !record.internal && !liveIds.has(record.id) && matches(record))
|
||||
.filter((record) => !liveIds.has(record.id) && matches(record))
|
||||
.filter(
|
||||
(record) =>
|
||||
filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record),
|
||||
@@ -85,3 +106,13 @@ export class AgentDirectory {
|
||||
.filter((agent) => this.deps.isProviderVisible(agent.provider));
|
||||
}
|
||||
}
|
||||
|
||||
function admits(
|
||||
agent: DiscoverableAgent,
|
||||
inBackgroundWorkspace: boolean,
|
||||
visibility: BackgroundVisibility,
|
||||
): boolean {
|
||||
if (agent.internal) return false;
|
||||
if (agent.background) return visibility === true;
|
||||
return !inBackgroundWorkspace || visibility !== false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { createHostResolver, type HostOperations } from "./index.js";
|
||||
|
||||
type Handler = ((event?: unknown) => void) | null;
|
||||
|
||||
/** A connected host from before background agents: background workspaces, no background agents. */
|
||||
class OlderHostSocket {
|
||||
readyState = 1;
|
||||
readonly requests: Array<Record<string, unknown>> = [];
|
||||
onopen: Handler = null;
|
||||
onmessage: Handler = null;
|
||||
onclose: Handler = null;
|
||||
onerror: Handler = null;
|
||||
private readonly createSent = Promise.withResolvers<Record<string, unknown>>();
|
||||
|
||||
constructor() {
|
||||
queueMicrotask(() => this.onopen?.());
|
||||
}
|
||||
|
||||
createRequest(): Promise<Record<string, unknown>> {
|
||||
return this.createSent.promise;
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
const frame = JSON.parse(data);
|
||||
if (frame.type === "hello") {
|
||||
this.reply({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "srv_older",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: {
|
||||
providerUsageList: true,
|
||||
providersSnapshotCwd: true,
|
||||
ownedSubscriptions: true,
|
||||
backgroundWorkspaces: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (frame.type !== "session") return;
|
||||
const message = frame.message;
|
||||
this.requests.push(message);
|
||||
if (message.type === "fetch_workspaces_request") {
|
||||
this.reply({
|
||||
type: "fetch_workspaces_response",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
entries: [workspace],
|
||||
pageInfo: emptyPage,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (message.type === "create_agent_request") this.createSent.resolve(message);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3;
|
||||
this.onclose?.({ code: 1000, reason: "closed" });
|
||||
}
|
||||
|
||||
private reply(message: object): void {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: "session", message }) });
|
||||
}
|
||||
}
|
||||
|
||||
const emptyPage = { nextCursor: null, prevCursor: null, hasMore: false };
|
||||
|
||||
const workspace = {
|
||||
id: "workspace_older",
|
||||
projectId: "project_older",
|
||||
projectDisplayName: "Older",
|
||||
projectRootPath: "/repo/older",
|
||||
workspaceDirectory: "/repo/older",
|
||||
projectKind: "git",
|
||||
workspaceKind: "directory",
|
||||
name: "older",
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: "2026-05-16T00:00:00.000Z",
|
||||
scripts: [],
|
||||
gitRuntime: null,
|
||||
githubRuntime: null,
|
||||
};
|
||||
|
||||
const clients: DaemonClient[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.splice(0).map((client) => client.close()));
|
||||
});
|
||||
|
||||
async function olderHost() {
|
||||
const socket = new OlderHostSocket();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://older.test",
|
||||
clientId: "older-host-test",
|
||||
webSocketFactory: () => socket,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
await client.connect();
|
||||
return { client, socket };
|
||||
}
|
||||
|
||||
function resolverFor(client: DaemonClient, callerBackground: boolean) {
|
||||
return createHostResolver({
|
||||
serverId: "origin",
|
||||
callerAgentId: "caller",
|
||||
caller: () => ({ background: callerBackground }),
|
||||
local: {} as HostOperations,
|
||||
hosts: () => ({
|
||||
directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }),
|
||||
use: (_serverId, action) => action(client),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const createArgs = {
|
||||
workspaceId: workspace.id,
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Helper",
|
||||
initialPrompt: "Summarize",
|
||||
};
|
||||
|
||||
test("a visible caller still creates agents on a host without background agents", async () => {
|
||||
const { client, socket } = await olderHost();
|
||||
const creating = resolverFor(client, false).use("srv_older", (host) =>
|
||||
host.createAgent(createArgs, {} as never),
|
||||
);
|
||||
creating.catch(() => {});
|
||||
|
||||
const request = await socket.createRequest();
|
||||
expect(request).toMatchObject({ workspaceId: workspace.id });
|
||||
expect(request).not.toHaveProperty("background");
|
||||
});
|
||||
|
||||
test("a background caller's remote child asks the host to update instead of turning visible", async () => {
|
||||
const { client, socket } = await olderHost();
|
||||
|
||||
await expect(
|
||||
resolverFor(client, true).use("srv_older", (host) => host.createAgent(createArgs, {} as never)),
|
||||
).rejects.toThrow("Update the host to use background agents.");
|
||||
expect(socket.requests.map((request) => request.type)).not.toContain("create_agent_request");
|
||||
});
|
||||
@@ -16,6 +16,11 @@ import type { ConnectedHosts } from "../../connected-hosts/index.js";
|
||||
import { resolveRequiredProviderModel } from "../mcp-shared.js";
|
||||
import { selectAgentListItems } from "../agent-projections.js";
|
||||
import { summarizeAgentActivity } from "../activity-curator.js";
|
||||
import {
|
||||
inheritAgentBackground,
|
||||
inheritWorkspaceBackground,
|
||||
type BackgroundCaller,
|
||||
} from "../create-agent/intent.js";
|
||||
import { expandUserPath } from "../../path-utils.js";
|
||||
import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js";
|
||||
|
||||
@@ -177,6 +182,7 @@ const remoteCreate = z
|
||||
workspaceId: z.string().min(1),
|
||||
labels: z.record(z.string(), z.string()).optional(),
|
||||
notifyOnFinish: z.boolean().optional(),
|
||||
background: z.boolean().optional(),
|
||||
idempotencyKey: z.string().optional(),
|
||||
settings: z
|
||||
.object({
|
||||
@@ -197,6 +203,8 @@ export function createHostResolver(input: {
|
||||
local: HostOperations;
|
||||
hosts: () => ToolHosts;
|
||||
callerAgentId?: string;
|
||||
/** The destination can't see the caller, so its background defaults resolve before routing. */
|
||||
caller: () => BackgroundCaller | null;
|
||||
lockedCwd?: string;
|
||||
}) {
|
||||
return {
|
||||
@@ -214,9 +222,7 @@ export function createHostResolver(input: {
|
||||
async createAgent(args) {
|
||||
const parsed = remoteCreate.parse(args);
|
||||
const { provider, model } = resolveRequiredProviderModel(parsed.provider);
|
||||
const workspace = (await client.fetchWorkspaces({})).entries.find(
|
||||
(row) => row.id === parsed.workspaceId,
|
||||
);
|
||||
const workspace = await client.findWorkspace(parsed.workspaceId);
|
||||
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
|
||||
const labels = {
|
||||
...parsed.labels,
|
||||
@@ -235,6 +241,10 @@ export function createHostResolver(input: {
|
||||
featureValues: parsed.settings?.features,
|
||||
},
|
||||
workspaceId: parsed.workspaceId,
|
||||
// Older hosts reject any background with a workspaceId, so a visible create omits it.
|
||||
...(inheritAgentBackground(parsed.background, input.caller())
|
||||
? { background: true }
|
||||
: {}),
|
||||
labels,
|
||||
initialPrompt: parsed.initialPrompt,
|
||||
idempotencyKey: parsed.idempotencyKey,
|
||||
@@ -359,7 +369,10 @@ export function createHostResolver(input: {
|
||||
worktreeSlug: args.worktreeSlug as string | undefined,
|
||||
},
|
||||
title: args.title as string | undefined,
|
||||
background: args.background as boolean | undefined,
|
||||
background: inheritWorkspaceBackground(
|
||||
args.background as boolean | undefined,
|
||||
input.caller(),
|
||||
),
|
||||
});
|
||||
if (response.error || !response.workspace)
|
||||
throw new Error(response.error ?? "Workspace creation failed");
|
||||
|
||||
@@ -1211,4 +1211,137 @@ describe("Suite E: Workspace Tools", () => {
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("a background agent's children and workspaces are background unless it overrides", async () => {
|
||||
const visibleAgentId = await createTopLevelAgent();
|
||||
const workspaceId = daemonHandle.daemon.agentManager.getAgent(visibleAgentId)?.workspaceId;
|
||||
const caller = await callToolStructured(topLevelClient, "create_agent", {
|
||||
workspaceId,
|
||||
title: "Background caller",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
background: true,
|
||||
});
|
||||
const callerId = str(caller.agentId);
|
||||
const background = (agentId: string) =>
|
||||
daemonHandle.daemon.agentManager.getAgent(agentId)?.background;
|
||||
expect(background(callerId)).toBe(true);
|
||||
const scoped = await createMcpClient(
|
||||
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${callerId}`,
|
||||
);
|
||||
const ownedWorkspaces: string[] = [];
|
||||
try {
|
||||
const inheritedChild = await callToolStructured(scoped, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Inherited child",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
notifyOnFinish: false,
|
||||
});
|
||||
expect(background(str(inheritedChild.agentId))).toBe(true);
|
||||
const visibleChild = await callToolStructured(scoped, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Visible child",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
notifyOnFinish: false,
|
||||
background: false,
|
||||
});
|
||||
expect(background(str(visibleChild.agentId))).toBe(false);
|
||||
const inheritedWorkspace = await callToolStructured(scoped, "create_workspace", {
|
||||
isolation: "local",
|
||||
});
|
||||
ownedWorkspaces.push(str(inheritedWorkspace.workspaceId));
|
||||
expect(inheritedWorkspace.background).toBe(true);
|
||||
} finally {
|
||||
await scoped.close();
|
||||
await archiveAgentIfPresent(callerId);
|
||||
await archiveAgentIfPresent(visibleAgentId);
|
||||
for (const id of ownedWorkspaces)
|
||||
await topLevelClient
|
||||
.callTool({ name: "archive_workspace", args: { workspaceId: id } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("a background subagent stays out of default lists, notifies its parent and archives with it", async () => {
|
||||
const callerPrompts: string[] = [];
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn(prompt, config) {
|
||||
if (config.title === "Background parent" && typeof prompt === "string")
|
||||
callerPrompts.push(prompt);
|
||||
},
|
||||
}),
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let mcp: McpClient | undefined;
|
||||
try {
|
||||
await client.connect();
|
||||
const caller = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.paseoHome,
|
||||
title: "Background parent",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
mcp = await createMcpClient(
|
||||
buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: caller.id }),
|
||||
);
|
||||
const created = await callToolStructured(mcp, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Background child",
|
||||
initialPrompt: "say done and stop",
|
||||
notifyOnFinish: true,
|
||||
background: true,
|
||||
settings: { modeId: "full-access" },
|
||||
});
|
||||
const childId = str(created.agentId);
|
||||
expect((await client.fetchAgent(childId))?.agent).toMatchObject({
|
||||
background: true,
|
||||
workspaceId: caller.workspaceId,
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: caller.id },
|
||||
});
|
||||
expect((await client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain(
|
||||
childId,
|
||||
);
|
||||
expect(
|
||||
(await client.fetchAgents({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
),
|
||||
).toContain(childId);
|
||||
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "parent finish notification",
|
||||
check: () => (callerPrompts.length > 0 ? true : null),
|
||||
});
|
||||
expect(parseAgentMessage(callerPrompts[0]).source).toMatchObject({
|
||||
kind: "agent-notification",
|
||||
agentId: childId,
|
||||
event: "finished",
|
||||
});
|
||||
expect((await client.fetchAgent(childId))?.agent.requiresAttention).toBe(false);
|
||||
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "parent idle",
|
||||
check: () =>
|
||||
daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null,
|
||||
});
|
||||
await client.archiveAgent(caller.id);
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "child archived with parent",
|
||||
check: async () => ((await client.fetchAgent(childId))?.agent.archivedAt ? true : null),
|
||||
});
|
||||
} finally {
|
||||
await mcp?.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -1647,11 +1647,11 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: existingCwd }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects public internal helpers and the old top-level execution background option", async () => {
|
||||
it("rejects public internal helpers", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = new AgentManager({
|
||||
@@ -1669,9 +1669,6 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" };
|
||||
await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/);
|
||||
await expect(tool.handler({ ...input, background: true })).rejects.toThrow(
|
||||
/Unrecognized key/,
|
||||
);
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
@@ -1793,7 +1790,7 @@ describe("create_agent MCP tool", () => {
|
||||
cwd: existingCwd,
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "wks_existing" },
|
||||
{ workspaceId: "wks_existing", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1837,7 +1834,7 @@ describe("create_agent MCP tool", () => {
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2033,7 +2030,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Fix auth bug",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2068,7 +2065,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Fix auth",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2112,6 +2109,7 @@ describe("create_agent MCP tool", () => {
|
||||
{
|
||||
labels: { source: "mcp" },
|
||||
workspaceId: "workspace-created",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -2214,7 +2212,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Directory agent",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-directory-auto-title" },
|
||||
{ workspaceId: "workspace-directory-auto-title", background: false },
|
||||
);
|
||||
expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({
|
||||
title: "Directory Workspace Title",
|
||||
@@ -2828,12 +2826,13 @@ describe("create_agent MCP tool", () => {
|
||||
source: "voice",
|
||||
},
|
||||
workspaceId: "wks_voice",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
await rm(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects background from caller agents and defaults notify-on-finish on", async () => {
|
||||
it("defaults notify-on-finish on for caller agents", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
@@ -2853,16 +2852,6 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await expect(
|
||||
tool.handler({
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: false,
|
||||
}),
|
||||
).rejects.toThrow(/Unrecognized key/);
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Child",
|
||||
@@ -3019,6 +3008,7 @@ describe("create_agent MCP tool", () => {
|
||||
source: "handoff",
|
||||
},
|
||||
workspaceId: "wks_parent",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3080,6 +3070,7 @@ describe("create_agent MCP tool", () => {
|
||||
[PARENT_AGENT_ID_LABEL]: "parent-agent",
|
||||
},
|
||||
workspaceId: "wks_parent",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3218,6 +3209,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(agentIdArg).toBeUndefined();
|
||||
expect(optionsArg).toEqual({
|
||||
workspaceId: "workspace-created",
|
||||
background: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3295,7 +3287,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "dynamic" }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3334,7 +3326,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -365,7 +365,21 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("appends concrete models from Claude settings.json", async () => {
|
||||
it("appends concrete models from Claude settings.json with thinking options", async () => {
|
||||
const customModelThinkingOptions = [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
{ id: "max", label: "Max" },
|
||||
];
|
||||
const fableThinkingOptions = [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
{ id: "xhigh", label: "Extra High" },
|
||||
{ id: "max", label: "Max" },
|
||||
{ id: CLAUDE_ULTRACODE_THINKING_OPTION_ID, label: "Ultra Code" },
|
||||
];
|
||||
const configDir = await createClaudeConfigDir({
|
||||
model: "us.anthropic.claude-opus-4-7[1m]",
|
||||
env: {
|
||||
@@ -393,42 +407,49 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
id: "us.anthropic.claude-opus-4-7[1m]",
|
||||
label: "us.anthropic.claude-opus-4-7[1m]",
|
||||
description: "From Claude settings.json model",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "openrouter/anthropic/claude-sonnet-4.5",
|
||||
label: "openrouter/anthropic/claude-sonnet-4.5",
|
||||
description: "From Claude settings.json env.ANTHROPIC_MODEL",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "ollama/qwen3-coder",
|
||||
label: "ollama/qwen3-coder",
|
||||
description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-fable-5-1[1M]",
|
||||
label: "claude-fable-5-1[1M]",
|
||||
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL",
|
||||
thinkingOptions: fableThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "bedrock-opus-from-env",
|
||||
label: "bedrock-opus-from-env",
|
||||
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "glm-5.1",
|
||||
label: "glm-5.1",
|
||||
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "glm-5",
|
||||
label: "glm-5",
|
||||
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
]);
|
||||
expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1);
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function getClaudeModelsWithSettings(
|
||||
const manifestModel = getClaudeModels(claudeCodeVersion).find(
|
||||
(candidate) => candidate.id === model.id,
|
||||
);
|
||||
models.push({ ...manifestModel, ...model });
|
||||
models.push(resolveConfiguredClaudeModel({ ...manifestModel, ...model }));
|
||||
}
|
||||
|
||||
return models;
|
||||
|
||||
@@ -94,7 +94,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as
|
||||
const { tools } = await manifest.json();
|
||||
expect(
|
||||
tools.find((tool: { name: string }) => tool.name === "create_agent").inputSchema.properties,
|
||||
).not.toHaveProperty("background");
|
||||
).toHaveProperty("background");
|
||||
expect(
|
||||
tools.find((tool: { name: string }) => tool.name === "update_schedule").inputSchema
|
||||
.properties,
|
||||
|
||||
@@ -181,13 +181,18 @@ describe("OpenCodeBridge", () => {
|
||||
expect(createDefinition.inputSchema).toMatchObject({
|
||||
properties: { notifyOnFinish: { default: true } },
|
||||
});
|
||||
expect(createDefinition.inputSchema.properties).not.toHaveProperty("background");
|
||||
expect(createDefinition.inputSchema.properties).toHaveProperty("background");
|
||||
expect(sendDefinition.inputSchema).toMatchObject({
|
||||
properties: { notifyOnFinish: { default: true } },
|
||||
});
|
||||
await expect(
|
||||
catalog.executeTool("create_agent", { ...createInput, background: true }),
|
||||
).rejects.toThrow("background");
|
||||
await catalog.executeTool("create_agent", {
|
||||
...createInput,
|
||||
title: "Background child",
|
||||
background: true,
|
||||
});
|
||||
expect(
|
||||
agentManager.listAgents().find((agent) => agent.config.title === "Background child"),
|
||||
).toMatchObject({ background: true });
|
||||
|
||||
const v1Module = await import(plugin.pluginUrl);
|
||||
const hooks = await v1Module.default({}, plugin);
|
||||
|
||||
@@ -2183,48 +2183,6 @@ describe("PiRpcAgentSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"closing Pi releases an active turn without publishing a terminal outcome (autonomous=%s)",
|
||||
async (autonomous) => {
|
||||
const { pi, session, events } = await createSession();
|
||||
if (autonomous) {
|
||||
pi.latestSession().emit({ type: "agent_start" });
|
||||
pi.latestSession().emit({ type: "turn_start" });
|
||||
} else {
|
||||
await session.startTurn("keep working");
|
||||
}
|
||||
const before = events.turnLifecycleEvents();
|
||||
|
||||
await session.close();
|
||||
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
expect(events.turnLifecycleEvents()).toEqual(before);
|
||||
},
|
||||
);
|
||||
|
||||
test("closing Pi before prompt acknowledgement preserves the unfinished turn", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
pi.latestSession().holdNextPrompt();
|
||||
await session.startTurn("keep working");
|
||||
expect(pi.latestSession().prompts).toHaveLength(1);
|
||||
const before = events.turnLifecycleEvents();
|
||||
|
||||
await session.close();
|
||||
|
||||
expect(events.turnLifecycleEvents()).toEqual(before);
|
||||
});
|
||||
|
||||
test("a rejected prompt still fails an open Pi session", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
pi.latestSession().holdNextPrompt();
|
||||
await session.startTurn("keep working");
|
||||
|
||||
await pi.latestSession().failHeldPrompt(new Error("Prompt rejected"));
|
||||
|
||||
await expect(events.nextTurnFailure()).resolves.toMatchObject({ error: "Prompt rejected" });
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("fails the active turn when the Pi process exits mid-turn", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
|
||||
@@ -2236,6 +2194,33 @@ describe("PiRpcAgentSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"rejects new turns with Reload guidance after the Pi process exits (active: %s)",
|
||||
async (active) => {
|
||||
const { pi, session, events } = await createSession();
|
||||
try {
|
||||
if (active) {
|
||||
await session.startTurn("before crash");
|
||||
}
|
||||
const runtime = pi.latestSession();
|
||||
runtime.emit({ type: "process_exit", error: "Pi exited" });
|
||||
if (active) {
|
||||
await expect(events.nextTurnFailure()).resolves.toMatchObject({ error: "Pi exited" });
|
||||
}
|
||||
const promptCount = runtime.prompts.length;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await expect(session.startTurn("after crash")).rejects.toThrow(
|
||||
"Pi process exited. Reload agent before sending another message.",
|
||||
);
|
||||
}
|
||||
expect(runtime.prompts).toHaveLength(promptCount);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("fails an autonomous turn when the Pi process exits before settlement", async () => {
|
||||
const { pi, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
@@ -1223,6 +1223,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private readonly logger: Logger;
|
||||
private readonly usagePoller: PiUsagePoller;
|
||||
private closed = false;
|
||||
private runtimeDead = false;
|
||||
private readonly closeController = new AbortController();
|
||||
private readonly pendingExtensionHydrations = new Set<Promise<void>>();
|
||||
private pendingOutput: Promise<void> | null = null;
|
||||
@@ -1305,6 +1306,9 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<StartTurnResult> {
|
||||
if (this.runtimeDead) {
|
||||
throw new Error("Pi process exited. Reload agent before sending another message.");
|
||||
}
|
||||
if (this.activeTurnId) {
|
||||
throw new Error("A Pi turn is already active");
|
||||
}
|
||||
@@ -1729,17 +1733,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private publish(event: AgentStreamEvent): void {
|
||||
// Runtime release preserves unfinished work for restart recovery. Cleanup
|
||||
// may still emit timeline updates, but must not settle or restart its turn.
|
||||
if (
|
||||
this.closed &&
|
||||
(event.type === "turn_started" ||
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_canceled" ||
|
||||
event.type === "turn_failed")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const subscriber of this.subscribers) subscriber(event);
|
||||
}
|
||||
|
||||
@@ -2201,6 +2194,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private handleProcessExit(error: string): void {
|
||||
this.runtimeDead = true;
|
||||
this.rejectAllExtensionResults(new Error(error));
|
||||
this.interruptingTurn = null;
|
||||
if (!this.activeTurnId && !this.activeTurnStarted) {
|
||||
|
||||
@@ -427,13 +427,6 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCalls += 1;
|
||||
if (this.activeHeldPrompt) {
|
||||
await this.failHeldPrompt(new Error("Pi RPC session is closed"));
|
||||
}
|
||||
this.emit({
|
||||
type: "process_exit",
|
||||
error: "Pi RPC process exited with code 0 and signal null",
|
||||
});
|
||||
}
|
||||
|
||||
emit(event: PiRuntimeEvent): void {
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type ArchiveDependencies,
|
||||
} from "../../workspace-archive-service.js";
|
||||
import { createAgentCommand } from "../create-agent/create.js";
|
||||
import { inheritWorkspaceBackground } from "../create-agent/intent.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
|
||||
import type { FirstAgentContext } from "../../messages.js";
|
||||
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
|
||||
@@ -153,7 +154,7 @@ export interface PaseoToolHostDependencies {
|
||||
ensureWorkspaceForCreate?: (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
) => Promise<string>;
|
||||
browserToolsEnabled?: boolean;
|
||||
browserToolsBroker?: BrowserToolsBroker | null;
|
||||
@@ -718,6 +719,7 @@ function createToolCatalog(
|
||||
const hostResolver = createHostResolver({
|
||||
serverId: options.serverId ?? "self",
|
||||
callerAgentId,
|
||||
caller: () => resolveCallerAgent(),
|
||||
lockedCwd: callerContext?.lockedCwd,
|
||||
hosts: () => {
|
||||
if (!options.hosts) throw new Error("Connected hosts are not configured");
|
||||
@@ -860,6 +862,14 @@ function createToolCatalog(
|
||||
return parentAgent;
|
||||
};
|
||||
|
||||
const workspaceBackgroundIntent = (background: boolean | undefined) => {
|
||||
const caller = resolveCallerAgent();
|
||||
return {
|
||||
background: inheritWorkspaceBackground(background, caller),
|
||||
callerWorkspaceId: caller?.workspaceId,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveInheritedProviderConfig = (
|
||||
selectedProvider: string,
|
||||
): Pick<AgentSessionConfig, "providerOptions"> | undefined => {
|
||||
@@ -1191,6 +1201,12 @@ function createToolCatalog(
|
||||
.trim()
|
||||
.min(1, "initialPrompt is required")
|
||||
.describe("Required first task to run immediately after creation."),
|
||||
background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True creates a background agent: hidden from tabs, default lists, your subagent track and notifications. You still get its finish notification and can address it by id. Omit to inherit your own value (false without a caller); use it for helpers the user does not need to watch.",
|
||||
),
|
||||
};
|
||||
const legacyCreateAgentPlacementFields = {
|
||||
relationship: AgentRelationshipInputSchema.describe(
|
||||
@@ -1397,7 +1413,7 @@ function createToolCatalog(
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True hides the workspace from default lists and the sidebar while its contents remain durable and accessible; omit background to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks.",
|
||||
"True hides the workspace from default lists and the sidebar while its contents remain durable and accessible; omit background to inherit your own visibility (true when you or your current workspace are background, false without a caller), and override it only when the user explicitly asks.",
|
||||
),
|
||||
mode: z
|
||||
.enum(["branch-off", "checkout-branch", "checkout-pr"])
|
||||
@@ -1467,10 +1483,12 @@ function createToolCatalog(
|
||||
if (!options.createDirectoryWorkspace) {
|
||||
throw new Error("Workspace provisioning is not configured");
|
||||
}
|
||||
workspace = await options.createDirectoryWorkspace(cwd, title, projectId, {
|
||||
background,
|
||||
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
|
||||
});
|
||||
workspace = await options.createDirectoryWorkspace(
|
||||
cwd,
|
||||
title,
|
||||
projectId,
|
||||
workspaceBackgroundIntent(background),
|
||||
);
|
||||
} else {
|
||||
let cwd =
|
||||
path !== undefined || !projectId ? resolveScopedCwd(path, { required: true }) : null;
|
||||
@@ -1501,8 +1519,7 @@ function createToolCatalog(
|
||||
...(worktreeSlug ? { worktreeSlug } : {}),
|
||||
...worktreeTarget,
|
||||
...(title ? { title } : {}),
|
||||
background,
|
||||
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
|
||||
...workspaceBackgroundIntent(background),
|
||||
},
|
||||
);
|
||||
if (!result.ok) {
|
||||
@@ -1648,6 +1665,7 @@ function createToolCatalog(
|
||||
mode: parsedArgs.settings?.modeId,
|
||||
promptFailure: "throw",
|
||||
notifyOnFinish,
|
||||
background: parsedArgs.background,
|
||||
detached: resolvedArgs.detached,
|
||||
callerAgentId,
|
||||
callerContext,
|
||||
@@ -1821,9 +1839,11 @@ function createToolCatalog(
|
||||
const cwd = process.cwd();
|
||||
return {
|
||||
cwd,
|
||||
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, {
|
||||
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
|
||||
}),
|
||||
workspaceId: await options.ensureWorkspaceForCreate(
|
||||
cwd,
|
||||
firstAgentContext,
|
||||
workspaceBackgroundIntent(undefined),
|
||||
),
|
||||
};
|
||||
}
|
||||
const caller = resolveCallerAgent();
|
||||
@@ -1927,9 +1947,11 @@ function createToolCatalog(
|
||||
}
|
||||
return {
|
||||
cwd,
|
||||
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, {
|
||||
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
|
||||
}),
|
||||
workspaceId: await options.ensureWorkspaceForCreate(
|
||||
cwd,
|
||||
firstAgentContext,
|
||||
workspaceBackgroundIntent(undefined),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1119,7 +1119,7 @@ export async function createPaseoDaemon(
|
||||
const ensureWorkspaceForCreateExternal = async (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
): Promise<string> => {
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
@@ -1172,7 +1172,7 @@ export async function createPaseoDaemon(
|
||||
const ensureWorkspaceForCreateAndBroadcastExternal = async (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
): Promise<string> => {
|
||||
const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext, context);
|
||||
await emitWorkspaceUpdatesExternal([workspaceId]);
|
||||
|
||||
@@ -1732,6 +1732,77 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"a background caller's MCP and SDK children and workspaces on B are background unless it overrides",
|
||||
{ timeout: 30000 },
|
||||
async () => {
|
||||
const a = await daemon();
|
||||
const b = await daemon();
|
||||
const { paseo } = await api(a);
|
||||
await paseo.hosts.add({ connection: `127.0.0.1:${b.port}`, label: "Fleet B" });
|
||||
const directory = await online(paseo);
|
||||
const workspace = await paseo.workspaces.create({
|
||||
source: { kind: "directory", path: a.staticDir },
|
||||
});
|
||||
const parent = await workspace.agents.create({
|
||||
config: { provider: "claude/claude-test" },
|
||||
title: "Background parent",
|
||||
background: true,
|
||||
});
|
||||
const mcp = new Client({ name: "background-delegation", version: "1" });
|
||||
cleanup.push(() => mcp.close());
|
||||
await mcp.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`),
|
||||
),
|
||||
);
|
||||
const call = async (name: string, args: Record<string, unknown>) => {
|
||||
const response = await mcp.callTool({ name, arguments: args });
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
return z.record(z.string(), z.unknown()).parse(response.structuredContent);
|
||||
};
|
||||
const serverId = directory.hosts[0]!.serverId!;
|
||||
const hidden = await call("create_workspace", {
|
||||
serverId,
|
||||
isolation: "local",
|
||||
path: b.staticDir,
|
||||
title: "Inherited workspace",
|
||||
});
|
||||
const helper = await call("create_agent", {
|
||||
serverId,
|
||||
workspaceId: hidden.workspaceId,
|
||||
title: "Inherited helper",
|
||||
provider: "claude/claude-test",
|
||||
initialPrompt: "Summarize",
|
||||
});
|
||||
const visible = await call("create_agent", {
|
||||
serverId,
|
||||
workspaceId: hidden.workspaceId,
|
||||
title: "Visible helper",
|
||||
provider: "claude/claude-test",
|
||||
initialPrompt: "Summarize",
|
||||
background: false,
|
||||
});
|
||||
|
||||
const bApi = await api(b);
|
||||
const workspaces = await bApi.client.fetchWorkspaces({ includeBackground: true });
|
||||
expect(workspaces.entries.find((entry) => entry.id === hidden.workspaceId)).toMatchObject({
|
||||
background: true,
|
||||
});
|
||||
const snapshot = async (agentId: unknown) =>
|
||||
(await bApi.paseo.agents.ref(String(agentId)).refresh())?.agent;
|
||||
expect(await snapshot(helper.agentId)).toMatchObject({ background: true });
|
||||
expect((await snapshot(visible.agentId))?.background).toBeUndefined();
|
||||
|
||||
const sdkChild = await bApi.paseo.workspaces.ref(String(hidden.workspaceId)).agents.create({
|
||||
config: { provider: "claude/claude-test" },
|
||||
title: "SDK helper",
|
||||
parent,
|
||||
});
|
||||
expect(sdkChild.current()).toMatchObject({ background: true });
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"remote agent reports, local CLI sends and automatic completion reach the same recipient",
|
||||
{ timeout: 30000 },
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
test.each(["agent", "workspace"] as const)(
|
||||
"%s creation recognizes an unopened persisted caller after restart",
|
||||
async (kind) => {
|
||||
const artifacts = path.resolve(".artifacts");
|
||||
await mkdir(artifacts, { recursive: true });
|
||||
const root = await mkdtemp(path.join(artifacts, "persisted-caller-"));
|
||||
const cwd = path.join(root, "project");
|
||||
await mkdir(cwd);
|
||||
let ctx: DaemonTestContext | undefined;
|
||||
try {
|
||||
ctx = await createDaemonTestContext({
|
||||
paseoHomeRoot: root,
|
||||
staticDir: cwd,
|
||||
cleanup: false,
|
||||
mcpEnabled: false,
|
||||
});
|
||||
const parent = await ctx.client.createAgent({ provider: "claude", cwd, background: true });
|
||||
await ctx.client.sendMessage(parent.id, "Reply DONE.");
|
||||
expect((await ctx.client.waitForFinish(parent.id, 5_000)).status).toBe("idle");
|
||||
await ctx.cleanup();
|
||||
ctx = undefined;
|
||||
ctx = await createDaemonTestContext({
|
||||
paseoHomeRoot: root,
|
||||
staticDir: cwd,
|
||||
cleanup: false,
|
||||
mcpEnabled: false,
|
||||
});
|
||||
expect(ctx.daemon.daemon.agentManager.getAgent(parent.id)).toBeNull();
|
||||
expect(
|
||||
(await ctx.client.fetchAgents({ filter: { includeBackground: true } })).entries,
|
||||
).toContainEqual(
|
||||
expect.objectContaining({ agent: expect.objectContaining({ id: parent.id }) }),
|
||||
);
|
||||
|
||||
if (kind === "agent") {
|
||||
const child = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
callerAgentId: parent.id,
|
||||
});
|
||||
expect(child.workspaceId).toBe(parent.workspaceId);
|
||||
expect(child.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id);
|
||||
} else {
|
||||
const result = await ctx.client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
callerAgentId: parent.id,
|
||||
});
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.workspace?.background).toBe(true);
|
||||
}
|
||||
expect(ctx.daemon.daemon.agentManager.getAgent(parent.id)).toBeNull();
|
||||
} finally {
|
||||
await ctx?.cleanup();
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("creation still rejects a caller removed by workspace archive", async () => {
|
||||
const artifacts = path.resolve(".artifacts");
|
||||
await mkdir(artifacts, { recursive: true });
|
||||
const root = await mkdtemp(path.join(artifacts, "archived-caller-"));
|
||||
const cwd = path.join(root, "project");
|
||||
await mkdir(cwd);
|
||||
execFileSync("git", ["init", cwd], { stdio: "pipe" });
|
||||
const ctx = await createDaemonTestContext({
|
||||
paseoHomeRoot: root,
|
||||
staticDir: cwd,
|
||||
cleanup: false,
|
||||
mcpEnabled: false,
|
||||
});
|
||||
try {
|
||||
const parent = await ctx.client.createAgent({ provider: "claude", cwd });
|
||||
const archived = await ctx.client.archiveWorkspace(parent.workspaceId);
|
||||
expect(archived.error).toBeNull();
|
||||
expect(ctx.daemon.daemon.agentManager.getAgent(parent.id)).toBeNull();
|
||||
await expect(
|
||||
ctx.client.createAgent({ provider: "claude", cwd, callerAgentId: parent.id }),
|
||||
).rejects.toThrow(`Caller agent ${parent.id} not found`);
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
}
|
||||
}, 60_000);
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WorkspaceDescriptorPayload,
|
||||
WorkspaceProjectDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { BackgroundVisibility } from "../agent/directory.js";
|
||||
import { type CollectionRead, VersionedCollection } from "./internal/versioned-collection.js";
|
||||
|
||||
interface AgentDirectoryEntry {
|
||||
@@ -69,7 +70,7 @@ export class DirectorySyncService {
|
||||
synchronizeWorkspaces(
|
||||
snapshot: Iterable<WorkspaceDescriptorPayload>,
|
||||
cursor: DirectorySyncCursor,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> {
|
||||
this.workspaces.replaceAll(snapshot);
|
||||
const read = this.read(this.workspaces, cursor, includeBackground);
|
||||
@@ -84,7 +85,7 @@ export class DirectorySyncService {
|
||||
synchronizeAgents(
|
||||
snapshot: Iterable<AgentDirectoryEntry>,
|
||||
cursor: DirectorySyncCursor,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> {
|
||||
this.agents.replaceAll(snapshot);
|
||||
const read = this.read(this.agents, cursor, includeBackground);
|
||||
@@ -113,7 +114,7 @@ export class DirectorySyncService {
|
||||
value: WorkspaceDescriptorPayload | null,
|
||||
id: string,
|
||||
includeSequence: boolean,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): T & Partial<DirectoryVersion> {
|
||||
if (value) {
|
||||
this.workspaces.replace(value);
|
||||
@@ -133,7 +134,7 @@ export class DirectorySyncService {
|
||||
value: AgentDirectoryEntry | null,
|
||||
id: string,
|
||||
includeSequence: boolean,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): T & Partial<DirectoryVersion> {
|
||||
if (value) {
|
||||
this.agents.replace(value);
|
||||
@@ -148,7 +149,7 @@ export class DirectorySyncService {
|
||||
);
|
||||
}
|
||||
|
||||
private metadata(read: CollectionRead<unknown>, includeBackground = false) {
|
||||
private metadata(read: CollectionRead<unknown>, includeBackground: BackgroundVisibility = false) {
|
||||
return {
|
||||
generation: this.generationFor(includeBackground),
|
||||
headSeq: read.headSeq,
|
||||
@@ -162,7 +163,7 @@ export class DirectorySyncService {
|
||||
payload: T,
|
||||
includeSequence: boolean,
|
||||
version: DirectoryVersion | null,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): T & Partial<DirectoryVersion> {
|
||||
return version && includeSequence
|
||||
? { ...payload, ...version, generation: this.generationFor(includeBackground) }
|
||||
@@ -172,7 +173,7 @@ export class DirectorySyncService {
|
||||
private read<T>(
|
||||
collection: VersionedCollection<T>,
|
||||
cursor: DirectorySyncCursor,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): CollectionRead<T> {
|
||||
if (cursor.generation !== this.generationFor(includeBackground)) {
|
||||
return collection.readSnapshot(
|
||||
@@ -185,7 +186,9 @@ export class DirectorySyncService {
|
||||
return collection.readAfter(cursor.afterSeq);
|
||||
}
|
||||
|
||||
private generationFor(includeBackground: boolean): string {
|
||||
// Each visibility scope admits a different subset, so cursors never cross scopes.
|
||||
private generationFor(includeBackground: BackgroundVisibility): string {
|
||||
if (includeBackground === "workspaces") return `${this.generation}:background-workspaces`;
|
||||
return includeBackground ? `${this.generation}:background` : this.generation;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: StoredAgentRecord["owner"];
|
||||
background?: boolean;
|
||||
} {
|
||||
return {
|
||||
createdAt: new Date(record.createdAt),
|
||||
@@ -149,6 +150,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
background: record.background,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type { CreateAgentOptions } from "./test-utils/index.js";
|
||||
import type { CreateAgentWorktreeTarget } from "./messages.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
const tempRoots: string[] = [];
|
||||
@@ -477,7 +478,7 @@ test("background workspaces keep durable agents addressable through restart and
|
||||
),
|
||||
).toContain(hidden.id);
|
||||
const child = await ctx.client.createAgent({ config, callerAgentId: hidden.id });
|
||||
expect(child.workspaceId).toBe(hidden.workspaceId);
|
||||
expect(child).toMatchObject({ workspaceId: hidden.workspaceId, background: true });
|
||||
const inherited = await ctx.client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
callerAgentId: hidden.id,
|
||||
@@ -509,9 +510,16 @@ test("background workspaces keep durable agents addressable through restart and
|
||||
false,
|
||||
);
|
||||
|
||||
await expect(
|
||||
ctx.client.createAgent({ config, workspaceId: hidden.workspaceId, background: false }),
|
||||
).rejects.toThrow("workspace creation intent");
|
||||
expect(hidden.background).toBe(true);
|
||||
const visibleInHidden = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: hidden.workspaceId,
|
||||
background: false,
|
||||
});
|
||||
expect(visibleInHidden.background).toBeUndefined();
|
||||
expect((await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain(
|
||||
visibleInHidden.id,
|
||||
);
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
expect(
|
||||
@@ -616,3 +624,111 @@ test("default and inclusive observers retain independent snapshots, live updates
|
||||
await otherClient.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("a background agent in a visible workspace is hidden from default discovery through restart", async () => {
|
||||
const cwd = createGitRepo();
|
||||
const config = { ...getFullAccessConfig("codex"), cwd };
|
||||
const visible = await ctx.client.createAgent({ config });
|
||||
const hidden = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: visible.workspaceId,
|
||||
background: true,
|
||||
});
|
||||
expect(hidden).toMatchObject({ workspaceId: visible.workspaceId, background: true });
|
||||
expect(visible.background).toBeUndefined();
|
||||
const defaultIds = async () =>
|
||||
(await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id);
|
||||
const inclusiveIds = async () =>
|
||||
(await ctx.client.fetchAgents({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
);
|
||||
expect(await defaultIds()).toEqual([visible.id]);
|
||||
expect(await inclusiveIds()).toEqual(expect.arrayContaining([visible.id, hidden.id]));
|
||||
expect((await ctx.client.fetchWorkspaces()).entries).toContainEqual(
|
||||
expect.objectContaining({ id: visible.workspaceId, background: false }),
|
||||
);
|
||||
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
expect((await ctx.client.fetchAgent(hidden.id))?.agent).toMatchObject({
|
||||
background: true,
|
||||
requiresAttention: false,
|
||||
});
|
||||
|
||||
const homeRoot = path.dirname(ctx.daemon.paseoHome);
|
||||
tempRoots.push(ctx.daemon.staticDir);
|
||||
await ctx.client.close();
|
||||
await ctx.daemon.daemon.stop();
|
||||
await ctx.daemon.daemon.agentManager.flush();
|
||||
ctx = await createDaemonTestContext({ paseoHomeRoot: homeRoot });
|
||||
|
||||
expect(await defaultIds()).toEqual([visible.id]);
|
||||
expect(await inclusiveIds()).toContain(hidden.id);
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done again.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
expect((await ctx.client.fetchAgent(hidden.id))?.agent.background).toBe(true);
|
||||
expect(await defaultIds()).toEqual([visible.id]);
|
||||
await ctx.client.archiveAgent(hidden.id);
|
||||
expect(
|
||||
(await ctx.client.fetchAgentHistory()).entries.map((entry) => entry.agent.id),
|
||||
).not.toContain(hidden.id);
|
||||
expect(
|
||||
(await ctx.client.fetchAgentHistory({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
),
|
||||
).toContain(hidden.id);
|
||||
}, 30_000);
|
||||
|
||||
test("a client without background_agents never receives background agents, even in inclusive reads", async () => {
|
||||
const oldApp = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
capabilities: { [CLIENT_CAPS.backgroundAgents]: false },
|
||||
});
|
||||
await oldApp.connect();
|
||||
try {
|
||||
const cwd = createGitRepo();
|
||||
const config = { ...getFullAccessConfig("codex"), cwd };
|
||||
const visible = await ctx.client.createAgent({ config });
|
||||
const hidden = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: visible.workspaceId,
|
||||
background: true,
|
||||
});
|
||||
const backgroundWorkspace = await ctx.client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
background: true,
|
||||
});
|
||||
const workspaceAgent = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: backgroundWorkspace.workspace?.id,
|
||||
});
|
||||
const observed = oldApp.observeAgents({ scope: "active", sync: {}, includeBackground: true });
|
||||
const snapshot = await observed.ready;
|
||||
expect(snapshot.entries.map((entry) => entry.agent.id).toSorted()).toEqual(
|
||||
[visible.id, workspaceAgent.id].toSorted(),
|
||||
);
|
||||
const upserts: string[] = [];
|
||||
observed.subscribe({
|
||||
snapshot: () => {},
|
||||
update: (message) => {
|
||||
if (message.type === "agent_update" && message.payload.kind === "upsert")
|
||||
upserts.push(message.payload.agent.id);
|
||||
},
|
||||
});
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
await ctx.client.sendAgentMessage(visible.id, "Say done.");
|
||||
await ctx.client.waitForFinish(visible.id, 10_000);
|
||||
await expect.poll(() => upserts).toContain(visible.id);
|
||||
expect(upserts).not.toContain(hidden.id);
|
||||
expect(
|
||||
(await oldApp.fetchAgents({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
),
|
||||
).not.toContain(hidden.id);
|
||||
expect((await oldApp.fetchAgent(hidden.id))?.agent.id).toBe(hidden.id);
|
||||
await observed.release();
|
||||
} finally {
|
||||
await oldApp.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
|
||||
import { AgentDirectory } from "./agent/directory.js";
|
||||
import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js";
|
||||
import { ConnectedHostSession } from "./connected-hosts/index.js";
|
||||
import type { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import { searchTimeline } from "./agent/chat-search/index.js";
|
||||
@@ -119,7 +119,13 @@ import type {
|
||||
ManagedAgent,
|
||||
} from "./agent/agent-manager.js";
|
||||
import { createAgentCommand } from "./agent/create-agent/create.js";
|
||||
import { resolveCreateAgentIntent, type CreateAgentIntent } from "./agent/create-agent/intent.js";
|
||||
import {
|
||||
inheritAgentBackground,
|
||||
inheritWorkspaceBackground,
|
||||
resolveCreateAgentIntent,
|
||||
type CreateAgentIntent,
|
||||
type CreateAgentCaller,
|
||||
} from "./agent/create-agent/intent.js";
|
||||
import {
|
||||
archiveAgentCommand,
|
||||
cancelAgentRunCommand,
|
||||
@@ -388,6 +394,7 @@ type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_
|
||||
interface ResolvedSessionCreateAgentIntent {
|
||||
config: AgentSessionConfig;
|
||||
intent: CreateAgentIntent;
|
||||
background: boolean;
|
||||
createdDirectoryWorkspace: boolean;
|
||||
}
|
||||
|
||||
@@ -1162,8 +1169,8 @@ export class Session {
|
||||
),
|
||||
});
|
||||
this.agentUpdates = createAgentUpdatesService({
|
||||
includesWorkspace: (agent, includeBackground) =>
|
||||
this.agentDirectory.includes(agent, includeBackground),
|
||||
includesWorkspace: (agent, backgroundVisibility) =>
|
||||
this.agentDirectory.includes(agent, backgroundVisibility),
|
||||
emit: (message) => this.emit(message),
|
||||
enrichAgentPayload: (payload) => this.enrichAgentPayload(payload),
|
||||
buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record),
|
||||
@@ -4527,14 +4534,6 @@ export class Session {
|
||||
throw new Error(
|
||||
"Public internal agents were removed; create a background workspace instead.",
|
||||
);
|
||||
if (
|
||||
request.background !== undefined &&
|
||||
(request.workspaceId || (request.callerAgentId && !request.worktree))
|
||||
) {
|
||||
throw new Error(
|
||||
"background is workspace creation intent; configure the selected workspace at creation.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createSessionAgent(
|
||||
@@ -4585,8 +4584,7 @@ export class Session {
|
||||
cwd: config.cwd,
|
||||
target: worktree,
|
||||
firstAgentContext,
|
||||
background: msg.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId),
|
||||
...(await this.workspaceBackgroundIntent(msg.background, msg.callerAgentId)),
|
||||
});
|
||||
createdWorktreeForCleanup = createdWorktree;
|
||||
const resolvedIntent = await this.resolveSessionCreateAgentIntent({
|
||||
@@ -4617,6 +4615,7 @@ export class Session {
|
||||
agentId,
|
||||
config: resolvedIntent.config,
|
||||
workspaceId: resolvedIntent.intent.workspaceId,
|
||||
background: resolvedIntent.background,
|
||||
initialPrompt,
|
||||
source: msg.callerAgentId
|
||||
? { kind: "agent-message", agentId: msg.callerAgentId }
|
||||
@@ -4671,20 +4670,13 @@ export class Session {
|
||||
workspacePromptTitle: string | null;
|
||||
}): Promise<ResolvedSessionCreateAgentIntent> {
|
||||
const { request, createdWorktree } = input;
|
||||
const callerAgent = request.callerAgentId
|
||||
? this.agentManager.getAgent(request.callerAgentId)
|
||||
: null;
|
||||
if (request.callerAgentId && !callerAgent) {
|
||||
throw new Error(`Caller agent ${request.callerAgentId} not found`);
|
||||
}
|
||||
const callerAgent = await this.resolveCreationCaller(request.callerAgentId);
|
||||
|
||||
let config = request.config;
|
||||
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId,
|
||||
caller: callerAgent
|
||||
? { id: callerAgent.id, cwd: callerAgent.cwd, workspaceId: callerAgent.workspaceId }
|
||||
: null,
|
||||
caller: callerAgent,
|
||||
labels: request.labels,
|
||||
resolveWorkspace: async (workspaceId) => {
|
||||
if (createdWorktree?.workspace.workspaceId === workspaceId) {
|
||||
@@ -4701,7 +4693,7 @@ export class Session {
|
||||
createdWorktree: null,
|
||||
cwd: config.cwd,
|
||||
initialTitle: input.workspacePromptTitle,
|
||||
background: request.background,
|
||||
background: inheritWorkspaceBackground(request.background, callerAgent),
|
||||
callerWorkspaceId: callerAgent?.workspaceId,
|
||||
}),
|
||||
cwd: config.cwd,
|
||||
@@ -4712,6 +4704,7 @@ export class Session {
|
||||
return {
|
||||
config,
|
||||
intent,
|
||||
background: inheritAgentBackground(request.background, callerAgent),
|
||||
createdDirectoryWorkspace: !createdWorktree && !request.workspaceId && !callerAgent,
|
||||
};
|
||||
}
|
||||
@@ -5663,7 +5656,22 @@ export class Session {
|
||||
return matchedEntries;
|
||||
}
|
||||
|
||||
private async listFetchAgentsEntries(request: AgentDirectoryRequestMessage): Promise<{
|
||||
/**
|
||||
* Inclusive discovery for the requesting source. Exact-ID reads never pass through this.
|
||||
* COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor
|
||||
* >= v0.15.0. Sources without the capability see background workspaces' agents only.
|
||||
*/
|
||||
private resolveBackgroundVisibility(
|
||||
includeBackground: boolean | undefined,
|
||||
): BackgroundVisibility {
|
||||
if (!includeBackground) return false;
|
||||
return this.supports(CLIENT_CAPS.backgroundAgents) ? true : "workspaces";
|
||||
}
|
||||
|
||||
private async listFetchAgentsEntries(
|
||||
request: AgentDirectoryRequestMessage,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
): Promise<{
|
||||
entries: FetchAgentsResponseEntry[];
|
||||
pageInfo: FetchAgentsResponsePageInfo;
|
||||
searchTruncated?: boolean;
|
||||
@@ -5679,9 +5687,7 @@ export class Session {
|
||||
let agents = await this.listAgentPayloads({
|
||||
labels: filter?.labels,
|
||||
includeArchived: filter?.includeArchived,
|
||||
includeBackground:
|
||||
filter?.includeBackground ||
|
||||
(request.type === "fetch_agents_request" && request.includeBackground),
|
||||
includeBackground: backgroundVisibility,
|
||||
includeUnavailablePersisted: request.type === "fetch_agent_history_request",
|
||||
});
|
||||
const activePlacementsByWorkspaceId =
|
||||
@@ -6415,16 +6421,17 @@ export class Session {
|
||||
})
|
||||
: null;
|
||||
const subscriptionId = owner?.responseId;
|
||||
const backgroundVisibility = this.resolveBackgroundVisibility(
|
||||
request.includeBackground || request.filter?.includeBackground,
|
||||
);
|
||||
try {
|
||||
if (owner) {
|
||||
this.agentUpdates.beginSubscription({
|
||||
subscriptionId: owner.id,
|
||||
isProviderVisible: (provider) =>
|
||||
this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)),
|
||||
filter: {
|
||||
...request.filter,
|
||||
...(request.includeBackground ? { includeBackground: true } : {}),
|
||||
},
|
||||
filter: request.filter,
|
||||
backgroundVisibility,
|
||||
syncEnabled: Boolean(request.sync),
|
||||
emit: (message) => {
|
||||
if (message.type === "agent_update") owner.emit(message);
|
||||
@@ -6434,8 +6441,8 @@ export class Session {
|
||||
}
|
||||
|
||||
const payload = request.sync
|
||||
? await this.readAgentDirectorySync(request)
|
||||
: await this.listFetchAgentsEntries(request);
|
||||
? await this.readAgentDirectorySync(request, backgroundVisibility)
|
||||
: await this.listFetchAgentsEntries(request, backgroundVisibility);
|
||||
const snapshotUpdatedAtByAgentId = new Map<string, number>();
|
||||
for (const entry of payload.entries) {
|
||||
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
|
||||
@@ -6479,7 +6486,10 @@ export class Session {
|
||||
request: Extract<SessionInboundMessage, { type: "fetch_agent_history_request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const payload = await this.listFetchAgentsEntries(request);
|
||||
const payload = await this.listFetchAgentsEntries(
|
||||
request,
|
||||
this.resolveBackgroundVisibility(request.filter?.includeBackground),
|
||||
);
|
||||
this.emit({
|
||||
type: "fetch_agent_history_response",
|
||||
payload: {
|
||||
@@ -6844,6 +6854,7 @@ export class Session {
|
||||
|
||||
private async readAgentDirectorySync(
|
||||
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
) {
|
||||
if (request.scope !== "active" || request.filter) {
|
||||
throw new SessionRequestError(
|
||||
@@ -6851,19 +6862,19 @@ export class Session {
|
||||
"Sequenced agent directory reads require scope=active and no filter.",
|
||||
);
|
||||
}
|
||||
const snapshot = await this.listFetchAgentsEntries({
|
||||
...request,
|
||||
includeBackground: true,
|
||||
page: { limit: Number.MAX_SAFE_INTEGER },
|
||||
});
|
||||
// The canonical collection is complete; only the read below is scoped.
|
||||
const snapshot = await this.listFetchAgentsEntries(
|
||||
{ ...request, page: { limit: Number.MAX_SAFE_INTEGER } },
|
||||
true,
|
||||
);
|
||||
const result = this.directorySync.synchronizeAgents(
|
||||
snapshot.entries,
|
||||
request.sync ?? {},
|
||||
request.includeBackground,
|
||||
backgroundVisibility,
|
||||
);
|
||||
const entries = await Promise.all(
|
||||
result.entries.map(async (entry) =>
|
||||
(await this.agentDirectory.includes(entry.agent, request.includeBackground)) ? entry : null,
|
||||
(await this.agentDirectory.includes(entry.agent, backgroundVisibility)) ? entry : null,
|
||||
),
|
||||
);
|
||||
return { ...result, entries: entries.filter((entry) => entry !== null) };
|
||||
@@ -6964,11 +6975,34 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private getCallerWorkspaceId(callerAgentId?: string): string | undefined {
|
||||
if (!callerAgentId) return undefined;
|
||||
const caller = this.agentManager.getAgent(callerAgentId);
|
||||
private async resolveCreationCaller(
|
||||
callerAgentId?: string,
|
||||
): Promise<(CreateAgentCaller & { background?: boolean }) | null> {
|
||||
if (!callerAgentId) return null;
|
||||
let caller: (CreateAgentCaller & { background?: boolean }) | null =
|
||||
this.agentManager.getAgent(callerAgentId);
|
||||
if (!caller) {
|
||||
const stored = await this.agentStorage.get(callerAgentId);
|
||||
if (stored && !stored.archivedAt) caller = stored;
|
||||
}
|
||||
if (!caller) throw new Error(`Caller agent ${callerAgentId} not found`);
|
||||
return caller.workspaceId;
|
||||
return {
|
||||
id: caller.id,
|
||||
cwd: caller.cwd,
|
||||
workspaceId: caller.workspaceId,
|
||||
background: caller.background,
|
||||
};
|
||||
}
|
||||
|
||||
private async workspaceBackgroundIntent(
|
||||
background: boolean | undefined,
|
||||
callerAgentId: string | undefined,
|
||||
): Promise<{ background?: boolean; callerWorkspaceId?: string }> {
|
||||
const caller = await this.resolveCreationCaller(callerAgentId);
|
||||
return {
|
||||
background: inheritWorkspaceBackground(background, caller),
|
||||
callerWorkspaceId: caller?.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
private async createRequestedWorkspace(
|
||||
@@ -7017,8 +7051,7 @@ export class Session {
|
||||
{
|
||||
expectsInitialAgent: Boolean(request.firstAgentContext),
|
||||
workspaceId,
|
||||
background: request.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
|
||||
...(await this.workspaceBackgroundIntent(request.background, request.callerAgentId)),
|
||||
},
|
||||
);
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
@@ -7081,8 +7114,7 @@ export class Session {
|
||||
githubPrNumber: source.githubPrNumber,
|
||||
firstAgentContext: request.firstAgentContext,
|
||||
title: request.title,
|
||||
background: request.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
|
||||
...(await this.workspaceBackgroundIntent(request.background, request.callerAgentId)),
|
||||
},
|
||||
source.baseBranch
|
||||
? { resolveDefaultBranch: async () => source.baseBranch as string }
|
||||
@@ -7900,7 +7932,8 @@ export class Session {
|
||||
);
|
||||
const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const));
|
||||
const candidates = agents
|
||||
.filter((agent) => !agent.archivedAt && agent.workspaceId === workspace.workspaceId)
|
||||
.filter((agent) => !agent.archivedAt && !agent.background)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId)
|
||||
.filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id)
|
||||
.filter((agent) => agent.status === "idle" || agent.status === "closed")
|
||||
.filter((agent) => agent.requiresAttention !== true)
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "../../messages.js";
|
||||
import type { ManagedAgent } from "../../agent/agent-manager.js";
|
||||
import type { StoredAgentRecord } from "../../agent/agent-storage.js";
|
||||
import type { BackgroundVisibility } from "../../agent/directory.js";
|
||||
import { resolveEffectiveThinkingOptionId, toAgentPayload } from "../../agent/agent-projections.js";
|
||||
|
||||
type AgentUpdatePayload = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||
@@ -19,6 +20,7 @@ interface AgentUpdatesSubscriptionState {
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
syncEnabled: boolean;
|
||||
filter?: AgentUpdatesFilter;
|
||||
backgroundVisibility: BackgroundVisibility;
|
||||
isProviderVisible: (provider: string) => boolean;
|
||||
isBootstrapping: boolean;
|
||||
pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>;
|
||||
@@ -31,6 +33,7 @@ export interface AgentUpdatesService {
|
||||
isProviderVisible?: (provider: string) => boolean;
|
||||
emit?: (message: SessionOutboundMessage) => void;
|
||||
filter?: AgentUpdatesFilter;
|
||||
backgroundVisibility?: BackgroundVisibility;
|
||||
syncEnabled?: boolean;
|
||||
}): void;
|
||||
flushBootstrapped(
|
||||
@@ -47,7 +50,10 @@ export interface AgentUpdatesService {
|
||||
}
|
||||
|
||||
export interface AgentUpdatesServiceDeps {
|
||||
includesWorkspace?(agent: AgentSnapshotPayload, includeBackground?: boolean): Promise<boolean>;
|
||||
includesWorkspace?(
|
||||
agent: AgentSnapshotPayload,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
): Promise<boolean>;
|
||||
emit(message: SessionOutboundMessage): void;
|
||||
enrichAgentPayload(payload: AgentSnapshotPayload): Promise<AgentSnapshotPayload>;
|
||||
buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload;
|
||||
@@ -60,7 +66,7 @@ export interface AgentUpdatesServiceDeps {
|
||||
project: ProjectPlacementPayload | null,
|
||||
agentId: string,
|
||||
includeSequence: boolean,
|
||||
includeBackground?: boolean,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
): T;
|
||||
logger: pino.Logger;
|
||||
}
|
||||
@@ -173,7 +179,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
project,
|
||||
agentId,
|
||||
sub.syncEnabled === true,
|
||||
sub.filter?.includeBackground,
|
||||
sub.backgroundVisibility,
|
||||
);
|
||||
|
||||
function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void {
|
||||
@@ -194,10 +200,12 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
isProviderVisible?: (provider: string) => boolean;
|
||||
emit?: (message: SessionOutboundMessage) => void;
|
||||
filter?: AgentUpdatesFilter;
|
||||
backgroundVisibility?: BackgroundVisibility;
|
||||
syncEnabled?: boolean;
|
||||
}): void {
|
||||
subscriptions.set(input.subscriptionId, {
|
||||
...input,
|
||||
backgroundVisibility: input.backgroundVisibility ?? false,
|
||||
emit: input.emit ?? deps.emit,
|
||||
syncEnabled: input.syncEnabled ?? false,
|
||||
isProviderVisible: input.isProviderVisible ?? deps.isProviderVisibleToClient,
|
||||
@@ -249,9 +257,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
|
||||
: null;
|
||||
const backgroundVisible = await Promise.all(
|
||||
observers.map(
|
||||
(sub) => deps.includesWorkspace?.(payload, sub.filter?.includeBackground) ?? true,
|
||||
),
|
||||
observers.map((sub) => deps.includesWorkspace?.(payload, sub.backgroundVisibility) ?? true),
|
||||
);
|
||||
return (
|
||||
project !== null &&
|
||||
@@ -273,7 +279,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
for (const sub of observers) {
|
||||
const matches =
|
||||
project &&
|
||||
((await deps.includesWorkspace?.(payload, sub.filter?.includeBackground)) ?? true) &&
|
||||
((await deps.includesWorkspace?.(payload, sub.backgroundVisibility)) ?? true) &&
|
||||
matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter });
|
||||
bufferOrEmit(
|
||||
sub,
|
||||
|
||||
@@ -2013,6 +2013,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
creationLifecycle: true,
|
||||
hubAgentRpc: true,
|
||||
backgroundWorkspaces: true,
|
||||
// COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03.
|
||||
backgroundAgents: true,
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
directorySync: true,
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
|
||||
@@ -262,6 +262,7 @@ interface AgentState {
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: AgentSnapshotPayload["attentionReason"];
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
function createAgent(
|
||||
@@ -273,6 +274,7 @@ function createAgent(
|
||||
provider: "codex",
|
||||
cwd: input.cwd,
|
||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||
...(input.background ? { background: true } : {}),
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
@@ -330,6 +332,15 @@ describe("WorkspaceDirectory", () => {
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||
});
|
||||
|
||||
test("a running background agent does not make its workspace running", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasRootAgent({ id: "root-agent", status: "idle" });
|
||||
workspace.hasRootAgent({ id: "classifier", status: "running", background: true });
|
||||
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("done");
|
||||
});
|
||||
|
||||
test("same-cwd workspaces attribute agent status only to the owner", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
|
||||
@@ -267,8 +267,12 @@ export class WorkspaceDirectory {
|
||||
});
|
||||
}
|
||||
|
||||
// Background agents report to their creator; they never move the workspace's status.
|
||||
const activeAgents = agents.filter(
|
||||
(agent) => !agent.archivedAt && this.deps.isProviderVisibleToClient(agent.provider),
|
||||
(agent) =>
|
||||
!agent.archivedAt &&
|
||||
!agent.background &&
|
||||
this.deps.isProviderVisibleToClient(agent.provider),
|
||||
);
|
||||
this.applyAgentBucketContributions({
|
||||
activeAgents,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { existsSync } from "node:fs";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path, { join } from "node:path";
|
||||
@@ -421,20 +423,20 @@ function linkedWorktreeFacts(gitCommonDir: string) {
|
||||
});
|
||||
}
|
||||
|
||||
const SETTLE_ATTEMPTS = 1_000;
|
||||
|
||||
async function settle(assertion: () => void): Promise<void> {
|
||||
let lastFailure: unknown = null;
|
||||
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
||||
// Observation setup resolves real filesystem paths even while the polling clock is frozen.
|
||||
// Give that I/O wall-clock time without advancing the service's polling deadlines.
|
||||
const deadline = performance.now() + 5_000;
|
||||
while (true) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (failure) {
|
||||
lastFailure = failure;
|
||||
if (performance.now() >= deadline) throw failure;
|
||||
}
|
||||
await delay(1);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
throw lastFailure;
|
||||
}
|
||||
|
||||
async function waitForRefreshesToSettle(service: WorkspaceGitServiceImpl): Promise<void> {
|
||||
@@ -1743,48 +1745,65 @@ describe("WorkspaceGitServiceImpl with workspace folders that no longer exist",
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
test("a deleted worktree leaves its shared repository polling and fetch to the surviving worktree", async () => {
|
||||
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
||||
const deletedWorktree = await createTemporaryFolder();
|
||||
const survivingWorktree = await createTemporaryFolder();
|
||||
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
|
||||
let survivingWorktreeEdits = 0;
|
||||
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
|
||||
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
|
||||
deletions: 0,
|
||||
}));
|
||||
const service = createService(git.deps);
|
||||
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
const survivingSubscription = service.registerWorkspace({ cwd: survivingWorktree }, vi.fn());
|
||||
await waitForObservationToSettle(service, 2);
|
||||
|
||||
await rm(deletedWorktree, { recursive: true, force: true });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
|
||||
|
||||
for (let tick = 0; tick < 3; tick += 1) {
|
||||
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await settle(() => {
|
||||
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
|
||||
test.each([0, 250])(
|
||||
"a deleted worktree leaves its shared repository polling and fetch to the surviving worktree (watch delay: %ims)",
|
||||
async (watchDelayMs) => {
|
||||
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
||||
const deletedWorktree = await createTemporaryFolder();
|
||||
const survivingWorktree = await createTemporaryFolder();
|
||||
const watchReady = createDeferred<void>();
|
||||
const git = createFolderBackedGit({
|
||||
facts: linkedWorktreeFacts(gitCommonDir),
|
||||
heldWatch: { directory: survivingWorktree, release: watchReady.promise },
|
||||
});
|
||||
}
|
||||
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
|
||||
let survivingWorktreeEdits = 0;
|
||||
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
|
||||
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
|
||||
deletions: 0,
|
||||
}));
|
||||
const service = createService(git.deps);
|
||||
try {
|
||||
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
|
||||
await waitForObservationToSettle(service, 1);
|
||||
const survivingSubscription = service.registerWorkspace(
|
||||
{ cwd: survivingWorktree },
|
||||
vi.fn(),
|
||||
);
|
||||
const pollingTime = Date.now();
|
||||
void delay(watchDelayMs).then(() => watchReady.resolve());
|
||||
await waitForObservationToSettle(service, 2);
|
||||
expect(Date.now()).toBe(pollingTime);
|
||||
|
||||
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
|
||||
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
|
||||
survivingWorktree,
|
||||
survivingWorktree,
|
||||
]);
|
||||
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
|
||||
await rm(deletedWorktree, { recursive: true, force: true });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await waitForRefreshesToSettle(service);
|
||||
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
|
||||
|
||||
deletedSubscription.unsubscribe();
|
||||
survivingSubscription.unsubscribe();
|
||||
await service.dispose();
|
||||
});
|
||||
for (let tick = 0; tick < 3; tick += 1) {
|
||||
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await settle(() => {
|
||||
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
|
||||
});
|
||||
}
|
||||
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
|
||||
|
||||
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
|
||||
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
|
||||
survivingWorktree,
|
||||
survivingWorktree,
|
||||
]);
|
||||
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
|
||||
|
||||
deletedSubscription.unsubscribe();
|
||||
survivingSubscription.unsubscribe();
|
||||
} finally {
|
||||
watchReady.resolve();
|
||||
await service.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("deleting every worktree of a repository stops its polling and background fetch", async () => {
|
||||
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
||||
|
||||
@@ -4,16 +4,17 @@ Install this directory as a plugin, open an agent, and choose **Button examples:
|
||||
Command Center. One header button and one composer pill appear. Choose another mode to update
|
||||
those same registrations. Switching agents moves the example to that agent's workspace and composer.
|
||||
|
||||
| Command Center command | Header | Composer | Try |
|
||||
| ------------------------------------- | ------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Button examples: action** | Refresh icon only | Refresh icon and label | Refresh reads the workspace from the daemon and updates the label and tooltip. Paseo owns pending and error feedback. |
|
||||
| **Button examples: menu** | Wrench, Tools, chevron | Wrench and Tools | Refresh, a separator, custom details, a Display submenu, and a disabled item. |
|
||||
| **Button examples: popover** | Reactive status dot, Details, chevron | Reactive status dot and Details | Workspace data updates through `useWorkspace`; Done closes the surface. |
|
||||
| **Button examples: hide / show** | Hide or restore the button | Hide or restore the pill | Visibility changes through `registration.update({ visible })`. |
|
||||
| **Button examples: disable / enable** | Disable or enable the button | Disable or enable the pill | Disabled state changes through `registration.update({ disabled })`. |
|
||||
| Command Center command | Header | Composer | Try |
|
||||
| ------------------------------------- | ------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Button examples: action** | Refresh icon only | Refresh icon and label | Refresh reads the workspace from the daemon and updates the label and tooltip. Paseo owns pending and error feedback. |
|
||||
| **Button examples: menu** | Wrench, Tools, chevron | Wrench, Tools, chevron | Refresh, a separator, custom details, a Display submenu, and a disabled item. |
|
||||
| **Button examples: popover** | Reactive status dot, Details, chevron | Status dot, Details, chevron | Workspace data updates through `useWorkspace`; Done closes the surface. |
|
||||
| **Button examples: hide / show** | Hide or restore the button | Hide or restore the pill | Visibility changes through `registration.update({ visible })`. |
|
||||
| **Button examples: disable / enable** | Disable or enable the button | Disable or enable the pill | Disabled state changes through `registration.update({ disabled })`. |
|
||||
|
||||
On compact layouts the header button is borderless and icon-only. Menus and custom content open
|
||||
bottom sheets. Composer pills keep their icon and label, without a chevron, on every layout.
|
||||
bottom sheets. Composer pills keep their icon and label on every layout, and menus and popovers keep
|
||||
their chevron.
|
||||
|
||||
The example contributes one header button at a time. Additional actions live under the named
|
||||
**Tools** button; the example does not add a three-dot control or trigger plugin overflow.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Settings example
|
||||
|
||||
Install this directory with `paseo plugin install /absolute/path/to/plugin-examples/settings`.
|
||||
Open Settings → Plugins → settings-example → Display, or run **Configure agent monitor** in Command Center.
|
||||
Open Settings → Plugins and press the settings button (cog) on the settings-example row, or run **Configure agent monitor** in Command Center.
|
||||
|
||||
The select and switch save immediately. The title editor keeps a draft and its original revision,
|
||||
so another client's save produces a conflict instead of overwriting its changes. Close and reopen
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
interface Database {
|
||||
prepare(sql: string): { all(...args: string[]): unknown[] };
|
||||
close(): void;
|
||||
}
|
||||
interface Sqlite {
|
||||
DatabaseSync: new (path: string, options: { readOnly: boolean }) => Database;
|
||||
}
|
||||
interface DatabaseLogin {
|
||||
key: string | null;
|
||||
}
|
||||
export interface StorePaths {
|
||||
file: string;
|
||||
database: string;
|
||||
}
|
||||
|
||||
const fileSchema = z.object({
|
||||
"opencode-go": z.object({ type: z.literal("api"), key: z.string().min(1) }).optional(),
|
||||
});
|
||||
const rowSchema = z.object({ value: z.string() });
|
||||
const keySchema = z.object({ type: z.literal("key"), key: z.string().min(1) });
|
||||
|
||||
export function defaultStorePaths(env: NodeJS.ProcessEnv = process.env): StorePaths {
|
||||
const dataHome = env.XDG_DATA_HOME || join(env.HOME || homedir(), ".local", "share");
|
||||
const directory = join(dataHome, "opencode");
|
||||
const database = resolve(directory, env.OPENCODE_DB || "opencode.db");
|
||||
return { file: join(directory, "auth.json"), database };
|
||||
}
|
||||
|
||||
export async function readFileKey(path: string): Promise<string | null> {
|
||||
try {
|
||||
const auth = fileSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
||||
return auth["opencode-go"]?.key ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readDatabaseLogin(path: string): DatabaseLogin | null {
|
||||
// Keep the legacy source loadable on Node versions without node:sqlite.
|
||||
const sqlite = z
|
||||
.object({
|
||||
DatabaseSync: z.custom<Sqlite["DatabaseSync"]>((value) => typeof value === "function"),
|
||||
})
|
||||
.safeParse(process.getBuiltinModule?.("node:sqlite"));
|
||||
if (!sqlite.success || !existsSync(path)) return null;
|
||||
const db = new sqlite.data.DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
// V1 also has opencode.db, but only v2 has the credential table.
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credential'")
|
||||
.all();
|
||||
if (!tables.length) return null;
|
||||
const [value] = db
|
||||
.prepare(
|
||||
// OpenCode also selects migrated rows with no active flag, ordered by creation time.
|
||||
"SELECT value FROM credential WHERE integration_id = ? ORDER BY active DESC, time_created DESC, id DESC LIMIT 1",
|
||||
)
|
||||
.all("opencode-go");
|
||||
const row = rowSchema.safeParse(value);
|
||||
if (!row.success) return { key: null };
|
||||
let credential: unknown;
|
||||
try {
|
||||
credential = JSON.parse(row.data.value);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) return { key: null };
|
||||
throw error;
|
||||
}
|
||||
const parsed = keySchema.safeParse(credential);
|
||||
return { key: parsed.success ? parsed.data.key : null };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { discover, fetchUsage } from "./usage.js";
|
||||
import { defaultStorePaths } from "./stores.js";
|
||||
|
||||
import { inputSchema } from "../shared/input.js";
|
||||
|
||||
let fixtureDirectory: string;
|
||||
let fixturePath: string;
|
||||
function stores(path: string) {
|
||||
return { file: path, database: join(dirname(path), "opencode.db") };
|
||||
}
|
||||
async function database() {
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const db = new DatabaseSync(join(fixtureDirectory, "opencode.db"));
|
||||
db.exec(
|
||||
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, value TEXT, active INTEGER, time_updated INTEGER, time_created INTEGER DEFAULT 0)",
|
||||
);
|
||||
return db;
|
||||
}
|
||||
beforeEach(async () => {
|
||||
fixtureDirectory = await mkdtemp(join(tmpdir(), "go-default-"));
|
||||
fixturePath = join(fixtureDirectory, "auth.json");
|
||||
@@ -22,6 +34,9 @@ afterEach(async () => {
|
||||
|
||||
test("discovery input rejects an explicit API key", () => {
|
||||
expect(inputSchema.safeParse({ apiKey: "unused" }).success).toBe(false);
|
||||
expect(
|
||||
inputSchema.safeParse({ store: "database", path: "opencode.db", apiKey: "unused" }).success,
|
||||
).toBe(false);
|
||||
expect(() => inputSchema.parse({})).toThrow();
|
||||
});
|
||||
|
||||
@@ -33,14 +48,216 @@ const upstreamResponse = {
|
||||
},
|
||||
};
|
||||
|
||||
test("uses the active v2 Go key instead of a stale legacy key without writing either store", async () => {
|
||||
const databasePath = join(fixtureDirectory, "opencode.db");
|
||||
const db = await database();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
insert.run("inactive", "opencode-go", JSON.stringify({ type: "key", key: "inactive-key" }), 0, 3);
|
||||
insert.run("active", "opencode-go", JSON.stringify({ type: "key", key: "active-key" }), 1, 1);
|
||||
insert.run("zen", "opencode", JSON.stringify({ type: "key", key: "zen-key" }), 1, 4);
|
||||
db.close();
|
||||
const beforeDatabase = await readFile(databasePath);
|
||||
const beforeFile = await readFile(fixturePath);
|
||||
const accounts = await discover(stores(fixturePath));
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: { store: "database", path: databasePath },
|
||||
},
|
||||
]);
|
||||
const input = inputSchema.parse(accounts[0]!.input);
|
||||
const report = await fetchUsage(input, async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer active-key");
|
||||
return Response.json(upstreamResponse);
|
||||
});
|
||||
expect(report.status).toBe("available");
|
||||
expect(report.windows.map((window) => window.usedPct)).toEqual([21, 42, 100]);
|
||||
expect(await readFile(databasePath)).toEqual(beforeDatabase);
|
||||
expect(await readFile(fixturePath)).toEqual(beforeFile);
|
||||
});
|
||||
|
||||
test("discovers v2 Go usage without a legacy auth.json", async () => {
|
||||
const db = await database();
|
||||
db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("go", "opencode-go", JSON.stringify({ type: "key", key: "database-key" }), 1, 1);
|
||||
db.close();
|
||||
await rm(fixturePath);
|
||||
const accounts = await discover(stores(fixturePath));
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: { store: "database", path: join(fixtureDirectory, "opencode.db") },
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(accounts)).not.toContain("database-key");
|
||||
});
|
||||
|
||||
test("refresh follows the active Go account and re-reads its rotated key", async () => {
|
||||
const db = await database();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
insert.run("first", "opencode-go", JSON.stringify({ type: "key", key: "first-key" }), 1, 1);
|
||||
insert.run("second", "opencode-go", JSON.stringify({ type: "key", key: "second-key" }), 0, 2);
|
||||
db.close();
|
||||
const paths = stores(fixturePath);
|
||||
const accounts = await discover(paths);
|
||||
const input = inputSchema.parse(accounts[0]!.input);
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const writer = new DatabaseSync(paths.database);
|
||||
writer.exec("UPDATE credential SET active = CASE id WHEN 'second' THEN 1 ELSE 0 END");
|
||||
writer
|
||||
.prepare("UPDATE credential SET value = ? WHERE id = 'second'")
|
||||
.run(JSON.stringify({ type: "key", key: "rotated-key" }));
|
||||
writer.close();
|
||||
expect(await discover(paths)).toEqual(accounts);
|
||||
const before = await readFile(paths.database);
|
||||
const report = await fetchUsage(input, async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer rotated-key");
|
||||
return Response.json(upstreamResponse);
|
||||
});
|
||||
expect(report.status).toBe("available");
|
||||
expect(JSON.stringify(report)).not.toContain("rotated-key");
|
||||
expect(await readFile(paths.database)).toEqual(before);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[null, false],
|
||||
[null, true],
|
||||
[0, false],
|
||||
[0, true],
|
||||
])("discovers a selected Go key with active=%s and legacy file=%s", async (active, legacy) => {
|
||||
const db = await database();
|
||||
db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("migrated", "opencode-go", JSON.stringify({ type: "key", key: "migrated-key" }), active, 1);
|
||||
db.close();
|
||||
if (!legacy) await rm(fixturePath);
|
||||
const accounts = await discover(stores(fixturePath));
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: { store: "database", path: join(fixtureDirectory, "opencode.db") },
|
||||
},
|
||||
]);
|
||||
const report = await fetchUsage(inputSchema.parse(accounts[0]!.input), async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer migrated-key");
|
||||
return Response.json(upstreamResponse);
|
||||
});
|
||||
expect(report.status).toBe("available");
|
||||
});
|
||||
|
||||
test("selects the newest created Go key when no explicit active key exists", async () => {
|
||||
const db = await database();
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
insert.run("older", "opencode-go", JSON.stringify({ type: "key", key: "older-key" }), null, 1, 9);
|
||||
insert.run("newer-a", "opencode-go", JSON.stringify({ type: "key", key: "tie-key" }), null, 2, 8);
|
||||
insert.run(
|
||||
"newer-b",
|
||||
"opencode-go",
|
||||
JSON.stringify({ type: "key", key: "selected-key" }),
|
||||
null,
|
||||
2,
|
||||
1,
|
||||
);
|
||||
db.close();
|
||||
const accounts = await discover(stores(fixturePath));
|
||||
expect(accounts).toHaveLength(1);
|
||||
await fetchUsage(inputSchema.parse(accounts[0]!.input), async (_url, init) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer selected-key");
|
||||
return Response.json(upstreamResponse);
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["other provider", "opencode", 1, JSON.stringify({ type: "key", key: "zen-key" })],
|
||||
["Console OAuth", "opencode-go", 1, JSON.stringify({ type: "oauth", access: "oauth-token" })],
|
||||
["malformed credential", "opencode-go", 1, "invalid JSON"],
|
||||
["empty key", "opencode-go", 1, JSON.stringify({ type: "key", key: "" })],
|
||||
])(
|
||||
"does not revive a legacy key for a v2 store with %s",
|
||||
async (_name, provider, active, value) => {
|
||||
const db = await database();
|
||||
db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("credential", provider, value, active, 1);
|
||||
db.close();
|
||||
expect(await discover(stores(fixturePath))).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps legacy discovery when the v1 session database has no credential table", async () => {
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const db = new DatabaseSync(join(fixtureDirectory, "opencode.db"));
|
||||
db.exec("CREATE TABLE session (id TEXT PRIMARY KEY)");
|
||||
db.close();
|
||||
expect(await discover(stores(fixturePath))).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: { store: "file", path: fixturePath },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("fails before fetching when the selected database disappears", async () => {
|
||||
await expect(
|
||||
fetchUsage({ store: "database", path: join(fixtureDirectory, "missing.db") }, async () => {
|
||||
throw new Error("must not fetch");
|
||||
}),
|
||||
).rejects.toThrow("login store no longer exists");
|
||||
});
|
||||
|
||||
test("resolves default and overridden OpenCode store paths", () => {
|
||||
expect(defaultStorePaths({ HOME: fixtureDirectory })).toEqual({
|
||||
file: join(fixtureDirectory, ".local", "share", "opencode", "auth.json"),
|
||||
database: join(fixtureDirectory, ".local", "share", "opencode", "opencode.db"),
|
||||
});
|
||||
const customDatabase = join(fixtureDirectory, "custom.db");
|
||||
expect(
|
||||
defaultStorePaths({ XDG_DATA_HOME: fixtureDirectory, OPENCODE_DB: customDatabase }),
|
||||
).toEqual({
|
||||
file: join(fixtureDirectory, "opencode", "auth.json"),
|
||||
database: customDatabase,
|
||||
});
|
||||
});
|
||||
|
||||
test("discovers a relative OPENCODE_DB under the OpenCode data directory", async () => {
|
||||
const paths = defaultStorePaths({
|
||||
XDG_DATA_HOME: fixtureDirectory,
|
||||
OPENCODE_DB: "go.db",
|
||||
});
|
||||
const databasePath = join(fixtureDirectory, "opencode", "go.db");
|
||||
await mkdir(dirname(databasePath));
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const db = new DatabaseSync(databasePath);
|
||||
db.exec(
|
||||
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, value TEXT, active INTEGER, time_updated INTEGER, time_created INTEGER DEFAULT 0)",
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO credential (id, integration_id, value, active, time_updated) VALUES (?, ?, ?, ?, ?)",
|
||||
).run("go", "opencode-go", JSON.stringify({ type: "key", key: "relative-key" }), 1, 1);
|
||||
db.close();
|
||||
expect(await discover(paths)).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: { store: "database", path: databasePath },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("discovers and fetches the default key from read-only auth.json", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "paseo-go-usage-"));
|
||||
const path = join(directory, "auth.json");
|
||||
const content = JSON.stringify({ "opencode-go": { type: "api", key: "fixture-key" } });
|
||||
try {
|
||||
await writeFile(path, content);
|
||||
expect(await discover(path)).toEqual([
|
||||
{ key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { path } },
|
||||
expect(await discover(stores(path))).toEqual([
|
||||
{ key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { store: "file", path } },
|
||||
]);
|
||||
const requests: Array<{ url: string; authorization: string | null }> = [];
|
||||
const fetchApi = async (url: string | URL | Request, init?: RequestInit) => {
|
||||
@@ -50,7 +267,7 @@ test("discovers and fetches the default key from read-only auth.json", async ()
|
||||
});
|
||||
return Response.json(upstreamResponse);
|
||||
};
|
||||
const report = await fetchUsage({ path }, fetchApi as typeof fetch);
|
||||
const report = await fetchUsage({ store: "file", path }, fetchApi);
|
||||
expect(requests).toEqual([
|
||||
{ url: "https://opencode.ai/zen/go/v1/usage", authorization: "Bearer fixture-key" },
|
||||
]);
|
||||
@@ -75,9 +292,9 @@ test("omits default discovery when auth.json lacks an OpenCode Go API key", asyn
|
||||
const path = join(directory, "auth.json");
|
||||
try {
|
||||
await writeFile(path, JSON.stringify({ openai: { type: "oauth", access: "other-token" } }));
|
||||
expect(await discover(path)).toEqual([]);
|
||||
expect(await discover(stores(path))).toEqual([]);
|
||||
await expect(
|
||||
fetchUsage({ path }, async () => {
|
||||
fetchUsage({ store: "file", path }, async () => {
|
||||
throw new Error("must not fetch");
|
||||
}),
|
||||
).rejects.toThrow("login store no longer exists");
|
||||
@@ -88,7 +305,7 @@ test("omits default discovery when auth.json lacks an OpenCode Go API key", asyn
|
||||
|
||||
test.each([401, 403])("maps HTTP %i to unavailable", async (status) => {
|
||||
const report = await fetchUsage(
|
||||
{ path: fixturePath },
|
||||
{ store: "file", path: fixturePath },
|
||||
async () => new Response(null, { status }),
|
||||
);
|
||||
expect(report).toEqual({
|
||||
@@ -100,5 +317,5 @@ test.each([401, 403])("maps HTTP %i to unavailable", async (status) => {
|
||||
test.each(["missing file", "unrelated file"])("discovers no accounts for %s", async (scenario) => {
|
||||
const path = join(fixtureDirectory, "missing.json");
|
||||
if (scenario === "unrelated file") await writeFile(path, "{}");
|
||||
expect(await discover(path)).toEqual([]);
|
||||
expect(await discover(stores(path))).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
hashAccountKey,
|
||||
@@ -11,15 +8,7 @@ import {
|
||||
type UsageReport,
|
||||
} from "@getpaseo/plugin/server/usage";
|
||||
import type { Input } from "../shared/input.js";
|
||||
|
||||
const authSchema = z
|
||||
.object({
|
||||
"opencode-go": z
|
||||
.object({ type: z.literal("api"), key: z.string().min(1) })
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
import { defaultStorePaths, readDatabaseLogin, readFileKey } from "./stores.js";
|
||||
const windowSchema = z.object({
|
||||
status: z.enum(["ok", "rate-limited"]),
|
||||
percent: z.number().finite(),
|
||||
@@ -29,32 +18,30 @@ const responseSchema = z.object({
|
||||
usage: z.object({ rolling: windowSchema, weekly: windowSchema, monthly: windowSchema }),
|
||||
});
|
||||
|
||||
function authPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return join(
|
||||
env.XDG_DATA_HOME || join(env.HOME || homedir(), ".local", "share"),
|
||||
"opencode",
|
||||
"auth.json",
|
||||
);
|
||||
}
|
||||
|
||||
export async function readDefaultKey(path = authPath()): Promise<string | null> {
|
||||
try {
|
||||
const auth = authSchema.parse(JSON.parse(await readFile(path, "utf8")));
|
||||
return auth["opencode-go"]?.key ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
export async function discover(paths = defaultStorePaths()): Promise<UsageAccount[]> {
|
||||
const login = readDatabaseLogin(paths.database);
|
||||
if (login) {
|
||||
return login.key
|
||||
? [
|
||||
{
|
||||
key: hashAccountKey(paths.database),
|
||||
input: { store: "database", path: paths.database },
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function discover(path = authPath()): Promise<UsageAccount[]> {
|
||||
return (await readDefaultKey(path)) ? [{ key: hashAccountKey(path), input: { path } }] : [];
|
||||
const key = await readFileKey(paths.file);
|
||||
return key
|
||||
? [{ key: hashAccountKey(paths.file), input: { store: "file", path: paths.file } }]
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function fetchUsage(
|
||||
input: Input,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
const apiKey = await readDefaultKey(input.path);
|
||||
const apiKey =
|
||||
input.store === "database" ? readDatabaseLogin(input.path)?.key : await readFileKey(input.path);
|
||||
if (!apiKey) throw new Error("OpenCode Go login store no longer exists");
|
||||
const response = await fetchApi("https://opencode.ai/zen/go/v1/usage", {
|
||||
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z.object({ path: z.string().min(1) }).strict();
|
||||
export const inputSchema = z
|
||||
.object({ store: z.enum(["file", "database"]), path: z.string().min(1) })
|
||||
.strict();
|
||||
export type Input = z.infer<typeof inputSchema>;
|
||||
|
||||
+2
-2
@@ -289,10 +289,10 @@ behavior.
|
||||
|
||||
```bash
|
||||
paseo ls # Non-archived agents in active workspaces
|
||||
paseo ls -a # Also include archived agents
|
||||
paseo ls -a # Also include archived and background agents, with a BACKGROUND column
|
||||
paseo ls -g # Non-archived agents across all workspaces
|
||||
paseo ls -a -g --json # All agents, including archived, as JSON
|
||||
paseo ls --background # Also include agents in background workspaces
|
||||
paseo ls --background # Also include background agents and agents in background workspaces
|
||||
```
|
||||
|
||||
## Streaming output
|
||||
|
||||
@@ -270,10 +270,10 @@ paseo plugin remove my-plugin
|
||||
|
||||
```bash
|
||||
paseo ls # Non-archived agents in active workspaces
|
||||
paseo ls -a # Also include archived agents
|
||||
paseo ls -a # Also include archived and background agents, with a BACKGROUND column
|
||||
paseo ls -g # Non-archived agents across all workspaces
|
||||
paseo ls -a -g --json # All agents, including archived, as JSON
|
||||
paseo ls --background # Also include agents in background workspaces
|
||||
paseo ls --background # Also include background agents and agents in background workspaces
|
||||
```
|
||||
|
||||
## 流式输出 {#streaming-output}
|
||||
|
||||
+12
-12
@@ -87,18 +87,18 @@ MCP does not expose an agent-detach tool. Detaching is a manual user action in t
|
||||
|
||||
### Agents
|
||||
|
||||
| Tool | Function |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `create_agent` | Create an agent, optionally placing it in an existing workspace with `workspaceId`. Use `initialPrompt` to start work; returns without waiting for completion. |
|
||||
| `send_agent_prompt` | Send a prompt using `agentId` and `prompt`; returns without waiting for completion. |
|
||||
| `get_agent_status` | Return the latest snapshot for an agent. |
|
||||
| `list_agents` | List recent agents as compact metadata; `includeBackground: true` includes background work. |
|
||||
| `cancel_agent` | Abort an agent's current run but keep the agent alive. |
|
||||
| `archive_agent` | Soft-delete an agent and remove it from the active list. |
|
||||
| `kill_agent` | Terminate an agent session permanently. |
|
||||
| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. |
|
||||
| `get_agent_activity` | Return recent agent timeline entries as a curated summary. |
|
||||
| `set_agent_mode` | Switch an agent's session mode. |
|
||||
| Tool | Function |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `create_agent` | Create an agent, optionally placing it in an existing workspace with `workspaceId`. Use `initialPrompt` to start work; returns without waiting for completion. `background: true` hides it from tabs, default lists, your subagent track and notifications; you still get its finish notification. Omission inherits your own value. |
|
||||
| `send_agent_prompt` | Send a prompt using `agentId` and `prompt`; returns without waiting for completion. |
|
||||
| `get_agent_status` | Return the latest snapshot for an agent. |
|
||||
| `list_agents` | List recent agents as compact metadata; `includeBackground: true` includes background work. |
|
||||
| `cancel_agent` | Abort an agent's current run but keep the agent alive. |
|
||||
| `archive_agent` | Soft-delete an agent and remove it from the active list. |
|
||||
| `kill_agent` | Terminate an agent session permanently. |
|
||||
| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. |
|
||||
| `get_agent_activity` | Return recent agent timeline entries as a curated summary. |
|
||||
| `set_agent_mode` | Switch an agent's session mode. |
|
||||
|
||||
Paseo agents receive finish, error, and permission notifications by default; set `notifyOnFinish: false` to disable them. External MCP clients use `get_agent_status` and `get_agent_activity` to check results.
|
||||
|
||||
|
||||
+12
-12
@@ -79,18 +79,18 @@ MCP 不提供解除 Agent 父子关系的工具。解除关系是用户在应用
|
||||
|
||||
### Agent {#agents}
|
||||
|
||||
| 工具 | 功能 |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `create_agent` | 创建 Agent,可用 `workspaceId` 把它放到已有的工作区。用 `initialPrompt` 开始工作;不等待完成就返回。 |
|
||||
| `send_agent_prompt` | 用 `agentId` 和 `prompt` 发送提示词;不等待完成就返回。 |
|
||||
| `get_agent_status` | 返回 Agent 的最新快照。 |
|
||||
| `list_agents` | 以精简元数据列出最近的 Agent;`includeBackground: true` 会包含后台工作。 |
|
||||
| `cancel_agent` | 中止 Agent 当前的运行,但保留该 Agent。 |
|
||||
| `archive_agent` | 软删除 Agent,并把它从活动列表中移除。 |
|
||||
| `kill_agent` | 永久终止 Agent 会话。 |
|
||||
| `update_agent` | 更新 Agent 的名称、标签或运行时设置,例如模式、模型、思考选项和功能。 |
|
||||
| `get_agent_activity` | 以整理后的摘要返回 Agent 最近的时间线条目。 |
|
||||
| `set_agent_mode` | 切换 Agent 的会话模式。 |
|
||||
| 工具 | 功能 |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `create_agent` | 创建 Agent,可用 `workspaceId` 把它放到已有的工作区。用 `initialPrompt` 开始工作;不等待完成就返回。`background: true` 会把它从标签页、默认列表、你的子 Agent 列表和通知中隐藏;你仍会收到它的完成通知。省略时继承你自己的设置。 |
|
||||
| `send_agent_prompt` | 用 `agentId` 和 `prompt` 发送提示词;不等待完成就返回。 |
|
||||
| `get_agent_status` | 返回 Agent 的最新快照。 |
|
||||
| `list_agents` | 以精简元数据列出最近的 Agent;`includeBackground: true` 会包含后台工作。 |
|
||||
| `cancel_agent` | 中止 Agent 当前的运行,但保留该 Agent。 |
|
||||
| `archive_agent` | 软删除 Agent,并把它从活动列表中移除。 |
|
||||
| `kill_agent` | 永久终止 Agent 会话。 |
|
||||
| `update_agent` | 更新 Agent 的名称、标签或运行时设置,例如模式、模型、思考选项和功能。 |
|
||||
| `get_agent_activity` | 以整理后的摘要返回 Agent 最近的时间线条目。 |
|
||||
| `set_agent_mode` | 切换 Agent 的会话模式。 |
|
||||
|
||||
Paseo Agent 默认会收到完成、错误和权限通知;设置 `notifyOnFinish: false` 可以关闭它们。外部 MCP 客户端使用 `get_agent_status` 和 `get_agent_activity` 查看结果。
|
||||
|
||||
|
||||
@@ -588,7 +588,7 @@ server.addHook("agent.permission.after", async ({ agent, requestId, resolution }
|
||||
});
|
||||
```
|
||||
|
||||
Agent hooks exclude the daemon's private disposable helpers. Public background workspaces and their ordinary agents use the same hooks.
|
||||
Agent hooks exclude the daemon's private disposable helpers. Background agents and the agents in background workspaces use the same hooks.
|
||||
|
||||
## Ordering and returned values
|
||||
|
||||
|
||||
@@ -571,7 +571,7 @@ server.addHook("agent.permission.after", async ({ agent, requestId, resolution }
|
||||
});
|
||||
```
|
||||
|
||||
Agent 钩子不包括 daemon 的私有临时辅助 Agent。公开的后台工作区及其普通 Agent 使用相同钩子。
|
||||
Agent 钩子不包括 daemon 的私有临时辅助 Agent。后台 Agent 和后台工作区中的 Agent 使用相同钩子。
|
||||
|
||||
## 顺序与返回值 {#ordering-and-returned-values}
|
||||
|
||||
|
||||
@@ -144,8 +144,8 @@ pill.remove();
|
||||
```
|
||||
|
||||
Move dynamic text from the former component into updates from your model or SDK subscription.
|
||||
Custom icon components may still use hooks. Composer pills always show icon and label and never
|
||||
show chevrons. See [buttons](./reference.md#button-descriptor) for menus, popovers, and visibility.
|
||||
Custom icon components may still use hooks. Composer pills always show icon and label; menus and
|
||||
popovers add a chevron. See [buttons](./reference.md#button-descriptor) for menus, popovers, and visibility.
|
||||
|
||||
## 4. Separate imports
|
||||
|
||||
|
||||
@@ -73,16 +73,16 @@ The required root manifest is `paseo-plugin.json`:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Behavior |
|
||||
| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Yes | Default installation ID. |
|
||||
| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. |
|
||||
| `icon` | No | Relative path to a PNG inside the plugin package. |
|
||||
| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. |
|
||||
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. |
|
||||
| `translations` | No | Listing text in other languages, read by the registry and ignored by Paseo. See [other languages](/docs/plugins/publishing#other-languages). |
|
||||
| `requirements` | No | Supported Paseo versions, described below. |
|
||||
| `build` | No | Preparation commands, described in the CLI reference. |
|
||||
| Field | Required | Behavior |
|
||||
| -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Yes | Default installation ID. |
|
||||
| `name` | No | Non-empty display name for the registry, the website, your row in **Settings → Plugins**, and your settings header. Without it, Paseo shows the ID. |
|
||||
| `icon` | No | Relative path to a PNG inside the plugin package. |
|
||||
| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. |
|
||||
| `description` | No | Non-empty summary shown below the plugin name in **Settings → Plugins**. |
|
||||
| `translations` | No | Listing text in other languages, read by the registry and ignored by Paseo. See [other languages](/docs/plugins/publishing#other-languages). |
|
||||
| `requirements` | No | Supported Paseo versions, described below. |
|
||||
| `build` | No | Preparation commands, described in the CLI reference. |
|
||||
|
||||
Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..`
|
||||
segments. Registry listings for visual plugins require an interface image even though `media` is
|
||||
@@ -1302,8 +1302,11 @@ Themes need a host that supports them. A client released before `addTheme` canno
|
||||
## Settings screens
|
||||
|
||||
Register a component with `client.addSettingsScreen({ id, title, icon, Component })` in
|
||||
`index.client.tsx`. It appears under **Settings → Plugins → your plugin** on that host.
|
||||
`id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an
|
||||
`index.client.tsx`. Your plugin's row in **Settings → Plugins** on that host then shows a settings
|
||||
button (cog) that opens your first screen. When you register several screens, the settings page shows
|
||||
one tab per screen, labeled with its `title` and `icon`, in registration order. With one screen there
|
||||
are no tabs. The page header shows your manifest `name`, or your plugin ID when you leave `name`
|
||||
out; it never includes a screen title. `id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an
|
||||
idempotent remover, and plugin teardown removes remaining screens.
|
||||
|
||||
Call `client.openSettings(id)` or a Command Center callback's `openSettings(id)` to open one
|
||||
@@ -1897,7 +1900,10 @@ decisions.
|
||||
`client.addComposerPill({ id, workspaceId, agentId, button })` uses the same [button descriptor](#button-descriptor)
|
||||
and returns the same registration. It targets one agent's composer track alongside Tasks and
|
||||
Subagents. Composer pills always show the icon and `label` (or `title` when `label` is omitted).
|
||||
They never show a chevron, including for menus and popovers.
|
||||
Menus and popovers add an upward chevron on every layout, because their surface opens above the
|
||||
pill; actions show none, so a pill that runs on tap reads differently from one that opens something.
|
||||
The track keeps its space above the composer whether or not it has pills, so adding and removing
|
||||
pills does not move the transcript.
|
||||
|
||||
```tsx
|
||||
const pill = client.addComposerPill({
|
||||
@@ -2078,6 +2084,8 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
|
||||
|
||||
The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection.
|
||||
|
||||
To run a helper the user shouldn't see, such as a classifier or summarizer, create it as a [background agent](/docs/sdk/agents#run-a-helper-as-a-background-agent) in an existing workspace.
|
||||
|
||||
### Discover hosts and target another host
|
||||
|
||||
Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback
|
||||
|
||||
@@ -73,10 +73,10 @@ my-plugin/
|
||||
| 字段 | 是否必填 | 行为 |
|
||||
| -------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | 是 | 默认的安装 ID。 |
|
||||
| `name` | 否 | 注册表和网站使用的非空显示名称;与安装 ID 无关。 |
|
||||
| `name` | 否 | 注册表、网站、**设置 → 插件** 中的插件行以及插件设置页标题使用的非空显示名称。没有 `name` 时显示插件 ID。 |
|
||||
| `icon` | 否 | 插件包内 PNG 文件的相对路径。 |
|
||||
| `media` | 否 | 包内图片或视频路径的数组,或 HTTPS URL。空数组是有效的。 |
|
||||
| `description` | 否 | 在 **设置 → 插件** 中显示在插件 ID 下方的非空摘要。 |
|
||||
| `description` | 否 | 在 **设置 → 插件** 中显示在插件名称下方的非空摘要。 |
|
||||
| `translations` | 否 | 其他语言的展示文本,由注册表读取,Paseo 会忽略它。见[其他语言](/docs/plugins/publishing#other-languages)。 |
|
||||
| `requirements` | 否 | 支持的 Paseo 版本,见下文。 |
|
||||
| `build` | 否 | 准备命令,见 CLI 参考。 |
|
||||
@@ -1087,7 +1087,7 @@ export default function contribute(client: PluginClientContext) {
|
||||
|
||||
## 设置界面 {#settings-screens}
|
||||
|
||||
在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。它会出现在该主机的 **设置 → 插件 → 你的插件** 下。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。
|
||||
在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。之后,该主机 **设置 → 插件** 中你的插件行会显示一个设置按钮(齿轮),点击后打开你的第一个界面。注册多个界面时,设置页面会按注册顺序为每个界面显示一个标签页,使用其 `title` 和 `icon`;只有一个界面时不显示标签页。页面标题显示 manifest 中的 `name`;没有 `name` 时显示插件 ID,且从不包含界面标题。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。
|
||||
|
||||
调用 `client.openSettings(id)` 或 Command Center 回调中的 `openSettings(id)` 可以打开你自己的某个界面。即使多台主机安装了同一个插件,每次安装也有各自的值和路由。
|
||||
|
||||
@@ -1779,6 +1779,8 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
|
||||
|
||||
返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。
|
||||
|
||||
如果要运行用户不需要看到的辅助任务,例如分类或摘要,请在已有工作区中把它创建为[后台 Agent](/docs/sdk/agents#run-a-helper-as-a-background-agent)。
|
||||
|
||||
### 发现主机并指定另一台主机 {#discover-hosts-and-target-another-host}
|
||||
|
||||
使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机
|
||||
|
||||
@@ -130,6 +130,29 @@ use `client.agents.list({ filter: { includeBackground: true } })` to include the
|
||||
Exact-ID handles work normally. See [workspace creation](./workspaces.md#create-a-fresh-workspace)
|
||||
for defaults and caller inheritance.
|
||||
|
||||
## Run a helper as a background agent
|
||||
|
||||
Pass `background: true` to run a helper in a workspace the user may have open. A background agent
|
||||
opens no tab, stays out of its parent's subagent track and the sidebar status, and raises no
|
||||
notifications. Default lists leave it out.
|
||||
|
||||
```ts
|
||||
const helper = await client.workspaces.ref(workspaceId).agents.create({
|
||||
config: { provider: "codex/gpt-5.5" },
|
||||
prompt: "Summarize the last turn in one line.",
|
||||
background: true,
|
||||
});
|
||||
const result = await helper.waitForFinish();
|
||||
console.log(result.lastMessage);
|
||||
await helper.archive();
|
||||
```
|
||||
|
||||
An agent created under a background `parent` is background unless you pass `background: false`.
|
||||
A parent on another host passes this on only as a handle; with a bare address, pass `background`.
|
||||
Placing a background agent in an existing workspace or under a parent requires
|
||||
`features.backgroundAgents`; on an older host the SDK throws "Update the host to use background
|
||||
agents." before sending.
|
||||
|
||||
## Request structured output
|
||||
|
||||
```ts
|
||||
|
||||
@@ -124,6 +124,23 @@ const workspace = await client.workspaces.create({
|
||||
|
||||
后台 Agent 会保留历史记录,并在 daemon 重启后继续存在。默认的发现结果会隐藏它们;使用 `client.agents.list({ filter: { includeBackground: true } })` 可以包含它们。精确 ID 的句柄可以正常使用。默认值和调用方继承规则见[创建工作区](./workspaces.md#create-a-fresh-workspace)。
|
||||
|
||||
## 以后台 Agent 运行辅助任务 {#run-a-helper-as-a-background-agent}
|
||||
|
||||
传入 `background: true`,可以在用户可能正打开的工作区中运行辅助 Agent。后台 Agent 不会打开标签页,不会出现在父 Agent 的子 Agent 列表和侧边栏状态中,也不会触发通知。默认列表不包含它。
|
||||
|
||||
```ts
|
||||
const helper = await client.workspaces.ref(workspaceId).agents.create({
|
||||
config: { provider: "codex/gpt-5.5" },
|
||||
prompt: "Summarize the last turn in one line.",
|
||||
background: true,
|
||||
});
|
||||
const result = await helper.waitForFinish();
|
||||
console.log(result.lastMessage);
|
||||
await helper.archive();
|
||||
```
|
||||
|
||||
在后台 `parent` 下创建的 Agent 默认也是后台 Agent,除非传入 `background: false`。位于其他主机的父 Agent 只有以句柄传入时才会传递该设置;只传地址时,请显式传入 `background`。把后台 Agent 放到已有工作区或某个父 Agent 下需要 `features.backgroundAgents`;在较旧的主机上,SDK 会在发送前抛出 "Update the host to use background agents."。
|
||||
|
||||
## 请求结构化输出 {#request-structured-output}
|
||||
|
||||
```ts
|
||||
|
||||
@@ -61,8 +61,9 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o
|
||||
|
||||
`parent` accepts a host-local agent ID, an `AgentAddress` (`{ serverId, agentId }`), or an agent handle. A handle’s `address` keeps its host identity after disconnect. When creating through `hosts.client(serverId)`, pass the parent handle or complete address; the SDK carries parent metadata to the destination.
|
||||
|
||||
`background` is workspace creation intent. It cannot accompany an existing `workspaceId`.
|
||||
See [background workspaces](./workspaces.md) for caller inheritance and inclusive discovery.
|
||||
`background` creates a [background agent](./agents.md#run-a-helper-as-a-background-agent). A
|
||||
workspace the call creates is background too. With a `parent` and no value, the parent's own
|
||||
`background` applies. See [background workspaces](./workspaces.md) for inclusive discovery.
|
||||
|
||||
`config` accepts:
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ nav: API 参考
|
||||
|
||||
`parent` 接受主机本地的 Agent ID、`AgentAddress`(`{ serverId, agentId }`)或 Agent 句柄。句柄的 `address` 在断开连接后仍保留其主机身份。通过 `hosts.client(serverId)` 创建时,请传入父 Agent 句柄或完整地址;SDK 会把父级元数据带到目标主机。
|
||||
|
||||
`background` 表示创建工作区的意图。它不能与已有的 `workspaceId` 一起使用。调用方继承规则和包含后台的发现方式见[后台工作区](./workspaces.md)。
|
||||
`background` 会创建一个[后台 Agent](./agents.md#run-a-helper-as-a-background-agent)。本次调用创建的工作区也是后台工作区。带 `parent` 且省略该值时,沿用父 Agent 自己的 `background`。包含后台的发现方式见[后台工作区](./workspaces.md)。
|
||||
|
||||
`config` 接受:
|
||||
|
||||
|
||||
@@ -61,10 +61,10 @@ remain durable, with ordinary history, lifecycle hooks, archive, and exact-ID ac
|
||||
sidebar offers **Show background** without dropping replicated state.
|
||||
|
||||
`background` is creation-only. Omission defaults to false; when `callerAgentId` identifies an agent
|
||||
creating a new workspace, omission inherits its workspace's value. Explicit false or true overrides
|
||||
inheritance. Agent creation with `workspaceId` rejects explicit background creation intent; configure
|
||||
visibility when creating the workspace. SDK/RPC implicit agent creation also accepts `background`
|
||||
as workspace creation intent, never as an agent property.
|
||||
creating a new workspace, omission inherits the caller's visibility: true when the caller agent or
|
||||
its workspace is background. Explicit false or true overrides inheritance. Agent creation that
|
||||
creates its workspace applies `background` to both the workspace and the agent. To hide one agent
|
||||
in an existing workspace, see [background agents](./agents.md#run-a-helper-as-a-background-agent).
|
||||
|
||||
Creation and inclusive discovery require `features.backgroundWorkspaces`; the client rejects an
|
||||
unsupported host with an update-host message.
|
||||
@@ -135,8 +135,8 @@ do {
|
||||
|
||||
Background workspaces are left out unless `filter.includeBackground` is true. Use
|
||||
`client.workspaces.list({ filter: { includeBackground: true } })` or
|
||||
`client.agents.list({ filter: { includeBackground: true } })`. Workspace descriptors carry
|
||||
`background`; agents derive visibility from their workspace and have no separate setting.
|
||||
`client.agents.list({ filter: { includeBackground: true } })`. Workspace and agent snapshots carry
|
||||
`background`. An agent is hidden when it or its workspace is background.
|
||||
|
||||
## Refresh and archive a handle
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ const workspace = await client.workspaces.create({
|
||||
|
||||
传入 `background: true` 可以让工作区不出现在默认的发现结果中。它的 Agent 和终端仍然是持久的,有正常的历史记录、生命周期钩子、归档和精确 ID 访问。应用侧边栏提供 **显示后台工作区**,不会丢弃已同步的状态。
|
||||
|
||||
`background` 只能在创建时设置。省略时默认为 false;当 `callerAgentId` 指明是某个 Agent 在创建新工作区时,省略则继承该 Agent 所在工作区的值。显式的 false 或 true 会覆盖继承。带 `workspaceId` 创建 Agent 时会拒绝显式的后台创建意图;请在创建工作区时配置可见性。SDK/RPC 的隐式 Agent 创建也接受 `background`,但它表示工作区的创建意图,而不是 Agent 的属性。
|
||||
`background` 只能在创建时设置。省略时默认为 false;当 `callerAgentId` 指明是某个 Agent 在创建新工作区时,省略则继承调用方的可见性:调用方 Agent 或其工作区为后台时即为 true。显式的 false 或 true 会覆盖继承。同时创建工作区的 Agent 创建请求会把 `background` 同时应用到工作区和 Agent。要在已有工作区中隐藏单个 Agent,见[后台 Agent](./agents.md#run-a-helper-as-a-background-agent)。
|
||||
|
||||
创建和包含后台工作区的发现都需要 `features.backgroundWorkspaces`;对于不支持的主机,客户端会拒绝并提示更新主机。
|
||||
|
||||
@@ -122,7 +122,7 @@ do {
|
||||
} while (cursor);
|
||||
```
|
||||
|
||||
除非 `filter.includeBackground` 为 true,否则后台工作区不会被列出。使用 `client.workspaces.list({ filter: { includeBackground: true } })` 或 `client.agents.list({ filter: { includeBackground: true } })`。工作区描述符带有 `background`;Agent 的可见性来自它所在的工作区,没有单独的设置。
|
||||
除非 `filter.includeBackground` 为 true,否则后台工作区不会被列出。使用 `client.workspaces.list({ filter: { includeBackground: true } })` 或 `client.agents.list({ filter: { includeBackground: true } })`。工作区和 Agent 快照都带有 `background`。Agent 本身或其工作区为后台时,Agent 会被隐藏。
|
||||
|
||||
## 刷新和归档句柄 {#refresh-and-archive-a-handle}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user