Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andClaude Opus 5.5 c5b97d5675 docs(hub): name run startup as the ambiguous boundary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 09:16:19 +00:00
paseo-bot[bot]andClaude Opus 5.5 f0203177e4 docs(hub): scope the retry promise to failures before the run starts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 09:11:41 +00:00
paseo-bot[bot]andClaude Opus 5.5 a10aedf60c test(server): lock that a send failing after dispatch is never resubmitted
Scope the docs/hub.md retry sentence to direct sends: agent-sourced sends
still write their receipt before queueing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:46:41 +00:00
fd35980d8a fix(server): let a message rejected before dispatch be retried
A send_agent_message_request with a messageId wrote its pending receipt
before loading the agent, setting its mode, and starting the run. When one
of those steps failed, nothing reached the provider, yet every retry with
the same messageId reported agent_request_outcome_unknown.

The pending receipt is now written at the dispatch boundary: the send
awaits beforeDispatch after the agent is loaded and its mode is set, just
before the prompt is handed to a run. A failure before that point leaves
no receipt, so the retry submits the message once. Failures after it stay
ambiguous, as before.

Closes #9297

Co-authored-by: tmad4000 <1430007+tmad4000@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:21:00 +00:00
60 changed files with 177 additions and 959 deletions
+4
View File
@@ -61,6 +61,10 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
example because the agent could not be loaded or the requested mode could not be set, leaves no
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
as an unfinished delivery.
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
-15
View File
@@ -624,21 +624,6 @@ one section's order from built-ins, plugin groups, and the section's preference
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute workspace row badge items
The [public reference](../public-docs/plugins/reference.md#workspace-row-badge-items) owns the author API.
The host lives in `packages/app/src/plugins/workspace-row-badge-items/`; `WorkspaceMetaRow` draws a host's
items after its own.
- Plugin items render with no wrapper view. The meta line spaces children with `gap`, so an empty
wrapper would leave a gap on rows the plugin has nothing to say about. That is also why
the row adds no separator around plugin items; a plugin renders its slot.
- A row only renders items from plugins on its own host. The Show menu coalesces items across hosts
by `<pluginId>/<itemId>`, the key in the `sidebarPluginRowBadgeItems` setting, and places them in
their own group below a separator. A missing key is visible.
- Previews (Settings > Host appearance) pass no `workspace` to `WorkspaceMetaRow`, so plugin items
stay off them.
## Contribute file panels
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
@@ -1,148 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
closeSidebarDisplayPreferences,
openMobileAgentSidebar,
openSidebarDisplayPage,
} from "../support/helpers/sidebar";
const PLUGIN_ID = "row-badges-e2e";
const TITLE = "Badge host workspace";
const PLUGIN_SOURCE = `import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
function Review() {
return <WorkspaceRowBadge icon="Eye" label="In review" tone="warning" />;
}
function Docs() {
return <WorkspaceRowBadge icon="BookOpen" label="Docs" />;
}
function IconOnly() {
return <WorkspaceRowBadge icon="Star" />;
}
function EmptyLabel() {
return <WorkspaceRowBadge icon="Circle" label="" />;
}
function Quiet() {
return null;
}
function Broken() {
throw new Error("badge failed");
}
export default function contribute(client) {
client.addWorkspaceRowBadgeItem({ id: "review", title: "Review state", icon: "Eye", Component: Review });
client.addWorkspaceRowBadgeItem({ id: "docs", title: "Docs state", icon: "BookOpen", Component: Docs });
client.addWorkspaceRowBadgeItem({ id: "icon", title: "Icon state", icon: "Star", Component: IconOnly });
client.addWorkspaceRowBadgeItem({ id: "empty", title: "Empty label state", icon: "Circle", Component: EmptyLabel });
client.addWorkspaceRowBadgeItem({ id: "quiet", title: "Quiet state", icon: "Moon", Component: Quiet });
client.addWorkspaceRowBadgeItem({ id: "broken", title: "Broken state", icon: "Bug", Component: Broken });
return () => {};
}
`;
// The meta line the badges draw on. Each item sits in one wrapper of its own, so the line is two up.
const LABELS = { review: "In review", docs: "Docs" } as const;
function badgeLine(page: Page, itemId: keyof typeof LABELS) {
return badge(page, itemId).locator("xpath=../..");
}
function badge(page: Page, itemId: keyof typeof LABELS) {
return page.getByLabel(LABELS[itemId], { exact: true }).locator("visible=true");
}
async function toggleShowItem(page: Page, label: string, touch: boolean) {
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: label, exact: true }).click();
if (touch) {
await page
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
.click({ position: { x: 5, y: 5 } });
} else {
await closeSidebarDisplayPreferences(page);
}
}
test("a plugin badge shows on workspace rows, hides quietly, and follows its Show toggle", async ({
page,
}, info) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({ repoPrefix: "row-badges-", title: TITLE });
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-row-badges-"));
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoAppShell(page);
await test.step("the badge draws on the row; a null item and a throwing item draw nothing", async () => {
await expect(badge(page, "review")).toBeVisible();
await expect(badge(page, "review")).toContainText("In review");
// The null and throwing items after them draw nothing, and the line adds nothing of its own.
await expect(badgeLine(page, "review")).toHaveText("In reviewDocs");
// An icon-only badge is named by its item's title.
await expect(
page.getByLabel("Icon state", { exact: true }).locator("visible=true"),
).toBeVisible();
await expect(
page.getByLabel("Empty label state", { exact: true }).locator("visible=true"),
).toBeVisible();
await expect(page.getByText(TITLE).first()).toBeVisible();
await page.screenshot({ path: info.outputPath("wide.png") });
});
await test.step("every item has a Show toggle, and switching one off removes only that badge", async () => {
await openSidebarDisplayPage(page, "sidebar-display-show");
for (const label of ["Review state", "Quiet state", "Broken state", "Docs state"]) {
await expect(page.getByRole("menuitem", { name: label, exact: true })).toBeVisible();
}
await expect(page.getByTestId("sidebar-plugin-row-badge-items-separator")).toBeVisible();
await page.screenshot({ path: info.outputPath("show-menu.png") });
await page.getByRole("menuitem", { name: "Review state", exact: true }).click();
await closeSidebarDisplayPreferences(page);
await expect(badge(page, "review")).toHaveCount(0);
// The first badge is gone, so the next one leads the line without a "·".
await expect(badgeLine(page, "docs")).toHaveText("Docs");
await toggleShowItem(page, "Review state", false);
await expect(badge(page, "review")).toBeVisible();
});
await test.step("the badge draws on a compact layout", async () => {
await page.setViewportSize({ width: 390, height: 844 });
await openMobileAgentSidebar(page);
await expect(badge(page, "review")).toBeVisible();
await page.screenshot({ path: info.outputPath("compact.png") });
await toggleShowItem(page, "Review state", true);
await expect(badge(page, "review")).toHaveCount(0);
await toggleShowItem(page, "Review state", true);
await expect(badge(page, "review")).toBeVisible();
});
} finally {
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
});
@@ -58,8 +58,6 @@ import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
import { useWorkspaceLabelProjection } from "@/workspace-labels";
import { WorkspaceLabelDot } from "@/workspace-labels/swatch";
import { WorkspaceLabelManagerModal } from "@/workspace-labels/manager-modal";
import { PluginIconView } from "@/plugins/icons";
import type { PluginWorkspaceRowBadgeItemGroup } from "@/plugins/workspace-row-badge-items";
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -67,7 +65,6 @@ const ThemedSettings2 = withUnistyles(Settings2);
/** CI's mark: the subject of the checks row, and the shape the icon-only option leaves behind. */
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircle = withUnistyles(Circle);
const ThemedPluginIconView = withUnistyles(PluginIconView);
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
const OPTION_ICON_SIZE = 14;
@@ -610,9 +607,9 @@ function OptionList<Value extends string>({
}
/**
* Three groups: Paseo's own row items, plugin badge items, then the one thing the slot to the
* right of the title holds. Picking the trailing choice already showing empties the slot and
* gives the width back to the title.
* Two groups, split by the separator. Above it, what a row may say about a workspace — each one
* independent. Below it, the one thing the slot to the right of the title holds, so picking the
* one already showing empties the slot and gives the width back to the title.
*
* CI is the one item above the separator with three answers rather than two, so it opens a page
* instead of ticking, and it goes last: a row that navigates does not belong in the middle of a
@@ -635,12 +632,6 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
/>
))}
<ChecksSubTrigger />
{preferences.pluginRowBadgeItems.length > 0 ? (
<MenuSeparator testID="sidebar-plugin-row-badge-items-separator" />
) : null}
{preferences.pluginRowBadgeItems.map((item) => (
<PluginRowBadgeItemOption key={item.key} item={item} preferences={preferences} />
))}
<MenuSeparator />
{TRAILING_CHOICES.map((choice) => (
<OptionItem
@@ -658,37 +649,6 @@ function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
);
}
/** A plugin's workspace row badge item, toggled like the row's own. */
function PluginRowBadgeItemOption({
item,
preferences,
}: {
item: PluginWorkspaceRowBadgeItemGroup;
preferences: Preferences;
}): ReactElement {
const handleSelect = useCallback(
() => preferences.togglePluginRowBadgeItem(item.key),
[item.key, preferences],
);
const leading = useMemo(
() => (
<ThemedPluginIconView icon={item.icon} size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />
),
[item.icon],
);
return (
<MenuItem
selected={preferences.isPluginRowBadgeItemVisible(item.key)}
leading={leading}
closeOnSelect={false}
onSelect={handleSelect}
testID={`sidebar-row-badge-item-plugin-${item.pluginId}-${item.itemId}`}
>
{item.title}
</MenuItem>
);
}
/**
* No value on the row, only the chevron. The three answers are all long enough that the value
* fills the row and stops reading as an answer sitting at the right edge — it turns into a second
@@ -10,12 +10,6 @@ import {
type SidebarGroupMode,
type SidebarLabelFilter,
} from "@/stores/sidebar-view-store";
import { useInstalledPlugins } from "@/plugins/registry";
import {
groupPluginWorkspaceRowBadgeItems,
isWorkspaceRowBadgeItemVisible,
type PluginWorkspaceRowBadgeItemGroup,
} from "@/plugins/workspace-row-badge-items";
import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay } from "./checks-display";
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
@@ -33,10 +27,6 @@ export interface SidebarDisplayPreferences {
setTitleSource: (source: WorkspaceTitleSource) => void;
rowItems: SidebarRowItems;
toggleRowItem: (item: SidebarRowItem) => void;
/** Workspace row badge items contributed by installed plugins, one per plugin item across hosts. */
pluginRowBadgeItems: readonly PluginWorkspaceRowBadgeItemGroup[];
isPluginRowBadgeItemVisible: (key: string) => boolean;
togglePluginRowBadgeItem: (key: string) => void;
checksDisplay: SidebarChecksDisplay;
setChecksDisplay: (display: SidebarChecksDisplay) => void;
trailing: SidebarWorkspaceTrailing;
@@ -85,30 +75,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
sidebarWorkspaceTrailing,
sidebarRowItems,
sidebarChecksDisplay,
sidebarPluginRowBadgeItems,
},
updateSettings,
} = useAppSettings();
const installedPlugins = useInstalledPlugins();
const pluginRowBadgeItems = useMemo(
() => groupPluginWorkspaceRowBadgeItems(installedPlugins),
[installedPlugins],
);
const isPluginRowBadgeItemVisible = useCallback(
(key: string) => isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
[sidebarPluginRowBadgeItems],
);
const togglePluginRowBadgeItem = useCallback(
(key: string) => {
void updateSettings({
sidebarPluginRowBadgeItems: {
...sidebarPluginRowBadgeItems,
[key]: !isWorkspaceRowBadgeItemVisible(sidebarPluginRowBadgeItems, key),
},
});
},
[updateSettings, sidebarPluginRowBadgeItems],
);
const setTitleSource = useCallback(
(source: WorkspaceTitleSource) => {
@@ -154,9 +123,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
setTitleSource,
rowItems: sidebarRowItems,
toggleRowItem,
pluginRowBadgeItems,
isPluginRowBadgeItemVisible,
togglePluginRowBadgeItem,
checksDisplay: sidebarChecksDisplay,
setChecksDisplay,
trailing: sidebarWorkspaceTrailing,
@@ -182,9 +148,6 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
setTitleSource,
sidebarRowItems,
toggleRowItem,
pluginRowBadgeItems,
isPluginRowBadgeItemVisible,
togglePluginRowBadgeItem,
sidebarChecksDisplay,
setChecksDisplay,
sidebarWorkspaceTrailing,
@@ -186,7 +186,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
prHint={workspace.prHint}
serviceSummary={serviceSummary}
labels={labels}
workspace={workspace}
/>
</View>
</View>
@@ -16,10 +16,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarMetaPreferences } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import {
PluginWorkspaceRowBadgeItems,
usePluginWorkspaceRowBadgeItems,
} from "@/plugins/workspace-row-badge-items";
import { CheckIndicator } from "./check-indicator";
import type { CheckSummary, CheckSummaryState } from "./check-summary";
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
@@ -63,9 +59,6 @@ const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
* state has its own colors — a label is a name a person chose, so it carries its own identity
* color on a tint instead (see `WorkspaceLabelChip`). It sits last so the reported facts are
* read first, and it stays the same height as the rest of the line.
*
* Plugin items follow everything the row knows itself. A plugin renders its slot as it likes:
* the row adds no separator, since only the plugin knows whether it has anything to say.
*/
export function WorkspaceMetaRow({
currentBranch,
@@ -74,7 +67,6 @@ export function WorkspaceMetaRow({
prHint,
serviceSummary,
labels = EMPTY_LABELS,
workspace = null,
}: {
currentBranch: string | null;
projectName: string | null;
@@ -82,8 +74,6 @@ export function WorkspaceMetaRow({
prHint: PrHint | null;
serviceSummary: WorkspaceServiceSummary | null;
labels?: readonly WorkspaceLabelDefinition[];
/** The real workspace behind the row; previews leave it out and get no plugin items. */
workspace?: { serverId: string; workspaceId: string } | null;
}) {
const { rowItems, checksDisplay } = useSidebarMetaPreferences();
const items = selectMetaRowItems({
@@ -96,9 +86,8 @@ export function WorkspaceMetaRow({
visible: rowItems,
checksDisplay,
});
const pluginItems = usePluginWorkspaceRowBadgeItems(workspace?.serverId ?? null);
if (items.length === 0 && (!workspace || pluginItems.length === 0)) return null;
if (items.length === 0) return null;
return (
<View style={styles.row}>
@@ -108,9 +97,6 @@ export function WorkspaceMetaRow({
<MetaItemNode item={item} hostBadge={hostBadge} leading={index === 0} />
</Fragment>
))}
{workspace ? (
<PluginWorkspaceRowBadgeItems targets={pluginItems} workspaceId={workspace.workspaceId} />
) : null}
</View>
);
}
@@ -96,8 +96,6 @@ export interface AppSettings {
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
sidebarRowItems: SidebarRowItems;
sidebarChecksDisplay: SidebarChecksDisplay;
/** Plugin workspace row badge items by `<pluginId>/<itemId>`; a missing key shows. */
sidebarPluginRowBadgeItems: Record<string, boolean>;
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
sidebarNavItems: SidebarNavPreference[];
/** Sidebar footer items in display order; empty means the default order, all visible. */
@@ -158,7 +156,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
sidebarWorkspaceTrailing: "timestamp",
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
sidebarPluginRowBadgeItems: {},
sidebarNavItems: [],
sidebarFooterItems: [],
usage: DEFAULT_USAGE_PREFERENCES,
@@ -256,7 +253,6 @@ const StoredAppSettingsSchema = z
.enum(["iconAndText", "icon", "none"])
.optional()
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
sidebarPluginRowBadgeItems: z.record(z.string(), z.boolean()).catch({}),
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
usage: UsagePreferencesSchema,
@@ -26,7 +26,6 @@ function installation(): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -105,7 +105,6 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -37,7 +37,6 @@ function installed(): InstalledPlugin {
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspaceRowBadgeItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -47,7 +47,6 @@ function plugin(): InstalledPlugin {
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspaceRowBadgeItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
-41
View File
@@ -101,7 +101,6 @@ describe("evaluatePluginClientBundle", () => {
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }),
plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component }),
plugin.addSidebarFooterItem({ id: "status", title: "Status", Component }),
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Status", icon: "Blocks", Component }),
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
@@ -133,7 +132,6 @@ describe("evaluatePluginClientBundle", () => {
plugin.settingsScreens,
plugin.sidebarItems.header,
plugin.sidebarItems.footer,
plugin.workspaceRowBadgeItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
@@ -154,7 +152,6 @@ describe("evaluatePluginClientBundle", () => {
plugin.settingsScreens,
plugin.sidebarItems.header,
plugin.sidebarItems.footer,
plugin.workspaceRowBadgeItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
@@ -285,44 +282,6 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("Sidebar item main has no title");
});
it("collects workspace row badge items and rejects duplicate and malformed ones", () => {
const plugin = evaluatePluginClientBundle(
"example",
bundle(`
function Item() { return null; }
plugin.addWorkspaceRowBadgeItem({ id: "status", title: " Status ", icon: "Blocks", Component: Item });
`),
);
expect(
plugin.workspaceRowBadgeItems.map(({ id, title, icon }) => ({ id, title, icon })),
).toEqual([{ id: "status", title: "Status", icon: "Blocks" }]);
const row = (fields: string) =>
bundle(`function Item() { return null; } plugin.addWorkspaceRowBadgeItem({ ${fields} });`);
expect(() =>
evaluatePluginClientBundle(
"example",
bundle(`
function Item() { return null; }
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "One", icon: "Blocks", Component: Item });
plugin.addWorkspaceRowBadgeItem({ id: "status", title: "Two", icon: "Blocks", Component: Item });
`),
),
).toThrow("Duplicate workspace row badge item: status");
expect(() =>
evaluatePluginClientBundle(
"example",
row(`id: "status", title: " ", icon: "Blocks", Component: Item`),
),
).toThrow("Workspace row badge item status has no title");
expect(() =>
evaluatePluginClientBundle(
"example",
row(`id: "status", title: "Status", icon: "Blocks", Component: "Item"`),
),
).toThrow("Workspace row badge item status is not a component");
});
it("expands the addSurface and addSidebarItem aliases to a screen and a legacy header item", () => {
const plugin = evaluatePluginClientBundle(
"example",
-21
View File
@@ -37,7 +37,6 @@ import type {
PluginScreenContribution,
PluginSidebarItemContribution,
PluginWorkspacePanelContribution,
PluginWorkspaceRowBadgeItemContribution,
} from "./types";
import type { ComponentType } from "react";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
@@ -134,7 +133,6 @@ export function runPluginClientBundle(
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -153,7 +151,6 @@ export function runPluginClientBundle(
header: new Set(),
footer: new Set(),
};
const workspaceRowItemIds = new Set<string>();
const filePanelIds = new Set<string>();
const composerItemIds = new Set<string>();
const newWorkspaceItemIds = new Set<string>();
@@ -326,23 +323,6 @@ export function runPluginClientBundle(
addSidebarFooterItem(contribution: PluginSidebarItemContribution) {
return addSidebarItem("footer", contribution);
},
addWorkspaceRowBadgeItem(contribution: PluginWorkspaceRowBadgeItemContribution) {
const itemId = requireId(contribution.id, "workspace row badge item id");
if (workspaceRowItemIds.has(itemId))
throw new Error(`Duplicate workspace row badge item: ${itemId}`);
const title = normalizePluginText(contribution.title);
if (!title) throw new Error(`Workspace row badge item ${itemId} has no title`);
const icon = parsePluginIcon(`Workspace row badge item ${itemId}`, contribution.icon);
if (!isComponentType(contribution.Component)) {
throw new Error(`Workspace row badge item ${itemId} is not a component`);
}
workspaceRowItemIds.add(itemId);
return register(
collector.workspaceRowBadgeItems,
{ id: itemId, title, icon, Component: contribution.Component },
() => workspaceRowItemIds.delete(itemId),
);
},
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
// Titled by its id; a legacy item pointing at it lends the header its own title instead.
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
@@ -654,7 +634,6 @@ export function runPluginClientBundle(
sidebarViews: collector.sidebarViews,
sidebarItems: collector.sidebarItems,
legacySidebarItems: collector.legacySidebarItems,
workspaceRowBadgeItems: collector.workspaceRowBadgeItems,
filePanels: collector.filePanels,
composerItems: collector.composerItems,
newWorkspaceItems: collector.newWorkspaceItems,
@@ -46,7 +46,6 @@ function installation(invoked: string[]): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
+1 -1
View File
@@ -14,7 +14,7 @@ import type { InstalledPlugin } from "./types";
/** `withUnistyles` mapping that hands a host the plugin theme. */
export const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function renderNothing() {
function renderNothing() {
return null;
}
@@ -11,7 +11,6 @@ export {
export { ExternalLink } from "@/components/ui/external-link";
export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
export { WorkspaceRowBadge } from "@/plugins/workspace-row-badge-items/kit";
export { DocumentFrame } from "@/file-panels/document-frame";
-1
View File
@@ -125,7 +125,6 @@ export class PluginRegistry {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -37,7 +37,6 @@ function installed(
footer: [{ id: "status", title: "Status", Component: () => null }],
},
legacySidebarItems,
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -51,7 +51,6 @@ function installation(
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
workspaceRowBadgeItems: [],
legacySidebarItems: sidebarItems.map((item) => ({
...item,
title: item.id,
-1
View File
@@ -64,7 +64,6 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -27,7 +27,6 @@ function plugin(input: {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -34,7 +34,6 @@ vi.mock("../client-runtime", () => ({
}));
vi.mock("../icons", () => ({
Icon: () => null,
PluginIconView: () => null,
pluginIconComponent: () => () => null,
}));
-5
View File
@@ -17,7 +17,6 @@ import type {
PluginSidebarContribution as SDKPluginSidebarContribution,
PluginSidebarViewContribution,
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemContribution as SDKPluginWorkspaceRowBadgeItemContribution,
PluginScreenContribution as SDKPluginScreenContribution,
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
PluginTimelineRendererContribution,
@@ -34,8 +33,6 @@ type PluginClientSlashCommandContribution = SDKPluginClientSlashCommandContribut
type PluginComposerPillContribution = SDKPluginComposerPillContribution<PluginText>;
type PluginSidebarContribution = SDKPluginSidebarContribution<PluginText>;
type PluginSidebarItemContribution = SDKPluginSidebarItemContribution<PluginText>;
type PluginWorkspaceRowBadgeItemContribution =
SDKPluginWorkspaceRowBadgeItemContribution<PluginText>;
type PluginScreenContribution = SDKPluginScreenContribution<PluginText>;
type PluginSettingsScreenContribution = SDKPluginSettingsScreenContribution<PluginText>;
type WorkspacePanelInput = SDKPluginWorkspacePanelContribution<PluginText>;
@@ -59,7 +56,6 @@ export interface EvaluatedPlugin {
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
legacySidebarItems: PluginSidebarContribution[];
workspaceRowBadgeItems: PluginWorkspaceRowBadgeItemContribution[];
filePanels: PluginFilePanelContribution[];
composerItems: PluginComposerItemContribution[];
newWorkspaceItems: PluginNewWorkspaceItemContribution[];
@@ -99,7 +95,6 @@ export type {
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemContribution,
PluginScreenContribution,
PluginSettingsScreenContribution,
PluginThemeContribution,
@@ -24,7 +24,6 @@ function installed(): InstalledPlugin {
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
workspaceRowBadgeItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
@@ -1,96 +0,0 @@
import type { PluginTheme } from "@getpaseo/plugin";
import type { PluginWorkspaceRowBadgeItemProps } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { withUnistyles } from "react-native-unistyles";
import { useAppSettings } from "@/hooks/use-settings";
import { useInstalledPlugins } from "../registry";
import { pluginThemeMapping, renderNothing, usePluginItemHost } from "../item-host";
import { PluginEnvironmentProvider } from "../popover";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import type { InstalledPlugin, PluginWorkspaceRowBadgeItemContribution } from "../types";
import { isWorkspaceRowBadgeItemVisible, workspaceRowBadgeItemKey } from "./model";
import { WorkspaceRowBadgeTitleContext } from "./kit";
export { WorkspaceRowBadge } from "./kit";
export {
groupPluginWorkspaceRowBadgeItems,
isWorkspaceRowBadgeItemVisible,
type PluginWorkspaceRowBadgeItemGroup,
} from "./model";
interface RowBadgeItemTarget {
plugin: InstalledPlugin;
item: PluginWorkspaceRowBadgeItemContribution;
}
/** The visible plugin row items installed on this workspace's host. */
export function usePluginWorkspaceRowBadgeItems(serverId: string | null): RowBadgeItemTarget[] {
const installed = useInstalledPlugins();
const {
settings: { sidebarPluginRowBadgeItems },
} = useAppSettings();
return useMemo(
() =>
installed
.filter((plugin) => plugin.serverId === serverId)
.flatMap((plugin) => plugin.workspaceRowBadgeItems.map((item) => ({ plugin, item })))
.filter(({ plugin, item }) =>
isWorkspaceRowBadgeItemVisible(
sidebarPluginRowBadgeItems,
workspaceRowBadgeItemKey(plugin.id, item.id),
),
),
[installed, serverId, sidebarPluginRowBadgeItems],
);
}
/**
* Plugin items on a workspace's meta line, after the row's own. No wrapper views: an item that
* renders nothing must leave no gap behind it on the line.
*/
export function PluginWorkspaceRowBadgeItems({
targets,
workspaceId,
}: {
targets: readonly RowBadgeItemTarget[];
workspaceId: string;
}) {
return targets.map((target) => (
<ThemedRowBadgeItemHost
key={workspaceRowBadgeItemKey(target.plugin.id, target.item.id)}
target={target}
workspaceId={workspaceId}
uniProps={pluginThemeMapping}
/>
));
}
function RowBadgeItemHost({
target: { plugin, item },
workspaceId,
theme,
}: {
target: RowBadgeItemTarget;
workspaceId: string;
theme: PluginTheme;
}) {
const { environment, props: hostProps } = usePluginItemHost(plugin, theme);
const props = useMemo<PluginWorkspaceRowBadgeItemProps>(
() => ({ ...hostProps, workspaceId }),
[hostProps, workspaceId],
);
const title = plugin.i18n.resolve(item.title, plugin.locale);
const Item = item.Component;
return (
<SurfaceErrorBoundary installation={plugin} Surface={Item} renderError={renderNothing}>
<PluginEnvironmentProvider environment={environment}>
<WorkspaceRowBadgeTitleContext.Provider value={title}>
<Item {...props} />
</WorkspaceRowBadgeTitleContext.Provider>
</PluginEnvironmentProvider>
</SurfaceErrorBoundary>
);
}
const ThemedRowBadgeItemHost = withUnistyles(RowBadgeItemHost);
@@ -1,93 +0,0 @@
import type { WorkspaceRowBadgeProps, WorkspaceRowBadgeTone } from "@getpaseo/plugin/client/ui";
import { createContext, useContext, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge";
import type { Theme } from "@/styles/theme";
import { parsePluginIcon, PluginIconView } from "../icons";
/** The item's title, the accessible name of a badge that has neither a label nor its own. */
export const WorkspaceRowBadgeTitleContext = createContext<string | undefined>(undefined);
const ThemedPluginIconView = withUnistyles(PluginIconView);
function toneColor(theme: Theme, tone: WorkspaceRowBadgeTone): string {
if (tone === "success") return theme.colors.statusSuccess;
if (tone === "warning") return theme.colors.statusWarning;
if (tone === "danger") return theme.colors.statusDanger;
if (tone === "info") return theme.colors.statusDotRunning;
return theme.colors.foregroundMuted;
}
const TONE_MAPPINGS: Record<WorkspaceRowBadgeTone, (theme: Theme) => { color: string }> = {
muted: (theme) => ({ color: toneColor(theme, "muted") }),
success: (theme) => ({ color: toneColor(theme, "success") }),
warning: (theme) => ({ color: toneColor(theme, "warning") }),
danger: (theme) => ({ color: toneColor(theme, "danger") }),
info: (theme) => ({ color: toneColor(theme, "info") }),
};
/**
* A plugin's badge on the line under a workspace title, drawn like the row's own peers: one glyph
* at the line's size and a short word, in the color of the state it reports. The plugin owns the
* slot, so the badge draws no separator.
*/
export function WorkspaceRowBadge({
icon,
label,
tone = "muted",
accessibilityLabel,
}: WorkspaceRowBadgeProps) {
const itemTitle = useContext(WorkspaceRowBadgeTitleContext);
// Parsed at render, not registration, so an invalid icon throws to the item's boundary.
const parsedIcon = useMemo(
() => (icon === undefined ? null : parsePluginIcon("Workspace row badge", icon)),
[icon],
);
if (!parsedIcon && !label) return null;
return (
<View style={styles.item} accessibilityLabel={accessibilityLabel ?? (label || itemTitle)}>
{parsedIcon ? (
<ThemedPluginIconView
icon={parsedIcon}
size={HOST_BADGE_ICON_SIZE}
uniProps={TONE_MAPPINGS[tone]}
/>
) : null}
{label ? (
<Text style={[styles.text, textToneStyle(tone)]} numberOfLines={1}>
{label}
</Text>
) : null}
</View>
);
}
// Read inside render, never into a module-scope table — see docs/unistyles.md.
function textToneStyle(tone: WorkspaceRowBadgeTone) {
if (tone === "success") return styles.success;
if (tone === "warning") return styles.warning;
if (tone === "danger") return styles.danger;
if (tone === "info") return styles.info;
return styles.muted;
}
const styles = StyleSheet.create((theme) => ({
item: {
flexDirection: "row",
alignItems: "center",
gap: 3,
minWidth: 0,
flexShrink: 1,
},
text: {
fontSize: theme.fontSize.sm,
lineHeight: 16,
flexShrink: 1,
},
muted: { color: toneColor(theme, "muted") },
success: { color: toneColor(theme, "success") },
warning: { color: toneColor(theme, "warning") },
danger: { color: toneColor(theme, "danger") },
info: { color: toneColor(theme, "info") },
}));
@@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import type { InstalledPlugin } from "../types";
import { groupPluginWorkspaceRowBadgeItems, isWorkspaceRowBadgeItemVisible } from "./model";
function Item() {
return null;
}
function installed(id: string, serverId: string, itemIds: string[]): InstalledPlugin {
return {
id,
serverId,
locale: "en",
i18n: { resolve: (text: string) => text },
workspaceRowBadgeItems: itemIds.map((itemId) => ({
id: itemId,
title: `${id} ${itemId}`,
icon: "Gauge",
Component: Item,
})),
} as unknown as InstalledPlugin;
}
describe("groupPluginWorkspaceRowBadgeItems", () => {
it("lists each plugin item once across hosts, in install order", () => {
const groups = groupPluginWorkspaceRowBadgeItems([
installed("status", "host-a", ["state"]),
installed("asks", "host-a", ["open"]),
installed("status", "host-b", ["state"]),
]);
expect(groups.map(({ key, title }) => ({ key, title }))).toEqual([
{ key: "status/state", title: "status state" },
{ key: "asks/open", title: "asks open" },
]);
});
});
describe("isWorkspaceRowBadgeItemVisible", () => {
it("shows items until they are switched off", () => {
expect(isWorkspaceRowBadgeItemVisible({}, "status/state")).toBe(true);
expect(isWorkspaceRowBadgeItemVisible({ "status/state": true }, "status/state")).toBe(true);
expect(isWorkspaceRowBadgeItemVisible({ "status/state": false }, "status/state")).toBe(false);
});
});
@@ -1,45 +0,0 @@
import type { PluginIcon } from "@getpaseo/plugin/client";
import type { InstalledPlugin } from "../types";
/** One workspace row badge item, coalesced across every host that contributes it. */
export interface PluginWorkspaceRowBadgeItemGroup {
/** `<pluginId>/<itemId>`: the key its Show toggle is stored under. */
key: string;
pluginId: string;
itemId: string;
title: string;
icon: PluginIcon;
}
export function workspaceRowBadgeItemKey(pluginId: string, itemId: string): string {
return `${pluginId}/${itemId}`;
}
/** An item missing from the stored record has never been toggled, so it shows. */
export function isWorkspaceRowBadgeItemVisible(
visibility: Readonly<Record<string, boolean>>,
key: string,
): boolean {
return visibility[key] !== false;
}
/** Distinct items in install order, for the Show menu. Each host's copy draws on its own rows. */
export function groupPluginWorkspaceRowBadgeItems(
plugins: readonly InstalledPlugin[],
): PluginWorkspaceRowBadgeItemGroup[] {
const groups = new Map<string, PluginWorkspaceRowBadgeItemGroup>();
for (const plugin of plugins) {
for (const item of plugin.workspaceRowBadgeItems) {
const key = workspaceRowBadgeItemKey(plugin.id, item.id);
if (groups.has(key)) continue;
groups.set(key, {
key,
pluginId: plugin.id,
itemId: item.id,
title: plugin.i18n.resolve(item.title, plugin.locale),
icon: item.icon,
});
}
}
return [...groups.values()];
}
-5
View File
@@ -306,11 +306,6 @@ export interface PaseoAgentTimelineRefetchOptions {
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
/**
* Extends a `tail` or `before` page back to the start of the oldest turn it touches, so
* `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn.
*/
wholeTurns?: boolean;
requestId?: string;
}
+1 -11
View File
@@ -393,7 +393,6 @@ describe("lifecycle hook SDK types", () => {
expect(
typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
@@ -427,17 +426,8 @@ describe("lifecycle hook SDK types", () => {
const { metadata, ...withoutMetadata } = request;
return withoutMetadata;
});
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
const { agent, outcome } = event;
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
const kind: "completed" | "failed" | "canceled" = outcome.kind;
// @ts-expect-error Turn payloads carry no history; read it from the agent's timeline.
event.timeline;
const turn = await paseo.agents
.ref(agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const items: readonly AgentTimelineItem[] = turn.entries
.filter((entry) => entry.turnId === event.turnId)
.map(({ item }) => item);
// @ts-expect-error Notification payloads have no creation request.
outcome.request;
});
-19
View File
@@ -121,22 +121,6 @@ export interface PluginSidebarViewContribution<Text extends PluginText = string>
Component: ComponentType<PluginSidebarViewProps>;
}
export interface PluginWorkspaceRowBadgeItemProps extends PluginHostProps {
workspaceId: string;
}
/**
* Part of the line under a workspace's title in the sidebar, on every workspace of the plugin's host.
* Render `WorkspaceRowBadge`, or nothing when there is nothing to say about this workspace.
*/
export interface PluginWorkspaceRowBadgeItemContribution<Text extends PluginText = string> {
id: string;
/** The item's toggle in the sidebar's Show menu. */
title: Text;
icon: PluginIcon;
Component: ComponentType<PluginWorkspaceRowBadgeItemProps>;
}
/**
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
* and color; buttons widen the props with their host and target context.
@@ -230,9 +214,6 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
addWorkspaceRowBadgeItem(
contribution: PluginWorkspaceRowBadgeItemContribution<PluginText>,
): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution<PluginText>): PluginCleanup;
addCommandCenterItem(
contribution: PluginCommandCenterItemContribution<PluginText>,
-2
View File
@@ -18,8 +18,6 @@ export type {
PluginSidebarViewProps,
PluginSidebarViewContribution,
PluginSidebarItemContribution,
PluginWorkspaceRowBadgeItemProps,
PluginWorkspaceRowBadgeItemContribution,
PluginIcon,
PluginPanelLocation,
PluginOpenPanelOptions,
-13
View File
@@ -173,19 +173,6 @@ export declare const SidebarRow: ComponentType<SidebarRowProps>;
/** A line between groups of rows. Render it from a sidebar item's `Component`. */
export declare const SidebarSeparator: ComponentType;
export type WorkspaceRowBadgeTone = "muted" | "success" | "warning" | "danger" | "info";
export interface WorkspaceRowBadgeProps {
/** A Lucide icon name, inline SVG, or a component. */
icon?: SidebarIcon;
label?: string;
/** Default "muted". */
tone?: WorkspaceRowBadgeTone;
/** Defaults to `label`, then the item's `title`. */
accessibilityLabel?: string;
}
/** One item on a sidebar workspace row's meta line. Render it from a workspace row badge item's `Component`. */
export declare const WorkspaceRowBadge: ComponentType<WorkspaceRowBadgeProps>;
/** Paseo's loading state. Fills the panel; the panel decides when to show it. */
export declare const Loading: ComponentType;
+6 -6
View File
@@ -2,6 +2,7 @@ import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
JsonValue,
} from "@getpaseo/protocol/agent-types";
@@ -103,13 +104,12 @@ export interface PluginHookEvents {
"agent.turn.after": {
agent: PluginHookAgent;
turnId: string | null;
/**
* The payload carries no history. Read the turn with
* `paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true })`
* and keep the entries whose `turnId` equals this `turnId`. The result is empty when a newer turn
* started before the read, or when `turnId` is `null`.
*/
outcome: PluginTurnOutcome;
/**
* @deprecated Always empty. Fetch history on demand with
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
*/
timeline: readonly AgentTimelineItem[];
};
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission.after": {
@@ -13334,6 +13334,7 @@ test.each([
},
turnId: "hook-turn",
outcome,
timeline: [],
},
},
]);
@@ -210,6 +210,8 @@ export async function unarchiveAgentState(
}
export interface SendPromptToAgentParams {
/** Awaited after the agent is loaded and its mode is set, before the prompt is handed to a run. */
beforeDispatch?: () => Promise<void>;
onDispatch?: StartAgentRunOptions["onDispatch"];
agentManager: AgentManager;
agentStorage: AgentStorage;
@@ -357,6 +359,7 @@ export async function sendPreparedPromptToAgent(
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
}
await params.beforeDispatch?.();
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
replaceRunning: true,
onDispatch: params.onDispatch,
@@ -182,6 +182,46 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts
).toMatchObject({ payload: { accepted: false } });
});
test("Hub retries a message whose send failed before reaching the provider", async () => {
const hub = await launchRelationship();
const agentId = await hub.createUnrelatedLocalAgent();
const message = {
type: "send_agent_message_request",
agentId,
messageId: "mode-change-failed",
text: "retry me",
sessionMode: "full-access",
};
hub.failNextProviderModeChange();
expect(await hub.requestOrdinary({ ...message, requestId: "first-attempt" })).toMatchObject({
payload: { accepted: false, error: expect.stringContaining("mode change failure") },
});
expect(hub.providerPromptTexts().filter((text) => text === "retry me")).toHaveLength(0);
expect(await hub.requestOrdinary({ ...message, requestId: "retry" })).toMatchObject({
payload: { accepted: true, error: null },
});
expect(hub.providerPromptTexts().filter((text) => text === "retry me")).toHaveLength(1);
});
test("Hub never resubmits a message whose send failed after reaching the provider", async () => {
const hub = await launchRelationship();
const agentId = await hub.createUnrelatedLocalAgent();
const message = {
type: "send_agent_message_request",
agentId,
messageId: "turn-start-failed",
text: "do not repeat me",
};
hub.failProviderPromptStart("do not repeat me");
await hub.requestOrdinary({ ...message, requestId: "first-attempt" });
expect(await hub.requestOrdinary({ ...message, requestId: "retry" })).toMatchObject({
payload: { accepted: false, error: "agent_request_outcome_unknown" },
});
expect(hub.providerPromptTexts().filter((text) => text === "do not repeat me")).toHaveLength(0);
});
test("ordinary Hub requests survive daemon restart and restore an archived workspace", async () => {
const hub = await launchRelationship();
const create = {
@@ -458,6 +458,7 @@ export class HubRelationshipHarness {
private readonly claimedCliSockets = new Set<WebSocket>();
private readonly promptsToFail = new Set<string>();
private failNextSessionClose = false;
private failNextModeChange = false;
private observedEnrollments = 0;
private observedSockets = 0;
private readonly codex: ControlledAgentClient;
@@ -474,6 +475,11 @@ export class HubRelationshipHarness {
this.failNextSessionClose = false;
throw new Error("Requested provider session close failure");
},
beforeSetMode: async () => {
if (!this.failNextModeChange) return;
this.failNextModeChange = false;
throw new Error("Requested provider mode change failure");
},
onStartTurn: (prompt) => {
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
if (this.promptsToFail.delete(promptText)) {
@@ -674,6 +680,10 @@ export class HubRelationshipHarness {
this.promptsToFail.add(prompt);
}
failNextProviderModeChange(): void {
this.failNextModeChange = true;
}
failNextProviderSessionClose(): void {
this.failNextSessionClose = true;
}
@@ -23,7 +23,8 @@ test("message retries survive reconstruction without submitting twice", async ()
agentId: "agent",
messageId: "arrival",
request: { text: "hello" },
send: async () => {
send: async (beforeDispatch: () => Promise<void>) => {
await beforeDispatch();
deliveries++;
},
};
@@ -41,7 +42,8 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
agentId: "agent",
messageId: "arrival",
request: {},
send: async () => {
send: async (beforeDispatch: () => Promise<void>) => {
await beforeDispatch();
deliveries++;
throw new Error("connection lost");
},
@@ -53,7 +55,7 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
expect(deliveries).toBe(1);
});
test("failed local message preparation does not leave an ambiguous receipt", async () => {
test("a send that fails before beforeDispatch can be retried with the same message", async () => {
const { requests, directory } = await fixture();
let available = false;
let sends = 0;
@@ -61,14 +63,13 @@ test("failed local message preparation does not leave an ambiguous receipt", asy
agentId: "agent",
messageId: "message",
request: {},
prepare: async () => {
if (!available) throw new Error("load failed");
},
send: async () => {
send: async (beforeDispatch: () => Promise<void>) => {
if (!available) throw new Error("mode change failed");
await beforeDispatch();
sends++;
},
};
await expect(requests.send(input)).rejects.toThrow("load failed");
await expect(requests.send(input)).rejects.toThrow("mode change failed");
available = true;
await new MessageReceipts(directory).send(input);
available = false;
@@ -13,8 +13,11 @@ interface SendMessageInput {
agentId: string;
messageId: string;
request: unknown;
send: () => Promise<void>;
prepare?: () => Promise<void>;
/**
* Delivers the message. It awaits `beforeDispatch` immediately before the provider can receive
* the message; a failure before that call proves nothing was submitted and leaves no receipt.
*/
send: (beforeDispatch: () => Promise<void>) => Promise<void>;
}
/** Owns message delivery receipts; creation is owned by CreationService. */
@@ -48,10 +51,12 @@ export class MessageReceipts {
// A provider may have accepted the message before its receipt was committed.
throw new Error("agent_request_outcome_unknown");
}
await input.prepare?.();
const receipt = { fingerprint, agentId: input.agentId };
await writeJsonFileAtomic(file, { ...receipt, state: "pending" });
await input.send();
let pending: Promise<void> | null = null;
await input.send(() => {
pending ??= writeJsonFileAtomic(file, { ...receipt, state: "pending" });
return pending;
});
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
}
}
@@ -163,14 +163,8 @@ export default function contribute(server) {
server.addHook("agent.turn.before", async (event) => {
console.log(JSON.stringify({ hook: "agent.turn.before", event }));
});
server.addHook("agent.turn.after", async (event, context) => {
const page = await context.paseo.agents
.ref(event.agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const turn = page.entries
.filter((entry) => entry.turnId === event.turnId)
.map(({ item }) => item.type);
console.log(JSON.stringify({ hook: "agent.turn.after", event, turn }));
server.addHook("agent.turn.after", async (event) => {
console.log(JSON.stringify({ hook: "agent.turn.after", event }));
});
server.addHook("agent.permission.before", async (event, context) => {
console.log(JSON.stringify({ hook: "agent.permission.before", event }));
@@ -226,7 +220,6 @@ export default function contribute(server) {
{
hook: "agent.turn.after",
event: { outcome: { kind: "completed" }, timeline: [] },
turn: ["user_message", "tool_call"],
},
{ hook: "agent.turn.before", event: { agent: { id: agent.id } } },
]);
@@ -157,7 +157,8 @@ test.skipIf(process.platform !== "linux")(
"This is a hook integration test. Reply exactly: out of credits. If the next user message is Try again., reply exactly RETRY_OK instead. Do not use tools.",
);
await waitForTurns(client, agent.id, 2);
expect(await latestReplyText(client, agent.id)).toBe("RETRY_OK");
const retryTurns = await endedTurns(client, agent.id);
expect(assistantText(retryTurns[1].data.timeline)).toBe("RETRY_OK");
proof.followup = "RETRY_OK";
await client.sendMessage(
@@ -206,7 +207,8 @@ test.skipIf(process.platform !== "linux")(
const envPermission = await waitForPermission(client, agent.id, envCommand);
await client.respondToPermission(agent.id, envPermission, { behavior: "allow" });
await waitForTurns(client, agent.id, 5);
expect(await latestReplyText(client, agent.id)).toBe("created|opened");
const envTurns = await endedTurns(client, agent.id);
expect(assistantText(envTurns[4].data.timeline)).toBe("created|opened");
proof.environmentOutput = "created|opened";
await client.refreshAgent(agent.id);
@@ -330,16 +332,6 @@ test.skipIf(process.platform !== "linux")(
600_000,
);
// Turn hooks carry no history; read the reply the way plugins do.
async function latestReplyText(client: DaemonClient, agentId: string): Promise<string> {
const page = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 1,
wholeTurns: true,
});
return assistantText(page.entries.map((entry) => entry.item));
}
function assistantText(timeline: readonly AgentTimelineItem[]): string {
let text = "";
for (const item of timeline) {
@@ -195,18 +195,21 @@ export function publishAgentStream(
lifecycle.emit("agent.turn.after", {
agent,
turnId: event.turnId ?? null,
timeline: [],
outcome: { kind: "completed" },
});
} else if (event.type === "turn_failed") {
lifecycle.emit("agent.turn.after", {
agent,
turnId: event.turnId ?? null,
timeline: [],
outcome: { kind: "failed", error: { message: event.error, code: event.code } },
});
} else if (event.type === "turn_canceled") {
lifecycle.emit("agent.turn.after", {
agent,
turnId: event.turnId ?? null,
timeline: [],
outcome: { kind: "canceled", reason: event.reason },
});
} else if (event.type === "permission_requested") {
@@ -564,7 +564,7 @@ export class PluginRuntime {
type: "hook",
requestId: randomUUID(),
name,
input: wireEvent(name, event),
input: event,
})
.then(() => undefined)
.catch((error) => {
@@ -1482,11 +1482,3 @@ function readConnectionId(value: unknown): string | null {
const connectionId = Reflect.get(value, "connectionId");
return typeof connectionId === "string" ? connectionId : null;
}
// COMPAT(pluginTurnTimeline): added in v0.16.0-beta.3; plugins built before the field left the SDK type read `timeline`; remove after 2027-05-31
function wireEvent<Name extends keyof PluginHookEvents>(
name: Name,
event: PluginHookEvents[Name],
): PluginHookEvents[Name] {
return name === "agent.turn.after" ? { ...event, timeline: [] } : event;
}
+6 -4
View File
@@ -8917,8 +8917,9 @@ export class Session {
queued = acceptAdmission;
});
let deliveryStatus: "queued" | undefined;
const send = async () => {
const send = async (beforeDispatch: () => Promise<void>) => {
if (msg.sourceAgentId) {
await beforeDispatch();
const receipt = this.agentMessages.enqueue({
recipientId: agentId,
messageId: msg.messageId,
@@ -8949,6 +8950,7 @@ export class Session {
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
sessionMode: msg.sessionMode,
clearPendingPermissions: true,
beforeDispatch,
logger: this.sessionLogger,
});
if (result.disposition === "turn_started") {
@@ -8971,12 +8973,12 @@ export class Session {
activeTurnBehavior:
msg.activeTurnBehavior ?? (msg.sourceAgentId ? "steer" : "interrupt"),
},
prepare: async () => {
send: async (beforeDispatch) => {
if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text);
await send(beforeDispatch);
},
send,
})
: send();
: send(async () => {});
void sent.catch((error) => {
if (deliveryStatus === "queued") {
this.handleAgentRunError(agentId, error, "Failed to dispatch queued agent message");
@@ -84,6 +84,7 @@ interface FakeAgentSessionOptions {
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
beforeSetMode?: (modeId: string) => Promise<void>;
historyEvents?: readonly AgentStreamEvent[];
}
@@ -95,6 +96,7 @@ export interface TestAgentClientOptions {
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
steerActiveTurn?: AgentSession["steerActiveTurn"];
onInterrupt?: () => void;
beforeSetMode?: (modeId: string) => Promise<void>;
supportsMcpServers?: boolean;
historyEvents?: readonly AgentStreamEvent[];
}
@@ -369,6 +371,7 @@ class FakeAgentSession implements AgentSession {
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
private readonly onInterrupt: (() => void) | undefined;
private readonly beforeSetMode: ((modeId: string) => Promise<void>) | undefined;
private readonly closeSession: (() => Promise<void>) | undefined;
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
@@ -385,6 +388,7 @@ class FakeAgentSession implements AgentSession {
this.memoryMarker = options.memoryMarker ?? null;
this.steerActiveTurn = options.steerActiveTurn;
this.onInterrupt = options.onInterrupt;
this.beforeSetMode = options.beforeSetMode;
this.closeSession = options.closeSession;
this.onStartTurn = options.onStartTurn;
this.beforeTurnComplete = options.beforeTurnComplete;
@@ -891,6 +895,7 @@ class FakeAgentSession implements AgentSession {
}
async setMode(modeId: string): Promise<void> {
await this.beforeSetMode?.(modeId);
this.config.modeId = modeId;
}
@@ -1264,6 +1269,7 @@ class FakeAgentClient implements AgentClient {
beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
beforeSetMode: this.options.beforeSetMode,
historyEvents: this.options.historyEvents,
});
}
@@ -1294,6 +1300,7 @@ class FakeAgentClient implements AgentClient {
beforeTurnComplete: this.options.beforeTurnComplete,
steerActiveTurn: this.options.steerActiveTurn,
onInterrupt: this.options.onInterrupt,
beforeSetMode: this.options.beforeSetMode,
historyEvents: this.options.historyEvents,
});
}
@@ -306,7 +306,7 @@ export function createProviderSnapshotManagerStub(): {
export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] {
return {
send: (input) => input.send(),
send: (input) => input.send(async () => {}),
};
}
@@ -23,9 +23,8 @@ export default function contribute(server: PluginServerContext) {
return;
}
const agent = context.paseo.agents.ref(event.agent.id);
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
const text = latestOutputText(turn.map(({ item }) => item));
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
const text = latestOutputText(page.entries.map(({ item }) => item));
if (/out of credits/i.test(text)) {
console.log("Sending a follow-up after out of credits", event.agent.id);
await agent.send("Try again.");
@@ -1,9 +0,0 @@
# Workspace row badges
Install this directory as a plugin. Any workspace that needs input or has failed shows a badge on
the line under its title in the sidebar. Workspaces in any other state show nothing.
The component reads its own data with `useWorkspace(workspaceId, selector)` and returns `null` when
it has nothing to say. **Show → Workspace attention** in the sidebar's display preferences toggles it.
See the [reference](../../public-docs/plugins/reference.md#workspace-row-badge-items).
@@ -1,29 +0,0 @@
import { useWorkspace } from "@getpaseo/plugin/client";
import type {
PluginClientContext,
PluginWorkspaceRowBadgeItemProps,
} from "@getpaseo/plugin/client";
import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
function WorkspaceAttention({ workspaceId }: PluginWorkspaceRowBadgeItemProps) {
const status = useWorkspace(workspaceId, (workspace) => workspace.status);
if (status === "needs_input") {
return <WorkspaceRowBadge icon="CircleHelp" label="Needs input" tone="warning" />;
}
if (status === "failed") {
return <WorkspaceRowBadge icon="CircleAlert" label="Failed" tone="danger" />;
}
return null;
}
export default function contribute(client: PluginClientContext) {
// Apps older than this API do not have the method, and requirements.paseo cannot exclude them.
if (!client.addWorkspaceRowBadgeItem) return () => {};
client.addWorkspaceRowBadgeItem({
id: "attention",
title: "Workspace attention",
icon: "CircleAlert",
Component: WorkspaceAttention,
});
return () => {};
}
@@ -1 +0,0 @@
{ "id": "workspace-row-badges", "requirements": { "paseo": ">=0.16.0" } }
+7 -5
View File
@@ -62,7 +62,7 @@ Paseo only sees the returned promise, so detached work gets no error reporting a
| `agent.close.after` | no | `{ agent }` | |
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
| `agent.turn.before` | no | `{ agent, turnId }` | |
| `agent.turn.after` | no | `{ agent, turnId, outcome }` | |
| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | |
| `agent.permission.before` | no | `{ agent, request }` | |
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
@@ -525,7 +525,7 @@ server.addHook("agent.turn.before", async ({ agent, turnId }) => {
## agent.turn.after
A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome }`. `outcome.kind` is `completed`, `failed`, or `canceled`. The payload carries no history. Read the turn from the agent's timeline: `refetch({ direction: "tail", limit: 1, wholeTurns: true })` returns the latest turn whole, as below. Keep the entries whose `turnId` equals `event.turnId`, because a turn that starts before your read, such as a follow-up another plugin sent, replaces this one as the latest. The filtered list is empty in that case, and when `event.turnId` is `null`. Plugins built against an older SDK that read `event.timeline` get an empty array.
A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome, timeline }`. `outcome.kind` is `completed`, `failed`, or `canceled`. `timeline` is deprecated and always empty; fetch history through the SDK as below.
### Send a follow-up when a turn ends
@@ -545,9 +545,8 @@ export default function contribute(server: PluginServerContext) {
}
const agent = context.paseo.agents.ref(event.agent.id);
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
const text = latestOutputText(turn.map(({ item }) => item));
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
const text = latestOutputText(page.entries.map(({ item }) => item));
if (/out of credits/i.test(text)) {
await agent.send("Try again.");
}
@@ -563,6 +562,9 @@ Turn ends: "out of credits"
→ a new turn starts
```
The handler fetches only the last 20 timeline items. Output before that page is not inspected.
Use `cursor` and `direction` to page further when your plugin needs more history.
This sends a new message with the existing SDK. Persistent matches keep sending follow-ups;
add limits or delays in your plugin when needed. Attachments and tool effects are not replayed.
+6 -5
View File
@@ -60,7 +60,7 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没
| `agent.close.after` | 否 | `{ agent }` | |
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
| `agent.turn.after` | 否 | `{ agent, turnId, outcome }` | |
| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | |
| `agent.permission.before` | 否 | `{ agent, request }` | |
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
@@ -514,7 +514,7 @@ server.addHook("agent.turn.before", async ({ agent, turnId }) => {
## agent.turn.after {#agentturnafter}
实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。载荷不含历史。请从 Agent 的时间线读取本轮:`refetch({ direction: "tail", limit: 1, wholeTurns: true })` 会完整返回最近一轮,见下面的示例。只保留 `turnId` 等于 `event.turnId` 的条目:如果在读取前已有新一轮开始(例如另一个插件发送的后续消息),最近一轮就不再是本轮。此时过滤后的列表为空;`event.turnId` 为 `null` 时也为空。基于旧版 SDK 构建、仍读取 `event.timeline` 的插件只会拿到空数组。
实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome, timeline }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。`timeline` 已弃用且始终为空;请按下面的示例通过 SDK 获取历史。
### 在轮次结束时发送后续消息 {#send-a-follow-up-when-a-turn-ends}
@@ -531,9 +531,8 @@ export default function contribute(server: PluginServerContext) {
}
const agent = context.paseo.agents.ref(event.agent.id);
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
const text = latestOutputText(turn.map(({ item }) => item));
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
const text = latestOutputText(page.entries.map(({ item }) => item));
if (/out of credits/i.test(text)) {
await agent.send("Try again.");
}
@@ -549,6 +548,8 @@ Turn ends: "out of credits"
→ a new turn starts
```
该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 `cursor` 和 `direction` 继续翻页。
这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。
## agent.permission.before {#agentpermissionbefore}
+24 -41
View File
@@ -67,31 +67,30 @@ is a compile error.
Use this table as the complete registration checklist.
| Old registration and location | New registration and location |
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` |
| `plugin.addSurface(id, Component)` in the old root entry | `client.addScreen({ id, title, Component })` in `index.client.tsx` |
| `plugin.addSidebarItem({ id, title, icon, surface })` in the old root entry | `client.addSidebarHeaderItem({ id, title, Component })` in `index.client.tsx`; see [sidebar items](reference#sidebar-items) |
| New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` |
| New sidebar replacement view | `client.addSidebarView({ id, title, Component })` in `index.client.tsx`; see [sidebar views](sidebar-views) |
| New badge on sidebar workspace rows | `client.addWorkspaceRowBadgeItem({ id, title, icon, Component })` in `index.client.tsx`; see [workspace row badge items](reference#workspace-row-badge-items) |
| New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` |
| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` |
| Agent panel with `context: "agent"` | `client.addAgentPanel(panel)` in `index.client.tsx`; omit `context` |
| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` |
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
| New composer item contribution | `client.addComposerItem(item)` in `index.client.tsx`; see [composer items](reference#composer-items) |
| New New workspace item contribution | `client.addNewWorkspaceItem(item)` in `index.client.tsx`; see [New workspace items](reference#new-workspace-items) |
| New header contribution | `client.addHeaderButton({ id, workspaceId, button })` |
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) |
| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` |
| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` |
| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` |
| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
| `ZodOutput<typeof contract.input>` handler parameter types | `RpcInput<typeof contract>` from `@getpaseo/plugin`; `RpcOutput` for return types |
| Old registration and location | New registration and location |
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` |
| `plugin.addSurface(id, Component)` in the old root entry | `client.addScreen({ id, title, Component })` in `index.client.tsx` |
| `plugin.addSidebarItem({ id, title, icon, surface })` in the old root entry | `client.addSidebarHeaderItem({ id, title, Component })` in `index.client.tsx`; see [sidebar items](reference#sidebar-items) |
| New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` |
| New sidebar replacement view | `client.addSidebarView({ id, title, Component })` in `index.client.tsx`; see [sidebar views](sidebar-views) |
| New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` |
| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` |
| Agent panel with `context: "agent"` | `client.addAgentPanel(panel)` in `index.client.tsx`; omit `context` |
| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` |
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
| New composer item contribution | `client.addComposerItem(item)` in `index.client.tsx`; see [composer items](reference#composer-items) |
| New New workspace item contribution | `client.addNewWorkspaceItem(item)` in `index.client.tsx`; see [New workspace items](reference#new-workspace-items) |
| New header contribution | `client.addHeaderButton({ id, workspaceId, button })` |
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) |
| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` |
| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` |
| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` |
| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
| `ZodOutput<typeof contract.input>` handler parameter types | `RpcInput<typeof contract>` from `@getpaseo/plugin`; `RpcOutput` for return types |
Register the screen that replaces an `addSidebarItem` under that item's `id` to keep saved links
working. A `/plugin/<plugin>/sidebar/<id>` link, and the older `/plugin/<plugin>/<id>`, opens the
@@ -208,22 +207,6 @@ setting `id` as an option key and each `value` as that option, and sends changes
| `ProviderSetting` type | `ProviderOptionControl` |
| `agent.create.before` `config.featureValues` | `config.providerOptions`; a returned `featureValues` edit is still applied |
### Turn history in v0.16
`agent.turn.after` has sent an empty `timeline` since v0.13, and the SDK type no longer declares it.
A plugin that reads `event.timeline` now fails typecheck. Read the turn from the agent instead:
```ts
const turn = await context.paseo.agents
.ref(event.agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const items = turn.entries.filter((entry) => entry.turnId === event.turnId).map(({ item }) => item);
```
The filter drops entries from a turn that started after this one ended, such as a follow-up another plugin sent. The list is empty when such a turn has already started, or when `event.turnId` is `null`.
Plugins built against an older SDK keep receiving `timeline: []` until removal after 2027-05-31.
## 5. Recognize half-migration errors
| Compiler or load error | Meaning and fix |
+23 -39
View File
@@ -60,30 +60,29 @@ my-plugin/
把下表作为完整的注册检查清单。
| 旧的注册及位置 | 新的注册及位置 |
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| 旧根入口中的 `plugin.handle(contract, handler)` | `index.server.ts` 中的 `server.handle(contract, handler)` |
| 旧根入口中的 `plugin.addSurface(id, Component)` | `index.client.tsx` 中的 `client.addScreen({ id, title, Component })` |
| 旧根入口中的 `plugin.addSidebarItem({ id, title, icon, surface })` | `index.client.tsx` 中的 `client.addSidebarHeaderItem({ id, title, Component })`;见[侧边栏项](reference#sidebar-items) |
| 新的侧边栏底部贡献 | `index.client.tsx` 中的 `client.addSidebarFooterItem({ id, title, Component })` |
| 新的工作区行徽章贡献 | `index.client.tsx` 中的 `client.addWorkspaceRowBadgeItem({ id, title, icon, Component })`;见[工作区行徽章项](reference#workspace-row-badge-items) |
| 新的文件面板贡献 | `index.client.tsx` 中的 `client.addFilePanel(panel)` |
| 旧根入口中的 `plugin.addWorkspacePanel(panel)` | `index.client.tsx` 中的 `client.addWorkspacePanel(panel)` |
| 带有 `context: "agent"` 的 Agent 面板 | `index.client.tsx` 中的 `client.addAgentPanel(panel)`;省略 `context` |
| 旧根入口中的 `plugin.addCommandCenterItem(item)` | `index.client.tsx` 中的 `client.addCommandCenterItem(item)` |
| 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` |
| 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 |
| 旧客户端回调中的 `client.addComposerPill(pill)` | `index.client.tsx` 或被导入的 `client/` 函数中的 `client.addComposerPill(pill)` |
| 新的编辑器项贡献 | `index.client.tsx` 中的 `client.addComposerItem(item)`;见[编辑器项](reference#composer-items) |
| 新的“新建工作区”项贡献 | `index.client.tsx` 中的 `client.addNewWorkspaceItem(item)`;见[新建工作区项](reference#new-workspace-items) |
| 新的标题栏贡献 | `client.addHeaderButton({ id, workspaceId, button })` |
| 旧根入口中的 `plugin.addAttachmentSource(source)` | `index.client.tsx` 中的 `client.addAttachmentSource(source)` |
| 新的设置界面贡献 | `index.client.tsx` 中的 `client.addSettingsScreen(screen)`;见[设置界面](reference#settings-screens) |
| 旧根入口中的 `plugin.addTheme(theme)` | `index.client.tsx` 中的 `client.addTheme(theme)` |
| 旧根入口中的 `plugin.addTimelineTransformer(transformer)` | `index.client.tsx` 中的 `client.addTimelineTransformer(transformer)` |
| 旧根入口中的 `plugin.addTimelineRenderer(renderer)` | `index.client.tsx` 中的 `client.addTimelineRenderer(renderer)` |
| shared 文件中的 `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
| 处理函数参数类型 `ZodOutput<typeof contract.input>` | 来自 `@getpaseo/plugin` 的 `RpcInput<typeof contract>`;返回类型使用 `RpcOutput` |
| 旧的注册及位置 | 新的注册及位置 |
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| 旧根入口中的 `plugin.handle(contract, handler)` | `index.server.ts` 中的 `server.handle(contract, handler)` |
| 旧根入口中的 `plugin.addSurface(id, Component)` | `index.client.tsx` 中的 `client.addScreen({ id, title, Component })` |
| 旧根入口中的 `plugin.addSidebarItem({ id, title, icon, surface })` | `index.client.tsx` 中的 `client.addSidebarHeaderItem({ id, title, Component })`;见[侧边栏项](reference#sidebar-items) |
| 新的侧边栏底部贡献 | `index.client.tsx` 中的 `client.addSidebarFooterItem({ id, title, Component })` |
| 新的文件面板贡献 | `index.client.tsx` 中的 `client.addFilePanel(panel)` |
| 旧根入口中的 `plugin.addWorkspacePanel(panel)` | `index.client.tsx` 中的 `client.addWorkspacePanel(panel)` |
| 带有 `context: "agent"` 的 Agent 面板 | `index.client.tsx` 中的 `client.addAgentPanel(panel)`;省略 `context` |
| 旧根入口中的 `plugin.addCommandCenterItem(item)` | `index.client.tsx` 中的 `client.addCommandCenterItem(item)` |
| 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` |
| 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 |
| 旧客户端回调中的 `client.addComposerPill(pill)` | `index.client.tsx` 或被导入的 `client/` 函数中的 `client.addComposerPill(pill)` |
| 新的编辑器项贡献 | `index.client.tsx` 中的 `client.addComposerItem(item)`;见[编辑器项](reference#composer-items) |
| 新的“新建工作区”项贡献 | `index.client.tsx` 中的 `client.addNewWorkspaceItem(item)`;见[新建工作区项](reference#new-workspace-items) |
| 新的标题栏贡献 | `client.addHeaderButton({ id, workspaceId, button })` |
| 旧根入口中的 `plugin.addAttachmentSource(source)` | `index.client.tsx` 中的 `client.addAttachmentSource(source)` |
| 新的设置界面贡献 | `index.client.tsx` 中的 `client.addSettingsScreen(screen)`;见[设置界面](reference#settings-screens) |
| 旧根入口中的 `plugin.addTheme(theme)` | `index.client.tsx` 中的 `client.addTheme(theme)` |
| 旧根入口中的 `plugin.addTimelineTransformer(transformer)` | `index.client.tsx` 中的 `client.addTimelineTransformer(transformer)` |
| 旧根入口中的 `plugin.addTimelineRenderer(renderer)` | `index.client.tsx` 中的 `client.addTimelineRenderer(renderer)` |
| shared 文件中的 `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
| 处理函数参数类型 `ZodOutput<typeof contract.input>` | 来自 `@getpaseo/plugin` 的 `RpcInput<typeof contract>`;返回类型使用 `RpcOutput` |
用原 `addSidebarItem` 项的 `id` 注册替代它的界面,这样已保存的链接仍然有效。`/plugin/<plugin>/sidebar/<id>` 链接以及更早的 `/plugin/<plugin>/<id>` 链接,会打开具有该 `id` 的 `addSidebarItem`,否则打开具有该 `id` 的界面。
@@ -171,21 +170,6 @@ Provider 插件只有一个配置记录 `providerOptions`,并用控件描述
| `ProviderSetting` 类型 | `ProviderOptionControl` |
| `agent.create.before` 的 `config.featureValues` | `config.providerOptions`;返回的 `featureValues` 修改仍会被应用 |
### v0.16 中的轮次历史 {#turn-history-in-v016}
自 v0.13 起,`agent.turn.after` 发送的 `timeline` 始终为空,SDK 类型也不再声明它。读取 `event.timeline` 的插件现在无法通过类型检查。请改为从 Agent 读取本轮:
```ts
const turn = await context.paseo.agents
.ref(event.agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const items = turn.entries.filter((entry) => entry.turnId === event.turnId).map(({ item }) => item);
```
过滤会去掉本轮结束后才开始的新一轮的条目,例如另一个插件发送的后续消息。如果这样的新一轮已经开始,或 `event.turnId` 为 `null`,列表为空。
基于旧版 SDK 构建的插件在 2027-05-31 之后移除兼容层之前仍会收到 `timeline: []`。
## 5. 识别只迁移了一半的错误 {#5-recognize-half-migration-errors}
| 编译器或加载错误 | 含义与修复方法 |
-54
View File
@@ -854,60 +854,6 @@ function hideAlerts() {
}
```
### Workspace row badge items
`addWorkspaceRowBadgeItem({ id, title, icon, Component })` adds an item to the line under each workspace
title in the sidebar, after the row's own items (host, pull request, checks, services, labels).
`title` and `icon` label the item's toggle in the sidebar's **Show** menu, in a separate plugin group
below a separator. Items start visible.
`Component` renders once per workspace row on the plugin's host and receives
`PluginWorkspaceRowBadgeItemProps`: `theme`, `host`, `layout`, and `workspaceId`. Render
`WorkspaceRowBadge` from `@getpaseo/plugin/client/ui`, or `null` when there is nothing to say about
the workspace:
| Prop | Meaning |
| -------------------- | ------------------------------------------------------------------------------ |
| `icon` | Optional. Any [icon](#icons) form, drawn at the line's glyph size. |
| `label` | Optional. A short word. A badge with neither `icon` nor `label` draws nothing. |
| `tone` | `"muted"` (default), `"success"`, `"warning"`, `"danger"`, or `"info"`. |
| `accessibilityLabel` | Defaults to `label`, then the item's `title`. |
The line adds no separator around your badges, and returning `null` leaves no gap. Render as many
badges as you like. Every row
renders the component, so share one query across rows instead of fetching per workspace: the
plugin's React Query client is shared by all of its components on a host.
```tsx
import type { PluginWorkspaceRowBadgeItemProps } from "@getpaseo/plugin/client";
import { useRpc } from "@getpaseo/plugin/client";
import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
import { useQuery } from "@tanstack/react-query";
function ReviewState({ workspaceId }: PluginWorkspaceRowBadgeItemProps) {
const list = useRpc(reviewStates);
const { data } = useQuery({
queryKey: ["review-states"],
queryFn: () => list({}),
refetchInterval: 5000,
});
const state = data?.[workspaceId];
if (!state) return null;
return <WorkspaceRowBadge icon="Eye" label={state.label} tone="warning" />;
}
client.addWorkspaceRowBadgeItem({
id: "review",
title: "Review",
icon: "Eye",
Component: ReviewState,
});
```
Apps older than this API have no `addWorkspaceRowBadgeItem`; check for it before calling.
See the [workspace row badges example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/workspace-row-badges).
## Host UI
Import Paseo-owned UI from `@getpaseo/plugin/client/ui` in client code. This example
-45
View File
@@ -705,51 +705,6 @@ function hideAlerts() {
}
```
### 工作区行徽章项 {#workspace-row-badge-items}
`addWorkspaceRowBadgeItem({ id, title, icon, Component })` 在侧边栏每个工作区标题下方的那一行中添加一项,位于该行自带的各项(主机、拉取请求、检查、服务、标签)之后。`title` 和 `icon` 用于标注该项在侧边栏 **Show** 菜单中的开关,插件开关位于分隔线下方的独立分组中。各项默认可见。
`Component` 为插件所在主机上的每个工作区行渲染一次,并接收 `PluginWorkspaceRowBadgeItemProps`:`theme`、`host`、`layout` 和 `workspaceId`。渲染 `@getpaseo/plugin/client/ui` 中的 `WorkspaceRowBadge`;工作区没有需要说明的内容时返回 `null`:
| 属性 | 含义 |
| -------------------- | --------------------------------------------------------------------- |
| `icon` | 可选。任意[图标](#icons)形式,以该行的图标尺寸绘制。 |
| `label` | 可选。一个简短的词。既没有 `icon` 也没有 `label` 的徽章不绘制。 |
| `tone` | `"muted"`(默认)、`"success"`、`"warning"`、`"danger"` 或 `"info"`。 |
| `accessibilityLabel` | 默认为 `label`,其次是该项的 `title`。 |
该行不会在你的徽章周围添加分隔符,返回 `null` 不会留下空隙。可以渲染任意数量的徽章。每一行都会渲染该组件,所以请在各行之间共享一次查询,而不是为每个工作区单独获取:插件的 React Query 客户端由它在某台主机上的所有组件共享。
```tsx
import type { PluginWorkspaceRowBadgeItemProps } from "@getpaseo/plugin/client";
import { useRpc } from "@getpaseo/plugin/client";
import { WorkspaceRowBadge } from "@getpaseo/plugin/client/ui";
import { useQuery } from "@tanstack/react-query";
function ReviewState({ workspaceId }: PluginWorkspaceRowBadgeItemProps) {
const list = useRpc(reviewStates);
const { data } = useQuery({
queryKey: ["review-states"],
queryFn: () => list({}),
refetchInterval: 5000,
});
const state = data?.[workspaceId];
if (!state) return null;
return <WorkspaceRowBadge icon="Eye" label={state.label} tone="warning" />;
}
client.addWorkspaceRowBadgeItem({
id: "review",
title: "Review",
icon: "Eye",
Component: ReviewState,
});
```
早于此 API 的应用没有 `addWorkspaceRowBadgeItem`;调用前请先检查它是否存在。
参见[工作区行徽章示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/workspace-row-badges)。
## 宿主 UI {#host-ui}
在客户端代码中从 `@getpaseo/plugin/client/ui` 导入由 Paseo 提供的 UI。下面的示例会打开一个受控的弹窗、渲染一个宿主图标,并用 toast 确认操作:
+1 -1
View File
@@ -118,7 +118,7 @@ workspace the call creates is background too. With a `parent` and no value, the
### Timeline handle
`agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, `wholeTurns`, and `requestId`. `wholeTurns: true` extends a `tail` or `before` page back to the start of the oldest turn it reaches, so `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn. It requires `features.wholeTurnTimeline`; older hosts ignore it and return a page of `limit` items.
`agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`.
`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events).
+1 -1
View File
@@ -114,7 +114,7 @@ nav: API 参考
### 时间线句柄 {#timeline-handle}
`agent.timeline.refetch(options?)` 获取一页时间线。选项有 `direction`、`cursor`、`limit`、`projection`、`wholeTurns` 和 `requestId`。`wholeTurns: true` 会把 `tail` 或 `before` 页向前扩展到它所触及的最早一轮的开头,因此 `{ direction: "tail", limit: 1, wholeTurns: true }` 返回最近一轮。它需要 `features.wholeTurnTimeline`;旧版主机会忽略它,返回 `limit` 条记录。
`agent.timeline.refetch(options?)` 获取一页时间线。选项有 `direction`、`cursor`、`limit`、`projection` 和 `requestId`。
`agent.timeline.subscribe(handler)` 为该 Agent 建立网络订阅需求,并在重连后恢复它。它返回的取消订阅函数会释放这项需求;开始工作之前,请 await `unsubscribe.ready` 以等待 daemon 的初始确认。投递只包含实时内容。重连时会发出一个本地的 `subscription_restored` 事件;错过的历史请用 `refetch()` 显式请求。实时的替换会使之前的 epoch 失效。回调的形状、分页和失败行为见[时间线事件](./events.md#follow-timeline-events)。