diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 134026d81702a..01a63066212b6 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -265,6 +265,15 @@ function useSettingsIndexSections(): SettingsListSection[] { serverPage("SettingsProviderAccounts", "person.crop.circle", "Providers"), serverPage("SettingsThreads", "text.bubble", "Threads"), page("SettingsScheduledTasks", "clock", "Scheduled tasks"), + // Plugin views, bands and toasts draw only on web and desktop so far. + { + id: "plugins", + icon: "square.grid.2x2", + label: "Plugins", + value: "Not supported on mobile yet. Use web or desktop.", + valuePosition: "below", + disabled: true, + }, ], }, { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5cf2588d23edf..f36d54e8f2e73 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.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.pushRegisterDevice]: AuthOrchestrationReadScope, [WS_METHODS.pushUnregisterDevice]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsBandsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsViewRead]: AuthOrchestrationReadScope, [WS_METHODS.scheduledTasksSubscribe]: AuthOrchestrationReadScope, [WS_METHODS.reviewSessionsReadHost]: AuthOrchestrationReadScope, [WS_METHODS.reviewSessionsSubscribe]: AuthOrchestrationReadScope, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 0a478a5c35cc6..e4be604765a0e 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -82,6 +82,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"; import { requestUrl } from "../requestUrl.ts"; @@ -1231,6 +1233,8 @@ export const layerAttachmentToolkit = toolkitRegistration( AttachmentHandlers.layer, ); +const layerPluginToolkit = toolkitRegistration(PluginToolkit, PluginHandlers.layer); + export const layerPullRequestsToolkit = toolkitRegistration( PullRequestsToolkit, PullRequestsHandlers.layer, @@ -1295,6 +1299,7 @@ export const layerTools = Layer.mergeAll( layerPullRequestsToolkit, layerDeviceToolkit, layerHtmlToolkit, + layerPluginToolkit, ); // Remote MCP tools depend on what an admin connected, so the context budget leaves them out. diff --git a/apps/server/src/mcp/builtInToolNames.ts b/apps/server/src/mcp/builtInToolNames.ts index 374ebefe91b9b..f7d2e0028960e 100644 --- a/apps/server/src/mcp/builtInToolNames.ts +++ b/apps/server/src/mcp/builtInToolNames.ts @@ -59,6 +59,8 @@ export const BUILT_IN_MCP_TOOL_NAMES: ReadonlyArray = [ "t3_group_resource", "t3_group_update", "t3_pending_request", + "t3_plugin_list", + "t3_plugin_reload", "t3_preview_close", "t3_preview_list", "t3_project_clone", 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..c51437585a12c --- /dev/null +++ b/apps/server/src/mcp/toolkits/plugins/handlers.ts @@ -0,0 +1,17 @@ +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 = Effect.fn("PluginToolkit.listing")(function* (reload: boolean) { + 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..aaa2b56494c5d --- /dev/null +++ b/apps/server/src/mcp/toolkits/plugins/tools.ts @@ -0,0 +1,63 @@ +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, 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"); + +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 5f1051baf8f30..bd3f9ee069a89 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -42,6 +42,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"; import * as RemoteMcpServers from "../../../remoteMcp/RemoteMcpServers.ts"; import * as DocumentText from "../../../provider/DocumentText.ts"; @@ -79,6 +80,7 @@ const layerStubServices = Layer.mergeAll( Layer.mock(ThreadGroupStore.ThreadGroupStoreV2)({}), Layer.mock(GroupResourceScanner.GroupResourceScanner)({}), Layer.mock(ReviewSessionService.ReviewSessionService)({}), + Layer.mock(PluginHost.PluginHost)({ pluginsDir: "/unused" }), Layer.mock(PeerForwarding.PeerForwarding)({}), Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), Layer.mock(PeerLinks.PeerLinks)({}), diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 5ab2fdc2e26d7..de0666456a9ee 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -94,6 +94,14 @@ const RPC_AGGREGATES = { [WS_METHODS.pushRegisterDevice]: "server", [WS_METHODS.pushUnregisterDevice]: "server", [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 3055ad55e10e3..9988e562575ab 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -85,6 +85,7 @@ import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH } from "@t3tools/prov 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"; @@ -594,6 +595,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 7408b616d1d00..cdd41db4f68f4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -184,6 +184,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, @@ -898,6 +899,15 @@ 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; +}; const encodeClaudeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeClaudeHistoryFork = Schema.decodeUnknownEffect( Schema.fromJsonString( @@ -979,6 +989,7 @@ export const layerQueryRunner: Layer.Layer< const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; + const modUi = yield* ClaudeModUi.ClaudeModUi; const agentScope = yield* AgentScope; const platform = yield* HostProcess.Platform; const runFork = Effect.runForkWith(yield* Effect.context()); @@ -1175,6 +1186,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), @@ -1208,6 +1226,7 @@ export const layerQueryRunner: Layer.Layer< payload: message, }), ), + Stream.tap((message) => modUi.ingest(input.threadId, message, modUiRequest)), ), offer: (message, onQueued) => Effect.uninterruptibleMask((restore) => @@ -1317,6 +1336,11 @@ 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", @@ -1821,6 +1845,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_environment_links", "mcp__t3-code__html_preview", "mcp__t3-code__html_render", + "mcp__t3-code__t3_plugin_list", ]; // T3 waits can block for an hour; exceed Claude's default 60 s timeout. diff --git a/apps/server/src/plugins/ClaudeModUi.ts b/apps/server/src/plugins/ClaudeModUi.ts new file mode 100644 index 0000000000000..79e67b50a9f58 --- /dev/null +++ b/apps/server/src/plugins/ClaudeModUi.ts @@ -0,0 +1,252 @@ +/** + * Draws Claude Code mods' `ui_status` lines, `ui_toast` notices and `AbovePrompt` band in a + * headless session. A band arrives only when asked with a `ui_render` control request, so this + * asks on attach and on each `ui_invalidate`. + */ +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. */ +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 = Effect.fn("ClaudeModUi.clearThread")(function* ( + threadId: ThreadId, + entry: ThreadEntry, + ) { + 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; +}); + +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..e05c51b7ae3d6 --- /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, { 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..d940e32f50df4 --- /dev/null +++ b/apps/server/src/plugins/PluginHost.ts @@ -0,0 +1,848 @@ +// @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. +/** + * Loads plugins from `/plugins`, reloading on file changes. A plugin's `entry` runs in its + * own Node process; crashes restart with backoff and repeated ones stop it until its files change. + * Plugin code is trusted OS-user code: the process boundary contains crashes, not access. + */ +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; + revision: number; + 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; + } +}; + +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 pluginDataRoot = NodePath.join(config.stateDir, "plugin-data"); + 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, + revision: record.revision, + ...(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": + 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.`); + // 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"], + 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, + dataDir, + }), + }, + }); + } 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 = Effect.fn("PluginHost.onChildExit")(function* ( + record: PluginRecord, + child: RunningChild, + code: number | null, + signal: NodeJS.Signals | null, + ) { + 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 = Effect.fn("PluginHost.activate")(function* (record: PluginRecord) { + 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, + revision: (existing?.revision ?? 0) + 1, + 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 = Effect.fn("PluginHost.request")(function* ( + record: PluginRecord, + build: (id: number) => HostToRunner, + ): Effect.fn.Return { + 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); + yield* Effect.try({ + try: () => child.process.send(build(id)), + catch: () => { + child.pending.delete(id); + return 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: Effect.fn("PluginHost.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: Effect.fn("PluginHost.readView")(function* (pluginId, viewId) { + 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: Effect.fn("PluginHost.callView")(function* (input) { + 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: Effect.fn("PluginHost.runCommand")(function* (input) { + const record = yield* recordFor(input.pluginId); + yield* request(record, (id) => ({ + t: "command", + id, + name: input.command, + threadId: input.threadId, + args: input.args, + })); + }), + press: Effect.fn("PluginHost.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 }), + }; + yield* Effect.sync(() => { + try { + record.child?.process.send(message); + } catch { + // A stopped plugin's band is already gone. + } + }); + }), + ), + publishEvent: Effect.fn("PluginHost.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. */ +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..76407842f1944 --- /dev/null +++ b/apps/server/src/plugins/PluginUiStore.ts @@ -0,0 +1,247 @@ +/** + * What plugins and Claude Code mods draw now: thread statuses, bands and toasts, in memory and + * cleared with their producer. Clients get change signals and read current state, so a slow + * client holds one pending signal, never a backlog of snapshots. + */ +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", +) {} + +const ANSI_SEQUENCE = /\u001b\[[0-9;?]*[A-Za-z]/g; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]+/g; + +/** One plain line: no control characters or ANSI styling, cut to `max`. */ +const plainLine = (value: string, max: number): string => { + const clean = value.replace(ANSI_SEQUENCE, "").replace(CONTROL_CHARACTERS, " "); + 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"] = Effect.fn("PluginUiStore.toast")(function* (input) { + 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..b9e6c15c76aeb --- /dev/null +++ b/apps/server/src/plugins/pluginRunner.ts @@ -0,0 +1,197 @@ +/** + * The program a plugin's child process runs (`node -e`): it imports the `entry`, calls + * `activate(ctx)` and relays `ctx` over Node IPC. The `ctx` API is documented in + * PLUGIN_AUTHORING_GUIDE (mcp/toolkits/plugins/tools.ts). + */ + +/** 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/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index c9d157ddb5da6..bb74c479902fc 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -49,6 +49,7 @@ import { buildCodexAdditionalContext } from "./CodexDeveloperInstructions.ts"; import { DocumentText } from "./DocumentText.ts"; import { ProviderRegistry } from "./ProviderRegistry.ts"; import budget from "./agentContextBudget.json" with { type: "json" }; +import { PluginHost } from "../plugins/PluginHost.ts"; // Registration only stores tool definitions, so the handlers' services are never called. const toolHandlerServices = Layer.mergeAll( @@ -61,6 +62,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(McpProviderSessions)({}), Layer.mock(OrchestratorV2)({}), Layer.mock(PeerForwarding)({}), + Layer.mock(PluginHost)({ pluginsDir: "/unused" }), Layer.mock(PeerLinkRequests)({}), Layer.mock(PeerLinks)({}), Layer.mock(PreviewAutomationBroker)({}), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 96ce9a2145848..f8de102f4ee2e 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 7463, "codexAdditionalContextBytes": 9193, - "mcpToolListJsonBytes": 67442 + "mcpToolListJsonBytes": 68060 } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 85c07df1d467d..d0bb94fe2a3b6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -111,6 +111,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 ManagedLimits from "./policy/managedLimits.ts"; @@ -644,6 +646,7 @@ const layerProviderInstallationRefresh = Layer.effectDiscard( const layerRuntimeCoreDependenciesBase = Layer.mergeAll( AgentAwarenessRelay.layer, DirectPush.layer, + PluginHost.layerEvents, HeldHooksWaker.layer, layerThreadSettlementWorker, layerIdleCompactionWorker, @@ -719,6 +722,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 7662b20a29b5b..5b7020769befb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -103,6 +103,7 @@ import { WsCtrlRpcGroup, WsCursorMcpRpcGroup, WsReviewSessionsRpcGroup, + WsPluginsRpcGroup, WsRpcGroup, CursorMcpOperationError, type CursorMcpFolderInput, @@ -244,6 +245,7 @@ import * as DefectReporter from "./observability/DefectReporter.ts"; import * as EventLoopMonitor from "./observability/EventLoopMonitor.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"; @@ -636,6 +638,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); const ServerCtrlRpcGroup = WsCtrlRpcGroup.middleware(RpcInstrumentation); const ServerCursorMcpRpcGroup = WsCursorMcpRpcGroup.middleware(RpcInstrumentation); @@ -1364,6 +1367,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), + }); + }), +); + // Takes the service rather than requiring it: one more requirement on the // per-connection layer below tips TypeScript into "excessively deep". /** Ctrl's own RPCs; see `WsCtrlRpcGroup` for why they are a separate group. */ @@ -3733,6 +3753,7 @@ export const layer = Layer.unwrap( layerReviewSessionsRpc, layerCtrlRpc, layerCursorMcpRpc.pipe(Layer.provide(CursorMcpServers.layer)), + layerPluginsRpc, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), // Handler-context reporters see only request defects, not server-wide defects. diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index 200c15dbfe72d..6956b4633e48c 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 0e696b25b3e28..99bb402755e7f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -228,6 +228,9 @@ import { collectInlineContextIds } from "../lib/composerContextReferences"; 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 type { PluginViewLauncher } from "./RightPanelTabs"; import { useEnvironmentScope } from "~/state/session"; import { primaryServerAvailableEditorsAtom, @@ -2814,6 +2817,28 @@ export default function ChatView(props: ChatViewProps) { // A delegated task is a remote thread's subagent. const isSubagentThread = activeThread?.lineage.relationshipToParent === "subagent" || delegatedFrom !== null; + const pluginPlugins = useEnvironmentQuery( + activeThreadRef + ? pluginEnvironment.snapshot({ environmentId: activeThreadRef.environmentId, input: {} }) + : null, + ).data?.plugins; + const pluginViewLaunchers: ReadonlyArray = + !activeThreadRef || !pluginPlugins + ? [] + : 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, + }), + })), + ); // Side chats do not nest or start from subagents, and older servers would fork a listed thread. const sideChatsOffered = !embedded && @@ -3636,6 +3661,12 @@ export default function ChatView(props: ChatViewProps) { } /> ) : 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, + })); + export interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; @@ -144,6 +162,8 @@ export 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; /** Absent where side chats are not offered. */ onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; @@ -314,7 +334,7 @@ function SurfaceMenuItem(props: { aria-keyshortcuts={props.shortcut} > {props.children} - {props.shortcut} + {props.shortcut === "" ? null : {props.shortcut}} ); if (props.available || !props.disabledReason) return item; @@ -332,6 +352,7 @@ function RightPanelEmptyState(props: { onAddPullRequests: () => void; onAddDevice: () => void; onAddGroup: () => void; + pluginViews: ReadonlyArray | undefined; /** Absent where side chats are not offered. */ onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; @@ -426,6 +447,7 @@ function RightPanelEmptyState(props: { }, ] : []), + ...pluginViewActions(props.pluginViews), ].filter(isListedSurfaceAction); type SurfaceAction = (typeof actions)[number]; @@ -533,7 +555,7 @@ function RightPanelEmptyState(props: { > {action.label} - {action.shortcut} + {action.shortcut === "" ? null : {action.shortcut}} {action.label === "Browser" && props.browserProfiles.length > 1 ? ( @@ -574,7 +596,7 @@ function RightPanelEmptyState(props: { > {actionIcon(action, "size-4")} {action.label} - {action.shortcut} + {action.shortcut === "" ? null : {action.shortcut}} } /> @@ -602,6 +624,8 @@ function surfaceTitle( switch (surface.kind) { case "group": return groupTitle ?? "Group"; + case "plugin": + return surface.title; case "diff": return "Diff"; case "files": @@ -713,6 +737,8 @@ function SurfaceIcon({ return ; case "group": return ; + case "plugin": + return ; case "side-chat": return ; case "device": @@ -1199,6 +1225,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }, ] : []), + ...pluginViewActions(props.pluginViews), ].filter(isListedSurfaceAction); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { @@ -1579,6 +1606,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddPullRequests={props.onAddPullRequests} onAddDevice={props.onAddDevice} onAddGroup={props.onAddGroup ?? noop} + pluginViews={props.pluginViews} onAddSideChat={props.onAddSideChat} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b8070b8aff222..02166cf33cb0c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -234,6 +234,8 @@ import { observeResize } from "~/lib/observeResize"; const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); import { toastManager } from "../ui/toast"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { pluginEnvironment } from "~/state/plugins"; import { BuildingIcon, PlayIcon } from "lucide-react"; import { Button } from "../ui/button"; import { useManagedPolicy } from "../../state/managedPolicy"; @@ -1854,6 +1856,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const { onUsageLimitsCommand } = props; + const runPluginCommand = useAtomCommand(pluginEnvironment.runCommand, { + label: "Run plugin command", + }); const onSelectComposerItem = (item: ComposerCommandItem) => { if (composerSelectLockRef.current) return; composerSelectLockRef.current = true; @@ -1937,6 +1942,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( diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index c572075282fb1..9bfe74053fcc7 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -5,6 +5,7 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import { + type PluginCommandDeclaration, type EnvironmentId, type EnvironmentMachineKind, type ProjectEntry, @@ -20,6 +21,7 @@ import { FolderIcon, MessagesSquareIcon, PackageIcon, + PuzzleIcon, SettingsIcon, UserRoundIcon, type LucideIcon, @@ -62,6 +64,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"; @@ -214,6 +226,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..defc700f0e372 --- /dev/null +++ b/apps/web/src/components/plugins/PluginToastCoordinator.tsx @@ -0,0 +1,46 @@ +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); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- shows new snapshot toasts through the imperative toast manager + 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..3c712dc20ab02 --- /dev/null +++ b/apps/web/src/components/plugins/PluginViewPanel.tsx @@ -0,0 +1,136 @@ +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 { useEffect, useState } 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 callView = useAtomCommand(pluginEnvironment.callView, { + reportFailure: false, + reportDefect: false, + }); + const onFrameMessage = (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))), + ), + ); + }; + + 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 (html === null) return null; + return ( + + ); +} + +/** Keyed by the HTML, so each version gets its own blob URL, revoked when it is replaced. */ +function PluginViewFrame(props: { + readonly html: string; + readonly title: string; + readonly threadRef: ScopedThreadRef | undefined; + readonly onFrameMessage: (data: unknown, reply: (message: unknown) => void) => void; +}) { + const [src] = useState(() => URL.createObjectURL(new Blob([props.html], { type: "text/html" }))); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases this frame's blob URL on unmount + useEffect(() => () => URL.revokeObjectURL(src), [src]); + 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/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/RemoteMcpSettings.tsx b/apps/web/src/components/settings/RemoteMcpSettings.tsx index e48025ee629fe..2f78bda1407d5 100644 --- a/apps/web/src/components/settings/RemoteMcpSettings.tsx +++ b/apps/web/src/components/settings/RemoteMcpSettings.tsx @@ -23,6 +23,7 @@ import { RemoteMcpDuplicateCheck } from "./RemoteMcpDuplicateCheck"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { useSettingsScope } from "./SettingsScopeContext"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; +import { PluginSettings } from "./PluginSettings"; const AUTH_MODE_LABELS: Record = { oauth: "Sign in (OAuth)", @@ -83,6 +84,7 @@ export function ToolsSettingsPanel() { return ( + ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index ace78995a7b04..c4e97fc8ccd7b 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -892,6 +892,13 @@ export const SETTINGS_SEARCH_ITEMS = [ targetId: "device-sources", searchTerms: ["simulator support xcode android studio sdk avd runtime"], }, + { + id: "plugins", + title: "Plugins", + to: "/settings/tools", + 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/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index b6663d21b6d0e..a87183ebdfc3a 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1,3 +1,4 @@ +import { SidebarPluginStatus } from "../plugins/SidebarPluginStatus"; import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; @@ -1077,6 +1078,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onOpenPullRequest={handlePrClick} /> ) : null; + const pluginStatus = ( + + ); const terminalStatusIcon = ( )} + {pluginStatus} {terminalStatusIcon} {prBadge} {diff ? ( @@ -1234,6 +1239,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {title} {multiline ? titleLineIcons : null} {pinIndicator} + {multiline && !metaLineEmpty ? null : pluginStatus} {multiline ? null : terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1642,6 +1648,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP Regenerating title ) : null} + {metaLineEmpty ? pluginStatus : null} {titleLineIcons} {metaLine} diff --git a/apps/web/src/reopenClosedView.ts b/apps/web/src/reopenClosedView.ts index 005887ad81faa..516831d5cc3bb 100644 --- a/apps/web/src/reopenClosedView.ts +++ b/apps/web/src/reopenClosedView.ts @@ -136,6 +136,9 @@ export async function reopenClosedView( case "pull-request": panels.openPullRequest(ref, surface); break; + case "plugin": + panels.openPluginView(ref, surface); + break; case "side-chat": panels.openSideChat(ref, surface.threadId); break; diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index d34d586aeb559..dcba3755327d1 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -22,6 +22,7 @@ const RIGHT_PANEL_KINDS = [ "pull-request", "pull-requests", "group", + "plugin", "side-chat", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -83,7 +84,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"; @@ -133,7 +136,7 @@ interface RightPanelStoreState { ) => boolean; open: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; openSideChat: (ref: ScopedThreadRef, threadId: ThreadId) => void; openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; @@ -163,6 +166,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; setPullRequestDetailTab: ( ref: ScopedThreadRef, surfaceId: string, @@ -197,7 +204,7 @@ interface RightPanelStoreState { toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, - kind: Exclude, + kind: Exclude, ) => void; setThreadPanelOpen: ( ref: ScopedThreadRef, @@ -220,7 +227,10 @@ const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = { }; const singletonSurface = ( - kind: Exclude, + kind: Exclude< + RightPanelKind, + "file" | "preview" | "terminal" | "pull-request" | "side-chat" | "plugin" + >, ): RightPanelSurface => { switch (kind) { case "diff": @@ -270,6 +280,20 @@ const terminalSurface = (terminalId: string): RightPanelSurface => ({ activeTerminalId: terminalId, }); +export type PluginViewSurface = Extract; + +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: { @@ -510,6 +534,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" || surface.kind === "files") && "folderTrail" in surface && @@ -799,6 +830,12 @@ export const useRightPanelStore = create()( }; }), ), + openPluginView: (ref, view) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => + upsertSurface(current, pluginViewSurface(view)), + ), + ), setPullRequestDetailTab: (ref, surfaceId, detailTab) => set((state) => { const threadKey = scopedThreadKey(ref); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 0e468bca81902..adaac174afa4c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -32,6 +32,7 @@ import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkC import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; +import { PluginToastCoordinator } from "../components/plugins/PluginToastCoordinator"; import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; @@ -213,6 +214,7 @@ function RootRouteBranch() { + {import.meta.env.DEV && 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/docs/README.md b/docs/README.md index 9645d0b51f2d5..24723c6dfee06 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) @@ -42,6 +43,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..1885f89cd25d8 --- /dev/null +++ b/docs/user/plugins.md @@ -0,0 +1,91 @@ +# 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. 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. + +## 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. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index ce7016d11d057..40e640344d7f5 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -335,6 +335,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 264da16592b1c..b85c1e01836fd 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -60,6 +60,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.serverGetDatabaseStorage | 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/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index 493683596ccf6..6761dbaa39fa0 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -343,6 +343,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 84b8eca9c3dff..7df5045e456f2 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -75,6 +75,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, // Each edits Cursor's MCP config or its saved sign-ins on this machine. [WS_METHODS.cursorMcpSignIn]: AuthProvidersManageScope, [WS_METHODS.cursorMcpSaveServer]: AuthProvidersManageScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e280ad39daf37..3cc1a2e3c9f1a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -67,6 +67,7 @@ export * from "./previewAutomation.ts"; export * from "./previewCapture.ts"; export * from "./usage.ts"; export * from "./scheduledTask.ts"; +export * from "./plugins.ts"; export * from "./reviewSession.ts"; export * from "./cursorMcp.ts"; export * from "./worktreeMcp.ts"; diff --git a/packages/contracts/src/plugins.ts b/packages/contracts/src/plugins.ts new file mode 100644 index 0000000000000..49352988bb4ec --- /dev/null +++ b/packages/contracts/src/plugins.ts @@ -0,0 +1,278 @@ +/** + * Plugins under `/plugins` and Claude Code mods draw into the same surfaces. Names and + * shapes follow upstream's plugin proposal (pingdotgg/t3code #16047-#16065) where they overlap, + * so a later upstream merge stays cheap. Plugin code is trusted; views run sandboxed. + */ +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; + +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 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)), + 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, + /** 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), +}); +export type PluginSummary = typeof PluginSummary.Type; + +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; + +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, + /** 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, + /** The page with the theme bootstrap and the `t3` bridge already injected. */ + html: Schema.String, +}); +export type PluginViewReadResult = typeof PluginViewReadResult.Type; + +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 01d519080151b..1254ee622e6c3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -382,6 +382,20 @@ import { ScheduledTaskUpsertInput, ScheduledTaskMutationResult, } from "./scheduledTask.ts"; +import { + PluginBandPressInput, + PluginBandsSnapshot, + PluginBandsSubscribeInput, + PluginCommandRunInput, + PluginError, + PluginSetEnabledInput, + PluginsSnapshot, + PluginsSubscribeInput, + PluginViewCallInput, + PluginViewCallResult, + PluginViewReadInput, + PluginViewReadResult, +} from "./plugins.ts"; import { RemoteMcpAddInput, RemoteMcpAuthorizeInput, @@ -595,6 +609,15 @@ export const WS_METHODS = { remoteMcpAuthorize: "remoteMcp.authorize", remoteMcpReconnect: "remoteMcp.reconnect", remoteMcpRemove: "remoteMcp.remove", + // 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", scheduledTasksSubscribe: "scheduledTasks.subscribe", scheduledTasksUpsert: "scheduledTasks.upsert", scheduledTasksSetEnabled: "scheduledTasks.setEnabled", @@ -2241,6 +2264,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); + /** * RPCs Ctrl adds on top of T3 Code, kept out of `WsBaseRpcGroup` so it stays the * size upstream typechecks: that group's handler layer sits at TypeScript's @@ -2486,4 +2570,5 @@ export const WsRpcGroup = WsBaseRpcGroup.merge( WsReviewSessionsRpcGroup, WsCtrlRpcGroup, WsCursorMcpRpcGroup, + WsPluginsRpcGroup, ); diff --git a/packages/shared/package.json b/packages/shared/package.json index 23aed9aa53f5c..4c5671299b22b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -167,6 +167,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 12af0f39336b2..00f1e62232548 100644 --- a/packages/shared/src/htmlRender.ts +++ b/packages/shared/src/htmlRender.ts @@ -371,7 +371,7 @@ function rootRule(theme: HtmlRenderTheme): string { // Forward shortcuts after page handlers; text fields retain their keys. 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);window.addEventListener("click",function(e){var t=e.composedPath()[0],u;if(!e.isTrusted||e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||!t||t.tagName!=="IMG"||t.closest("a[href],button"))return;u=t.currentSrc||t.src;if(u)setTimeout(function(){if(!e.defaultPrevented)window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(OPEN_IMAGE_METHOD)},params:{src:u,name:t.alt||t.title||""}},"*");});});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"); const defaultCss = `${rootRule(dark)}@media (prefers-color-scheme: light){${rootRule(light)}}${BASE_CSS}`; @@ -382,6 +382,7 @@ function bootstrapMarkup(markup: string): string { : '', ``, ``, + extraScript === undefined ? "" : ``, ].join(""); } @@ -412,9 +413,14 @@ const blankNonMarkup = (html: string) => { return parts.join(""); }; -export function injectHtmlRenderBootstrap(html: string): string { +/** + * Inserts the theme bootstrap at the start of the document head, so a page's + * 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, 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.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 new file mode 100644 index 0000000000000..9de06a195fd60 --- /dev/null +++ b/packages/shared/src/pluginView.ts @@ -0,0 +1,55 @@ +/** + * The `t3` object a plugin view gets: `await t3.call(name, input)` runs the plugin's handler, + * `t3.ready(fn)` reports `{ pluginId, viewId, threadId }`. Messages are JSON-RPC shaped like the + * html_render bootstrap's, and the client answers only this frame's window. + */ +const PLUGIN_VIEW_READY_METHOD = "t3/plugin.ready"; +const PLUGIN_VIEW_INIT_METHOD = "t3/plugin.init"; +const PLUGIN_VIEW_CALL_METHOD = "t3/plugin.call"; +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; +} diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 7913a9066126a..52fe3e2922815 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -45,6 +45,8 @@ export type T3McpToolSummaryAction = | "worktree-list" | "worktree-status" | "project-list" + | "plugin-list" + | "plugin-reload" | "project-read" | "project-create" | "project-update" @@ -365,6 +367,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"),