From ebd3755f7f1fcb43eb850df11937c435e9d77cd6 Mon Sep 17 00:00:00 2001 From: nach <10445733+nachogames@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:39:08 -0500 Subject: [PATCH 1/4] feat(server): load local plugins and draw Claude Code mod UI Plugins are folders under /plugins with a t3-plugin.json manifest (upstream's shape). The host watches the folder, so a plugin an agent writes loads without a restart. An optional entry runs in a supervised Node child process and draws thread statuses, toasts and composer bands; views are themed HTML pages for the right panel with a t3.call bridge. Claude Code mod ui_status, ui_toast and AbovePrompt bands (ported from pingdotgg/t3code#15827) land in the same surfaces. Agents on any provider find the folder, an authoring guide and load errors through t3_plugin_list and t3_plugin_reload. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 3 + apps/server/src/mcp/McpHttpServer.ts | 5 + .../src/mcp/toolkits/plugins/handlers.ts | 18 + apps/server/src/mcp/toolkits/plugins/tools.ts | 62 ++ .../toolkits/worktree/registration.test.ts | 2 + .../src/observability/RpcInstrumentation.ts | 8 + .../Adapters/ClaudeAdapterV2.test.ts | 2 + .../Adapters/ClaudeAdapterV2.ts | 24 + apps/server/src/plugins/ClaudeModUi.ts | 263 ++++++ apps/server/src/plugins/PluginHost.test.ts | 179 ++++ apps/server/src/plugins/PluginHost.ts | 851 ++++++++++++++++++ apps/server/src/plugins/PluginUiStore.test.ts | 168 ++++ apps/server/src/plugins/PluginUiStore.ts | 254 ++++++ apps/server/src/plugins/pluginRunner.ts | 212 +++++ apps/server/src/server.ts | 5 + apps/server/src/ws.ts | 23 +- packages/client-runtime/src/t3ToolSummary.ts | 6 + .../contracts/src/clientRpcPermissions.ts | 7 + packages/contracts/src/index.ts | 1 + packages/contracts/src/plugins.ts | 287 ++++++ packages/contracts/src/rpc.ts | 87 +- packages/shared/package.json | 4 + packages/shared/src/htmlRender.ts | 10 +- packages/shared/src/pluginView.ts | 16 + packages/shared/src/t3McpToolPresentation.ts | 4 + 25 files changed, 2495 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/plugins/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/plugins/tools.ts create mode 100644 apps/server/src/plugins/ClaudeModUi.ts create mode 100644 apps/server/src/plugins/PluginHost.test.ts create mode 100644 apps/server/src/plugins/PluginHost.ts create mode 100644 apps/server/src/plugins/PluginUiStore.test.ts create mode 100644 apps/server/src/plugins/PluginUiStore.ts create mode 100644 apps/server/src/plugins/pluginRunner.ts create mode 100644 packages/contracts/src/plugins.ts create mode 100644 packages/shared/src/pluginView.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index c9d32fdac8a0e..7912f152faaa7 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -117,6 +117,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pushUnregisterDevice]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope, [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsBandsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsViewRead]: AuthOrchestrationReadScope, [WS_METHODS.scheduledTasksList]: AuthOrchestrationReadScope, [WS_METHODS.scheduledTasksSubscribe]: AuthOrchestrationReadScope, [WS_METHODS.reviewSessionsReadHost]: AuthOrchestrationReadScope, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index ac14b8393a40e..83b1fc6f011f5 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -61,6 +61,8 @@ import { DeviceStandardToolkit, } from "./toolkits/device/tools.ts"; import * as HtmlHandlers from "./toolkits/html/handlers.ts"; +import * as PluginHandlers from "./toolkits/plugins/handlers.ts"; +import { PluginToolkit } from "./toolkits/plugins/tools.ts"; import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts"; /** Where an MCP client discovers how to sign in (RFC 9728), at this request's own origin. */ @@ -846,6 +848,8 @@ export const layerAttachmentToolkit = toolkitRegistration( AttachmentHandlers.layer, ); +export const layerPluginToolkit = toolkitRegistration(PluginToolkit, PluginHandlers.layer); + export const layerPullRequestsToolkit = toolkitRegistration( PullRequestsToolkit, PullRequestsHandlers.layer, @@ -888,4 +892,5 @@ export const layer = Layer.mergeAll( layerPullRequestsToolkit, layerDeviceToolkit, layerHtmlToolkit, + layerPluginToolkit, ).pipe(Layer.provideMerge(layerMcpTransport)); diff --git a/apps/server/src/mcp/toolkits/plugins/handlers.ts b/apps/server/src/mcp/toolkits/plugins/handlers.ts new file mode 100644 index 0000000000000..c0e839fcd7380 --- /dev/null +++ b/apps/server/src/mcp/toolkits/plugins/handlers.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; + +import * as PluginHost from "../../../plugins/PluginHost.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { PLUGIN_AUTHORING_GUIDE, PluginToolkit } from "./tools.ts"; + +const listing = (reload: boolean) => + Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const plugins = yield* reload ? host.reload : host.list; + return { pluginsDir: host.pluginsDir, guide: PLUGIN_AUTHORING_GUIDE, plugins }; + }); + +export const layer = McpToolAccess.toLayer(PluginToolkit, { + t3_plugin_list: McpToolAccess.reads(() => listing(false)), + // Reloading runs the plugins' code, so read-only clients cannot ask for it. + t3_plugin_reload: McpToolAccess.writesWithCapability(() => listing(true)), +}); diff --git a/apps/server/src/mcp/toolkits/plugins/tools.ts b/apps/server/src/mcp/toolkits/plugins/tools.ts new file mode 100644 index 0000000000000..97dc2c59114c2 --- /dev/null +++ b/apps/server/src/mcp/toolkits/plugins/tools.ts @@ -0,0 +1,62 @@ +import { OrchestratorMcpFailure, PLUGIN_API_VERSION, PluginSummary } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; + +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as PluginHost from "../../../plugins/PluginHost.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +/** How to write a plugin, returned with every listing so an agent never needs another source. */ +export const PLUGIN_AUTHORING_GUIDE = [ + "A plugin is a folder in pluginsDir holding t3-plugin.json. T3 watches the folder and loads, reloads and restarts plugins on save; no restart needed.", + `t3-plugin.json: {"id": "owner.name" (lowercase, two or more dot segments), "name", "version", "description"?, "apiVersion": ${PLUGIN_API_VERSION}, "entry"?: "index.mjs", "views"?: [{"id", "title", "html": "view.html"}], "commands"?: [{"name", "description", "prompt"?}]}.`, + "views: a self-contained HTML page shown as a tab in the right panel, in a sandboxed frame with the app's theme as CSS variables (--background, --foreground, --muted-foreground, --border, --primary, --font-sans, --font-mono). In the page, `await t3.call(name, input)` runs the entry's ctx.handle(name, fn) and returns its value; `t3.ready(({ threadId }) => ...)` gives the thread the panel belongs to.", + "commands: a /name in the composer menu. With `prompt`, choosing it puts that text in the composer. Without, it calls the entry's ctx.onCommand(name, ({ threadId, args }) => ...).", + "entry: an ES or CommonJS module exporting activate(ctx); it runs in its own Node process with the user's access and may return a cleanup function. ctx.ui.setStatus(threadId, key, text, { tone, tooltip }) shows a short line on a thread (text '' clears it). ctx.ui.toast({ title, body, tone, threadId }) shows a toast. ctx.ui.setBand(threadId, tree) draws a band above that thread's composer (null clears it); build trees with ctx.ui.h(type, props, ...children) from Box (flex props), Text (color, bold, dimColor), Button ({ label, onPress }), Link ({ href, label }) and Code ({ source }). ctx.onEvent(fn) receives { type: 'run', threadId, runId, status } and { type: 'thread', threadId, change, title }. Tones: neutral, info, success, warning, error.", + "After writing files, call t3_plugin_reload and check the plugin's state, error and recentLogs. A thread id for testing is your own thread's id.", +].join("\n"); + +const PluginDetails = Schema.Struct({ + ...PluginSummary.fields, + recentLogs: Schema.Array(Schema.String), +}); + +const PluginListing = Schema.Struct({ + pluginsDir: Schema.String, + guide: Schema.String, + plugins: Schema.Array(PluginDetails), +}); + +const shared = { + success: PluginListing, + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + PluginHost.PluginHost, + ], +}; + +const PluginListTool = Tool.make("t3_plugin_list", { + ...shared, + description: + "List T3 plugins with their state, errors and recent log lines, plus the plugins folder and a guide to writing one. Plugins add UI to T3: right-panel views, thread status lines, toasts, a band above the composer, and / commands.", + parameters: Tool.EmptyParams, +}) + .annotate(Tool.Title, "List plugins") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +const PluginReloadTool = Tool.make("t3_plugin_reload", { + ...shared, + description: + "Rescan the plugins folder now, restart plugins whose files changed, and return the same listing as t3_plugin_list. Use it after writing or editing a plugin to see whether it loaded.", + parameters: Tool.EmptyParams, +}) + .annotate(Tool.Title, "Reload plugins") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + +export const PluginToolkit = Toolkit.make(PluginListTool, PluginReloadTool); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index b7a8018cb57da..19655f2fd2deb 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -38,6 +38,7 @@ import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts"; import * as GroupResourceScanner from "../../../threadGroups/GroupResourceScanner.ts"; +import * as PluginHost from "../../../plugins/PluginHost.ts"; import * as ReviewSessionService from "../../../reviewSessions/ReviewSessionService.ts"; const layerStubServices = Layer.mergeAll( @@ -67,6 +68,7 @@ const layerStubServices = Layer.mergeAll( Layer.mock(ThreadGroupStore.ThreadGroupStoreV2)({}), Layer.mock(GroupResourceScanner.GroupResourceScanner)({}), Layer.mock(ReviewSessionService.ReviewSessionService)({}), + Layer.mock(PluginHost.PluginHost)({ pluginsDir: "/unused" }), ); const ToolsListPayload = Schema.fromJsonString( diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index a6e36d89d1489..a0a8c92c1a09d 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -90,6 +90,14 @@ const RPC_AGGREGATES = { [WS_METHODS.serverGetBackgroundPolicy]: "server", [WS_METHODS.scheduledTasksList]: "scheduledTasks", [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", + [WS_METHODS.pluginsSubscribe]: "plugins", + [WS_METHODS.pluginsBandsSubscribe]: "plugins", + [WS_METHODS.pluginsBandPress]: "plugins", + [WS_METHODS.pluginsViewRead]: "plugins", + [WS_METHODS.pluginsViewCall]: "plugins", + [WS_METHODS.pluginsCommandRun]: "plugins", + [WS_METHODS.pluginsSetEnabled]: "plugins", + [WS_METHODS.pluginsReload]: "plugins", [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", [WS_METHODS.scheduledTasksDelete]: "scheduledTasks", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ccc40911d83ef..451166c2c3276 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -76,6 +76,7 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts"; import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; +import { PluginToolkit } from "../../mcp/toolkits/plugins/tools.ts"; import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import { GroupToolkit } from "../../mcp/toolkits/group/tools.ts"; @@ -701,6 +702,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { ...Object.values(EnvironmentToolkit.tools), ...Object.values(PreviewControlsToolkit.tools), ...Object.values(HtmlToolkit.tools), + ...Object.values(PluginToolkit.tools), ] .filter((tool) => Context.get(tool.annotations, Tool.Readonly)) .map((tool) => `mcp__t3-code__${tool.name}`) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1f5c4fc8ad6bb..2aa5eddeacd77 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -133,6 +133,7 @@ import { boundProviderEventForLogging, shouldPersistProviderEvent, } from "../../provider/EventNdjsonLogger.ts"; +import * as ClaudeModUi from "../../plugins/ClaudeModUi.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { claudeRateLimitEventToUpdate, @@ -702,6 +703,16 @@ export function makeClaudeAgentSdkProtocolLogger(input: { }; } +/** The SDK `Query`'s control-request sender, when this SDK build has one. */ +const claudeControlRequestOf = ( + queryRuntime: unknown, +): ClaudeModUi.ClaudeControlRequest | undefined => { + const request = (queryRuntime as { request?: unknown }).request; + return typeof request === "function" + ? (body) => (request as ClaudeModUi.ClaudeControlRequest).call(queryRuntime, body) + : undefined; +}; + export const layerQueryRunner: Layer.Layer< ClaudeAgentSdkQueryRunner, never, @@ -711,6 +722,7 @@ export const layerQueryRunner: Layer.Layer< Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; + const modUi = yield* ClaudeModUi.ClaudeModUi; const agentScope = yield* AgentScope; const platform = yield* HostProcess.Platform; @@ -834,6 +846,13 @@ export const layerQueryRunner: Layer.Layer< ), ); + // Mod UI (`ui_render`, `ui_press`) rides the SDK's control-request + // channel, which the SDK only exposes as an untyped method. + const modUiRequest = claudeControlRequestOf(queryRuntime); + if (modUiRequest !== undefined) { + yield* modUi.attach(input.threadId, modUiRequest); + } + return { messages: Stream.fromAsyncIterable(claudeQueryMessages(queryRuntime), (cause) => queryRunnerError(cause, "fromAsyncIterable"), @@ -845,6 +864,7 @@ export const layerQueryRunner: Layer.Layer< payload: message, }), ), + Stream.tap((message) => modUi.ingest(input.threadId, message, modUiRequest)), ), offer: (message) => Queue.offer(promptQueue, message).pipe( @@ -937,6 +957,9 @@ export const layerQueryRunner: Layer.Layer< ), close: Queue.shutdown(promptQueue).pipe( Effect.andThen(closeClaudeQuery(queryRuntime)), + Effect.ensuring( + modUiRequest === undefined ? Effect.void : modUi.detach(input.threadId, modUiRequest), + ), Effect.tap(() => logProtocolEvent({ direction: "outgoing", @@ -1202,6 +1225,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_queue_read", "mcp__t3-code__html_preview", "mcp__t3-code__html_render", + "mcp__t3-code__t3_plugin_list", ]; // Claude Code aborts an HTTP MCP call after 60 s ("The operation timed out.") diff --git a/apps/server/src/plugins/ClaudeModUi.ts b/apps/server/src/plugins/ClaudeModUi.ts new file mode 100644 index 0000000000000..ff6620fb806f4 --- /dev/null +++ b/apps/server/src/plugins/ClaudeModUi.ts @@ -0,0 +1,263 @@ +/** + * ClaudeModUi - draws what Claude Code mods put on screen in a headless + * Claude session: `$.ui.status` lines, `$.ui.toast` notices and the + * `AbovePrompt` band a mod's `ui.render` hook returns. They land in the same + * PluginUiStore surfaces local plugins use, with a `claude-mod` source. + * + * The session pushes `system` messages (`ui_status`, `ui_toast`, + * `ui_invalidate`) and draws a band only when asked with a `ui_render` + * control request. The query runner hands every message to `ingest` and + * registers the live query's control-request sender with `attach`; this + * service asks for the band on attach and on each invalidate. + * + * Ported from pingdotgg/t3code#15827. The protocol is internal to the Claude + * CLI, so any failure here is logged and leaves the thread without mod UI; it + * never affects a turn. + * + * @module ClaudeModUi + */ +import { + PLUGIN_BAND_COLUMNS, + type PluginUiElement, + type PluginUiSource, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as PluginUiStore from "./PluginUiStore.ts"; + +/** Sends one control request on the thread's live Claude query. */ +export type ClaudeControlRequest = (request: Record) => Promise; + +const BAND_INSTANCE_ID = "above-prompt"; +const BAND_MAX_ROWS = 12; +const DEFAULT_TOAST_TIMEOUT_MS = 4000; + +/** The mods' combined AbovePrompt band is one tree, drawn under one source. */ +export const CLAUDE_MOD_BAND_SOURCE: PluginUiSource = { kind: "claude-mod", name: "Claude Code" }; + +interface ThreadEntry { + request: ClaudeControlRequest | undefined; + rendering: boolean; + renderAgain: boolean; + /** Mods with a status line on this thread, cleared when the query closes. */ + statusMods: Set; +} + +export interface ClaudeModUiShape { + readonly attach: (threadId: ThreadId, request: ClaudeControlRequest) => Effect.Effect; + readonly detach: (threadId: ThreadId, request: ClaudeControlRequest) => Effect.Effect; + /** `from` is the sending query's sender; messages from a replaced query are dropped. */ + readonly ingest: ( + threadId: ThreadId, + message: unknown, + from: ClaudeControlRequest | undefined, + ) => Effect.Effect; + /** Forwards a band Button press to the thread's live query. */ + readonly press: (input: { + readonly threadId: ThreadId; + readonly plugin: string; + readonly handle: number; + readonly key?: string | undefined; + }) => Effect.Effect; +} + +/** Draws nothing; the query runner uses it wherever the server graph supplies no ClaudeModUi. */ +const noopClaudeModUi: ClaudeModUiShape = { + attach: () => Effect.void, + detach: () => Effect.void, + ingest: () => Effect.void, + press: () => Effect.void, +}; + +/** + * A reference, not a required service, so provider layers and their tests + * build without it; the server provides the real one below its providers. + */ +export class ClaudeModUi extends Context.Reference("t3/plugins/ClaudeModUi", { + defaultValue: () => noopClaudeModUi, +}) {} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * A band tree worth drawing: an element the mods drew, not the engine's own + * placeholder. The SDK resolves a control request with the whole + * `{ subtype, request_id, response }` envelope. + */ +export const bandFromRenderResponse = (envelope: unknown): PluginUiElement | null => { + const response = isRecord(envelope) ? envelope.response : undefined; + if (!isRecord(response) || response.hooked !== true) return null; + const tree = response.tree; + if (!isRecord(tree) || typeof tree.type !== "string" || tree.type === "engine") return null; + return tree as unknown as PluginUiElement; +}; + +export const make = Effect.gen(function* () { + const store = yield* PluginUiStore.PluginUiStore; + const threads = new Map(); + + const entryFor = (threadId: ThreadId): ThreadEntry => { + let entry = threads.get(threadId); + if (entry === undefined) { + entry = { request: undefined, rendering: false, renderAgain: false, statusMods: new Set() }; + threads.set(threadId, entry); + } + return entry; + }; + + const removeIfIdle = (threadId: ThreadId, entry: ThreadEntry) => + Effect.sync(() => { + if (!entry.rendering && entry.request === undefined && threads.get(threadId) === entry) { + threads.delete(threadId); + } + }); + + /** Asks the CLI to draw the AbovePrompt band and stores it if this query is still current. */ + const renderBand = (threadId: ThreadId, request: ClaudeControlRequest) => + Effect.tryPromise(() => + request({ + subtype: "ui_render", + surface: "desktop", + component: "AbovePrompt", + instance_id: BAND_INSTANCE_ID, + props: { + hasSurvey: false, + isWorking: false, + maxRows: BAND_MAX_ROWS, + bodyColumns: PLUGIN_BAND_COLUMNS, + scroll: { offset: 0, bodyRows: BAND_MAX_ROWS }, + }, + }), + ).pipe( + Effect.flatMap((response) => + threads.get(threadId)?.request === request + ? store.setBand(threadId, CLAUDE_MOD_BAND_SOURCE, bandFromRenderResponse(response)) + : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logDebug("claude-mod-ui.render-failed", { threadId, cause }), + ), + ); + + /** Asks for the band, folding invalidates that land mid-render into one more render. */ + const requestRender = (threadId: ThreadId) => + Effect.suspend(() => { + const entry = entryFor(threadId); + if (entry.request === undefined) return Effect.void; + if (entry.rendering) { + entry.renderAgain = true; + return Effect.void; + } + entry.rendering = true; + const loop: Effect.Effect = Effect.suspend(() => { + const request = entry.request; + entry.renderAgain = false; + if (request === undefined) return Effect.void; + return renderBand(threadId, request).pipe( + Effect.andThen(Effect.suspend(() => (entry.renderAgain ? loop : Effect.void))), + ); + }); + return loop.pipe( + Effect.ensuring( + Effect.sync(() => { + entry.rendering = false; + }).pipe(Effect.andThen(removeIfIdle(threadId, entry))), + ), + Effect.forkDetach, + Effect.asVoid, + ); + }); + + const clearThread = (threadId: ThreadId, entry: ThreadEntry) => + Effect.gen(function* () { + yield* store.setBand(threadId, CLAUDE_MOD_BAND_SOURCE, null); + for (const name of entry.statusMods) { + yield* store.clearSource({ kind: "claude-mod", name }, threadId); + } + entry.statusMods.clear(); + }); + + const attach: ClaudeModUiShape["attach"] = (threadId, request) => + Effect.suspend(() => { + entryFor(threadId).request = request; + return requestRender(threadId); + }); + + const detach: ClaudeModUiShape["detach"] = (threadId, request) => + Effect.suspend(() => { + const entry = threads.get(threadId); + // A replacement query may already have attached; only its own close clears it. + if (entry === undefined || entry.request !== request) return Effect.void; + entry.request = undefined; + return clearThread(threadId, entry).pipe(Effect.andThen(removeIfIdle(threadId, entry))); + }); + + const ingest: ClaudeModUiShape["ingest"] = (threadId, message, from) => { + if (!isRecord(message) || message.type !== "system") return Effect.void; + const entry = threads.get(threadId); + if (entry === undefined || entry.request !== from) return Effect.void; + switch (message.subtype) { + case "ui_status": { + if (typeof message.plugin !== "string") return Effect.void; + const text = typeof message.text === "string" ? message.text : ""; + entry.statusMods.add(message.plugin); + return store.setStatus( + threadId, + { kind: "claude-mod", name: message.plugin }, + { + key: "status", + text, + }, + ); + } + case "ui_toast": { + if (typeof message.plugin !== "string" || typeof message.text !== "string") { + return Effect.void; + } + return store.toast({ + source: { kind: "claude-mod", name: message.plugin }, + title: message.plugin, + body: message.text, + threadId, + timeoutMs: + typeof message.timeout_ms === "number" ? message.timeout_ms : DEFAULT_TOAST_TIMEOUT_MS, + }); + } + case "ui_invalidate": + return requestRender(threadId); + default: + return Effect.void; + } + }; + + const press: ClaudeModUiShape["press"] = (input) => + Effect.suspend(() => { + const request = threads.get(input.threadId)?.request; + if (request === undefined) return Effect.void; + return Effect.tryPromise(() => + request({ + subtype: "ui_press", + plugin: input.plugin, + handle: input.handle, + surface: "desktop", + ...(input.key === undefined ? {} : { key: input.key }), + }), + ).pipe( + Effect.catchCause((cause) => + Effect.logDebug("claude-mod-ui.press-failed", { threadId: input.threadId, cause }), + ), + Effect.asVoid, + ); + }); + + return { attach, detach, ingest, press } satisfies ClaudeModUiShape; +}); + +export const layer = Layer.effect(ClaudeModUi, make); + +/** The store and the Claude mod bridge that writes into it, shared by the whole server. */ +export const layerWithStore = layer.pipe(Layer.provideMerge(PluginUiStore.layer)); diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts new file mode 100644 index 0000000000000..e067e6e64ba67 --- /dev/null +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -0,0 +1,179 @@ +// @effect-diagnostics nodeBuiltinImport:off - the test writes plugin folders the way an agent would. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { type PluginsSnapshot, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import * as ClaudeModUi from "./ClaudeModUi.ts"; +import * as PluginHost from "./PluginHost.ts"; + +const threadId = ThreadId.make("thread-plugin-host"); + +const writePlugin = (pluginsDir: string, folder: string, files: Record) => { + const dir = NodePath.join(pluginsDir, folder); + NodeFS.mkdirSync(dir, { recursive: true }); + for (const [name, contents] of Object.entries(files)) { + NodeFS.writeFileSync(NodePath.join(dir, name), contents); + } +}; + +const HELLO_ENTRY = ` +export function activate(ctx) { + ctx.ui.setStatus(${JSON.stringify(threadId)}, "build", "green", { tone: "success" }); + ctx.handle("greet", (input, context) => ({ hello: input.name, view: context.viewId })); +} +`; + +const manifest = (id: string, extra: Record = {}) => + JSON.stringify({ id, name: id, version: "1.0.0", apiVersion: 1, ...extra }); + +/** The first snapshot, current or upcoming, that satisfies `predicate`. */ +const awaitSnapshot = (predicate: (snapshot: PluginsSnapshot) => boolean) => + Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + return yield* host.subscribe.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.timeout("20 seconds"), + ); + }); + +const pluginIn = (snapshot: PluginsSnapshot, id: string) => + snapshot.plugins.find((plugin) => plugin.id === id); + +describe("PluginHost", () => { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-plugins-")); + const pluginsDir = NodePath.join(baseDir, "plugins"); + writePlugin(pluginsDir, "hello", { + "t3-plugin.json": manifest("acme.hello", { + entry: "index.mjs", + views: [{ id: "main", title: "Hello", html: "view.html" }], + commands: [{ name: "hello", description: "Say hello", prompt: "Say hello" }], + }), + "index.mjs": HELLO_ENTRY, + "view.html": "

Hi

", + }); + writePlugin(pluginsDir, "broken", { + "t3-plugin.json": manifest("acme.broken", { apiVersion: 9 }), + }); + + const layer = PluginHost.layer.pipe( + Layer.provideMerge(ClaudeModUi.layerWithStore), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + Layer.provideMerge(NodeServices.layer), + ); + + // Live services: the folder watcher debounces on the real clock. + it.layer(layer, { timeout: 60_000, excludeTestServices: true })((it) => { + it.effect("runs a plugin's entry, which draws a status and answers its view", () => + Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const running = yield* awaitSnapshot( + (snapshot) => + pluginIn(snapshot, "acme.hello")?.state === "running" && snapshot.statuses.length > 0, + ); + assert.deepStrictEqual(running.statuses, [ + { + threadId, + source: { kind: "plugin", pluginId: "acme.hello", name: "acme.hello" }, + items: [{ key: "build", text: "green", tone: "success" }], + }, + ]); + assert.deepStrictEqual(pluginIn(running, "acme.hello")?.views, [ + { id: "main", title: "Hello" }, + ]); + + const value = yield* host.callView({ + pluginId: "acme.hello", + viewId: "main", + threadId, + handler: "greet", + input: { name: "Neil" }, + }); + assert.deepStrictEqual(value, { hello: "Neil", view: "main" }); + + const view = yield* host.readView("acme.hello", "main"); + assert.include(view.html, "

Hi

"); + assert.include(view.html, "window.t3="); + }), + ); + + it.effect("reports a manifest it cannot load", () => + Effect.gen(function* () { + const snapshot = yield* awaitSnapshot( + (next) => pluginIn(next, "acme.broken") !== undefined, + ); + const broken = pluginIn(snapshot, "acme.broken"); + assert.strictEqual(broken?.state, "error"); + assert.include(broken?.error ?? "", "apiVersion 9"); + }), + ); + + it.effect("stops a disabled plugin and clears what it drew", () => + Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* awaitSnapshot((snapshot) => pluginIn(snapshot, "acme.hello")?.state === "running"); + yield* host.setEnabled("acme.hello", false); + const stopped = yield* awaitSnapshot( + (snapshot) => pluginIn(snapshot, "acme.hello")?.state === "disabled", + ); + assert.deepStrictEqual(stopped.statuses, []); + yield* host.setEnabled("acme.hello", true); + yield* awaitSnapshot((snapshot) => pluginIn(snapshot, "acme.hello")?.state === "running"); + }), + ); + + it.effect("loads a plugin written while the server runs", () => + Effect.gen(function* () { + writePlugin(pluginsDir, "later", { + "t3-plugin.json": manifest("acme.later", { + views: [{ id: "panel", title: "Later", html: "panel.html" }], + }), + "panel.html": "

later

", + }); + const snapshot = yield* awaitSnapshot( + (next) => pluginIn(next, "acme.later")?.state === "ready", + ); + assert.deepStrictEqual(pluginIn(snapshot, "acme.later")?.views, [ + { id: "panel", title: "Later" }, + ]); + }), + ); + }); +}); + +describe("decodeBandTree", () => { + it("keeps element types, props and press handles, stamped with the plugin's id", () => { + assert.deepStrictEqual( + PluginHost.decodeBandTree( + { + type: "Box", + props: { gap: 1 }, + children: [ + "Deploy", + { type: "Button", props: { label: "Run" }, press: { plugin: "other", handle: 3 } }, + { notAnElement: true }, + ], + }, + "acme.deploys", + ), + { + type: "Box", + props: { gap: 1 }, + children: [ + "Deploy", + { type: "Button", props: { label: "Run" }, press: { plugin: "acme.deploys", handle: 3 } }, + ], + }, + ); + }); +}); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts new file mode 100644 index 0000000000000..9c7b011e6fcdd --- /dev/null +++ b/apps/server/src/plugins/PluginHost.ts @@ -0,0 +1,851 @@ +// @effect-diagnostics nodeBuiltinImport:off - plugins run as child processes over Node's IPC +// channel, which the Effect process service does not expose, and scans read small local files. +/** + * PluginHost - loads local plugins from `/plugins` and runs them. + * + * Each subfolder with a `t3-plugin.json` is one plugin. The host watches the + * folder, so a plugin an agent writes or edits loads without a restart. A + * plugin with an `entry` runs in its own Node child process (see + * pluginRunner.ts); a crash restarts it with backoff, and repeated crashes + * stop it in the `error` state until its files change. Plugin code is + * trusted OS-user code: the process boundary contains crashes, not access. + * + * Plugins load enabled. Turning one off is remembered in + * `/plugins.json`. + * + * @module PluginHost + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { + PLUGIN_API_VERSION, + PLUGIN_MANIFEST_FILE, + PLUGINS_DIR_NAME, + PluginError, + PluginManifest, + type PluginBandPressInput, + type PluginCommandRunInput, + type PluginsSnapshot, + type PluginSummary, + type PluginTone, + PluginTone as PluginToneSchema, + type PluginUiElement, + type PluginUiSource, + type PluginViewCallInput, + type PluginViewReadResult, + ThreadId, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import { injectHtmlRenderBootstrap } from "@t3tools/shared/htmlRender"; +import { PLUGIN_VIEW_BRIDGE_SCRIPT } from "@t3tools/shared/pluginView"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as ClaudeModUi from "./ClaudeModUi.ts"; +import * as PluginUiStore from "./PluginUiStore.ts"; +import { + PLUGIN_RUNNER_ENTRY_ENV, + PLUGIN_RUNNER_META_ENV, + PLUGIN_RUNNER_SOURCE, + type HostToRunner, + type PluginHostEvent, +} from "./pluginRunner.ts"; + +const decodeManifest = Schema.decodeUnknownExit(Schema.fromJsonString(PluginManifest)); +const EnabledStateJson = Schema.fromJsonString( + Schema.Struct({ disabled: Schema.Array(Schema.String) }), +); +const decodeEnabledState = Schema.decodeUnknownExit(EnabledStateJson); +const encodeEnabledState = Schema.encodeSync(EnabledStateJson); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const isTone = Schema.is(PluginToneSchema); +const isThreadId = Schema.is(ThreadId); + +const MAX_PLUGINS = 64; +const MAX_MANIFEST_BYTES = 64 * 1024; +const MAX_VIEW_BYTES = 1024 * 1024; +/** Files a fingerprint looks at; enough to notice edits, bounded for a folder with a build tree. */ +const MAX_FINGERPRINT_FILES = 400; +const RECENT_LOG_LINES = 40; +/** Crashes within the window before the host gives up until the files change. */ +const MAX_CRASHES = 3; +const CRASH_WINDOW_MS = 60_000; +const CALL_TIMEOUT = Duration.seconds(30); +const STOP_GRACE_MS = 2_000; + +export interface PluginDetails extends PluginSummary { + /** The last lines the plugin wrote to stdout and stderr. */ + readonly recentLogs: ReadonlyArray; +} + +export interface PluginHostShape { + readonly pluginsDir: string; + readonly subscribe: Stream.Stream; + readonly subscribeBands: PluginUiStore.PluginUiStoreShape["subscribeBands"]; + /** Rescans the folder now and restarts plugins whose files changed. */ + readonly reload: Effect.Effect>; + readonly list: Effect.Effect>; + readonly setEnabled: (pluginId: string, enabled: boolean) => Effect.Effect; + readonly readView: ( + pluginId: string, + viewId: string, + ) => Effect.Effect; + readonly callView: (input: PluginViewCallInput) => Effect.Effect; + readonly runCommand: (input: PluginCommandRunInput) => Effect.Effect; + readonly press: (input: PluginBandPressInput) => Effect.Effect; + /** Delivers an event to every running plugin. */ + readonly publishEvent: (event: PluginHostEvent) => Effect.Effect; +} + +export class PluginHost extends Context.Service()( + "t3/plugins/PluginHost", +) {} + +interface ScannedPlugin { + readonly dir: string; + readonly folder: string; + readonly fingerprint: string; + readonly manifest: PluginManifest | null; + readonly error: string | null; +} + +interface RunningChild { + readonly process: NodeChildProcess.ChildProcess; + readonly pending: Map>; + stopping: boolean; +} + +interface PluginRecord { + scanned: ScannedPlugin; + state: PluginSummary["state"]; + error: string | null; + child: RunningChild | null; + crashes: number[]; + logs: string[]; + restartFiber: Fiber.Fiber | null; +} + +/** A small regular file read through one descriptor, or null. */ +const readSmallFile = (filePath: string, maxBytes: number): string | null => { + try { + const info = NodeFS.statSync(filePath); + if (!info.isFile() || info.size > maxBytes) return null; + return NodeFS.readFileSync(filePath, "utf8"); + } catch { + return null; + } +}; + +/** Sizes and mtimes of the plugin's files, skipping dependencies and dotfiles. */ +const fingerprintOf = (dir: string): string => { + const parts: string[] = []; + const walk = (current: string, depth: number) => { + if (depth > 4 || parts.length >= MAX_FINGERPRINT_FILES) return; + let entries: NodeFS.Dirent[]; + try { + entries = NodeFS.readdirSync(current, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const full = NodePath.join(current, entry.name); + if (entry.isDirectory()) walk(full, depth + 1); + else if (entry.isFile()) { + try { + const info = NodeFS.statSync(full); + parts.push(`${NodePath.relative(dir, full)}:${info.size}:${info.mtimeMs}`); + } catch { + // Removed mid-scan; the next event rescans. + } + } + if (parts.length >= MAX_FINGERPRINT_FILES) return; + } + }; + walk(dir, 0); + return parts.join("|"); +}; + +/** Inside the plugin folder, following symlinks: a manifest path cannot point outside it. */ +const resolveInside = (dir: string, relative: string): string | null => { + try { + const root = NodeFS.realpathSync(dir); + const target = NodeFS.realpathSync(NodePath.join(dir, relative)); + return target === root || target.startsWith(root + NodePath.sep) ? target : null; + } catch { + return null; + } +}; + +export const scanPluginFolder = (dir: string, folder: string): ScannedPlugin => { + const base = { dir, folder, fingerprint: fingerprintOf(dir) }; + const raw = readSmallFile(NodePath.join(dir, PLUGIN_MANIFEST_FILE), MAX_MANIFEST_BYTES); + if (raw === null) { + return { + ...base, + manifest: null, + error: `${PLUGIN_MANIFEST_FILE} is missing or larger than ${MAX_MANIFEST_BYTES} bytes.`, + }; + } + const decoded = decodeManifest(raw); + if (decoded._tag === "Failure") { + return { + ...base, + manifest: null, + error: `${PLUGIN_MANIFEST_FILE} is invalid: ${Cause.pretty(decoded.cause)}`, + }; + } + const manifest = decoded.value; + const fail = (error: string): ScannedPlugin => ({ ...base, manifest, error }); + if (manifest.apiVersion !== PLUGIN_API_VERSION) { + return fail(`apiVersion ${manifest.apiVersion} is not supported; use ${PLUGIN_API_VERSION}.`); + } + if (manifest.entry !== undefined && resolveInside(dir, manifest.entry) === null) { + return fail(`entry ${manifest.entry} does not exist inside the plugin folder.`); + } + for (const view of manifest.views) { + if (resolveInside(dir, view.html) === null) { + return fail(`view ${view.id}: ${view.html} does not exist inside the plugin folder.`); + } + } + const viewIds = new Set(manifest.views.map((view) => view.id)); + if (viewIds.size !== manifest.views.length) return fail("Two views share an id."); + const commandNames = new Set(manifest.commands.map((command) => command.name)); + if (commandNames.size !== manifest.commands.length) return fail("Two commands share a name."); + return { ...base, manifest, error: null }; +}; + +const toneOf = (value: unknown): PluginTone | undefined => (isTone(value) ? value : undefined); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** A band tree from plugin code: elements with a string `type`, bounded in size. */ +export const decodeBandTree = (value: unknown, pluginId: string): PluginUiElement | null => { + let nodes = 0; + const visit = (node: unknown, depth: number): PluginUiElement | string | null => { + if (++nodes > 2000 || depth > 32) return null; + if (typeof node === "string") return node.slice(0, 4000); + if (!isRecord(node) || typeof node.type !== "string") return null; + const press = + isRecord(node.press) && typeof node.press.handle === "number" + ? { plugin: pluginId, handle: node.press.handle } + : undefined; + const children = Array.isArray(node.children) + ? node.children.map((child) => visit(child, depth + 1)).filter((child) => child !== null) + : []; + return { + type: node.type, + ...(isRecord(node.props) ? { props: node.props } : {}), + ...(press === undefined ? {} : { press }), + ...(children.length === 0 ? {} : { children }), + }; + }; + const tree = visit(value, 0); + return tree === null || typeof tree === "string" ? null : tree; +}; + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const store = yield* PluginUiStore.PluginUiStore; + const claudeModUi = yield* ClaudeModUi.ClaudeModUi; + const pluginsDir = NodePath.join(config.baseDir, PLUGINS_DIR_NAME); + const enabledStatePath = NodePath.join(config.stateDir, "plugins.json"); + const services = yield* Effect.context(); + const runFork = Effect.runForkWith(services); + + const records = new Map(); + const savedState = decodeEnabledState(readSmallFile(enabledStatePath, MAX_MANIFEST_BYTES) ?? ""); + const disabled = new Set(savedState._tag === "Success" ? savedState.value.disabled : []); + const catalogSignals = new Set>(); + const notifyCatalog = Effect.forEach(catalogSignals, (signal) => Queue.offer(signal, undefined), { + discard: true, + }); + const reconcileLock = yield* Semaphore.make(1); + let nextCallId = 1; + let shuttingDown = false; + + const idOf = (record: PluginRecord) => record.scanned.manifest?.id ?? record.scanned.folder; + const sourceOf = (record: PluginRecord): PluginUiSource => ({ + kind: "plugin", + pluginId: idOf(record), + name: record.scanned.manifest?.name ?? record.scanned.folder, + }); + + const summarize = (record: PluginRecord): PluginDetails => { + const manifest = record.scanned.manifest; + return { + id: idOf(record), + name: manifest?.name ?? record.scanned.folder, + version: manifest?.version ?? "", + ...(manifest?.description === undefined ? {} : { description: manifest.description }), + dir: record.scanned.dir, + enabled: !disabled.has(idOf(record)), + state: record.state, + ...(record.error === null ? {} : { error: record.error }), + views: (manifest?.views ?? []).map((view) => ({ id: view.id, title: view.title })), + commands: manifest?.commands ?? [], + recentLogs: [...record.logs], + }; + }; + + const list = Effect.sync(() => + [...records.values()] + .map(summarize) + .toSorted((left, right) => left.name.localeCompare(right.name)), + ); + + const appendLog = (record: PluginRecord, chunk: string) => { + for (const line of chunk.split(/\r?\n/)) { + if (line.trim() === "") continue; + record.logs.push(line.slice(0, 1000)); + } + if (record.logs.length > RECENT_LOG_LINES) + record.logs.splice(0, record.logs.length - RECENT_LOG_LINES); + }; + + const setState = (record: PluginRecord, state: PluginSummary["state"], error: string | null) => + Effect.suspend(() => { + if (record.state === state && record.error === error) return Effect.void; + record.state = state; + record.error = error; + return notifyCatalog; + }); + + /** Applies one message from a plugin process; anything malformed is dropped. */ + const onRunnerMessage = (record: PluginRecord, child: RunningChild, message: unknown) => { + if (!isRecord(message) || records.get(idOf(record)) !== record || record.child !== child) { + return Effect.void; + } + const source = sourceOf(record); + switch (message.t) { + case "ready": + record.crashes = []; + return setState(record, "running", null); + case "fatal": + appendLog(record, String(message.error)); + return Effect.void; + case "status": + if (!isThreadId(message.threadId) || typeof message.key !== "string") return Effect.void; + { + const tone = toneOf(message.tone); + return store.setStatus(message.threadId, source, { + key: message.key, + text: typeof message.text === "string" ? message.text : "", + ...(tone === undefined ? {} : { tone }), + ...(typeof message.tooltip === "string" ? { tooltip: message.tooltip } : {}), + }); + } + case "band": + if (!isThreadId(message.threadId)) return Effect.void; + return store.setBand( + message.threadId, + source, + message.tree === null ? null : decodeBandTree(message.tree, idOf(record)), + ); + case "toast": + if (typeof message.title !== "string") return Effect.void; + return store.toast({ + source, + title: message.title, + body: typeof message.body === "string" ? message.body : undefined, + tone: toneOf(message.tone), + threadId: isThreadId(message.threadId) ? message.threadId : undefined, + timeoutMs: + typeof message.timeoutMs === "number" && message.timeoutMs > 0 + ? Math.min(message.timeoutMs, 60_000) + : undefined, + }); + case "result": { + const pending = typeof message.id === "number" ? child.pending.get(message.id) : undefined; + if (pending === undefined) return Effect.void; + child.pending.delete(message.id as number); + return message.ok === true + ? Deferred.succeed(pending, message.value) + : Deferred.fail( + pending, + new PluginError({ message: String(message.error ?? "The plugin call failed.") }), + ); + } + default: + return Effect.void; + } + }; + + const failPending = (child: RunningChild, message: string) => + Effect.forEach( + [...child.pending.values()], + (pending) => Deferred.fail(pending, new PluginError({ message })), + { discard: true }, + ).pipe(Effect.andThen(Effect.sync(() => child.pending.clear()))); + + const stopChild = (record: PluginRecord) => + Effect.suspend(() => { + const restart = record.restartFiber; + record.restartFiber = null; + const cancelRestart = restart === null ? Effect.void : Fiber.interrupt(restart); + const child = record.child; + record.child = null; + if (child === null) + return cancelRestart.pipe(Effect.andThen(store.clearSource(sourceOf(record)))); + child.stopping = true; + const stop: HostToRunner = { t: "stop" }; + try { + if (child.process.connected) child.process.send(stop); + } catch { + // Already gone. + } + // A plugin that ignores the stop request is killed after a grace period. + const kill = runFork( + Effect.sleep(Duration.millis(STOP_GRACE_MS)).pipe( + Effect.andThen(Effect.sync(() => child.process.kill("SIGKILL"))), + ), + ); + child.process.once("exit", () => { + runFork(Fiber.interrupt(kill)); + }); + return cancelRestart.pipe( + Effect.andThen(failPending(child, "The plugin stopped.")), + Effect.andThen(store.clearSource(sourceOf(record))), + ); + }); + + const startChild = (record: PluginRecord): Effect.Effect => + Effect.suspend(() => { + const manifest = record.scanned.manifest; + if (shuttingDown || manifest === null || manifest.entry === undefined) return Effect.void; + const entryPath = resolveInside(record.scanned.dir, manifest.entry); + if (entryPath === null) + return setState(record, "error", `entry ${manifest.entry} is missing.`); + let processHandle: NodeChildProcess.ChildProcess; + try { + processHandle = NodeChildProcess.spawn(process.execPath, ["-e", PLUGIN_RUNNER_SOURCE], { + cwd: record.scanned.dir, + stdio: ["ignore", "pipe", "pipe", "ipc"], + env: { + ...process.env, + // Desktop runs the server inside Electron; this makes its binary act as Node. + ELECTRON_RUN_AS_NODE: "1", + [PLUGIN_RUNNER_ENTRY_ENV]: entryPath, + [PLUGIN_RUNNER_META_ENV]: encodeJson({ + id: manifest.id, + name: manifest.name, + version: manifest.version, + dir: record.scanned.dir, + }), + }, + }); + } catch (cause) { + return setState(record, "error", `Could not start the plugin: ${String(cause)}`); + } + const child: RunningChild = { process: processHandle, pending: new Map(), stopping: false }; + record.child = child; + processHandle.stdout + ?.setEncoding("utf8") + .on("data", (chunk: string) => appendLog(record, chunk)); + processHandle.stderr + ?.setEncoding("utf8") + .on("data", (chunk: string) => appendLog(record, chunk)); + processHandle.on("message", (message) => { + runFork(onRunnerMessage(record, child, message)); + }); + processHandle.on("error", (error) => appendLog(record, `Process error: ${error.message}`)); + processHandle.on("exit", (code, signal) => { + runFork(onChildExit(record, child, code, signal)); + }); + return setState(record, record.state === "restarting" ? "restarting" : "starting", null); + }); + + const onChildExit = ( + record: PluginRecord, + child: RunningChild, + code: number | null, + signal: NodeJS.Signals | null, + ) => + Effect.gen(function* () { + yield* failPending(child, "The plugin process exited."); + if (child.stopping || record.child !== child || records.get(idOf(record)) !== record) return; + record.child = null; + yield* store.clearSource(sourceOf(record)); + const now = yield* Clock.currentTimeMillis; + record.crashes = [...record.crashes.filter((at) => now - at < CRASH_WINDOW_MS), now]; + const reason = `The plugin exited (${signal ?? `code ${code}`}).`; + appendLog(record, reason); + if (record.crashes.length >= MAX_CRASHES) { + const last = record.logs.slice(-5).join("\n"); + yield* setState( + record, + "error", + `${reason} It crashed ${MAX_CRASHES} times, so it stays stopped until its files change.\n${last}`, + ); + return; + } + yield* setState(record, "restarting", reason); + const delay = 1000 * 2 ** (record.crashes.length - 1); + record.restartFiber = runFork( + Effect.sleep(Duration.millis(delay)).pipe( + Effect.andThen( + reconcileLock.withPermits(1)( + Effect.suspend(() => { + if (records.get(idOf(record)) !== record || disabled.has(idOf(record))) { + return Effect.void; + } + record.restartFiber = null; + return startChild(record); + }), + ), + ), + ), + ); + }); + + /** Starts or stops one plugin to match its manifest and enabled flag. */ + const activate = (record: PluginRecord) => + Effect.gen(function* () { + if (record.scanned.error !== null) { + yield* setState(record, "error", record.scanned.error); + return; + } + if (disabled.has(idOf(record))) { + yield* setState(record, "disabled", null); + return; + } + if (record.scanned.manifest?.entry === undefined) { + yield* setState(record, "ready", null); + return; + } + yield* startChild(record); + }); + + const writeEnabledState = Effect.sync(() => { + try { + NodeFS.mkdirSync(NodePath.dirname(enabledStatePath), { recursive: true }); + NodeFS.writeFileSync( + enabledStatePath, + `${encodeEnabledState({ disabled: [...disabled].toSorted() })}\n`, + ); + } catch { + // A failed write only loses the setting across restarts. + } + }); + + const scanAll = Effect.sync((): ScannedPlugin[] => { + let folders: NodeFS.Dirent[]; + try { + folders = NodeFS.readdirSync(pluginsDir, { withFileTypes: true }); + } catch { + return []; + } + return folders + .filter( + (entry) => !entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink()), + ) + .map((entry) => entry.name) + .toSorted() + .slice(0, MAX_PLUGINS) + .map((folder) => scanPluginFolder(NodePath.join(pluginsDir, folder), folder)) + .filter((scanned) => { + try { + return NodeFS.statSync(scanned.dir).isDirectory(); + } catch { + return false; + } + }); + }); + + /** Brings the running set in line with the folder. */ + const reconcile = reconcileLock.withPermits(1)( + Effect.gen(function* () { + const scanned = yield* scanAll; + const seen = new Set(); + let changed = false; + for (const next of scanned) { + const baseId = next.manifest?.id ?? next.folder; + const duplicate = seen.has(baseId); + const id = duplicate ? `${baseId} (${next.folder})` : baseId; + seen.add(id); + const scannedPlugin = duplicate + ? { + ...next, + manifest: null, + error: `Another plugin folder already uses the id ${baseId}.`, + } + : next; + const existing = records.get(id); + if (existing !== undefined && existing.scanned.fingerprint === scannedPlugin.fingerprint) { + continue; + } + changed = true; + if (existing !== undefined) yield* stopChild(existing); + const record: PluginRecord = { + scanned: scannedPlugin, + state: "starting", + error: null, + child: null, + crashes: [], + logs: existing?.logs ?? [], + restartFiber: null, + }; + records.set(id, record); + yield* activate(record); + } + for (const [id, record] of records) { + if (seen.has(id)) continue; + changed = true; + records.delete(id); + yield* stopChild(record); + } + if (changed) yield* notifyCatalog; + }), + ); + + yield* fs.makeDirectory(pluginsDir, { recursive: true }).pipe(Effect.ignoreCause({ log: true })); + yield* reconcile; + + const watcherScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + shuttingDown = true; + yield* Scope.close(watcherScope, Exit.void); + for (const record of records.values()) yield* stopChild(record); + }), + ); + // An editor save emits several events and may fire before the content is + // flushed, so a burst settles into one rescan. + yield* fs.watch(pluginsDir, { recursive: true }).pipe( + Stream.debounce(Duration.millis(250)), + Stream.runForEach(() => reconcile), + Effect.ignoreCause({ log: true }), + Effect.forkIn(watcherScope), + ); + + const recordFor = (pluginId: string) => + Effect.suspend(() => { + const record = records.get(pluginId); + return record === undefined + ? Effect.fail(new PluginError({ message: `No plugin named ${pluginId} is loaded.` })) + : Effect.succeed(record); + }); + + /** Sends a request to a running plugin and waits for its answer. */ + const request = ( + record: PluginRecord, + build: (id: number) => HostToRunner, + ): Effect.Effect => + Effect.gen(function* () { + const child = record.child; + if (child === null || record.state !== "running") { + return yield* new PluginError({ + message: `${idOf(record)} is not running (${record.state}).`, + }); + } + const id = nextCallId++; + const deferred = yield* Deferred.make(); + child.pending.set(id, deferred); + try { + child.process.send(build(id)); + } catch { + child.pending.delete(id); + return yield* new PluginError({ message: `${idOf(record)} could not be reached.` }); + } + return yield* Deferred.await(deferred).pipe( + Effect.timeoutOrElse({ + duration: CALL_TIMEOUT, + orElse: () => { + child.pending.delete(id); + return Effect.fail( + new PluginError({ message: `${idOf(record)} did not answer in time.` }), + ); + }, + }), + ); + }); + + const subscribe: Stream.Stream = Stream.unwrap( + Effect.gen(function* () { + const signal = yield* Queue.sliding(1); + catalogSignals.add(signal); + yield* Effect.addFinalizer(() => Effect.sync(() => catalogSignals.delete(signal))); + const read = Effect.gen(function* () { + const plugins = yield* list; + return { + pluginsDir, + plugins: plugins.map(({ recentLogs: _logs, ...summary }) => summary), + statuses: yield* store.statuses, + toasts: yield* store.toasts, + } satisfies PluginsSnapshot; + }); + // Store changes and catalog changes share one sliding signal, so a burst sends one frame. + yield* store.changes.pipe( + Stream.runForEach(() => Queue.offer(signal, undefined)), + Effect.forkScoped, + ); + return Stream.concat( + Stream.fromEffect(read), + Stream.fromQueue(signal).pipe(Stream.mapEffect(() => read)), + ); + }), + ); + + return PluginHost.of({ + pluginsDir, + subscribe, + subscribeBands: store.subscribeBands, + reload: reconcile.pipe(Effect.andThen(list)), + list, + setEnabled: (pluginId, enabled) => + reconcileLock.withPermits(1)( + Effect.gen(function* () { + const record = yield* recordFor(pluginId); + if (enabled === !disabled.has(pluginId)) return; + if (enabled) disabled.delete(pluginId); + else disabled.add(pluginId); + yield* writeEnabledState; + yield* stopChild(record); + record.crashes = []; + yield* activate(record); + yield* notifyCatalog; + }), + ), + readView: (pluginId, viewId) => + Effect.gen(function* () { + const record = yield* recordFor(pluginId); + const view = record.scanned.manifest?.views.find((candidate) => candidate.id === viewId); + if (view === undefined || disabled.has(pluginId) || record.scanned.error !== null) { + return yield* new PluginError({ message: `${pluginId} has no view ${viewId}.` }); + } + const path = resolveInside(record.scanned.dir, view.html); + const html = path === null ? null : readSmallFile(path, MAX_VIEW_BYTES); + if (html === null) { + return yield* new PluginError({ + message: `${view.html} is missing or larger than ${MAX_VIEW_BYTES} bytes.`, + }); + } + return { + title: view.title, + html: injectHtmlRenderBootstrap(html, PLUGIN_VIEW_BRIDGE_SCRIPT), + }; + }), + callView: (input) => + Effect.gen(function* () { + const record = yield* recordFor(input.pluginId); + return yield* request(record, (id) => ({ + t: "call", + id, + handler: input.handler, + input: input.input, + context: { viewId: input.viewId, threadId: input.threadId ?? null }, + })); + }), + runCommand: (input) => + Effect.gen(function* () { + const record = yield* recordFor(input.pluginId); + yield* request(record, (id) => ({ + t: "command", + id, + name: input.command, + threadId: input.threadId, + args: input.args, + })); + }), + press: (input) => + input.source.kind === "claude-mod" + ? claudeModUi.press({ + threadId: input.threadId, + plugin: input.target.plugin, + handle: input.target.handle, + key: input.key, + }) + : Effect.gen(function* () { + const record = yield* recordFor(input.target.plugin); + const message: HostToRunner = { + t: "press", + threadId: input.threadId, + handle: input.target.handle, + ...(input.key === undefined ? {} : { key: input.key }), + }; + try { + record.child?.process.send(message); + } catch { + // A stopped plugin's band is already gone. + } + }), + publishEvent: (event) => + Effect.sync(() => { + const message: HostToRunner = { t: "event", event }; + for (const record of records.values()) { + if (record.state !== "running" || record.child === null) continue; + try { + record.child.process.send(message); + } catch { + // It is exiting; the exit handler takes over. + } + } + }), + }); +}); + +export const layer = Layer.effect(PluginHost, make); + +/** A domain event as plugins see it, or null for the many they do not. */ +export const pluginEventOf = (event: OrchestrationV2DomainEvent): PluginHostEvent | null => { + switch (event.type) { + case "run.created": + case "run.updated": + return { + type: "run", + threadId: event.threadId, + runId: event.payload.id, + status: event.payload.status, + }; + case "thread.created": + case "thread.archived": + case "thread.deleted": + return { + type: "thread", + threadId: event.threadId, + change: + event.type === "thread.created" + ? "created" + : event.type === "thread.archived" + ? "archived" + : "deleted", + title: event.payload.title, + }; + default: + return null; + } +}; + +/** Feeds thread and run events to running plugins. */ +export const layerEvents = Layer.effectDiscard( + Effect.gen(function* () { + const host = yield* PluginHost; + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* forkParked( + Stream.runForEach(threads.streamDomainEvents, (event) => { + const pluginEvent = pluginEventOf(event); + return pluginEvent === null ? Effect.void : host.publishEvent(pluginEvent); + }).pipe(Effect.catchCause((cause) => Effect.logWarning("Plugin events stopped", { cause }))), + ); + }), +); diff --git a/apps/server/src/plugins/PluginUiStore.test.ts b/apps/server/src/plugins/PluginUiStore.test.ts new file mode 100644 index 0000000000000..1b4ff889e05e8 --- /dev/null +++ b/apps/server/src/plugins/PluginUiStore.test.ts @@ -0,0 +1,168 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as ClaudeModUi from "./ClaudeModUi.ts"; +import * as PluginUiStore from "./PluginUiStore.ts"; + +const threadId = ThreadId.make("thread-plugin-ui"); +const plugin = { kind: "plugin", pluginId: "acme.deploys", name: "Deploys" } as const; +const band = { type: "Box", children: [{ type: "Text", children: ["5h 42%"] }] }; + +const firstBands = ( + store: PluginUiStore.PluginUiStoreShape, + predicate: (count: number) => boolean, +) => + store.subscribeBands(threadId).pipe( + Stream.filter((snapshot) => predicate(snapshot.bands.length)), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +describe("PluginUiStore", () => { + it.effect("keeps a source's status items sorted by key and removes one on empty text", () => + Effect.gen(function* () { + const store = yield* PluginUiStore.make; + yield* store.setStatus(threadId, plugin, { + key: "b", + text: "build \u001b[32mgreen\u001b[0m", + }); + yield* store.setStatus(threadId, plugin, { key: "a", text: "deploy 3/5", tone: "info" }); + assert.deepStrictEqual(yield* store.statuses, [ + { + threadId, + source: plugin, + items: [ + { key: "a", text: "deploy 3/5", tone: "info" }, + { key: "b", text: "build green" }, + ], + }, + ]); + + yield* store.setStatus(threadId, plugin, { key: "a", text: "" }); + yield* store.setStatus(threadId, plugin, { key: "b", text: "" }); + assert.deepStrictEqual(yield* store.statuses, []); + }), + ); + + it.effect("keeps the newest toasts with fresh ids", () => + Effect.gen(function* () { + const store = yield* PluginUiStore.make; + for (let index = 0; index < 25; index += 1) { + yield* store.toast({ source: plugin, title: `Toast ${index}` }); + } + const toasts = yield* store.toasts; + assert.strictEqual(toasts.length, 20); + assert.strictEqual(toasts[0]?.title, "Toast 5"); + assert.strictEqual(new Set(toasts.map((toast) => toast.id)).size, 20); + }), + ); + + it.effect("clears a stopped source's statuses and bands", () => + Effect.gen(function* () { + const store = yield* PluginUiStore.make; + yield* store.setStatus(threadId, plugin, { key: "a", text: "running" }); + yield* store.setBand(threadId, plugin, band); + assert.strictEqual( + (yield* firstBands(store, (count) => count === 1)).bands[0]?.source, + plugin, + ); + + yield* store.clearSource(plugin); + assert.deepStrictEqual(yield* store.statuses, []); + assert.deepStrictEqual((yield* firstBands(store, () => true)).bands, []); + }), + ); +}); + +describe("ClaudeModUi", () => { + const withStore = Effect.gen(function* () { + const store = yield* PluginUiStore.make; + const modUi = yield* ClaudeModUi.make.pipe( + Effect.provideService(PluginUiStore.PluginUiStore, store), + ); + return { store, modUi }; + }); + const noBand: ClaudeModUi.ClaudeControlRequest = async () => ({ response: { hooked: false } }); + + it.effect("draws mod status lines and toasts as claude-mod sources", () => + Effect.gen(function* () { + const { store, modUi } = yield* withStore; + yield* modUi.attach(threadId, noBand); + const message = (body: Record) => + modUi.ingest(threadId, { type: "system", ...body }, noBand); + + yield* message({ subtype: "ui_status", plugin: "tier-badge", text: "T2 feature" }); + yield* message({ + subtype: "ui_toast", + plugin: "blast-radius", + text: "3 files", + timeout_ms: 2500, + }); + // A message from a query that is no longer attached draws nothing. + yield* modUi.ingest( + threadId, + { type: "system", subtype: "ui_status", plugin: "stale", text: "old" }, + async () => null, + ); + + assert.deepStrictEqual(yield* store.statuses, [ + { + threadId, + source: { kind: "claude-mod", name: "tier-badge" }, + items: [{ key: "status", text: "T2 feature" }], + }, + ]); + const [toast] = yield* store.toasts; + assert.strictEqual(toast?.title, "blast-radius"); + assert.strictEqual(toast?.body, "3 files"); + assert.strictEqual(toast?.timeoutMs, 2500); + }), + ); + + it.effect("asks for the AbovePrompt band on attach and clears everything on detach", () => + Effect.gen(function* () { + const { store, modUi } = yield* withStore; + const requests: Array> = []; + const request: ClaudeModUi.ClaudeControlRequest = async (body) => { + requests.push(body); + return { subtype: "success", response: { tree: band, hooked: true } }; + }; + + yield* modUi.attach(threadId, request); + const drawn = yield* firstBands(store, (count) => count === 1); + assert.deepStrictEqual(drawn.bands[0]?.tree, band); + assert.strictEqual(requests[0]?.component, "AbovePrompt"); + yield* modUi.ingest( + threadId, + { type: "system", subtype: "ui_status", plugin: "tier-badge", text: "T1" }, + request, + ); + + // Another query's close leaves this one's UI alone. + yield* modUi.detach(threadId, noBand); + assert.strictEqual((yield* store.statuses).length, 1); + + yield* modUi.detach(threadId, request); + assert.deepStrictEqual(yield* store.statuses, []); + assert.deepStrictEqual((yield* firstBands(store, (count) => count === 0)).bands, []); + }), + ); + + it.effect("ignores the engine's placeholder tree", () => + Effect.sync(() => { + assert.strictEqual( + ClaudeModUi.bandFromRenderResponse({ + response: { tree: { type: "engine", ref: 0 }, hooked: true }, + }), + null, + ); + assert.deepStrictEqual( + ClaudeModUi.bandFromRenderResponse({ response: { tree: band, hooked: true } }), + band, + ); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginUiStore.ts b/apps/server/src/plugins/PluginUiStore.ts new file mode 100644 index 0000000000000..b03a8894d4684 --- /dev/null +++ b/apps/server/src/plugins/PluginUiStore.ts @@ -0,0 +1,254 @@ +/** + * PluginUiStore - what local plugins and Claude Code mods draw right now: + * status lines on threads, bands above a thread's composer, and recent toasts. + * + * Everything lives in memory and disappears with its producer: a plugin that + * stops and a Claude session that closes clear their own entries. Clients + * subscribe to change signals and read the current state when signalled, so + * a slow client holds one pending signal, never a backlog of snapshots. + * + * @module PluginUiStore + */ +import { + PLUGIN_STATUS_MAX_ITEMS_PER_SOURCE, + PLUGIN_STATUS_TEXT_MAX_LENGTH, + PLUGIN_TOAST_MAX_RETAINED, + type PluginBandEntry, + type PluginBandsSnapshot, + type PluginStatusEntry, + type PluginStatusItem, + type PluginToast, + type PluginTone, + type PluginUiElement, + type PluginUiSource, + pluginUiSourceKey, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +/** Most threads that can carry status entries at once; new threads past it are refused. */ +const MAX_STATUS_ENTRIES = 512; +/** Most bands one thread can carry, one per source. */ +const MAX_BANDS_PER_THREAD = 8; + +export interface PluginToastInput { + readonly source: PluginUiSource; + readonly title: string; + readonly body?: string | undefined; + readonly tone?: PluginTone | undefined; + readonly threadId?: ThreadId | undefined; + readonly timeoutMs?: number | undefined; +} + +export interface PluginUiStoreShape { + /** Sets one status item, or removes it when `text` is empty. */ + readonly setStatus: ( + threadId: ThreadId, + source: PluginUiSource, + item: { readonly key: string; readonly text: string } & Partial< + Pick + >, + ) => Effect.Effect; + /** Sets a source's band on a thread, or removes it with `null`. */ + readonly setBand: ( + threadId: ThreadId, + source: PluginUiSource, + tree: PluginUiElement | null, + ) => Effect.Effect; + readonly toast: (input: PluginToastInput) => Effect.Effect; + /** Removes a source's statuses and bands, on one thread or on all of them. */ + readonly clearSource: (source: PluginUiSource, threadId?: ThreadId) => Effect.Effect; + readonly statuses: Effect.Effect>; + readonly toasts: Effect.Effect>; + /** Fires after any status or toast change; the caller reads the current state. */ + readonly changes: Stream.Stream; + readonly subscribeBands: (threadId: ThreadId) => Stream.Stream; +} + +export class PluginUiStore extends Context.Service()( + "t3/plugins/PluginUiStore", +) {} + +/** One plain line: no control characters or ANSI styling, cut to `max`. */ +export const plainLine = (value: string, max: number): string => { + // oxlint-disable-next-line no-control-regex -- stripping terminal styling is the point. + const clean = value + .replace(/\u001b\[[0-9;?]*[A-Za-z]/g, "") + .replace(/[\u0000-\u001f\u007f]+/g, " "); + const trimmed = clean.trim(); + return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed; +}; + +/** A set of one-slot "changed" signals, one per subscriber. */ +const makeSignals = () => { + const signals = new Set>(); + return { + notify: Effect.forEach(signals, (signal) => Queue.offer(signal, undefined), { discard: true }), + /** The value now, then again after every notify. */ + stream: (read: () => A, onEmpty?: () => void): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + const signal = yield* Queue.sliding(1); + signals.add(signal); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + signals.delete(signal); + if (signals.size === 0) onEmpty?.(); + }), + ); + return Stream.concat(Stream.sync(read), Stream.fromQueue(signal).pipe(Stream.map(read))); + }), + ), + get size() { + return signals.size; + }, + }; +}; + +export const make = Effect.sync(() => { + // threadId -> sourceKey -> entry + const statuses = new Map>(); + const bands = new Map>(); + let toasts: ReadonlyArray = []; + let toastCount = 0; + const global = makeSignals(); + const bandSignals = new Map>(); + + const notifyBands = (threadId: ThreadId) => bandSignals.get(threadId)?.notify ?? Effect.void; + + const setStatus: PluginUiStoreShape["setStatus"] = (threadId, source, input) => + Effect.suspend(() => { + const key = plainLine(input.key, 64); + if (key === "") return Effect.void; + const text = plainLine(input.text, PLUGIN_STATUS_TEXT_MAX_LENGTH); + const sourceKey = pluginUiSourceKey(source); + let thread = statuses.get(threadId); + const previous = thread?.get(sourceKey); + const others = (previous?.items ?? []).filter((item) => item.key !== key); + if (text === "") { + if (previous === undefined || others.length === previous.items.length) return Effect.void; + } else if (thread === undefined && statuses.size >= MAX_STATUS_ENTRIES) { + return Effect.void; + } + const items: PluginStatusItem[] = + text === "" + ? others + : [ + ...others, + { + key, + text, + ...(input.tone === undefined ? {} : { tone: input.tone }), + ...(input.tooltip === undefined ? {} : { tooltip: plainLine(input.tooltip, 240) }), + }, + ] + .toSorted((left, right) => left.key.localeCompare(right.key)) + .slice(0, PLUGIN_STATUS_MAX_ITEMS_PER_SOURCE); + if (thread === undefined) { + thread = new Map(); + statuses.set(threadId, thread); + } + if (items.length === 0) { + thread.delete(sourceKey); + if (thread.size === 0) statuses.delete(threadId); + } else { + thread.set(sourceKey, { threadId, source, items }); + } + return global.notify; + }); + + const setBand: PluginUiStoreShape["setBand"] = (threadId, source, tree) => + Effect.suspend(() => { + const sourceKey = pluginUiSourceKey(source); + let thread = bands.get(threadId); + if (tree === null) { + if (thread?.delete(sourceKey) !== true) return Effect.void; + if (thread.size === 0) bands.delete(threadId); + return notifyBands(threadId); + } + if (thread === undefined) { + thread = new Map(); + bands.set(threadId, thread); + } + if (!thread.has(sourceKey) && thread.size >= MAX_BANDS_PER_THREAD) return Effect.void; + thread.set(sourceKey, { threadId, source, tree }); + return notifyBands(threadId); + }); + + const toast: PluginUiStoreShape["toast"] = (input) => + Effect.gen(function* () { + const title = plainLine(input.title, 80); + if (title === "") return; + const now = yield* DateTime.now; + const body = input.body === undefined ? "" : plainLine(input.body, 240); + const next: PluginToast = { + id: `plugin-toast-${++toastCount}`, + source: input.source, + title, + ...(body === "" ? {} : { body }), + ...(input.tone === undefined ? {} : { tone: input.tone }), + ...(input.threadId === undefined ? {} : { threadId: input.threadId }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + createdAt: DateTime.formatIso(now), + }; + toasts = [...toasts, next].slice(-PLUGIN_TOAST_MAX_RETAINED); + yield* global.notify; + }); + + const clearSource: PluginUiStoreShape["clearSource"] = (source, onlyThreadId) => + Effect.suspend(() => { + const sourceKey = pluginUiSourceKey(source); + let statusChanged = false; + const bandThreads: ThreadId[] = []; + for (const [threadId, thread] of statuses) { + if (onlyThreadId !== undefined && threadId !== onlyThreadId) continue; + if (thread.delete(sourceKey)) statusChanged = true; + if (thread.size === 0) statuses.delete(threadId); + } + for (const [threadId, thread] of bands) { + if (onlyThreadId !== undefined && threadId !== onlyThreadId) continue; + if (thread.delete(sourceKey)) bandThreads.push(threadId); + if (thread.size === 0) bands.delete(threadId); + } + return Effect.andThen( + statusChanged ? global.notify : Effect.void, + Effect.forEach(bandThreads, notifyBands, { discard: true }), + ); + }); + + const readStatuses = () => [...statuses.values()].flatMap((thread) => [...thread.values()]); + + const subscribeBands: PluginUiStoreShape["subscribeBands"] = (threadId) => + Stream.suspend(() => { + let signals = bandSignals.get(threadId); + if (signals === undefined) { + signals = makeSignals(); + bandSignals.set(threadId, signals); + } + const own = signals; + return own.stream( + (): PluginBandsSnapshot => ({ bands: [...(bands.get(threadId)?.values() ?? [])] }), + () => { + if (bandSignals.get(threadId) === own) bandSignals.delete(threadId); + }, + ); + }); + + return PluginUiStore.of({ + setStatus, + setBand, + toast, + clearSource, + statuses: Effect.sync(readStatuses), + toasts: Effect.sync(() => toasts), + changes: global.stream(() => undefined), + subscribeBands, + }); +}); + +export const layer = Layer.effect(PluginUiStore, make); diff --git a/apps/server/src/plugins/pluginRunner.ts b/apps/server/src/plugins/pluginRunner.ts new file mode 100644 index 0000000000000..67159aeed0fb8 --- /dev/null +++ b/apps/server/src/plugins/pluginRunner.ts @@ -0,0 +1,212 @@ +/** + * The program a plugin's child process runs. It imports the plugin's `entry`, + * calls its `activate(ctx)` and relays between `ctx` and the host over the + * Node IPC channel. It ships as a string so the server needs no extra file at + * runtime: the host starts `node -e `. + * + * The `ctx` a plugin gets: + * + * ctx.plugin { id, name, version, dir } + * ctx.ui.setStatus(threadId, key, text, { tone, tooltip }) text "" clears + * ctx.ui.toast({ title, body, tone, threadId, timeoutMs }) + * ctx.ui.setBand(threadId, tree | null) + * ctx.ui.h(type, props, ...children) builds a band node; props.onPress is a function + * ctx.onEvent(fn) thread and run events, see PluginHostEvent + * ctx.handle(name, fn) answers `t3.call(name, input)` from the plugin's views + * ctx.onCommand(name, fn) runs `/name args` for commands without a prompt + * ctx.log(...args) same as console.log; the host keeps recent lines + * + * activate may return a function; it runs before the process is stopped. + */ + +/** Messages the host sends to the runner. */ +export type HostToRunner = + | { readonly t: "event"; readonly event: PluginHostEvent } + | { + readonly t: "call"; + readonly id: number; + readonly handler: string; + readonly input: unknown; + readonly context: { readonly viewId: string; readonly threadId: string | null }; + } + | { + readonly t: "command"; + readonly id: number; + readonly name: string; + readonly threadId: string; + readonly args: string; + } + | { + readonly t: "press"; + readonly threadId: string; + readonly handle: number; + readonly key?: string; + } + | { readonly t: "stop" }; + +/** Messages the runner sends to the host. Decoded defensively: plugin code can send anything. */ +export type RunnerToHost = + | { readonly t: "ready" } + | { readonly t: "fatal"; readonly error: string } + | { + readonly t: "status"; + readonly threadId: string; + readonly key: string; + readonly text: string; + readonly tone?: string; + readonly tooltip?: string; + } + | { readonly t: "band"; readonly threadId: string; readonly tree: unknown } + | { + readonly t: "toast"; + readonly title: string; + readonly body?: string; + readonly tone?: string; + readonly threadId?: string; + readonly timeoutMs?: number; + } + | { readonly t: "result"; readonly id: number; readonly ok: true; readonly value: unknown } + | { readonly t: "result"; readonly id: number; readonly ok: false; readonly error: string }; + +/** What plugin code receives through `ctx.onEvent`. */ +export type PluginHostEvent = + | { + readonly type: "run"; + readonly threadId: string; + readonly runId: string; + readonly status: string; + } + | { + readonly type: "thread"; + readonly threadId: string; + readonly change: "created" | "archived" | "deleted"; + readonly title: string | null; + }; + +export const PLUGIN_RUNNER_ENTRY_ENV = "T3_PLUGIN_ENTRY"; +export const PLUGIN_RUNNER_META_ENV = "T3_PLUGIN_META"; + +// Plain CommonJS so `node -e` runs it as is; `import()` loads ESM and CJS entries alike. +export const PLUGIN_RUNNER_SOURCE = String.raw` +"use strict"; +const { pathToFileURL } = require("node:url"); +const send = (message) => { try { if (process.connected) process.send(message); } catch {} }; +const meta = JSON.parse(process.env.${PLUGIN_RUNNER_META_ENV} || "{}"); +const entry = process.env.${PLUGIN_RUNNER_ENTRY_ENV}; +const text = (value) => (value instanceof Error ? (value.stack || value.message) : String(value)); +const eventHandlers = new Set(); +const callHandlers = new Map(); +const commandHandlers = new Map(); +// threadId -> handle -> function, replaced with each band the plugin draws. +const pressHandlers = new Map(); +let nextHandle = 1; +let dispose = null; + +function serialize(node, threadId, handles) { + if (node === null || node === undefined || node === false) return null; + if (typeof node === "string" || typeof node === "number") return String(node); + if (typeof node !== "object" || typeof node.type !== "string") return null; + const props = {}; + let press; + for (const [key, value] of Object.entries(node.props || {})) { + if (key === "onPress" && typeof value === "function") { + const handle = nextHandle++; + handles.set(handle, value); + press = { plugin: meta.id, handle }; + } else if (typeof value !== "function") { + props[key] = value; + } + } + const children = (node.children || []) + .flat(Infinity) + .map((child) => serialize(child, threadId, handles)) + .filter((child) => child !== null); + return { type: node.type, props, ...(press ? { press } : {}), children }; +} + +const ctx = { + plugin: meta, + ui: { + setStatus(threadId, key, value, options = {}) { + send({ t: "status", threadId: String(threadId), key: String(key), text: value == null ? "" : String(value), tone: options.tone, tooltip: options.tooltip }); + }, + toast(input) { + const toast = typeof input === "string" ? { title: input } : input || {}; + send({ t: "toast", title: String(toast.title ?? ""), body: toast.body == null ? undefined : String(toast.body), tone: toast.tone, threadId: toast.threadId, timeoutMs: toast.timeoutMs }); + }, + setBand(threadId, tree) { + const handles = new Map(); + const serialized = tree == null ? null : serialize(tree, threadId, handles); + pressHandlers.set(String(threadId), handles); + send({ t: "band", threadId: String(threadId), tree: serialized }); + }, + h(type, props, ...children) { + return { type, props: props || {}, children }; + }, + }, + onEvent(fn) { eventHandlers.add(fn); return () => eventHandlers.delete(fn); }, + handle(name, fn) { callHandlers.set(String(name), fn); }, + onCommand(name, fn) { commandHandlers.set(String(name), fn); }, + log: (...args) => console.log(...args), +}; + +async function answer(id, run) { + try { + const value = await run(); + send({ t: "result", id, ok: true, value: value === undefined ? null : JSON.parse(JSON.stringify(value)) }); + } catch (error) { + send({ t: "result", id, ok: false, error: text(error) }); + } +} + +process.on("message", (message) => { + if (!message || typeof message !== "object") return; + switch (message.t) { + case "event": + for (const fn of eventHandlers) { + Promise.resolve().then(() => fn(message.event)).catch((error) => console.error(text(error))); + } + return; + case "call": { + const fn = callHandlers.get(message.handler); + return answer(message.id, () => { + if (!fn) throw new Error("No handler named " + message.handler + "; register one with ctx.handle()."); + return fn(message.input, message.context); + }); + } + case "command": { + const fn = commandHandlers.get(message.name); + return answer(message.id, () => { + if (!fn) throw new Error("No command handler for /" + message.name + "; register one with ctx.onCommand()."); + return fn({ threadId: message.threadId, args: message.args }); + }); + } + case "press": { + const fn = pressHandlers.get(message.threadId)?.get(message.handle); + if (fn) Promise.resolve().then(() => fn({ threadId: message.threadId, key: message.key })).catch((error) => console.error(text(error))); + return; + } + case "stop": + Promise.resolve() + .then(() => (typeof dispose === "function" ? dispose() : undefined)) + .catch((error) => console.error(text(error))) + .finally(() => process.exit(0)); + return; + } +}); +process.on("disconnect", () => process.exit(0)); +process.on("unhandledRejection", (error) => console.error("Unhandled rejection:", text(error))); + +(async () => { + try { + const mod = await import(pathToFileURL(entry).href); + const activate = mod.activate || (mod.default && (mod.default.activate || mod.default)); + if (typeof activate !== "function") throw new Error("The entry must export an activate(ctx) function."); + dispose = await activate(ctx); + send({ t: "ready" }); + } catch (error) { + send({ t: "fatal", error: text(error) }); + setTimeout(() => process.exit(1), 50); + } +})(); +`; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 82edb27ba7ba3..31d5736f0114a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -88,6 +88,8 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerOwnership from "./serverOwnership.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as DirectPush from "./push/DirectPush.ts"; +import * as ClaudeModUi from "./plugins/ClaudeModUi.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; @@ -558,6 +560,7 @@ const layerProviderInstallationRefresh = Layer.effectDiscard( const layerRuntimeCoreDependenciesBase = Layer.mergeAll( AgentAwarenessRelay.layer, DirectPush.layer, + PluginHost.layerEvents, // Asks T3 Connect to deliver webhooks it held while this environment was offline. HeldHooksWaker.layer, layerThreadSettlementWorker, @@ -636,6 +639,8 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( CodexInstallation.CodexInstallation.layer, ), ), + // Below the provider registry: the Claude query runner draws mod UI into the plugin surfaces. + Layer.provideMerge(PluginHost.layer.pipe(Layer.provideMerge(ClaudeModUi.layerWithStore))), ); const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 77f14f50558af..b562a9417c79a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -103,6 +103,7 @@ import { WS_METHODS, WsBaseRpcGroup, WsReviewSessionsRpcGroup, + WsPluginsRpcGroup, WsRpcGroup, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; @@ -225,6 +226,7 @@ import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as DefectReporter from "./observability/DefectReporter.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as DirectPush from "./push/DirectPush.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; @@ -562,6 +564,7 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; const ServerWsRpcGroup = WsRpcGroup.middleware(RpcInstrumentation); const ServerWsBaseRpcGroup = WsBaseRpcGroup.middleware(RpcInstrumentation); const ServerReviewSessionsRpcGroup = WsReviewSessionsRpcGroup.middleware(RpcInstrumentation); +const ServerPluginsRpcGroup = WsPluginsRpcGroup.middleware(RpcInstrumentation); // When a resuming client's cursor is more than this many events behind the // current head, skip the per-event catch-up replay and send a fresh shell // snapshot instead. Replaying each intervening event costs a shell refetch; @@ -1252,6 +1255,23 @@ const layerReviewSessionsRpc = ServerReviewSessionsRpcGroup.toLayer( }), ); +const layerPluginsRpc = ServerPluginsRpcGroup.toLayer( + Effect.gen(function* () { + const plugins = yield* PluginHost.PluginHost; + return ServerPluginsRpcGroup.of({ + [WS_METHODS.pluginsSubscribe]: (_input) => plugins.subscribe, + [WS_METHODS.pluginsBandsSubscribe]: (input) => plugins.subscribeBands(input.threadId), + [WS_METHODS.pluginsBandPress]: (input) => plugins.press(input), + [WS_METHODS.pluginsViewRead]: (input) => plugins.readView(input.pluginId, input.viewId), + [WS_METHODS.pluginsViewCall]: (input) => + plugins.callView(input).pipe(Effect.map((value) => ({ value }))), + [WS_METHODS.pluginsCommandRun]: (input) => plugins.runCommand(input), + [WS_METHODS.pluginsSetEnabled]: (input) => plugins.setEnabled(input.pluginId, input.enabled), + [WS_METHODS.pluginsReload]: (_input) => Effect.asVoid(plugins.reload), + }); + }), +); + const layerWsRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, @@ -3372,7 +3392,7 @@ export const layer = Layer.unwrap( return httpEffect; }).pipe( Effect.provide( - Layer.merge( + Layer.mergeAll( layerWsRpc( session, clientOrigin, @@ -3381,6 +3401,7 @@ export const layer = Layer.unwrap( serverBrowser, ), layerReviewSessionsRpc, + layerPluginsRpc, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Request fibers run in the handlers' context, so this reporter sees diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index 289cd9a19555c..2388af74bdfb1 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -324,6 +324,12 @@ export function summarizeT3ToolCalls( case "project-list": label = phrase("Listed", "list", `projects ${times}`); break; + case "plugin-list": + label = phrase("Listed", "list", `plugins ${times}`); + break; + case "plugin-reload": + label = phrase("Reloaded", "reload", `plugins ${times}`); + break; case "project-read": label = phrase("Read", "read", quantity(countEntities(projectIds), "project")); break; diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index c9026bb16bc0b..11a24c3998c8c 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -55,6 +55,13 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.reviewSessionsRunLocalReview]: AuthOrchestrationOperateScope, [WS_METHODS.reviewSessionsDiscussInSideChat]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, + // Turning a plugin on runs its code on the host, like changing a setting would. + [WS_METHODS.pluginsSetEnabled]: AuthSettingsWriteScope, + [WS_METHODS.pluginsReload]: AuthSettingsWriteScope, + // Each reaches plugin code, which may act on the host. + [WS_METHODS.pluginsBandPress]: AuthOrchestrationOperateScope, + [WS_METHODS.pluginsViewCall]: AuthOrchestrationOperateScope, + [WS_METHODS.pluginsCommandRun]: AuthOrchestrationOperateScope, } as const; export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1324a178857a6..77e95610af329 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -61,6 +61,7 @@ export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; export * from "./scheduledTask.ts"; +export * from "./plugins.ts"; export * from "./reviewSession.ts"; export * from "./worktreeMcp.ts"; export * from "./resourceTelemetry.ts"; diff --git a/packages/contracts/src/plugins.ts b/packages/contracts/src/plugins.ts new file mode 100644 index 0000000000000..44d79404f969a --- /dev/null +++ b/packages/contracts/src/plugins.ts @@ -0,0 +1,287 @@ +/** + * Local plugins: folders under `/plugins` that add UI to the app. + * + * Plugin code is trusted OS-user code. The host runs a plugin's `entry` in a + * supervised child process, which keeps a crash out of the server but is not + * a sandbox. Views run in a sandboxed, opaque-origin frame. + * + * Names and shapes follow the upstream plugin proposal (pingdotgg/t3code + * #16047-#16065) where they overlap: the `t3-plugin.json` manifest, owner + * qualified ids, `apiVersion`, status items and notification fields. + * + * Claude Code mods draw into the same surfaces: their `ui_status` lines, + * `ui_toast` notices and `AbovePrompt` band arrive with a `claude-mod` source. + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { IsoDateTime, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** The one plugin API version this host implements. Other values do not load. */ +export const PLUGIN_API_VERSION = 1; +export const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; +/** The folder under the T3 home that holds one subfolder per plugin. */ +export const PLUGINS_DIR_NAME = "plugins"; + +/** Owner-qualified id such as `acme.notifier`: two or more lowercase dot segments. */ +export const PluginId = TrimmedNonEmptyString.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), +); +export type PluginId = typeof PluginId.Type; + +/** Relative path inside the plugin folder with the given extension, without `..` segments. */ +const pluginFilePath = (extension: string) => + Schema.String.check( + Schema.isMaxLength(256), + Schema.isPattern( + new RegExp(`^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\:\\0]+\\.(?:${extension})$`), + ), + ); + +const PluginViewId = Schema.String.check( + Schema.isMaxLength(32), + Schema.isPattern(/^[a-z][a-z0-9-]*$/), +); + +/** One entry of `views`: a page the right panel can show as a tab. */ +export const PluginViewDeclaration = Schema.Struct({ + id: PluginViewId, + title: TrimmedNonEmptyString.check(Schema.isMaxLength(60)), + /** Only the right panel exists today; a host shows only placements it knows. */ + placement: Schema.Literal("side-panel").pipe( + Schema.withDecodingDefault(Effect.succeed("side-panel" as const)), + ), + /** A self-contained HTML page. It runs with the app theme and the `t3` bridge. */ + html: pluginFilePath("html?"), +}); +export type PluginViewDeclaration = typeof PluginViewDeclaration.Type; + +export const PLUGIN_COMMAND_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; + +/** + * One entry of `commands`: a `/name` in the composer menu. A command with a + * `prompt` puts that text in the composer; one without calls the plugin's + * `commands` handler with the rest of the line. + */ +export const PluginCommandDeclaration = Schema.Struct({ + name: Schema.String.check(Schema.isMaxLength(32), Schema.isPattern(PLUGIN_COMMAND_NAME_PATTERN)), + description: Schema.String.check(Schema.isMaxLength(200)), + prompt: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(8000))), +}); +export type PluginCommandDeclaration = typeof PluginCommandDeclaration.Type; + +export const PluginManifest = Schema.Struct({ + id: PluginId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + version: TrimmedNonEmptyString.check(Schema.isMaxLength(64)), + description: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(500))), + apiVersion: Schema.Int, + /** Node module the host runs in a child process; it exports `activate(ctx)`. */ + entry: Schema.optionalKey(pluginFilePath("m?js")), + views: Schema.Array(PluginViewDeclaration) + .check(Schema.isMaxLength(8)) + .pipe(Schema.withDecodingDefault(Effect.succeed([]))), + commands: Schema.Array(PluginCommandDeclaration) + .check(Schema.isMaxLength(16)) + .pipe(Schema.withDecodingDefault(Effect.succeed([]))), +}); +export type PluginManifest = typeof PluginManifest.Type; + +/** + * `ready`: loaded with no code to run. `running`: its entry is up. + * `starting` and `restarting` are on the way; `error` stopped after a bad + * manifest or repeated crashes, and `disabled` is turned off. + */ +export const PluginState = Schema.Literals([ + "ready", + "starting", + "running", + "restarting", + "error", + "disabled", +]); +export type PluginState = typeof PluginState.Type; + +export const PluginSummary = Schema.Struct({ + /** The manifest id, or the folder name when the manifest could not be read. */ + id: Schema.String, + name: Schema.String, + version: Schema.String, + description: Schema.optionalKey(Schema.String), + dir: Schema.String, + enabled: Schema.Boolean, + state: PluginState, + error: Schema.optionalKey(Schema.String), + views: Schema.Array(Schema.Struct({ id: Schema.String, title: Schema.String })), + commands: Schema.Array(PluginCommandDeclaration), +}); +export type PluginSummary = typeof PluginSummary.Type; + +// --- What plugins draw --- + +export const PLUGIN_STATUS_TEXT_MAX_LENGTH = 80; +export const PLUGIN_STATUS_MAX_ITEMS_PER_SOURCE = 8; +export const PLUGIN_TOAST_MAX_RETAINED = 20; + +export const PluginTone = Schema.Literals(["neutral", "info", "success", "warning", "error"]); +export type PluginTone = typeof PluginTone.Type; + +/** Who drew something: a local plugin or a Claude Code mod in that thread's session. */ +export const PluginUiSource = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("plugin"), pluginId: Schema.String, name: Schema.String }), + Schema.Struct({ kind: Schema.Literal("claude-mod"), name: Schema.String }), +]); +export type PluginUiSource = typeof PluginUiSource.Type; + +export const pluginUiSourceKey = (source: PluginUiSource): string => + source.kind === "plugin" ? `plugin:${source.pluginId}` : `claude-mod:${source.name}`; + +/** Upstream's ContributionStatusItem: a short line keyed within its source. */ +export const PluginStatusItem = Schema.Struct({ + key: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(64)), + text: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(PLUGIN_STATUS_TEXT_MAX_LENGTH)), + tone: Schema.optionalKey(PluginTone), + tooltip: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(240))), +}); +export type PluginStatusItem = typeof PluginStatusItem.Type; + +export const PluginStatusEntry = Schema.Struct({ + threadId: ThreadId, + source: PluginUiSource, + items: Schema.Array(PluginStatusItem), +}); +export type PluginStatusEntry = typeof PluginStatusEntry.Type; + +export const PluginToast = Schema.Struct({ + /** Unique within the server's lifetime; clients show each id once. */ + id: Schema.String, + source: PluginUiSource, + title: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(80)), + body: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(240))), + tone: Schema.optionalKey(PluginTone), + threadId: Schema.optionalKey(ThreadId), + timeoutMs: Schema.optionalKey(Schema.Number), + createdAt: IsoDateTime, +}); +export type PluginToast = typeof PluginToast.Type; + +/** Where a band element's press goes: the CLI stamps Claude mod trees, the plugin runner stamps plugin trees. */ +export const PluginUiPressTarget = Schema.Struct({ + plugin: Schema.String, + handle: Schema.Number, +}); +export type PluginUiPressTarget = typeof PluginUiPressTarget.Type; + +export interface PluginUiElement { + readonly type: string; + readonly props?: { readonly [key: string]: unknown }; + readonly press?: PluginUiPressTarget; + readonly children?: ReadonlyArray; +} +export type PluginUiChild = string | PluginUiElement; + +/** + * One node of a band tree, in the Claude Code mod element table (Box, Text, + * Button, Link, Code, Markdown, div, span, b). Kept loose: clients draw the + * types they know and skip the rest. + */ +export const PluginUiElement: Schema.Codec = Schema.Struct({ + type: Schema.String, + props: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + press: Schema.optionalKey(PluginUiPressTarget), + children: Schema.optionalKey( + Schema.Array( + Schema.Union([ + Schema.String, + Schema.suspend((): Schema.Codec => PluginUiElement), + ]), + ), + ), +}); + +/** + * Terminal columns a band is laid out for. Claude mods are asked to fit this + * width; clients scale the font so it fits theirs. + */ +export const PLUGIN_BAND_COLUMNS = 100; + +export const PluginBandEntry = Schema.Struct({ + threadId: ThreadId, + source: PluginUiSource, + tree: PluginUiElement, +}); +export type PluginBandEntry = typeof PluginBandEntry.Type; + +/** Every plugin and what they show across threads; each frame replaces the last. */ +export const PluginsSnapshot = Schema.Struct({ + pluginsDir: Schema.String, + plugins: Schema.Array(PluginSummary), + statuses: Schema.Array(PluginStatusEntry), + /** Oldest first, at most PLUGIN_TOAST_MAX_RETAINED. */ + toasts: Schema.Array(PluginToast), +}); +export type PluginsSnapshot = typeof PluginsSnapshot.Type; + +export const PluginBandsSnapshot = Schema.Struct({ + bands: Schema.Array(PluginBandEntry), +}); +export type PluginBandsSnapshot = typeof PluginBandsSnapshot.Type; + +// --- RPC inputs --- + +export const PluginsSubscribeInput = Schema.Struct({}); +export const PluginBandsSubscribeInput = Schema.Struct({ threadId: ThreadId }); + +export const PluginBandPressInput = Schema.Struct({ + threadId: ThreadId, + source: PluginUiSource, + target: PluginUiPressTarget, + key: Schema.optionalKey(Schema.String), +}); +export type PluginBandPressInput = typeof PluginBandPressInput.Type; + +export const PluginViewReadInput = Schema.Struct({ + pluginId: Schema.String, + viewId: Schema.String, +}); +export const PluginViewReadResult = Schema.Struct({ + title: Schema.String, + /** The page with the theme bootstrap and the `t3` bridge already injected. */ + html: Schema.String, +}); +export type PluginViewReadResult = typeof PluginViewReadResult.Type; + +export const PLUGIN_VIEW_HANDLER_PATTERN = /^[A-Za-z][A-Za-z0-9_.-]*$/; + +export const PluginViewCallInput = Schema.Struct({ + pluginId: Schema.String, + viewId: Schema.String, + /** The thread whose right panel shows the view. */ + threadId: Schema.optionalKey(ThreadId), + handler: Schema.String.check( + Schema.isMaxLength(64), + Schema.isPattern(PLUGIN_VIEW_HANDLER_PATTERN), + ), + input: Schema.Unknown, +}); +export type PluginViewCallInput = typeof PluginViewCallInput.Type; +export const PluginViewCallResult = Schema.Struct({ value: Schema.Unknown }); + +export const PluginCommandRunInput = Schema.Struct({ + pluginId: Schema.String, + command: Schema.String, + threadId: ThreadId, + args: Schema.String.check(Schema.isMaxLength(8000)), +}); +export type PluginCommandRunInput = typeof PluginCommandRunInput.Type; + +export const PluginSetEnabledInput = Schema.Struct({ + pluginId: Schema.String, + enabled: Schema.Boolean, +}); +export type PluginSetEnabledInput = typeof PluginSetEnabledInput.Type; + +export class PluginError extends Schema.TaggedError()("PluginError", { + message: Schema.String, +}) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f3c867ce6cac1..351be20a02bb4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -352,6 +352,20 @@ import { ScheduledTaskUpsertInput, ScheduledTaskMutationResult, } from "./scheduledTask.ts"; +import { + PluginBandPressInput, + PluginBandsSnapshot, + PluginBandsSubscribeInput, + PluginCommandRunInput, + PluginError, + PluginSetEnabledInput, + PluginsSnapshot, + PluginsSubscribeInput, + PluginViewCallInput, + PluginViewCallResult, + PluginViewReadInput, + PluginViewReadResult, +} from "./plugins.ts"; import { ReviewItemEditReplyInput, ReviewItemResult, @@ -546,6 +560,16 @@ export const WS_METHODS = { serverGetUsageSummary: "server.getUsageSummary", serverRefreshUsageRates: "server.refreshUsageRates", + // Ctrl: local plugins and Claude Code mod UI. + pluginsSubscribe: "plugins.subscribe", + pluginsBandsSubscribe: "plugins.bands.subscribe", + pluginsBandPress: "plugins.bands.press", + pluginsViewRead: "plugins.view.read", + pluginsViewCall: "plugins.view.call", + pluginsCommandRun: "plugins.command.run", + pluginsSetEnabled: "plugins.setEnabled", + pluginsReload: "plugins.reload", + // Scheduled tasks scheduledTasksList: "scheduledTasks.list", scheduledTasksSubscribe: "scheduledTasks.subscribe", @@ -2038,6 +2062,67 @@ export const WsReviewSessionsRpcGroup = RpcGroup.make( WsReviewSessionsDiscussInSideChatRpc, ).middleware(RpcScopeAuthorization); +const PluginRpcError = Schema.Union([PluginError, EnvironmentAuthorizationError]); + +/** Plugins, their state and what they show across threads: one snapshot, then one per change. */ +const WsPluginsSubscribeRpc = Rpc.make(WS_METHODS.pluginsSubscribe, { + payload: PluginsSubscribeInput, + success: PluginsSnapshot, + error: EnvironmentAuthorizationError, + stream: true, +}); + +/** The bands plugins and Claude Code mods draw above one thread's composer. */ +const WsPluginsBandsSubscribeRpc = Rpc.make(WS_METHODS.pluginsBandsSubscribe, { + payload: PluginBandsSubscribeInput, + success: PluginBandsSnapshot, + error: EnvironmentAuthorizationError, + stream: true, +}); + +const WsPluginsBandPressRpc = Rpc.make(WS_METHODS.pluginsBandPress, { + payload: PluginBandPressInput, + error: PluginRpcError, +}); + +const WsPluginsViewReadRpc = Rpc.make(WS_METHODS.pluginsViewRead, { + payload: PluginViewReadInput, + success: PluginViewReadResult, + error: PluginRpcError, +}); + +const WsPluginsViewCallRpc = Rpc.make(WS_METHODS.pluginsViewCall, { + payload: PluginViewCallInput, + success: PluginViewCallResult, + error: PluginRpcError, +}); + +const WsPluginsCommandRunRpc = Rpc.make(WS_METHODS.pluginsCommandRun, { + payload: PluginCommandRunInput, + error: PluginRpcError, +}); + +const WsPluginsSetEnabledRpc = Rpc.make(WS_METHODS.pluginsSetEnabled, { + payload: PluginSetEnabledInput, + error: PluginRpcError, +}); + +const WsPluginsReloadRpc = Rpc.make(WS_METHODS.pluginsReload, { + payload: Schema.Struct({}), + error: PluginRpcError, +}); + +export const WsPluginsRpcGroup = RpcGroup.make( + WsPluginsSubscribeRpc, + WsPluginsBandsSubscribeRpc, + WsPluginsBandPressRpc, + WsPluginsViewReadRpc, + WsPluginsViewCallRpc, + WsPluginsCommandRunRpc, + WsPluginsSetEnabledRpc, + WsPluginsReloadRpc, +).middleware(RpcScopeAuthorization); + /** Everything but the review RPCs, so the server builds its handlers apart; clients use `WsRpcGroup`. */ export const WsBaseRpcGroup = RpcGroup.make( WsServerProbeRpc, @@ -2239,4 +2324,4 @@ export const WsBaseRpcGroup = RpcGroup.make( WsOrchestrationV2SubscribeThreadRpc, ).middleware(RpcScopeAuthorization); -export const WsRpcGroup = WsBaseRpcGroup.merge(WsReviewSessionsRpcGroup); +export const WsRpcGroup = WsBaseRpcGroup.merge(WsReviewSessionsRpcGroup, WsPluginsRpcGroup); diff --git a/packages/shared/package.json b/packages/shared/package.json index 7789c05a617c9..bcd6ec9277e3b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -155,6 +155,10 @@ "types": "./src/htmlRender.ts", "import": "./src/htmlRender.ts" }, + "./pluginView": { + "types": "./src/pluginView.ts", + "import": "./src/pluginView.ts" + }, "./favicon": { "types": "./src/favicon.ts", "import": "./src/favicon.ts" diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts index 5b4019830f6f5..bf7b807737b35 100644 --- a/packages/shared/src/htmlRender.ts +++ b/packages/shared/src/htmlRender.ts @@ -422,7 +422,7 @@ function rootRule(theme: HtmlRenderTheme): string { // match as resolveEventKeys reads them, so the client accepts what it claims. const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);var K=[],B=[],C=${JSON.stringify(EVENT_CODE_SHORTCUT_KEYS)};window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(e.source===window.parent&&d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(SHORTCUTS_METHOD)}&&p&&Array.isArray(p.shortcuts)&&Array.isArray(p.blocked)){K=p.shortcuts;B=p.blocked;}});window.addEventListener("keydown",function(e){var t=e.composedPath()[0],c=e.code||"",l=e.key.toLowerCase(),x=/^[a-z]$/.test(l)?[l]:[l,/^Key[A-Z]$/.test(c)?c.slice(3).toLowerCase():"",C.hasOwnProperty(c)?C[c]:""],f,m=function(s){return s&&s.metaKey===e.metaKey&&s.ctrlKey===e.ctrlKey&&s.shiftKey===e.shiftKey&&s.altKey===e.altKey&&x.indexOf(s.key)>-1;};if(!e.isTrusted||e.isComposing||e.getModifierState("AltGraph")&&!/^[a-z0-9]$/i.test(e.key)||t&&(t.isContentEditable||/^(?:INPUT|TEXTAREA|SELECT)$/.test(t.tagName))||!K.some(m)||B.some(m))return;f=function(v){window.removeEventListener("keydown",f);if(v!==e||e.defaultPrevented)return;e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(KEY_DOWN_METHOD)},params:{key:e.key,code:e.code,metaKey:e.metaKey,ctrlKey:e.ctrlKey,shiftKey:e.shiftKey,altKey:e.altKey,repeat:e.repeat}},"*");};window.addEventListener("keydown",f);setTimeout(function(){window.removeEventListener("keydown",f);});},true);}})();`; -function bootstrapMarkup(markup: string): string { +function bootstrapMarkup(markup: string, extraScript: string | undefined): string { const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); const light = htmlRenderTheme(T3_CODE_LIGHT_THEME_COLORS, "light"); // Without a client-provided theme (a direct download, the headless preview @@ -435,6 +435,7 @@ function bootstrapMarkup(markup: string): string { : '', ``, ``, + extraScript === undefined ? "" : ``, ].join(""); } @@ -468,11 +469,12 @@ const blankNonMarkup = (html: string) => { /** * Inserts the theme bootstrap at the start of the document head, so a page's - * own styles and scripts come after it. + * own styles and scripts come after it. `extraScript` runs right after the + * bootstrap, before any of the page's own scripts. */ -export function injectHtmlRenderBootstrap(html: string): string { +export function injectHtmlRenderBootstrap(html: string, extraScript?: string): string { const scan = blankNonMarkup(html); - const markup = bootstrapMarkup(scan); + const markup = bootstrapMarkup(scan, extraScript); const headOpen = /]*)?>/i.exec(scan); if (headOpen) { const at = headOpen.index + headOpen[0].length; diff --git a/packages/shared/src/pluginView.ts b/packages/shared/src/pluginView.ts new file mode 100644 index 0000000000000..f0aa6a7fc493c --- /dev/null +++ b/packages/shared/src/pluginView.ts @@ -0,0 +1,16 @@ +/** + * The `t3` object a plugin view page gets, injected after the theme bootstrap. + * + * await t3.call(name, input) runs the plugin's `ctx.handle(name, fn)` and returns its value + * t3.ready(fn) calls fn({ pluginId, viewId, threadId }) once the host answers + * t3.threadId the thread whose right panel shows the view, once ready + * + * Messages are JSON-RPC shaped like the html_render bootstrap's. The client + * accepts them only from this frame's window and answers on it. + */ +export const PLUGIN_VIEW_READY_METHOD = "t3/plugin.ready"; +export const PLUGIN_VIEW_INIT_METHOD = "t3/plugin.init"; +export const PLUGIN_VIEW_CALL_METHOD = "t3/plugin.call"; +export const PLUGIN_VIEW_CALL_ID_PREFIX = "t3-plugin-"; + +export const PLUGIN_VIEW_BRIDGE_SCRIPT = `(function(){if(window.parent===window)return;var n=0,p={},c=null,w=[];function post(m){window.parent.postMessage(m,"*");}window.addEventListener("message",function(e){var d=e.data;if(e.source!==window.parent||!d||d.jsonrpc!=="2.0")return;if(d.method===${JSON.stringify(PLUGIN_VIEW_INIT_METHOD)}&&d.params){c=d.params;window.t3.threadId=c.threadId||null;var q=w;w=[];q.forEach(function(f){f(c);});return;}if(typeof d.id==="string"&&p[d.id]){var r=p[d.id];delete p[d.id];if(d.error)r[1](new Error(d.error.message||"The plugin call failed."));else r[0](d.result);}});window.t3={threadId:null,call:function(h,i){return new Promise(function(res,rej){var id=${JSON.stringify(PLUGIN_VIEW_CALL_ID_PREFIX)}+(++n);p[id]=[res,rej];post({jsonrpc:"2.0",id:id,method:${JSON.stringify(PLUGIN_VIEW_CALL_METHOD)},params:{handler:String(h),input:i===undefined?null:i}});});},ready:function(f){if(c)f(c);else w.push(f);}};post({jsonrpc:"2.0",method:${JSON.stringify(PLUGIN_VIEW_READY_METHOD)}});})();`; diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index c85acee332932..bc20502f62788 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -44,6 +44,8 @@ export type T3McpToolSummaryAction = | "worktree-list" | "worktree-status" | "project-list" + | "plugin-list" + | "plugin-reload" | "project-read" | "project-create" | "project-update" @@ -325,6 +327,8 @@ const T3_MCP_TOOLS: Readonly> = { ), t3_thread_launch: tool(["Launch", "Launching", "Launched", "a project thread"], "thread-create"), t3_project_list: tool(["List", "Listing", "Listed", "projects"], "project-list"), + t3_plugin_list: tool(["List", "Listing", "Listed", "plugins"], "plugin-list"), + t3_plugin_reload: tool(["Reload", "Reloading", "Reloaded", "plugins"], "plugin-reload"), t3_project_read: tool(["Read", "Reading", "Read", "a project"], "project-read"), t3_project_create: tool(["Register", "Registering", "Registered", "a project"], "project-create"), t3_project_update: tool(["Update", "Updating", "Updated", "a project"], "project-update"), From 3d89ae8d7fce806bcd5ffa2bb4d390c4761de7d3 Mon Sep 17 00:00:00 2001 From: nach <10445733+nachogames@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:48:02 -0500 Subject: [PATCH 2/4] feat(web,mobile): show plugin views, statuses, bands, toasts and commands Plugin views open as right-panel tabs from the + menu and the empty-panel launcher. They render in the sandboxed, themed html_render frame and answer t3.call through the plugin. Plugin and Claude Code mod bands and statuses draw above the composer; a thread's first status shows on its sidebar row; toasts show once per client. Plugin / commands join the composer menu. Settings > Integrations lists plugins with their state and errors, turns them on or off, and reloads them. Mobile says plugins are not supported yet. Co-Authored-By: Claude Opus 5.5 --- .../features/settings/SettingsRouteScreen.tsx | 8 + apps/server/src/plugins/PluginHost.ts | 3 + apps/server/src/plugins/PluginUiStore.ts | 10 +- apps/web/src/closedViewStore.ts | 6 + apps/web/src/components/ChatView.tsx | 41 +++ apps/web/src/components/RightPanelTabs.tsx | 34 +- apps/web/src/components/Sidebar.tsx | 6 + apps/web/src/components/chat/ChatComposer.tsx | 63 +++- .../components/chat/ComposerCommandMenu.tsx | 15 + .../chat/composerSlashCommandSearch.ts | 11 +- .../components/files/BrowserDocumentFrame.tsx | 19 ++ .../src/components/plugins/PluginBandTree.tsx | 315 ++++++++++++++++++ .../components/plugins/PluginStatusItems.tsx | 35 ++ .../components/plugins/PluginThreadBand.tsx | 87 +++++ .../plugins/PluginToastCoordinator.tsx | 45 +++ .../components/plugins/PluginViewPanel.tsx | 124 +++++++ .../plugins/SidebarPluginStatus.tsx | 37 ++ .../plugins/pluginToasts.logic.test.ts | 24 ++ .../components/plugins/pluginToasts.logic.ts | 15 + .../settings/IntegrationsSettings.tsx | 2 + .../components/settings/PluginSettings.tsx | 95 ++++++ .../src/components/settings/settingsSearch.ts | 7 + apps/web/src/reopenClosedView.ts | 3 + apps/web/src/rightPanelStore.ts | 42 ++- apps/web/src/routes/__root.tsx | 2 + apps/web/src/state/plugins.ts | 6 + packages/client-runtime/package.json | 4 + packages/client-runtime/src/rpc/client.ts | 2 + packages/client-runtime/src/state/plugins.ts | 52 +++ packages/contracts/src/plugins.ts | 4 + packages/shared/src/pluginView.test.ts | 67 ++++ packages/shared/src/pluginView.ts | 44 +++ 32 files changed, 1213 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/plugins/PluginBandTree.tsx create mode 100644 apps/web/src/components/plugins/PluginStatusItems.tsx create mode 100644 apps/web/src/components/plugins/PluginThreadBand.tsx create mode 100644 apps/web/src/components/plugins/PluginToastCoordinator.tsx create mode 100644 apps/web/src/components/plugins/PluginViewPanel.tsx create mode 100644 apps/web/src/components/plugins/SidebarPluginStatus.tsx create mode 100644 apps/web/src/components/plugins/pluginToasts.logic.test.ts create mode 100644 apps/web/src/components/plugins/pluginToasts.logic.ts create mode 100644 apps/web/src/components/settings/PluginSettings.tsx create mode 100644 apps/web/src/state/plugins.ts create mode 100644 packages/client-runtime/src/state/plugins.ts create mode 100644 packages/shared/src/pluginView.test.ts diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 3de0ef2e69de5..5a4247e80ca14 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -166,6 +166,14 @@ function SettingsIndexSections() { + {/* Plugin views, bands and toasts draw only on web and desktop so far. */} + diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index 9c7b011e6fcdd..15608459d2a05 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -135,6 +135,7 @@ interface RunningChild { interface PluginRecord { scanned: ScannedPlugin; + revision: number; state: PluginSummary["state"]; error: string | null; child: RunningChild | null; @@ -301,6 +302,7 @@ const make = Effect.gen(function* () { dir: record.scanned.dir, enabled: !disabled.has(idOf(record)), state: record.state, + revision: record.revision, ...(record.error === null ? {} : { error: record.error }), views: (manifest?.views ?? []).map((view) => ({ id: view.id, title: view.title })), commands: manifest?.commands ?? [], @@ -598,6 +600,7 @@ const make = Effect.gen(function* () { if (existing !== undefined) yield* stopChild(existing); const record: PluginRecord = { scanned: scannedPlugin, + revision: (existing?.revision ?? 0) + 1, state: "starting", error: null, child: null, diff --git a/apps/server/src/plugins/PluginUiStore.ts b/apps/server/src/plugins/PluginUiStore.ts index b03a8894d4684..db168f27d07d3 100644 --- a/apps/server/src/plugins/PluginUiStore.ts +++ b/apps/server/src/plugins/PluginUiStore.ts @@ -74,12 +74,14 @@ export class PluginUiStore extends Context.Service { - // oxlint-disable-next-line no-control-regex -- stripping terminal styling is the point. - const clean = value - .replace(/\u001b\[[0-9;?]*[A-Za-z]/g, "") - .replace(/[\u0000-\u001f\u007f]+/g, " "); + const clean = value.replace(ANSI_SEQUENCE, "").replace(CONTROL_CHARACTERS, " "); const trimmed = clean.trim(); return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed; }; diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index 0a88ae09646cf..6bf7bb1d53da5 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -74,6 +74,12 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { Number.isSafeInteger(surface.revealRequestId) && (surface.attachment === undefined || isAttachment(surface.attachment)) ); + case "plugin": + return ( + typeof surface.pluginId === "string" && + typeof surface.viewId === "string" && + typeof surface.title === "string" + ); case "device": return ( (surface.title === undefined || typeof surface.title === "string") && diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 21bc0f5b5358b..5de9f0ef3f2b5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -419,6 +419,10 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; +import { pluginEnvironment } from "../state/plugins"; +import { PluginThreadBand } from "./plugins/PluginThreadBand"; +import { PluginViewPanel } from "./plugins/PluginViewPanel"; +import type { PluginViewLauncher } from "./RightPanelTabs"; import { useEnvironmentScope } from "~/state/session"; import { environmentServerConfigsAtom, @@ -5565,6 +5569,28 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !groupSurfaceAvailable) return; useRightPanelStore.getState().openGroup(activeThreadRef); }, [activeThreadRef, groupSurfaceAvailable]); + const pluginPlugins = useEnvironmentQuery( + activeThreadRef + ? pluginEnvironment.snapshot({ environmentId: activeThreadRef.environmentId, input: {} }) + : null, + ).data?.plugins; + const pluginViewLaunchers = useMemo((): ReadonlyArray => { + if (!activeThreadRef || !pluginPlugins) return []; + return pluginPlugins + .filter((plugin) => plugin.state !== "disabled" && plugin.state !== "error") + .flatMap((plugin) => + plugin.views.map((view) => ({ + key: `${plugin.id}:${view.id}`, + label: view.title, + onClick: () => + useRightPanelStore.getState().openPluginView(activeThreadRef, { + pluginId: plugin.id, + viewId: view.id, + title: view.title, + }), + })), + ); + }, [activeThreadRef, pluginPlugins]); const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); @@ -11332,6 +11358,13 @@ export default function ChatView(props: ChatViewProps) { ) : renderedRightPanelSurface?.kind === "group" ? ( + ) : renderedRightPanelSurface?.kind === "plugin" && activeThreadRef ? ( + ) : renderedRightPanelSurface?.kind === "device" ? ( ) : null} + {!composerMounted || routeKind === "draft" || !activeThreadRef ? null : ( + + )} {!composerMounted ? null : ( void; +} + +/** Launcher rows for plugin views: always available, no letter shortcut. */ +const pluginViewActions = (views: ReadonlyArray | undefined) => + (views ?? []).map((view) => ({ + label: view.label, + icon: Puzzle, + shortcut: "", + available: true, + disabledReason: "", + onClick: view.onClick, + })); + interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; @@ -146,6 +164,8 @@ interface RightPanelTabsProps { onAddDevice: () => void; /** Opens the routed chat's Group surface; absent where there is no routed chat. */ onAddGroup?: () => void; + /** Views local plugins offer, each opened as its own tab. */ + pluginViews?: ReadonlyArray; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -326,7 +346,7 @@ function SurfaceMenuItem(props: { aria-keyshortcuts={props.shortcut} > {props.children} - {props.shortcut} + {props.shortcut === "" ? null : {props.shortcut}} ); if (props.available || !props.disabledReason) return item; @@ -351,6 +371,7 @@ function RightPanelEmptyState(props: { onAddPullRequests: () => void; onAddDevice: () => void; onAddGroup: () => void; + pluginViews: ReadonlyArray | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -429,6 +450,7 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_UNAVAILABLE_HINTS.group, onClick: props.onAddGroup, }, + ...pluginViewActions(props.pluginViews), ].filter(isListedSurfaceAction); type SurfaceAction = (typeof actions)[number]; @@ -556,7 +578,7 @@ function RightPanelEmptyState(props: { > {action.label} - {action.shortcut} + {action.shortcut === "" ? null : {action.shortcut}} {/* Same choice the tab bar's "+" menu offers: the row opens the @@ -602,7 +624,7 @@ function RightPanelEmptyState(props: { > {actionIcon(action, "size-4")} {action.label} - {action.shortcut} + {action.shortcut === "" ? null : {action.shortcut}} } /> @@ -630,6 +652,8 @@ function surfaceTitle( switch (surface.kind) { case "group": return groupTitle ?? "Group"; + case "plugin": + return surface.title; case "diff": return "Diff"; case "files": @@ -732,6 +756,8 @@ function SurfaceIcon({ return ; case "group": return ; + case "plugin": + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -1030,6 +1056,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.group, onClick: props.onAddGroup ?? noop, }, + ...pluginViewActions(props.pluginViews), ].filter(isListedSurfaceAction); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { @@ -1524,6 +1551,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddPullRequests={props.onAddPullRequests} onAddDevice={props.onAddDevice} onAddGroup={props.onAddGroup ?? noop} + pluginViews={props.pluginViews} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 11b1d793d6358..9ec0a35a83a03 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { SidebarPluginStatus } from "./plugins/SidebarPluginStatus"; import { ENVIRONMENT_PICKER_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { EnvironmentBatteryIndicator } from "./EnvironmentBatteryIndicator"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; @@ -1781,6 +1782,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onOpenPullRequest={handlePrClick} /> ) : null; + const pluginStatus = ( + + ); const terminalStatusIcon = ( @@ -2286,6 +2291,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} + {pluginStatus} {terminalStatusIcon} {prBadge} {diff ? ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1fba57d0079c4..b8ca0fc0816c7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -272,6 +272,7 @@ import { type EnvironmentQueryTarget, } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; +import { pluginEnvironment } from "~/state/plugins"; import { useDebouncedValue } from "~/state/queries"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { resolveModelPickerSelectedModel } from "./ModelPickerContent"; @@ -2720,6 +2721,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [pullRequestProjectId, threadPullRequests], ); + const plugins = useEnvironmentQuery(pluginEnvironment.snapshot({ environmentId, input: {} })).data + ?.plugins; + const pluginCommandItems = useMemo( + () => + (plugins ?? []) + .filter((plugin) => plugin.enabled && plugin.state !== "error") + .flatMap((plugin) => + plugin.commands.map((command) => ({ + id: `plugin-command:${plugin.id}:${command.name}`, + type: "plugin-command" as const, + pluginId: plugin.id, + pluginName: plugin.name, + command, + label: `/${command.name}`, + description: command.description || plugin.name, + })), + ), + [plugins], + ); + const runPluginCommand = useAtomCommand(pluginEnvironment.runCommand, { + label: "Run plugin command", + }); + const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -2801,7 +2825,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (item) => item.command.name !== "compact" || compactSlashCommandAvailable, ); const slashCommandItems = slashCommandItemsForPromptPosition( - [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], + [ + ...builtInSlashCommandItems, + ...pluginCommandItems, + ...visibleProviderSlashCommandItems, + ...skillItems, + ], composerTrigger.rangeStart === 0, ); return searchSlashCommandItems(slashCommandItems, query); @@ -2888,6 +2917,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pullRequestProjectId, pullRequestRepository, pullRequestTriggerNumber, + pluginCommandItems, selectedProvider, selectedProviderSkills, selectedProviderSlashCommands, @@ -4191,6 +4221,34 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.type === "plugin-command") { + // A prompt command puts its text in the composer; any other runs in the plugin now. + const replacement = item.command.prompt ?? ""; + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (!applied) return; + setComposerHighlightedItemId(null); + if (item.command.prompt !== undefined) return; + if (activeThreadId === null) { + toastManager.add({ + type: "info", + title: `/${item.command.name} needs a thread`, + description: "Send a first message, then run it again.", + }); + return; + } + void runPluginCommand({ + environmentId, + input: { + pluginId: item.pluginId, + command: item.command.name, + threadId: activeThreadId, + args: "", + }, + }); + return; + } if (item.type === "skill") { const replacement = `$${item.skill.name} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( @@ -4266,15 +4324,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }, [ + activeThreadId, addComposerDraftReviewComment, addComposerDraftThreadContexts, applyPromptReplacement, attachmentDraftTarget, composerDraftTarget, + environmentId, handleInteractionModeChange, planModeUiEnabled, onUsageLimitsCommand, resolveActiveComposerTrigger, + runPluginCommand, ], ); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index eb3251642d78a..17f1c863a5346 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -4,6 +4,7 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import { + type PluginCommandDeclaration, type ProjectEntry, type ProviderDriverKind, type PullRequestContextMetadata, @@ -17,6 +18,7 @@ import { FolderIcon, MessagesSquareIcon, PackageIcon, + PuzzleIcon, SettingsIcon, UserRoundIcon, type LucideIcon, @@ -57,6 +59,16 @@ export type ComposerCommandItem = /** Muted note on where the command works, e.g. only at the message start. */ hint?: string; } + | { + id: string; + /** A `/name` a local plugin declares. */ + type: "plugin-command"; + pluginId: string; + pluginName: string; + command: PluginCommandDeclaration; + label: string; + description: string; + } | { id: string; type: "skill"; @@ -200,6 +212,9 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { theme={props.resolvedTheme} /> ) : null} + {props.item.type === "plugin-command" ? ( + + {typeof props.label === "string" ? props.label : href} + {renderChildren(node.children, path, press)} + + ); + } + case "Code": + case "Markdown": { + const source = typeof props.source === "string" ? props.source : null; + return ( + + {source ?? renderChildren(node.children, path, press)} + + ); + } + default: + // Input, Select, Svg, Client: not drawn on this surface yet. + return null; + } +} diff --git a/apps/web/src/components/plugins/PluginStatusItems.tsx b/apps/web/src/components/plugins/PluginStatusItems.tsx new file mode 100644 index 0000000000000..80fd71ef5b077 --- /dev/null +++ b/apps/web/src/components/plugins/PluginStatusItems.tsx @@ -0,0 +1,35 @@ +import { type PluginStatusEntry, pluginUiSourceKey } from "@t3tools/contracts"; + +import { cn } from "~/lib/utils"; + +/** Status items from plugins and Claude Code mods, each in its tone. */ +export function PluginStatusItems(props: { + readonly entries: ReadonlyArray; + readonly className?: string; +}) { + return props.entries.flatMap((entry) => + entry.items.map((item) => ( + + {item.text} + + )), + ); +} + +export function pluginToneClass(tone: string | undefined): string { + switch (tone) { + case "success": + return "text-success"; + case "warning": + return "text-warning"; + case "error": + return "text-destructive"; + case "info": + return "text-info"; + default: + return "text-muted-foreground"; + } +} diff --git a/apps/web/src/components/plugins/PluginThreadBand.tsx b/apps/web/src/components/plugins/PluginThreadBand.tsx new file mode 100644 index 0000000000000..bb700b1f7f91b --- /dev/null +++ b/apps/web/src/components/plugins/PluginThreadBand.tsx @@ -0,0 +1,87 @@ +import { + type EnvironmentId, + PLUGIN_BAND_COLUMNS, + type PluginStatusEntry, + pluginUiSourceKey, + type ThreadId, +} from "@t3tools/contracts"; + +import { pluginEnvironment } from "~/state/plugins"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { MONO_CH_EM, PluginBandTree } from "./PluginBandTree"; +import { PluginStatusItems } from "./PluginStatusItems"; + +/** + * What plugins and Claude Code mods draw above this thread's composer: their + * bands, then their status lines. Draws nothing when there is neither. + */ +export function PluginThreadBand(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const { environmentId, threadId } = props; + const bands = + useEnvironmentQuery(pluginEnvironment.bands({ environmentId, input: { threadId } })).data + ?.bands ?? []; + const statuses = useThreadPluginStatuses(environmentId, threadId); + const press = useAtomCommand(pluginEnvironment.pressBand, { + reportFailure: false, + reportDefect: false, + }); + + if (bands.length === 0 && statuses.length === 0) return null; + + return ( +
+ {bands.map((band) => ( +
+ {/* Shrinks below text-xs when the composer is narrower than the band's columns. */} +
+ { + void press({ + environmentId, + input: { + threadId, + source: band.source, + target, + ...(key === undefined ? {} : { key }), + }, + }); + }} + /> +
+
+ ))} + {statuses.length === 0 ? null : ( +
+ +
+ )} +
+ ); +} + +const EMPTY_STATUSES: ReadonlyArray = []; + +/** The status entries plugins and mods put on one thread. */ +export function useThreadPluginStatuses( + environmentId: EnvironmentId, + threadId: ThreadId, +): ReadonlyArray { + const statuses = + useEnvironmentQuery(pluginEnvironment.snapshot({ environmentId, input: {} })).data?.statuses ?? + EMPTY_STATUSES; + const own = statuses.filter((entry) => entry.threadId === threadId); + return own.length === 0 ? EMPTY_STATUSES : own; +} diff --git a/apps/web/src/components/plugins/PluginToastCoordinator.tsx b/apps/web/src/components/plugins/PluginToastCoordinator.tsx new file mode 100644 index 0000000000000..7692259584bd8 --- /dev/null +++ b/apps/web/src/components/plugins/PluginToastCoordinator.tsx @@ -0,0 +1,45 @@ +import type { EnvironmentId, PluginToast } from "@t3tools/contracts"; +import { useEffect, useRef } from "react"; + +import { useEnvironments } from "~/state/environments"; +import { pluginEnvironment } from "~/state/plugins"; +import { useEnvironmentQuery } from "~/state/query"; + +import { toastManager } from "../ui/toast"; +import { unseenPluginToasts } from "./pluginToasts.logic"; + +/** Shows each toast a plugin or Claude Code mod sends, once, on every connected environment. */ +export function PluginToastCoordinator() { + const { environments } = useEnvironments(); + return environments.map((environment) => ( + + )); +} + +function EnvironmentPluginToasts(props: { readonly environmentId: EnvironmentId }) { + const toasts = useEnvironmentQuery( + pluginEnvironment.snapshot({ environmentId: props.environmentId, input: {} }), + ).data?.toasts; + // Null until the first snapshot: toasts already in it predate this client. + const seen = useRef | null>(null); + useEffect(() => { + if (toasts === undefined) return; + const { show, seen: next } = unseenPluginToasts(toasts, seen.current); + seen.current = next; + for (const toast of show) showPluginToast(toast); + }, [toasts]); + return null; +} + +function showPluginToast(toast: PluginToast) { + const type = toast.tone === undefined || toast.tone === "neutral" ? "info" : toast.tone; + toastManager.add({ + type, + title: toast.source.kind === "plugin" ? `${toast.source.name}: ${toast.title}` : toast.title, + ...(toast.body === undefined ? {} : { description: toast.body }), + ...(toast.timeoutMs === undefined ? {} : { timeout: toast.timeoutMs }), + }); +} diff --git a/apps/web/src/components/plugins/PluginViewPanel.tsx b/apps/web/src/components/plugins/PluginViewPanel.tsx new file mode 100644 index 0000000000000..a948af263872c --- /dev/null +++ b/apps/web/src/components/plugins/PluginViewPanel.tsx @@ -0,0 +1,124 @@ +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import { + pluginViewErrorMessage, + pluginViewInitMessage, + pluginViewResultMessage, + readPluginViewMessage, +} from "@t3tools/shared/pluginView"; +import { useCallback, useEffect, useMemo } from "react"; + +import type { PluginViewSurface } from "~/rightPanelStore"; +import { pluginEnvironment } from "~/state/plugins"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; + +/** + * A plugin's view in the right panel: its page in the sandboxed, themed frame + * agent HTML renders use, answering the page's `t3.call` through the plugin. + * The page is read again whenever the plugin's files change. + */ +export function PluginViewPanel(props: { + readonly environmentId: EnvironmentId; + readonly threadRef: ScopedThreadRef | undefined; + readonly surface: PluginViewSurface; +}) { + const { environmentId, surface } = props; + const threadId = props.threadRef?.threadId ?? null; + const plugin = useEnvironmentQuery( + pluginEnvironment.snapshot({ environmentId, input: {} }), + ).data?.plugins.find((candidate) => candidate.id === surface.pluginId); + const offersView = + plugin !== undefined && + plugin.state !== "disabled" && + plugin.state !== "error" && + plugin.views.some((view) => view.id === surface.viewId); + const view = useEnvironmentQuery( + offersView + ? pluginEnvironment.view({ + environmentId, + input: { pluginId: surface.pluginId, viewId: surface.viewId, revision: plugin.revision }, + }) + : null, + ); + const html = view.data?.html ?? null; + const src = useMemo( + () => (html === null ? null : URL.createObjectURL(new Blob([html], { type: "text/html" }))), + [html], + ); + useEffect(() => () => (src === null ? undefined : URL.revokeObjectURL(src)), [src]); + + const callView = useAtomCommand(pluginEnvironment.callView, { + reportFailure: false, + reportDefect: false, + }); + const onFrameMessage = useCallback( + (data: unknown, reply: (message: unknown) => void) => { + const message = readPluginViewMessage(data); + if (message === undefined) return; + if (message.type === "ready") { + reply( + pluginViewInitMessage({ pluginId: surface.pluginId, viewId: surface.viewId, threadId }), + ); + return; + } + void callView({ + environmentId, + input: { + pluginId: surface.pluginId, + viewId: surface.viewId, + handler: message.handler, + input: message.input, + ...(threadId === null ? {} : { threadId }), + }, + }).then((result) => + reply( + result._tag === "Success" + ? pluginViewResultMessage(message.id, result.value.value) + : pluginViewErrorMessage(message.id, failureMessage(squashAtomCommandFailure(result))), + ), + ); + }, + [callView, environmentId, surface.pluginId, surface.viewId, threadId], + ); + + const notice = + plugin === undefined + ? "This plugin is no longer installed." + : plugin.state === "disabled" + ? `${plugin.name} is turned off. Turn it on in Settings → Plugins.` + : plugin.state === "error" + ? `${plugin.name} failed to load: ${plugin.error ?? "unknown error"}` + : !offersView + ? `${plugin.name} no longer has this view.` + : view.error; + + if (notice !== null && notice !== undefined) { + return ( +

+ {notice} +

+ ); + } + if (src === null) return null; + return ( + + ); +} + +const failureMessage = (failure: unknown): string => + typeof failure === "object" && + failure !== null && + "message" in failure && + typeof failure.message === "string" + ? failure.message + : "The plugin call failed."; diff --git a/apps/web/src/components/plugins/SidebarPluginStatus.tsx b/apps/web/src/components/plugins/SidebarPluginStatus.tsx new file mode 100644 index 0000000000000..1f574fde58e57 --- /dev/null +++ b/apps/web/src/components/plugins/SidebarPluginStatus.tsx @@ -0,0 +1,37 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PluginStatusItems, pluginToneClass } from "./PluginStatusItems"; +import { useThreadPluginStatuses } from "./PluginThreadBand"; + +/** + * The first status line plugins and Claude Code mods put on a thread, small + * beside the row's other markers; the rest show on hover. It subscribes on + * its own so a status change repaints this marker, not the row. + */ +export function SidebarPluginStatus(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const entries = useThreadPluginStatuses(props.environmentId, props.threadId); + const first = entries[0]?.items[0]; + if (first === undefined) return null; + return ( + + + } + > + {first.text} + + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/plugins/pluginToasts.logic.test.ts b/apps/web/src/components/plugins/pluginToasts.logic.test.ts new file mode 100644 index 0000000000000..5798982050dfc --- /dev/null +++ b/apps/web/src/components/plugins/pluginToasts.logic.test.ts @@ -0,0 +1,24 @@ +import type { PluginToast } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { unseenPluginToasts } from "./pluginToasts.logic"; + +const toast = (id: string): PluginToast => ({ + id, + source: { kind: "plugin", pluginId: "acme.deploys", name: "Deploys" }, + title: id, + createdAt: "2026-11-03T00:00:00.000Z", +}); + +describe("unseenPluginToasts", () => { + it("skips toasts sent before the client connected, then shows each new one once", () => { + const first = unseenPluginToasts([toast("a")], null); + expect(first.show).toEqual([]); + + const second = unseenPluginToasts([toast("a"), toast("b")], first.seen); + expect(second.show.map((entry) => entry.id)).toEqual(["b"]); + + const third = unseenPluginToasts([toast("b")], second.seen); + expect(third.show).toEqual([]); + }); +}); diff --git a/apps/web/src/components/plugins/pluginToasts.logic.ts b/apps/web/src/components/plugins/pluginToasts.logic.ts new file mode 100644 index 0000000000000..028a9d9fdf429 --- /dev/null +++ b/apps/web/src/components/plugins/pluginToasts.logic.ts @@ -0,0 +1,15 @@ +import type { PluginToast } from "@t3tools/contracts"; + +/** + * The toasts to show from a new frame, and the ids now seen. The first frame + * (`seen` null) shows nothing: those toasts were sent before this client + * connected. Ids the server no longer retains are forgotten. + */ +export function unseenPluginToasts( + toasts: ReadonlyArray, + seen: ReadonlySet | null, +): { readonly show: ReadonlyArray; readonly seen: ReadonlySet } { + const ids = new Set(toasts.map((toast) => toast.id)); + if (seen === null) return { show: [], seen: ids }; + return { show: toasts.filter((toast) => !seen.has(toast.id)), seen: ids }; +} diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a0c0a79bebc6e..830858516aba9 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -100,6 +100,7 @@ import { SelectValue, } from "../ui/select"; import { Switch } from "../ui/switch"; +import { PluginSettings } from "./PluginSettings"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { getClientSettings, @@ -1547,6 +1548,7 @@ export function IntegrationsSettingsPanel() { )} + ); } diff --git a/apps/web/src/components/settings/PluginSettings.tsx b/apps/web/src/components/settings/PluginSettings.tsx new file mode 100644 index 0000000000000..a20ffb1c68ac1 --- /dev/null +++ b/apps/web/src/components/settings/PluginSettings.tsx @@ -0,0 +1,95 @@ +import { AuthSettingsWriteScope, type EnvironmentId, type PluginSummary } from "@t3tools/contracts"; +import { RefreshCwIcon } from "lucide-react"; + +import { useEnvironmentScope } from "~/state/session"; +import { pluginEnvironment } from "~/state/plugins"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { useSettingsScope } from "./SettingsScopeContext"; + +const STATE_LABELS: Record = { + ready: "Loaded", + starting: "Starting", + running: "Running", + restarting: "Restarting after a crash", + error: "Failed to load", + disabled: "Off", +}; + +/** + * The selected environment's local plugins. Plugins are folders an agent or + * a person writes; this lists them, shows why one failed, and turns them on + * or off. Turning one on runs its code on that machine. + */ +export function PluginSettings() { + const { environment: selected } = useSettingsScope(); + const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; + const environmentId = connected ? selected.environmentId : null; + return ; +} + +function PluginSettingsSection(props: { readonly environmentId: EnvironmentId | null }) { + const { environmentId } = props; + const snapshot = useEnvironmentQuery( + environmentId === null ? null : pluginEnvironment.snapshot({ environmentId, input: {} }), + ).data; + const canManage = useEnvironmentScope(environmentId, AuthSettingsWriteScope); + const setEnabled = useAtomCommand(pluginEnvironment.setEnabled, "Change plugin"); + const reload = useAtomCommand(pluginEnvironment.reload, "Reload plugins"); + + return ( + void reload({ environmentId, input: {} })} + > + + Reload + + ) + } + > + + {(snapshot?.plugins ?? []).map((plugin) => ( + + void setEnabled({ environmentId, input: { pluginId: plugin.id, enabled } }) + } + /> + ) + } + /> + ))} + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5e5042c67b5f0..233d3b12a6685 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -737,6 +737,13 @@ export const SETTINGS_SEARCH_ITEMS = [ targetId: "devices", searchTerms: ["xcode android studio sdk avd runtime"], }, + { + id: "plugins", + title: "Plugins", + to: "/settings/integrations", + targetId: "plugins", + searchTerms: ["extensions mods claude code mods views status toast band slash commands"], + }, { id: "browser-profiles", title: "Browser profiles", diff --git a/apps/web/src/reopenClosedView.ts b/apps/web/src/reopenClosedView.ts index 551119a178a76..8f3b576dd2532 100644 --- a/apps/web/src/reopenClosedView.ts +++ b/apps/web/src/reopenClosedView.ts @@ -143,6 +143,9 @@ export async function reopenClosedView( case "pull-request": panels.openPullRequest(ref, surface); break; + case "plugin": + panels.openPluginView(ref, surface); + break; default: panels.open(ref, surface.kind); } diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 3d242a1921a44..47a0318d42dcc 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -31,6 +31,7 @@ const RIGHT_PANEL_KINDS = [ "pull-request", "pull-requests", "group", + "plugin", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -91,7 +92,9 @@ export type RightPanelSurface = * The routed chat's group. `section` asks the panel to scroll that section into view * when the surface is opened for it; each `openGroup` writes a fresh descriptor. */ - | { id: "group"; kind: "group"; section: GroupPanelSection | null }; + | { id: "group"; kind: "group"; section: GroupPanelSection | null } + /** A local plugin's view; the title is the manifest's, kept so a closed tab can reopen. */ + | { id: `plugin:${string}`; kind: "plugin"; pluginId: string; viewId: string; title: string }; export type GroupPanelSection = "header" | "workspace" | "chats" | "running" | "handoff"; @@ -155,7 +158,7 @@ interface RightPanelStoreState { ) => boolean; open: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; @@ -175,6 +178,10 @@ interface RightPanelStoreState { ) => void; /** Opens (or re-targets) the Group surface, optionally scrolled to one section. */ openGroup: (ref: ScopedThreadRef, options?: { section?: GroupPanelSection | null }) => void; + openPluginView: ( + ref: ScopedThreadRef, + view: { pluginId: string; viewId: string; title: string }, + ) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( ref: ScopedThreadRef, @@ -202,7 +209,7 @@ interface RightPanelStoreState { toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; setThreadPanelOpen: ( ref: ScopedThreadRef, @@ -225,7 +232,7 @@ const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = { }; const singletonSurface = ( - kind: Exclude, + kind: Exclude, ): RightPanelSurface => { switch (kind) { case "diff": @@ -275,6 +282,20 @@ const terminalSurface = (terminalId: string): RightPanelSurface => ({ activeTerminalId: terminalId, }); +export type PluginViewSurface = Extract; + +export const pluginViewSurface = (view: { + pluginId: string; + viewId: string; + title: string; +}): PluginViewSurface => ({ + id: `plugin:${encodeURIComponent(view.pluginId)}:${encodeURIComponent(view.viewId)}`, + kind: "plugin", + pluginId: view.pluginId, + viewId: view.viewId, + title: view.title, +}); + export type PullRequestSurface = Extract; export function pullRequestSurfaceId(target: { @@ -489,6 +510,13 @@ export function migratePersistedRightPanelState(persistedState: unknown): { if (surface.kind === "group") { return [{ id: "group", kind: "group", section: null }]; } + if (surface.kind === "plugin") { + return typeof surface.pluginId === "string" && + typeof surface.viewId === "string" && + typeof surface.title === "string" + ? [pluginViewSurface(surface)] + : []; + } if (surface.kind === "file") { const revealLine = typeof surface.revealLine === "number" && @@ -739,6 +767,12 @@ export const useRightPanelStore = create()( }; }), ), + openPluginView: (ref, view) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => + upsertSurface(current, pluginViewSurface(view)), + ), + ), openFile: (ref, requestedPath, line) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index de99c9fe8123e..914b91d68d1a3 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -37,6 +37,7 @@ import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationT import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; +import { PluginToastCoordinator } from "../components/plugins/PluginToastCoordinator"; import { PullRequestGuideRunCoordinator } from "../components/pullRequest/guide/PullRequestGuideRunCoordinator"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator"; @@ -262,6 +263,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? ( diff --git a/apps/web/src/state/plugins.ts b/apps/web/src/state/plugins.ts new file mode 100644 index 0000000000000..ba71951096951 --- /dev/null +++ b/apps/web/src/state/plugins.ts @@ -0,0 +1,6 @@ +import { createPluginEnvironmentAtoms } from "@t3tools/client-runtime/state/plugins"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +/** Local plugins, what they draw on threads, and their commands. */ +export const pluginEnvironment = createPluginEnvironmentAtoms(connectionAtomRuntime); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 15241a8873ad9..7a6031f8b2b17 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -287,6 +287,10 @@ "types": "./src/state/reviewSessions.ts", "default": "./src/state/reviewSessions.ts" }, + "./state/plugins": { + "types": "./src/state/plugins.ts", + "default": "./src/state/plugins.ts" + }, "./state/thread-inbox": { "types": "./src/state/threadInbox.ts", "default": "./src/state/threadInbox.ts" diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 216151329b826..cc7decaba5ba6 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -59,6 +59,8 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe | typeof WS_METHODS.reviewSessionsSubscribe + | typeof WS_METHODS.pluginsSubscribe + | typeof WS_METHODS.pluginsBandsSubscribe | typeof WS_METHODS.serverGetStorageCleanupReport | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata diff --git a/packages/client-runtime/src/state/plugins.ts b/packages/client-runtime/src/state/plugins.ts new file mode 100644 index 0000000000000..291b09e6126ba --- /dev/null +++ b/packages/client-runtime/src/state/plugins.ts @@ -0,0 +1,52 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import type * as EnvironmentRegistry from "../connection/registry.ts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; + +/** Local plugins on an environment, what they draw, and their commands. */ +export function createPluginEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + /** Every plugin with its state, plus thread statuses and recent toasts. */ + snapshot: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:plugins", + tag: WS_METHODS.pluginsSubscribe, + }), + /** The bands above one thread's composer, live while that thread is open. */ + bands: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:plugins:bands", + tag: WS_METHODS.pluginsBandsSubscribe, + idleTtlMs: 0, + }), + view: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:plugins:view", + tag: WS_METHODS.pluginsViewRead, + }), + callView: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:plugins:view-call", + tag: WS_METHODS.pluginsViewCall, + }), + pressBand: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:plugins:band-press", + tag: WS_METHODS.pluginsBandPress, + }), + runCommand: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:plugins:command-run", + tag: WS_METHODS.pluginsCommandRun, + }), + setEnabled: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:plugins:set-enabled", + tag: WS_METHODS.pluginsSetEnabled, + }), + reload: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:plugins:reload", + tag: WS_METHODS.pluginsReload, + }), + }; +} diff --git a/packages/contracts/src/plugins.ts b/packages/contracts/src/plugins.ts index 44d79404f969a..f15a3d46d2a21 100644 --- a/packages/contracts/src/plugins.ts +++ b/packages/contracts/src/plugins.ts @@ -112,6 +112,8 @@ export const PluginSummary = Schema.Struct({ dir: Schema.String, enabled: Schema.Boolean, state: PluginState, + /** Goes up each time the host reloads the plugin's files. */ + revision: Schema.Number, error: Schema.optionalKey(Schema.String), views: Schema.Array(Schema.Struct({ id: Schema.String, title: Schema.String })), commands: Schema.Array(PluginCommandDeclaration), @@ -244,6 +246,8 @@ export type PluginBandPressInput = typeof PluginBandPressInput.Type; export const PluginViewReadInput = Schema.Struct({ pluginId: Schema.String, viewId: Schema.String, + /** The plugin's revision the client saw; a new one reads the page afresh. */ + revision: Schema.optionalKey(Schema.Number), }); export const PluginViewReadResult = Schema.Struct({ title: Schema.String, diff --git a/packages/shared/src/pluginView.test.ts b/packages/shared/src/pluginView.test.ts new file mode 100644 index 0000000000000..1db166ec695df --- /dev/null +++ b/packages/shared/src/pluginView.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + PLUGIN_VIEW_BRIDGE_SCRIPT, + pluginViewInitMessage, + pluginViewResultMessage, + readPluginViewMessage, +} from "./pluginView.ts"; + +/** Runs the bridge in a fake framed window and returns what it posted and its `t3`. */ +function loadBridge() { + const posted: unknown[] = []; + const listeners: Array<(event: { source: unknown; data: unknown }) => void> = []; + const parent = { postMessage: (message: unknown) => posted.push(message) }; + const window: Record = { + parent, + addEventListener: (_type: string, listener: (typeof listeners)[number]) => + listeners.push(listener), + }; + new Function("window", PLUGIN_VIEW_BRIDGE_SCRIPT)(window); + const deliver = (data: unknown) => + listeners.forEach((listener) => listener({ source: parent, data })); + return { + posted, + deliver, + t3: window.t3 as { + call: (h: string, i: unknown) => Promise; + ready: (fn: (c: unknown) => void) => void; + }, + }; +} + +describe("plugin view bridge", () => { + it("announces itself, then round-trips a call through the client", async () => { + const { posted, deliver, t3 } = loadBridge(); + expect(readPluginViewMessage(posted[0])).toEqual({ type: "ready" }); + + let context: unknown; + t3.ready((value) => { + context = value; + }); + deliver(pluginViewInitMessage({ pluginId: "acme.hello", viewId: "main", threadId: "t1" })); + expect(context).toEqual({ pluginId: "acme.hello", viewId: "main", threadId: "t1" }); + + const answer = t3.call("greet", { name: "Neil" }); + const call = readPluginViewMessage(posted[1]); + expect(call).toMatchObject({ type: "call", handler: "greet", input: { name: "Neil" } }); + if (call?.type !== "call") throw new Error("expected a call"); + deliver(pluginViewResultMessage(call.id, { hello: "Neil" })); + await expect(answer).resolves.toEqual({ hello: "Neil" }); + }); + + it("ignores messages that are not bridge messages", () => { + expect(readPluginViewMessage({ jsonrpc: "2.0", method: "ui/notifications/size-changed" })).toBe( + undefined, + ); + expect( + readPluginViewMessage({ + jsonrpc: "2.0", + id: 4, + method: "t3/plugin.call", + params: { handler: "x" }, + }), + ).toBe(undefined); + expect(readPluginViewMessage("hello")).toBe(undefined); + }); +}); diff --git a/packages/shared/src/pluginView.ts b/packages/shared/src/pluginView.ts index f0aa6a7fc493c..d8fd0b3f5e165 100644 --- a/packages/shared/src/pluginView.ts +++ b/packages/shared/src/pluginView.ts @@ -14,3 +14,47 @@ export const PLUGIN_VIEW_CALL_METHOD = "t3/plugin.call"; export const PLUGIN_VIEW_CALL_ID_PREFIX = "t3-plugin-"; export const PLUGIN_VIEW_BRIDGE_SCRIPT = `(function(){if(window.parent===window)return;var n=0,p={},c=null,w=[];function post(m){window.parent.postMessage(m,"*");}window.addEventListener("message",function(e){var d=e.data;if(e.source!==window.parent||!d||d.jsonrpc!=="2.0")return;if(d.method===${JSON.stringify(PLUGIN_VIEW_INIT_METHOD)}&&d.params){c=d.params;window.t3.threadId=c.threadId||null;var q=w;w=[];q.forEach(function(f){f(c);});return;}if(typeof d.id==="string"&&p[d.id]){var r=p[d.id];delete p[d.id];if(d.error)r[1](new Error(d.error.message||"The plugin call failed."));else r[0](d.result);}});window.t3={threadId:null,call:function(h,i){return new Promise(function(res,rej){var id=${JSON.stringify(PLUGIN_VIEW_CALL_ID_PREFIX)}+(++n);p[id]=[res,rej];post({jsonrpc:"2.0",id:id,method:${JSON.stringify(PLUGIN_VIEW_CALL_METHOD)},params:{handler:String(h),input:i===undefined?null:i}});});},ready:function(f){if(c)f(c);else w.push(f);}};post({jsonrpc:"2.0",method:${JSON.stringify(PLUGIN_VIEW_READY_METHOD)}});})();`; + +export type PluginViewMessage = + | { readonly type: "ready" } + | { + readonly type: "call"; + readonly id: string; + readonly handler: string; + readonly input: unknown; + }; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** A bridge message a plugin view posted, or undefined for anything else. */ +export function readPluginViewMessage(data: unknown): PluginViewMessage | undefined { + if (!isRecord(data) || data.jsonrpc !== "2.0") return undefined; + if (data.method === PLUGIN_VIEW_READY_METHOD) return { type: "ready" }; + if ( + data.method === PLUGIN_VIEW_CALL_METHOD && + typeof data.id === "string" && + data.id.startsWith(PLUGIN_VIEW_CALL_ID_PREFIX) && + isRecord(data.params) && + typeof data.params.handler === "string" + ) { + return { type: "call", id: data.id, handler: data.params.handler, input: data.params.input }; + } + return undefined; +} + +export function pluginViewInitMessage(context: { + readonly pluginId: string; + readonly viewId: string; + readonly threadId: string | null; +}) { + return { jsonrpc: "2.0", method: PLUGIN_VIEW_INIT_METHOD, params: context } as const; +} + +export function pluginViewResultMessage(id: string, result: unknown) { + return { jsonrpc: "2.0", id, result } as const; +} + +export function pluginViewErrorMessage(id: string, message: string) { + return { jsonrpc: "2.0", id, error: { message } } as const; +} From 2b8c5553f48fbdad4d8e3f7ac3d3922b345bfa47 Mon Sep 17 00:00:00 2001 From: nach <10445733+nachogames@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:48:49 -0500 Subject: [PATCH 3/4] docs(plugins): how to write a plugin, and why the host is shaped this way Co-Authored-By: Claude Opus 5.5 --- docs/README.md | 2 + docs/internals/plugins.md | 31 ++++++++++++++ docs/user/plugins.md | 90 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 docs/internals/plugins.md create mode 100644 docs/user/plugins.md diff --git a/docs/README.md b/docs/README.md index d7628960cb8b6..43ca095818789 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ - [Import browser sessions](./user/browser-import.md) - [Use T3 Code as your default browser](./user/default-browser.md) - [Devices](./user/devices.md) +- [Plugins](./user/plugins.md) - [Usage and limits](./user/usage.md) - [Product usage data](./user/telemetry.md) - [Remote access](./user/remote-access.md) @@ -38,6 +39,7 @@ source alone does not explain. Most code changes do not need an internal documen - [Architecture overview](./internals/overview.md) - [Glossary](./internals/glossary.md) - [Connection runtime](./internals/connection-runtime.md) +- [Plugins](./internals/plugins.md) - [Providers](./internals/providers.md) - [Adding a provider](./internals/adding-a-provider.md) - [Pull request file revisions](./internals/pull-request-file-revisions.md) diff --git a/docs/internals/plugins.md b/docs/internals/plugins.md new file mode 100644 index 0000000000000..7e15f41dd6ee3 --- /dev/null +++ b/docs/internals/plugins.md @@ -0,0 +1,31 @@ +# Plugins + +Local plugins live in `/plugins` and are served by +[`PluginHost`](../../apps/server/src/plugins/PluginHost.ts). What they draw lives in memory in +[`PluginUiStore`](../../apps/server/src/plugins/PluginUiStore.ts) and disappears with its producer. + +## Decisions + +- **Trusted code, supervised process.** A plugin's `entry` runs in a Node child process started + with `node -e` and the runner in + [`pluginRunner.ts`](../../apps/server/src/plugins/pluginRunner.ts), so no extra file has to ship. + The process boundary keeps a crash or a hung plugin out of the server. It is not a sandbox. Desktop + runs the server inside Electron, so the child gets `ELECTRON_RUN_AS_NODE=1`. +- **Loaded by default.** Agents write plugins and expect to see them work, so a new folder loads + without consent. Turning a plugin off is the escape hatch, saved in `/plugins.json`. +- **One set of surfaces for plugins and Claude Code mods.** Mod `ui_status`, `ui_toast` and the + `AbovePrompt` band (from pingdotgg/t3code#15827) arrive with a `claude-mod` source. Plugin bands + use the mod element table, so one renderer draws both. A press names its source, and the host + routes it to the Claude query or the plugin process. +- **Views reuse the html_render frame.** Views are HTML pages in the same sandboxed, opaque-origin, + themed frame as agent HTML renders. The client reads the page over RPC and loads it from a blob + URL, so views work over every connection mode without an asset route. + [`pluginView.ts`](../../packages/shared/src/pluginView.ts) defines the `t3` bridge. + +## Upstream + +Upstream has a much larger plugin proposal (pingdotgg/t3code #16047–#16065): consent, npm installs, +settings, secrets, transforms and approvals. Maintainers closed its base PR and said they will +design plugins themselves. Ctrl keeps upstream's manifest file name, id rule, `apiVersion`, and the +field names of status items and notifications. If upstream ships its own system, migrating stays +mechanical. diff --git a/docs/user/plugins.md b/docs/user/plugins.md new file mode 100644 index 0000000000000..0e2dfcbd61ee7 --- /dev/null +++ b/docs/user/plugins.md @@ -0,0 +1,90 @@ +# Plugins + +Plugins change Ctrl's own interface. A plugin can add: + +- a **view**, shown as a tab in the right panel +- a **status line** on a thread, shown above the composer and on the thread's sidebar row +- a **toast** +- a **band** above a thread's composer, with buttons +- a **`/` command** in the composer menu + +Plugins run on web and desktop. The mobile app does not show them yet. + +## Start a plugin + +The easiest way is to ask an agent: "Write a Ctrl plugin that shows …". Agents on every provider +can call `t3_plugin_list`, which returns the plugins folder and a guide to writing a plugin, and +`t3_plugin_reload`, which returns load errors and recent log lines so the agent can fix them. + +A plugin is a folder in `plugins` inside your Ctrl home folder (`~/.ctrl/plugins` for the +desktop app). Ctrl watches the folder. When a plugin is added or a file changes, Ctrl loads it +again. You do not need to restart Ctrl. + +Settings → Integrations → Plugins lists each plugin, shows why one failed to load, and turns it on +or off. + +## A small plugin + +`~/.ctrl/plugins/hello/t3-plugin.json`: + +```json +{ + "id": "me.hello", + "name": "Hello", + "version": "1.0.0", + "apiVersion": 1, + "entry": "index.mjs", + "views": [{ "id": "main", "title": "Hello", "html": "view.html" }], + "commands": [ + { + "name": "standup", + "description": "Ask for a standup summary", + "prompt": "Summarize what changed today." + }, + { "name": "wave", "description": "Wave at this thread" } + ] +} +``` + +`index.mjs` runs in its own Node process: + +```js +export function activate(ctx) { + ctx.onEvent((event) => { + if (event.type === "run") ctx.ui.setStatus(event.threadId, "run", `run ${event.status}`); + }); + ctx.onCommand("wave", ({ threadId }) => { + ctx.ui.toast({ title: "Hello!", threadId, tone: "success" }); + ctx.ui.setBand( + threadId, + ctx.ui.h( + "Box", + { gap: 1 }, + "Waved.", + ctx.ui.h("Button", { label: "Clear", onPress: () => ctx.ui.setBand(threadId, null) }), + ), + ); + }); + ctx.handle("time", () => new Date().toLocaleTimeString()); +} +``` + +`view.html` uses the app's theme, and calls the plugin through `t3`: + +```html +

