feat(plugins): add live timeline rows and client slash commands

Plugins can transform live timeline source items at render time, append durable plugin rows from daemon handlers, and register client-only slash commands in the composer.

Related to #3940 and #3952.
This commit is contained in:
Mohamed Boudra
2026-09-02 13:12:09 +02:00
committed by GitHub
parent 49784fac15
commit e34aea206e
66 changed files with 2122 additions and 511 deletions
@@ -2114,7 +2114,10 @@ export class AgentManager {
return true;
}
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
async appendTimelineItem(
agentId: string,
item: AgentTimelineItem,
): Promise<{ seq: number; epoch: string }> {
const agent = this.requireAgent(agentId);
item = limitAgentTimelineItemContent(item);
this.touchUpdatedAt(agent);
@@ -2133,6 +2136,7 @@ export class AgentManager {
},
);
await this.persistSnapshot(agent);
return { seq: row.seq, epoch: this.timelineStore.getEpoch(agentId) };
}
async emitLiveTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
@@ -1,6 +1,7 @@
import type {
AgentProviderNotice,
AgentTaskItem,
JsonValue,
ProviderOptions,
ToolPolicy,
} from "@getpaseo/protocol/agent-types";
@@ -390,6 +391,15 @@ export interface CompactionTimelineItem {
preTokens?: number;
}
export interface PluginTimelineItem {
type: "plugin";
id: string;
pluginId: string;
kind: string;
version: number;
data: JsonValue;
}
export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
@@ -397,7 +407,8 @@ export type AgentTimelineItem =
| ToolCallTimelineItem
| { type: "todo"; items: AgentTaskItem[] }
| { type: "error"; message: string }
| CompactionTimelineItem;
| CompactionTimelineItem
| PluginTimelineItem;
export type AgentStreamEvent =
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
@@ -1,6 +1,15 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
export function assertPluginTimelineDataSize(data: JsonValue): void {
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
throw new Error(`Plugin timeline item data exceeds ${PLUGIN_TIMELINE_DATA_MAX_BYTES} bytes`);
}
}
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
if (
@@ -176,6 +176,45 @@ describe("projectTimelineRows", () => {
expect(tool?.collapsed).toContain("tool_lifecycle");
});
test("replaces plugin rows with the same identity across turns", () => {
const rows: AgentTimelineRow[] = [
{
seq: 1,
turnId: "turn-1",
timestamp: "2026-02-13T00:00:00.000Z",
item: {
type: "plugin",
id: "review-1",
pluginId: "review",
kind: "review",
version: 1,
data: { status: "running" },
},
},
{
seq: 2,
turnId: "turn-2",
timestamp: "2026-02-13T00:00:00.100Z",
item: {
type: "plugin",
id: "review-1",
pluginId: "review",
kind: "review",
version: 1,
data: { status: "complete" },
},
},
];
const projected = projectTimelineRows({ rows, mode: "projected" });
expect(projected).toHaveLength(1);
expect(projected[0]?.item).toEqual(rows[1]?.item);
expect(projected[0]?.turnId).toBe("turn-2");
expect(projected[0]?.sourceSeqRanges).toEqual([{ startSeq: 1, endSeq: 2 }]);
expect(projected[0]?.collapsed).toContain("identity");
});
test("returns canonical rows unchanged in canonical mode", () => {
const rows: AgentTimelineRow[] = [
{
@@ -1,5 +1,6 @@
import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
import type { AgentTimelineRow } from "./agent-manager.js";
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
export type TimelineProjectionMode = "canonical" | "projected";
@@ -8,7 +9,11 @@ export interface TimelineSeqRange {
endSeq: number;
}
export type TimelineProjectionKind = "assistant_merge" | "reasoning_merge" | "tool_lifecycle";
export type TimelineProjectionKind =
| "assistant_merge"
| "reasoning_merge"
| "tool_lifecycle"
| "identity";
export type TimelineLimitDirection = "tail" | "before" | "after";
export interface TimelineProjectionEntry {
@@ -120,43 +125,68 @@ function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[]
}));
}
function collapseToolLifecycle(entries: readonly WorkingEntry[]): WorkingEntry[] {
function mergeIdentityMetadata(
existing: WorkingEntry,
entry: WorkingEntry,
collapseKind: TimelineProjectionKind,
): Pick<WorkingEntry, "sourceSeqRanges" | "collapsed"> {
return {
sourceSeqRanges: mergeSeqRanges(existing.sourceSeqRanges, entry.sourceSeqRanges),
collapsed: existing.collapsed.includes(collapseKind)
? existing.collapsed
: [...existing.collapsed, collapseKind],
};
}
function mergeIdentityEntries(existing: WorkingEntry, entry: WorkingEntry): WorkingEntry | null {
switch (entry.item.type) {
case "tool_call":
if (existing.item.type !== "tool_call" || existing.turnId !== entry.turnId) return null;
return {
...existing,
item: mergeToolCallItems(existing.item, entry.item),
timestamp: entry.timestamp,
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
...mergeIdentityMetadata(existing, entry, "tool_lifecycle"),
};
case "plugin":
if (existing.item.type !== "plugin") return null;
return {
...entry,
seqStart: existing.seqStart,
...mergeIdentityMetadata(existing, entry, "identity"),
};
default:
return null;
}
}
function collapseByIdentity(entries: readonly WorkingEntry[]): WorkingEntry[] {
const output: WorkingEntry[] = [];
const toolIndexByCallId = new Map<string, number>();
const indexByIdentity = new Map<string, number>();
for (const entry of entries) {
if (entry.item.type !== "tool_call") {
const identity = timelineItemIdentity(entry.item);
if (identity === null) {
output.push(entry);
continue;
}
const existingIndex = toolIndexByCallId.get(entry.item.callId);
const existingIndex = indexByIdentity.get(identity);
if (existingIndex === undefined) {
toolIndexByCallId.set(entry.item.callId, output.length);
indexByIdentity.set(identity, output.length);
output.push(entry);
continue;
}
const existing = output[existingIndex];
if (!existing || existing.item.type !== "tool_call" || existing.turnId !== entry.turnId) {
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
if (!merged) {
indexByIdentity.set(identity, output.length);
output.push(entry);
continue;
}
const mergedItem = mergeToolCallItems(existing.item, entry.item);
const mergedRanges = mergeSeqRanges(existing.sourceSeqRanges, entry.sourceSeqRanges);
const collapsed = existing.collapsed.includes("tool_lifecycle")
? existing.collapsed
: ([...existing.collapsed, "tool_lifecycle"] as TimelineProjectionKind[]);
output[existingIndex] = {
...existing,
item: mergedItem,
timestamp: entry.timestamp,
seqEnd: Math.max(existing.seqEnd, entry.seqEnd),
sourceSeqRanges: mergedRanges,
collapsed,
};
output[existingIndex] = merged;
}
return output;
@@ -264,7 +294,7 @@ export function projectTimelineRows(input: {
return canonical;
}
const toolCollapsed = collapseToolLifecycle(canonical);
const toolCollapsed = collapseByIdentity(canonical);
const assistantMerged = mergeAssistantChunks(toolCollapsed);
return mergeReasoningChunks(assistantMerged);
}
@@ -282,7 +312,7 @@ export function selectTimelineWindowByProjectedLimit(input: {
const { rows, direction } = input;
const limit = Math.max(0, Math.floor(input.limit));
const canonical = makeCanonicalEntries(rows);
const projectedAll = mergeReasoningChunks(mergeAssistantChunks(collapseToolLifecycle(canonical)));
const projectedAll = mergeReasoningChunks(mergeAssistantChunks(collapseByIdentity(canonical)));
if (projectedAll.length === 0) {
return {
@@ -18,6 +18,7 @@ const INBOUND_PERMISSION = {
"agent.skills.save_selection.request": "daemon.manage",
"agent.skills.uninstall.request": "daemon.manage",
"agent.timeline.list_prompts.request": "workspace.read",
"agent.timeline.append.request": "workspace.write",
"agent.timeline.set_subscription.request": "workspace.read",
agent_permission_response: "workspace.write",
archive_agent_request: "workspace.write",
@@ -215,6 +216,7 @@ const OUTBOUND_PERMISSION = {
"agent.skills.save_selection.response": "daemon.manage",
"agent.skills.uninstall.response": "daemon.manage",
"agent.timeline.list_prompts.response": "workspace.read",
"agent.timeline.append.response": "workspace.write",
"agent.timeline.replacement": "workspace.read",
"agent.timeline.set_subscription.response": "workspace.read",
agent_archived: "workspace.read",
@@ -138,6 +138,13 @@ describe("plugin contribution targets", () => {
schema: { safeParse(value) { return { success: true, data: value }; } },
Component() { return null; },
});
plugin.addClientSlashCommand({
name: "review",
description: "Review changes",
argumentHint: "[scope]",
context: "agent",
onSubmit() {},
});
plugin.addClientSide((client) => {
return client.addComposerPill({
id: "composer-card",
@@ -156,12 +163,38 @@ describe("plugin contribution targets", () => {
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).toContain("timeline-card");
expect(clientBundle).toContain("composer-card");
expect(clientBundle).toContain("Review changes");
expect(serverBundle).not.toContain("timeline-card");
expect(serverBundle).not.toContain("composer-card");
expect(serverBundle).not.toContain("Review changes");
});
});
describe("plugin client runtime syntax", () => {
it("uses the automatic JSX runtime without a React import", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
const entryPath = path.join(directory, "index.tsx");
await writeFile(
entryPath,
`import { Text } from "react-native";
function Surface() {
return <Text>Automatic JSX</Text>;
}
export default function contribute(plugin) {
plugin.addSurface("automatic-jsx", Surface);
return () => undefined;
}
`,
);
const { clientBundle } = await compilePlugin(entryPath);
expect(clientBundle).toContain("react/jsx-runtime");
expect(clientBundle).not.toContain("React.createElement");
});
it("lowers async callbacks before Hermes evaluates the client bundle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
@@ -83,6 +83,7 @@ const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<str
"addSidebarItem",
"addWorkspacePanel",
"addCommandCenterItem",
"addClientSlashCommand",
"addClientSide",
"addAttachmentSource",
"addTheme",
@@ -323,6 +324,7 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
},
bundle: true,
format: "cjs",
jsx: target === "client" ? "automatic" : undefined,
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
@@ -37,6 +37,12 @@ const list = defineRpc({
output: z.object({ agentIds: z.array(z.string()) }),
});
const append = defineRpc({
name: "append",
input: z.object({ agentId: z.string(), status: z.string() }),
output: z.object({ seq: z.number(), epoch: z.string() }),
});
export default function contribute(plugin: PluginContext) {
plugin.handle(create, async ({ path }, { paseo }) => {
const workspace = await paseo.workspaces.create({
@@ -53,6 +59,15 @@ export default function contribute(plugin: PluginContext) {
const result = await paseo.agents.list({ page: { limit: 100 } });
return { agentIds: result.entries.map((entry) => entry.agent.id) };
});
plugin.handle(append, ({ agentId, status }, { paseo }) =>
paseo.agents.ref(agentId).timeline.append({
type: "plugin",
id: "review-1",
kind: "review",
version: 1,
data: { status },
}),
);
return () => undefined;
}`,
);
@@ -88,6 +103,22 @@ export default function contribute(plugin: PluginContext) {
expect(listed).toEqual({
agentIds: expect.arrayContaining([Reflect.get(created, "agentId")]),
});
const agentId = Reflect.get(created, "agentId");
await expect(
client.invokePluginRpc("paseo-api", "append", { agentId, status: "running" }),
).resolves.toEqual({ seq: expect.any(Number), epoch: expect.any(String) });
await client.invokePluginRpc("paseo-api", "append", { agentId, status: "complete" });
const timeline = await client.fetchAgentTimeline(agentId, { projection: "projected" });
expect(timeline.entries.filter((entry) => entry.item.type === "plugin")).toEqual([
expect.objectContaining({
item: expect.objectContaining({
type: "plugin",
id: "review-1",
pluginId: "paseo-api",
data: { status: "complete" },
}),
}),
]);
await client.removePlugin("paseo-api");
const workspaces = await client.fetchWorkspaces();
const agents = await client.fetchAgents();
@@ -10,6 +10,7 @@ import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
import { isPluginClientOnlySdkSpecifier, isPluginSdkSpecifier } from "./plugin-sdk-specifiers.js";
import { createPluginClientId } from "./plugin-session-identity.js";
type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise<unknown>;
@@ -106,7 +107,7 @@ const transportFactory = createPluginDaemonTransportFactory({
async function initialize(message: Extract<PluginProcessRequest, { type: "initialize" }>) {
daemonClient = new DaemonClient({
url: `ipc://plugin/${encodeURIComponent(message.pluginId)}`,
clientId: `plugin:${message.pluginId}`,
clientId: createPluginClientId(message.pluginId),
clientType: "cli",
appVersion: message.appVersion,
reconnect: { enabled: false },
@@ -0,0 +1,13 @@
const PLUGIN_CLIENT_ID_PREFIX = "plugin:";
export function createPluginClientId(pluginId: string): string {
return `${PLUGIN_CLIENT_ID_PREFIX}${pluginId}`;
}
export function parsePluginClientId(clientId: string): string | null {
return isPluginClientId(clientId) ? clientId.slice(PLUGIN_CLIENT_ID_PREFIX.length) : null;
}
export function isPluginClientId(clientId: string): boolean {
return clientId.startsWith(PLUGIN_CLIENT_ID_PREFIX);
}
+96 -1
View File
@@ -282,6 +282,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
});
interface SessionForTestOptions {
clientId?: string;
permissions?: readonly DaemonPermission[];
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown };
@@ -360,7 +361,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
const messages = options.messages ?? [];
const sessionOptions: SessionOptions = {
clientId: "test-client",
clientId: options.clientId ?? "test-client",
onMessage: (message) => messages.push(message),
...(options.targetedMessages
? {
@@ -1375,6 +1376,100 @@ function createStoredAgentRecord(
};
}
describe("plugin timeline append RPC", () => {
test("stamps the plugin identity and returns the timeline position", async () => {
const messages: SessionOutboundMessage[] = [];
const appendTimelineItem = vi.fn().mockResolvedValue({ seq: 7, epoch: "epoch-1" });
const session = createSessionForTest({
clientId: "plugin:review",
messages,
agentManager: { appendTimelineItem },
});
await session.handleMessage({
type: "agent.timeline.append.request",
requestId: "append-1",
agentId: "agent-1",
item: {
type: "plugin",
id: "review-1",
kind: "review",
version: 1,
data: { status: "running" },
},
});
expect(appendTimelineItem).toHaveBeenCalledWith("agent-1", {
type: "plugin",
id: "review-1",
pluginId: "review",
kind: "review",
version: 1,
data: { status: "running" },
});
expect(messages).toContainEqual({
type: "agent.timeline.append.response",
payload: { requestId: "append-1", seq: 7, epoch: "epoch-1" },
});
});
test("rejects append requests from non-plugin sessions", async () => {
const messages: SessionOutboundMessage[] = [];
const appendTimelineItem = vi.fn();
const session = createSessionForTest({ messages, agentManager: { appendTimelineItem } });
await session.handleMessage({
type: "agent.timeline.append.request",
requestId: "append-1",
agentId: "agent-1",
item: { type: "plugin", id: "review-1", kind: "review", version: 1, data: {} },
});
expect(appendTimelineItem).not.toHaveBeenCalled();
expect(messages).toContainEqual(
expect.objectContaining({
type: "rpc_error",
payload: expect.objectContaining({ requestId: "append-1", code: "handler_error" }),
}),
);
});
test("rejects plugin data larger than the append budget", async () => {
const messages: SessionOutboundMessage[] = [];
const appendTimelineItem = vi.fn();
const session = createSessionForTest({
clientId: "plugin:review",
messages,
agentManager: { appendTimelineItem },
});
await session.handleMessage({
type: "agent.timeline.append.request",
requestId: "append-large",
agentId: "agent-1",
item: {
type: "plugin",
id: "review-1",
kind: "review",
version: 1,
data: { text: "x".repeat(64 * 1024) },
},
});
expect(appendTimelineItem).not.toHaveBeenCalled();
expect(messages).toContainEqual(
expect.objectContaining({
type: "rpc_error",
payload: expect.objectContaining({
requestId: "append-large",
code: "handler_error",
error: expect.stringContaining("65536 bytes"),
}),
}),
);
});
});
describe("agent detach RPC", () => {
test("detaches a stored subagent and emits the updated standalone agent", async () => {
const messages: unknown[] = [];
+20
View File
@@ -107,6 +107,8 @@ import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
import { parsePluginClientId } from "./plugins/plugin-session-identity.js";
import {
projectTimelineRows,
selectProjectedTimelinePage,
@@ -2252,6 +2254,8 @@ export class Session {
switch (msg.type) {
case "fetch_agent_timeline_request":
return this.handleFetchAgentTimelineRequest(msg, source);
case "agent.timeline.append.request":
return this.handleAgentTimelineAppendRequest(msg);
case "agent.timeline.list_prompts.request":
return this.handleAgentTimelineListPromptsRequest(msg, source);
case "agent.provider_subagents.list.request":
@@ -7097,6 +7101,22 @@ export class Session {
}
}
private async handleAgentTimelineAppendRequest(
msg: Extract<SessionInboundMessage, { type: "agent.timeline.append.request" }>,
): Promise<void> {
const pluginId = parsePluginClientId(this.clientId);
if (!pluginId) throw new Error("Only plugin sessions can append plugin timeline items");
assertPluginTimelineDataSize(msg.item.data);
const { seq, epoch } = await this.agentManager.appendTimelineItem(msg.agentId, {
...msg.item,
pluginId,
});
this.emit({
type: "agent.timeline.append.response",
payload: { requestId: msg.requestId, seq, epoch },
});
}
private async handleAgentTimelineListPromptsRequest(
msg: Extract<SessionInboundMessage, { type: "agent.timeline.list_prompts.request" }>,
source?: object,
@@ -49,6 +49,7 @@ import type {
} from "./workspace-git-service.js";
import type { GitCommandRuntimeMetricsSnapshot } from "../utils/git-command-runtime-metrics.js";
import { snapshotGitCommandRuntimeMetrics } from "../utils/run-git-command.js";
import { createPluginClientId, isPluginClientId } from "./plugins/plugin-session-identity.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js";
import {
@@ -1521,10 +1522,10 @@ export class VoiceAssistantWebSocketServer {
}
const pluginId = this.pluginSocketIds.get(ws);
const expectedPluginClientId = pluginId ? `plugin:${pluginId}` : null;
const expectedPluginClientId = pluginId ? createPluginClientId(pluginId) : null;
if (
(expectedPluginClientId !== null && clientId !== expectedPluginClientId) ||
(expectedPluginClientId === null && clientId.startsWith("plugin:"))
(expectedPluginClientId === null && isPluginClientId(clientId))
) {
this.clearPendingConnection(ws);
pending.connectionLogger.warn({ clientId }, "Rejected reserved plugin clientId");
@@ -1670,6 +1671,7 @@ export class VoiceAssistantWebSocketServer {
pluginLogs: true,
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
pluginThemes: true,
pluginTimelineItems: true,
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
skillManagement: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.