Let plugins provide custom sidebar views across hosts (#8906)

* feat(plugin): expose stable sidebar entity data across hosts

Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase.

* feat(plugin): let users select a sidebar view

An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery.

* feat(plugin): add sidebar view examples and ref actions

Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders.

* feat(plugin): observe terminals across connected hosts

Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp.
This commit is contained in:
Mohamed Boudra
2026-11-07 18:05:31 +07:00
committed by GitHub
parent aac228e0a8
commit a449271d57
106 changed files with 4385 additions and 269 deletions
+42
View File
@@ -388,3 +388,45 @@ describe("plugin subscription SDK boundary", () => {
).toEqual([]);
});
});
describe("sidebar data SDK boundary", () => {
it("accepts references and preserves string-id panel consumers", () => {
expect(
typeErrors(`
import { useWorkspace, useAgent, usePaseo, type PluginClientContext, type PluginSidebarViewProps, type WorkspaceRef, type AgentRef, useProject, useTerminal, useWorkspaces, useAgents, useProjects, useTerminals, type ProjectRef, type TerminalRef, type Query, type Group } from "@getpaseo/plugin/client";
import type { PluginWorkspaceSnapshot } from "@getpaseo/plugin";
function Panel({ workspace, agent }: { workspace: WorkspaceRef; agent: AgentRef }) {
const snapshot: PluginWorkspaceSnapshot | null = useWorkspace(workspace);
const title: string | null = useWorkspace("workspace", item => item.name);
const status = useAgent(agent, item => ({ status: item.status }), (a, b) => a?.status === b?.status);
const agentSnapshot = useAgent(agent);
const custom = useWorkspace(workspace, undefined, (a, b) => a?.name === b?.name);
const workspaces: readonly WorkspaceRef[] = useWorkspaces({ orderBy: "name" });
const groups: readonly Group<AgentRef>[] = useAgents({ groupBy: item => item.status });
const projects: readonly ProjectRef[] = useProjects({ hosts: [workspace.hostId] });
const terminals: readonly TerminalRef[] = useTerminals();
const paseo = usePaseo();
void paseo.workspaces.ref(workspace).setPinned(true);
void paseo.workspaces.ref(workspace).setLabel("Later", { assigned: true });
void paseo.workspaces.ref("old-id").setTitle("Title");
if (projects[0]) { paseo.projects.ref(projects[0]).open(); void paseo.projects.ref(projects[0]).createWorkspace(); }
if (terminals[0]) { paseo.terminals.ref(terminals[0]).focus(); void paseo.terminals.ref(terminals[0]).kill(); }
void paseo.agents.ref(agent);
if (projects[0]) useProject(projects[0], item => item.displayName);
if (terminals[0]) useTerminal(terminals[0]);
function View(props: PluginSidebarViewProps) {
props.navigation.openWorkspace(workspace);
props.navigation.openAgent(agent);
props.dismiss();
return null;
}
function contribute(client: PluginClientContext) {
client.addSidebarView({ id: "workspaces", title: "Workspaces", Component: View });
}
const query: Query<PluginWorkspaceSnapshot> = { hosts: [workspace.hostId], orderBy: ["name", "desc"] };
return null;
}
`),
).toEqual([]);
});
});
+122 -35
View File
@@ -1,12 +1,30 @@
import { createContext, useContext, type ReactNode } from "react";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js";
import type { AgentRef, WorkspaceRef, ProjectRef, TerminalRef } from "../refs.js";
import type {
PluginAgentSnapshot,
PluginWorkspaceSnapshot,
PluginProjectSnapshot,
PluginTerminalSnapshot,
} from "../contracts.js";
import { createCollectionQuery, type Query, type Group } from "./query.js";
import { shallowEqual } from "./shallow.js";
export interface PluginClientStateSource {
subscribe(listener: () => void): () => void;
getWorkspace(id: string): PluginWorkspaceSnapshot | null;
getAgent(id: string): PluginAgentSnapshot | null;
subscribe(listener: () => void, options?: { terminals?: boolean }): () => void;
getWorkspace(id: string | WorkspaceRef): PluginWorkspaceSnapshot | null;
getAgent(id: string | AgentRef): PluginAgentSnapshot | null;
getProject(ref: ProjectRef): PluginProjectSnapshot | null;
getTerminal(ref: TerminalRef): PluginTerminalSnapshot | null;
getSnapshot(): PluginClientSnapshot;
}
export interface PluginClientSnapshot {
readonly workspaces: readonly PluginWorkspaceSnapshot[];
readonly agents: readonly PluginAgentSnapshot[];
readonly projects: readonly PluginProjectSnapshot[];
readonly terminals: readonly PluginTerminalSnapshot[];
readonly terminalErrors?: ReadonlyMap<string, Error>;
}
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
@@ -15,44 +33,113 @@ export function usePluginClientStateSource(): PluginClientStateSource | null {
return useContext(PluginClientStateContext);
}
function usePluginEntity<Entity, Selection>(input: {
id: string;
read(source: PluginClientStateSource, id: string): Entity | null;
selector(entity: Entity): Selection;
}): Selection | null {
/** Shared selection semantics for host surfaces and plugin entities. */
export function usePaseoSelector<Entity, Selection>(
source: { subscribe(listener: () => void): () => void },
read: () => Entity,
selector: (entity: Entity) => Selection,
equals: (left: Selection, right: Selection) => boolean = shallowEqual,
): Selection {
return useSyncExternalStoreWithSelector(source.subscribe, read, read, selector, equals);
}
type Equality<T> = { equals(left: T, right: T): boolean }["equals"];
function useStateSource(terminals: boolean) {
const source = usePluginClientStateSource();
if (!source) throw new Error("Plugin state hooks must run inside a workspace panel");
return useSyncExternalStoreWithSelector(
source.subscribe,
() => input.read(source, input.id),
() => input.read(source, input.id),
(entity) => (entity ? input.selector(entity) : null),
shallowEqual,
if (!source) throw new Error("Plugin state hooks must run inside a plugin host");
return useMemo(
() =>
terminals
? {
...source,
subscribe: (listener: () => void) => source.subscribe(listener, { terminals: true }),
}
: source,
[source, terminals],
);
}
export function useWorkspace<Selection>(
workspaceId: string,
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: workspaceId,
read: (source, id) => source.getWorkspace(id),
selector,
});
function createEntityHook<Ref, Entity>(
read: (source: PluginClientStateSource, ref: Ref) => Entity | null,
terminals = false,
) {
function useEntity(
ref: Ref,
selector?: undefined,
equals?: Equality<Entity | null>,
): Entity | null;
function useEntity<Selection>(
ref: Ref,
selector: (entity: Entity) => Selection,
equals?: Equality<Selection | null>,
): Selection | null;
function useEntity<Selection>(
ref: Ref,
selector?: (entity: Entity) => Selection,
equals?: Equality<Entity | Selection | null>,
): Entity | Selection | null {
const source = useStateSource(terminals);
function select(entity: Entity | null): Entity | Selection | null {
return entity !== null && selector ? selector(entity) : entity;
}
const equality = equals ?? (selector ? shallowEqual : Object.is);
return usePaseoSelector(source, () => read(source, ref), select, equality);
}
return useEntity;
}
export function useAgent<Selection>(
agentId: string,
selector: (agent: PluginAgentSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: agentId,
read: (source, id) => source.getAgent(id),
selector,
});
// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03.
export const useWorkspace = createEntityHook((source, ref: WorkspaceRef | string) =>
source.getWorkspace(ref),
);
// COMPAT(plugin-entity-id): string ids accepted since 0.16; remove after 2027-03.
export const useAgent = createEntityHook((source, ref: AgentRef | string) => source.getAgent(ref));
export const useProject = createEntityHook((source, ref: ProjectRef) => source.getProject(ref));
export const useTerminal = createEntityHook(
(source, ref: TerminalRef) => source.getTerminal(ref),
true,
);
function createCollectionHook<Snapshot extends { readonly ref: unknown; readonly hostId: string }>(
read: (source: PluginClientStateSource, query: Query<Snapshot>) => readonly Snapshot[],
terminals = false,
) {
function useCollection(
query: Query<Snapshot> & { groupBy: NonNullable<Query<Snapshot>["groupBy"]> },
): readonly Group<Snapshot["ref"]>[];
function useCollection(
query?: Query<Snapshot> & { groupBy?: undefined },
): readonly Snapshot["ref"][];
function useCollection(
query: Query<Snapshot>,
): readonly Snapshot["ref"][] | readonly Group<Snapshot["ref"]>[];
function useCollection(
query: Query<Snapshot> = {},
): readonly Snapshot["ref"][] | readonly Group<Snapshot["ref"]>[] {
const source = useStateSource(terminals);
const project = useMemo(() => createCollectionQuery<Snapshot>(), []);
return usePaseoSelector(
source,
() => read(source, query),
(snapshots) => project(snapshots, query),
Object.is,
);
}
return useCollection;
}
export const useWorkspaces = createCollectionHook((source) => source.getSnapshot().workspaces);
export const useAgents = createCollectionHook((source) => source.getSnapshot().agents);
export const useProjects = createCollectionHook((source) => source.getSnapshot().projects);
export const useTerminals = createCollectionHook<PluginTerminalSnapshot>((source, query) => {
const snapshot = source.getSnapshot();
for (const [hostId, error] of snapshot.terminalErrors ?? []) {
if (!query.hosts || query.hosts.includes(hostId)) throw error;
}
return snapshot.terminals;
}, true);
export function PluginClientStateProvider({
children,
source,
+22 -2
View File
@@ -1,6 +1,7 @@
import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js";
import type { PluginText } from "../text.js";
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { PluginPaseoApi } from "./reference-api.js";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile } from "./file.js";
@@ -96,6 +97,24 @@ export interface PluginSidebarItemContribution<Text extends PluginText = string>
Component: ComponentType<PluginSidebarItemProps>;
}
export interface PluginSidebarViewProps extends Pick<PluginHostProps, "theme" | "layout"> {
active: { workspace: WorkspaceRef | null; agent: AgentRef | null };
navigation: {
openWorkspace(ref: WorkspaceRef): void;
openAgent(ref: AgentRef): void;
openTerminal(ref: TerminalRef): void;
openProject(ref: ProjectRef): void;
};
dismiss(): void;
}
export interface PluginSidebarViewContribution<Text extends PluginText = string> {
id: string;
title: Text;
description?: Text;
Component: ComponentType<PluginSidebarViewProps>;
}
/**
* 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.
@@ -185,6 +204,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
playAudio(source: { base64: string; mimeType: string }): Promise<void>;
addSettingsScreen(contribution: PluginSettingsScreenContribution<PluginText>): PluginCleanup;
addScreen(contribution: PluginScreenContribution<PluginText>): PluginCleanup;
addSidebarView(contribution: PluginSidebarViewContribution<PluginText>): PluginCleanup;
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
@@ -308,7 +328,7 @@ export interface PluginTimelineRendererContribution<Schema extends ZodType = Zod
}
export interface PluginCommandCapabilities {
paseo: PaseoApi;
paseo: PluginPaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
+10 -1
View File
@@ -1,6 +1,11 @@
import type { PluginText } from "../text.js";
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export {
PluginClientStateProvider,
usePaseoSelector,
type PluginClientStateSource,
type PluginClientSnapshot,
} from "./client-state.js";
export {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
@@ -31,3 +36,7 @@ export {
isPluginMessage,
} from "./translations.js";
export type { PluginLocaleSource } from "./translations.js";
export { createEntityRefs } from "./refs.js";
export { createPluginReferenceApi } from "./reference-api.js";
+17 -1
View File
@@ -15,6 +15,8 @@ export type {
PluginOpenScreenInput,
PluginScreenLocation,
PluginSidebarItemProps,
PluginSidebarViewProps,
PluginSidebarViewContribution,
PluginSidebarItemContribution,
PluginIcon,
PluginPanelLocation,
@@ -70,7 +72,16 @@ export {
useFileUrl,
} from "./file.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export {
useAgent,
useWorkspace,
useProject,
useTerminal,
useWorkspaces,
useAgents,
useProjects,
useTerminals,
} from "./client-state.js";
export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js";
export type {
PluginSubscribeOptions,
@@ -102,3 +113,8 @@ export type { PluginI18n } from "./translations.js";
export type { PluginText, PluginMessage, PluginTranslations } from "../text.js";
export { useTabState } from "./tab.js";
export type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js";
export type { Query, Group, GroupKey } from "./query.js";
export type { PluginPaseoApi, PluginProjectHandle, PluginTerminalHandle } from "./reference-api.js";
+11 -5
View File
@@ -1,17 +1,23 @@
import type { PaseoApi } from "@getpaseo/client";
import type { PluginPaseoApi } from "./reference-api.js";
import { createContext, useContext, type ReactNode } from "react";
const PaseoApiContext = createContext<PaseoApi | null>(null);
const PaseoApiContext = createContext<PluginPaseoApi | null>(null);
export function usePaseoContextValue(): PaseoApi | null {
export function usePaseoContextValue(): PluginPaseoApi | null {
return useContext(PaseoApiContext);
}
export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) {
export function PaseoApiProvider({
children,
paseo,
}: {
children: ReactNode;
paseo: PluginPaseoApi;
}) {
return <PaseoApiContext.Provider value={paseo}>{children}</PaseoApiContext.Provider>;
}
export function usePaseo(): PaseoApi {
export function usePaseo(): PluginPaseoApi {
const paseo = usePaseoContextValue();
if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface");
return paseo;
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { createCollectionQuery, type GroupKey } from "./query.js";
const first = Object.freeze({ key: "a" });
const second = Object.freeze({ key: "b" });
const work: GroupKey = "work";
const snapshots = [
{ ref: first, hostId: "one", title: "A", status: "running", labels: [work, work] },
{ ref: second, hostId: "two", title: "B", status: "idle", labels: ["home"] },
];
describe("collection query structural sharing", () => {
it("preserves collection identity when a row changes without changing membership", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
const before = project(snapshots);
const after = project([{ ...snapshots[0]!, status: "waiting" }, snapshots[1]!]);
expect(after).toBe(before);
expect(after).toEqual([first, second]);
});
it("shares unaffected groups and deduplicates membership", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
const query = { groupBy: (item: (typeof snapshots)[number]) => item.labels };
const before = project(snapshots, query);
const after = project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query);
expect(after[1]).toBe(before[1]);
expect(before[0]).toEqual({ key: "work", title: null, color: null, items: [first] });
expect(after[0]).toEqual({ key: "new", title: null, color: null, items: [first] });
expect(project([{ ...snapshots[0]!, labels: ["new"] }, snapshots[1]!], query)).toBe(after);
});
it("filters hosts and predicates before sorting", () => {
const project = createCollectionQuery<(typeof snapshots)[number]>();
expect(project(snapshots, { hosts: ["two"] })).toEqual([second]);
expect(project(snapshots, { where: (item) => item.status === "running" })).toEqual([first]);
expect(project(snapshots, { orderBy: ["title", "desc"] })).toEqual([second, first]);
expect(project(snapshots, { orderBy: (a, b) => a.title.localeCompare(b.title) })).toEqual([
first,
second,
]);
});
it("keeps nulls last in either direction and metadata from the first group", () => {
const project = createCollectionQuery<{
ref: typeof first;
hostId: string;
title: string | null;
}>();
const items = [
{ ref: first, hostId: "one", title: null },
{ ref: second, hostId: "one", title: "Z" },
];
expect(project(items, { orderBy: ["title", "desc"] })).toEqual([second, first]);
expect(
project(items, { groupBy: (item) => ({ key: "all", title: item.title ?? "First" }) }),
).toEqual([{ key: "all", title: "First", color: null, items: [first, second] }]);
});
});
+115
View File
@@ -0,0 +1,115 @@
interface GroupDescriptor {
readonly key: string;
readonly title?: string;
readonly color?: string;
}
export type GroupKey = string | GroupDescriptor;
export interface Query<Snapshot> {
readonly hosts?: readonly string[];
readonly where?: (item: Snapshot) => boolean;
readonly orderBy?:
| keyof Snapshot
| readonly [keyof Snapshot, "asc" | "desc"]
| ((a: Snapshot, b: Snapshot) => number);
readonly groupBy?: (item: Snapshot) => GroupKey | readonly GroupKey[];
}
export interface Group<Ref> {
readonly key: string;
readonly title: string | null;
readonly color: string | null;
readonly items: readonly Ref[];
}
function groupKeys(value: GroupKey | readonly GroupKey[]): readonly GroupKey[] {
if (typeof value === "string") return [value];
if ("key" in value) return [value];
return value;
}
interface Referenced {
readonly hostId: string;
readonly ref: unknown;
}
function sameSequence<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((item, index) => item === right[index]);
}
function compareValues(left: unknown, right: unknown, direction: number): number {
if (Object.is(left, right)) return 0;
if (left === null || left === undefined) return 1;
if (right === null || right === undefined) return -1;
if (typeof left === "number" && typeof right === "number") return (left - right) * direction;
return String(left).localeCompare(String(right)) * direction;
}
/** A hook owns one projection; sharing survives both source and query changes. */
export function createCollectionQuery<Snapshot extends Referenced>() {
type Ref = Snapshot["ref"];
let previousItems: readonly Ref[] = Object.freeze([]);
let previousGroups: readonly Group<Ref>[] = Object.freeze([]);
return function project(
snapshots: readonly Snapshot[],
query: Query<Snapshot> = {},
): readonly Ref[] | readonly Group<Ref>[] {
const items = snapshots.filter((item) => {
if (query.hosts && !query.hosts.includes(item.hostId)) return false;
return !query.where || query.where(item);
});
const order = query.orderBy;
if (typeof order === "function") items.sort(order);
else if (order !== undefined) {
const field = typeof order === "object" ? order[0] : order;
const direction = typeof order === "object" && order[1] === "desc" ? -1 : 1;
items.sort((a, b) => compareValues(a[field], b[field], direction));
}
if (!query.groupBy) {
const refs = items.map((item) => item.ref);
if (!sameSequence(previousItems, refs)) previousItems = Object.freeze(refs);
return previousItems;
}
const groups = new Map<
string,
{ key: string; title: string | null; color: string | null; items: Ref[] }
>();
for (const item of items) {
const grouping = query.groupBy(item);
const keys = groupKeys(grouping);
const seen = new Set<string>();
for (const value of keys) {
const descriptor = typeof value === "string" ? { key: value } : value;
if (seen.has(descriptor.key)) continue;
seen.add(descriptor.key);
let group = groups.get(descriptor.key);
if (!group) {
group = {
key: descriptor.key,
title: descriptor.title ?? null,
color: descriptor.color ?? null,
items: [],
};
groups.set(descriptor.key, group);
}
group.items.push(item.ref);
}
}
const oldGroups = new Map(previousGroups.map((group) => [group.key, group]));
const nextGroups = Array.from(groups.values(), (group) => {
const previous = oldGroups.get(group.key);
if (
previous &&
previous.title === group.title &&
previous.color === group.color &&
sameSequence(previous.items, group.items)
)
return previous;
return Object.freeze({ ...group, items: Object.freeze(group.items) });
});
if (!sameSequence(previousGroups, nextGroups)) previousGroups = Object.freeze(nextGroups);
return previousGroups;
};
}
@@ -0,0 +1,88 @@
import type {
PaseoApi,
PaseoAgent,
PaseoAgentHandle,
PaseoWorkspace,
PaseoWorkspaceHandle,
PaseoWorkspaceCreateOptions,
} from "@getpaseo/client";
import type { PaseoTerminal, PaseoTerminalHandle } from "@getpaseo/client";
import type { PluginProjectSnapshot } from "../contracts.js";
import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js";
import type { PluginSidebarViewProps } from "./contracts.js";
export interface PluginProjectHandle {
readonly ref: ProjectRef;
open(): void;
createWorkspace(
input?: Omit<PaseoWorkspaceCreateOptions, "source"> & {
source?: PaseoWorkspaceCreateOptions["source"];
},
): Promise<PaseoWorkspaceHandle>;
}
export interface PluginTerminalHandle extends PaseoTerminalHandle {
focus(): void;
}
export interface PluginPaseoApi extends PaseoApi {
workspaces: Omit<PaseoApi["workspaces"], "ref"> & {
ref(workspace: string | PaseoWorkspace | WorkspaceRef): PaseoWorkspaceHandle;
};
agents: Omit<PaseoApi["agents"], "ref"> & {
ref(agent: string | PaseoAgent | AgentRef): PaseoAgentHandle;
};
projects: PaseoApi["projects"] & { ref(project: ProjectRef): PluginProjectHandle };
terminals: Omit<PaseoApi["terminals"], "ref"> & {
ref(terminal: TerminalRef): PluginTerminalHandle;
ref(terminal: string | PaseoTerminal): PaseoTerminalHandle;
};
}
/** Host adapter; the installation retains its single API identity across every surface. */
export function createPluginReferenceApi(input: {
paseo: PaseoApi;
resolveHost(hostId: string): PaseoApi;
readProject(ref: ProjectRef): PluginProjectSnapshot | null;
navigation: PluginSidebarViewProps["navigation"];
}): PluginPaseoApi {
const { paseo } = input;
const workspaceRef = paseo.workspaces.ref;
const agentRef = paseo.agents.ref;
const terminalRef = paseo.terminals.ref;
function refWorkspace(workspace: string | PaseoWorkspace | WorkspaceRef) {
if (typeof workspace !== "string" && "hostId" in workspace)
return input.resolveHost(workspace.hostId).workspaces.ref(workspace.id);
return workspaceRef(workspace);
}
function refAgent(agent: string | PaseoAgent | AgentRef) {
if (typeof agent !== "string" && "hostId" in agent)
return input.resolveHost(agent.hostId).agents.ref(agent.id);
return agentRef(agent);
}
function terminal(ref: TerminalRef): PluginTerminalHandle;
function terminal(ref: string | PaseoTerminal): PaseoTerminalHandle;
function terminal(ref: TerminalRef | string | PaseoTerminal): PaseoTerminalHandle {
if (typeof ref !== "string" && "hostId" in ref) {
const handle = input.resolveHost(ref.hostId).terminals.ref(ref.id);
return Object.assign(handle, { focus: () => input.navigation.openTerminal(ref) });
}
return terminalRef(ref);
}
function project(ref: ProjectRef): PluginProjectHandle {
return {
ref,
open: () => input.navigation.openProject(ref),
async createWorkspace(options = {}) {
const snapshot = input.readProject(ref);
if (!snapshot) throw new Error("Project is no longer available");
const source = options.source ?? { kind: "directory", path: snapshot.rootPath };
return input.resolveHost(ref.hostId).workspaces.create({ ...options, source });
},
};
}
return Object.assign(paseo, {
workspaces: Object.assign(paseo.workspaces, { ref: refWorkspace }),
agents: Object.assign(paseo.agents, { ref: refAgent }),
projects: Object.assign(paseo.projects, { ref: project }),
terminals: Object.assign(paseo.terminals, { ref: terminal }),
});
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { createEntityRefs } from "./refs.js";
describe("session entity references", () => {
it("interns frozen references separately by kind, host and entity", () => {
const refs = createEntityRefs();
for (const reference of [refs.workspace, refs.agent, refs.project, refs.terminal]) {
const first = reference("host", "entity");
expect(reference("host", "entity")).toBe(first);
expect(Object.isFrozen(first)).toBe(true);
expect(reference("other", "entity")).not.toBe(first);
expect(reference("host", "other")).not.toBe(first);
}
expect(refs.workspace("host", "entity")).not.toBe(refs.agent("host", "entity"));
});
it("keeps opaque ids unambiguous and interning scoped to one app session", () => {
const refs = createEntityRefs();
expect(refs.workspace("a:b", "c").key).not.toBe(refs.workspace("a", "b:c").key);
expect(createEntityRefs().workspace("host", "entity")).not.toBe(
refs.workspace("host", "entity"),
);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js";
/** Owned by the app session, shared by every plugin installation. */
export function createEntityRefs() {
function intern<Kind extends string>(kind: Kind) {
const references = new Map<
string,
Readonly<{ kind: Kind; key: string; hostId: string; id: string }>
>();
return function reference(hostId: string, id: string) {
const key = JSON.stringify([hostId, id]);
let ref = references.get(key);
if (!ref) {
ref = Object.freeze({ kind, key, hostId, id });
references.set(key, ref);
}
return ref;
};
}
return {
workspace: intern("workspace") satisfies (hostId: string, id: string) => WorkspaceRef,
agent: intern("agent") satisfies (hostId: string, id: string) => AgentRef,
project: intern("project") satisfies (hostId: string, id: string) => ProjectRef,
terminal: intern("terminal") satisfies (hostId: string, id: string) => TerminalRef,
};
}
+40
View File
@@ -1,3 +1,4 @@
import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "./refs.js";
import type { PluginText } from "./text.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginSerializableIcon } from "./icons.js";
@@ -19,7 +20,22 @@ export interface PluginTheme {
};
}
export type WorkspaceIndicator = "needs_input" | "failed" | "running" | "attention" | "done";
export interface PluginWorkspaceSnapshot {
readonly ref: WorkspaceRef;
readonly hostId: string;
readonly project: ProjectRef;
readonly pinnedAt: string | null;
readonly labels: readonly string[];
readonly background: boolean;
readonly branch: string | null;
/** Null when an older host has not supplied the workspace creation time. */
readonly createdAt: string | null;
readonly lastPromptAt: string | null;
readonly parentAgent: AgentRef | null;
readonly indicator: WorkspaceIndicator;
readonly indicatorLabel: string | null;
readonly id: string;
readonly projectId: string;
readonly projectDisplayName: string;
@@ -29,6 +45,7 @@ export interface PluginWorkspaceSnapshot {
readonly kind: "directory" | "local_checkout" | "checkout" | "worktree";
readonly name: string;
readonly title: string | null;
/** Raw daemon status; prefer indicator for presentation. */
readonly status: "needs_input" | "failed" | "running" | "attention" | "done";
readonly statusEnteredAt: string | null;
readonly archivingAt: string | null;
@@ -36,6 +53,10 @@ export interface PluginWorkspaceSnapshot {
}
export interface PluginAgentSnapshot {
readonly ref: AgentRef;
readonly hostId: string;
readonly workspace: WorkspaceRef;
readonly parentAgent: AgentRef | null;
readonly id: string;
readonly workspaceId: string;
readonly provider: string;
@@ -96,3 +117,22 @@ export interface PluginTimelineTransformResult {
}
export type PluginCleanup = () => void | Promise<void>;
export interface PluginProjectSnapshot {
readonly ref: ProjectRef;
readonly hostId: string;
readonly displayName: string;
readonly rootPath: string;
readonly kind: PluginWorkspaceSnapshot["projectKind"];
readonly iconRevision: string | null;
}
export interface PluginTerminalSnapshot {
readonly ref: TerminalRef;
readonly hostId: string;
readonly workspace: WorkspaceRef;
readonly title: string;
readonly isScript: boolean;
/** Null for terminals owned by workers started before timestamps were tracked. */
readonly createdAt: string | null;
}
+3
View File
@@ -3,6 +3,9 @@ export type {
PluginTheme,
PluginWorkspaceSnapshot,
PluginAgentSnapshot,
PluginProjectSnapshot,
PluginTerminalSnapshot,
WorkspaceIndicator,
PluginThemeColors,
PluginThemeContribution,
PluginAttachmentSourceContribution,
+11
View File
@@ -0,0 +1,11 @@
interface EntityRef<Kind extends string> {
readonly kind: Kind;
readonly key: string;
readonly hostId: string;
readonly id: string;
}
export interface WorkspaceRef extends EntityRef<"workspace"> {}
export interface AgentRef extends EntityRef<"agent"> {}
export interface ProjectRef extends EntityRef<"project"> {}
export interface TerminalRef extends EntityRef<"terminal"> {}
+4 -3
View File
@@ -4,10 +4,10 @@ import { defineRpc } from "./rpc.js";
export interface SettingsDefinition<Schema extends ZodType = ZodType> {
id: string;
scope: "host";
scope: "host" | "device";
version: number;
schema: Schema;
/** Convert a previous stored version to the current schema. Runs on the host. */
/** Convert a previous stored version to the current schema. Runs in the selected scope. */
migrate?: (values: unknown, fromVersion: number) => unknown | Promise<unknown>;
}
@@ -15,7 +15,8 @@ export function defineSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsDefinition<Schema> {
if (!/^[a-z][a-z0-9_-]*$/.test(definition.id)) throw new Error("Invalid settings ID");
if (definition.scope !== "host") throw new Error("Only host-scoped settings are supported");
if (definition.scope !== "host" && definition.scope !== "device")
throw new Error("Invalid settings scope");
if (!Number.isSafeInteger(definition.version) || definition.version < 1)
throw new Error("Invalid settings version");
return definition;