It is .

+ +``` + +## Claude Code mods + +Claude Code mods that draw a status line, a toast or the band above the prompt draw them in the +same places in Ctrl. Mod panes are not shown yet. + +## Trust + +A plugin's `entry` runs with your user's full access, the same as any program you install. Only +add plugins you trust. Views run in a sandboxed frame and can reach the machine only through their +own plugin. From 5e268104fa2edffb6d0d704ee5f572ed7c14fef7 Mon Sep 17 00:00:00 2001 From: nach <10445733+nachogames@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:45:24 -0500 Subject: [PATCH 4/4] fix(plugins): address review findings - A plugin that crashes after activating no longer restarts forever: ready no longer resets the crash count, which ages out over the crash window. - Plugins get ctx.plugin.dataDir outside the watched folder, since a write inside the plugin's own folder reloads it; the guide and docs say so. - The sidebar shows a plugin status once on multiline rows, and on card rows whose meta line is empty. - Plugin commands are documented as running when picked, with empty args. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/plugins/tools.ts | 3 ++- apps/server/src/plugins/PluginHost.ts | 6 +++++- apps/web/src/components/sidebar/SidebarThreadRow.tsx | 3 ++- docs/user/plugins.md | 3 ++- packages/contracts/src/plugins.ts | 4 ++-- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/server/src/mcp/toolkits/plugins/tools.ts b/apps/server/src/mcp/toolkits/plugins/tools.ts index 97dc2c59114c2..aaa2b56494c5d 100644 --- a/apps/server/src/mcp/toolkits/plugins/tools.ts +++ b/apps/server/src/mcp/toolkits/plugins/tools.ts @@ -11,8 +11,9 @@ export const PLUGIN_AUTHORING_GUIDE = [ "A plugin is a folder in pluginsDir holding t3-plugin.json. T3 watches the folder and loads, reloads and restarts plugins on save; no restart needed.", `t3-plugin.json: {"id": "owner.name" (lowercase, two or more dot segments), "name", "version", "description"?, "apiVersion": ${PLUGIN_API_VERSION}, "entry"?: "index.mjs", "views"?: [{"id", "title", "html": "view.html"}], "commands"?: [{"name", "description", "prompt"?}]}.`, "views: a self-contained HTML page shown as a tab in the right panel, in a sandboxed frame with the app's theme as CSS variables (--background, --foreground, --muted-foreground, --border, --primary, --font-sans, --font-mono). In the page, `await t3.call(name, input)` runs the entry's ctx.handle(name, fn) and returns its value; `t3.ready(({ threadId }) => ...)` gives the thread the panel belongs to.", - "commands: a /name in the composer menu. With `prompt`, choosing it puts that text in the composer. Without, it calls the entry's ctx.onCommand(name, ({ threadId, args }) => ...).", + "commands: a /name in the composer menu. With `prompt`, choosing it puts that text in the composer. Without, choosing it runs the entry's ctx.onCommand(name, ({ threadId, args }) => ...) right away; args is empty for now.", "entry: an ES or CommonJS module exporting activate(ctx); it runs in its own Node process with the user's access and may return a cleanup function. ctx.ui.setStatus(threadId, key, text, { tone, tooltip }) shows a short line on a thread (text '' clears it). ctx.ui.toast({ title, body, tone, threadId }) shows a toast. ctx.ui.setBand(threadId, tree) draws a band above that thread's composer (null clears it); build trees with ctx.ui.h(type, props, ...children) from Box (flex props), Text (color, bold, dimColor), Button ({ label, onPress }), Link ({ href, label }) and Code ({ source }). ctx.onEvent(fn) receives { type: 'run', threadId, runId, status } and { type: 'thread', threadId, change, title }. Tones: neutral, info, success, warning, error.", + "A write inside the plugin's folder reloads the plugin, so keep files the plugin writes in ctx.plugin.dataDir.", "After writing files, call t3_plugin_reload and check the plugin's state, error and recentLogs. A thread id for testing is your own thread's id.", ].join("\n"); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts index bbb90b86e9c8a..d940e32f50df4 100644 --- a/apps/server/src/plugins/PluginHost.ts +++ b/apps/server/src/plugins/PluginHost.ts @@ -261,6 +261,7 @@ const make = Effect.gen(function* () { const claudeModUi = yield* ClaudeModUi.ClaudeModUi; const pluginsDir = NodePath.join(config.baseDir, PLUGINS_DIR_NAME); const enabledStatePath = NodePath.join(config.stateDir, "plugins.json"); + const pluginDataRoot = NodePath.join(config.stateDir, "plugin-data"); const services = yield* Effect.context(); const runFork = Effect.runForkWith(services); @@ -331,7 +332,6 @@ const make = Effect.gen(function* () { const source = sourceOf(record); switch (message.t) { case "ready": - record.crashes = []; return setState(record, "running", null); case "fatal": appendLog(record, String(message.error)); @@ -428,8 +428,11 @@ const make = Effect.gen(function* () { const entryPath = resolveInside(record.scanned.dir, manifest.entry); if (entryPath === null) return setState(record, "error", `entry ${manifest.entry} is missing.`); + // Outside the watched plugins folder: a write there would reload the plugin. + const dataDir = NodePath.join(pluginDataRoot, encodeURIComponent(manifest.id)); let processHandle: NodeChildProcess.ChildProcess; try { + NodeFS.mkdirSync(dataDir, { recursive: true }); processHandle = NodeChildProcess.spawn(process.execPath, ["-e", PLUGIN_RUNNER_SOURCE], { cwd: record.scanned.dir, stdio: ["ignore", "pipe", "pipe", "ipc"], @@ -443,6 +446,7 @@ const make = Effect.gen(function* () { name: manifest.name, version: manifest.version, dir: record.scanned.dir, + dataDir, }), }, }); diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index b47147fff78f7..a87183ebdfc3a 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1239,7 +1239,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {title} {multiline ? titleLineIcons : null} {pinIndicator} - {pluginStatus} + {multiline && !metaLineEmpty ? null : pluginStatus} {multiline ? null : terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1648,6 +1648,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP Regenerating title ) : null} + {metaLineEmpty ? pluginStatus : null} {titleLineIcons} {metaLine} diff --git a/docs/user/plugins.md b/docs/user/plugins.md index 73d368877ab74..1885f89cd25d8 100644 --- a/docs/user/plugins.md +++ b/docs/user/plugins.md @@ -18,7 +18,8 @@ can call `t3_plugin_list`, which returns the plugins folder and a guide to writi A plugin is a folder in `plugins` inside your Ctrl home folder (`~/.ctrl/plugins` for the desktop app). Ctrl watches the folder. When a plugin is added or a file changes, Ctrl loads it -again. You do not need to restart Ctrl. +again. You do not need to restart Ctrl. For the same reason, a plugin keeps files it writes in +`ctx.plugin.dataDir`, not in its own folder. Settings → Tools → Plugins lists each plugin, shows why one failed to load, and turns it on or off. diff --git a/packages/contracts/src/plugins.ts b/packages/contracts/src/plugins.ts index 072de8f59b043..49352988bb4ec 100644 --- a/packages/contracts/src/plugins.ts +++ b/packages/contracts/src/plugins.ts @@ -52,8 +52,8 @@ const PLUGIN_COMMAND_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; /** * One entry of `commands`: a `/name` in the composer menu. A command with a - * `prompt` puts that text in the composer; one without calls the plugin's - * `commands` handler with the rest of the line. + * `prompt` puts that text in the composer; one without runs the plugin's + * command handler as soon as it is picked, with empty `args` for now. */ export const PluginCommandDeclaration = Schema.Struct({ name: Schema.String.check(Schema.isMaxLength(32), Schema.isPattern(PLUGIN_COMMAND_NAME_PATTERN)),