Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a89f5c8
feat(contracts): plugin manifest, state and RPC schemas, plugins capa…
ashx-j Oct 30, 2026
c1e79bd
feat(plugins): plugin SDK skeleton, hello fixture and internal doc
ashx-j Oct 30, 2026
e25eab1
feat(plugins): server plugin host
ashx-j Oct 30, 2026
2918b9f
fix(plugins): harden the plugin host lifecycle and access checks
ashx-j Oct 30, 2026
73a5e9b
feat(plugins): manage plugins and their settings in Settings
ashx-j Oct 30, 2026
7aae578
feat(plugins): server plugin API for events, threads, tools, scheduli…
ashx-j Oct 30, 2026
c2d9362
fix(plugins): keep slash commands and event order intact, gate plugin…
ashx-j Oct 30, 2026
9cf43ba
feat(plugins): web plugin loader, settings panels and right panels
ashx-j Oct 30, 2026
645cf7e
feat(plugins): palette, keybinding and composer commands from plugins
ashx-j Oct 30, 2026
5782adc
feat(plugins): timeline renderers, banners, code blocks and themes fr…
ashx-j Oct 30, 2026
6738076
feat(plugins): run-completed hook, richer send hook and agent plugin …
ashx-j Oct 30, 2026
85d0e4f
feat(plugins): install and remove plugins from Settings
ashx-j Oct 30, 2026
a25648b
fix(plugins): review fixes for the web runtime, run hook and installs
ashx-j Oct 30, 2026
48fb6c8
feat(plugins): update installed plugins from their source
ashx-j Oct 30, 2026
e3767cb
docs(plugins): bring the internal doc up to date
ashx-j Oct 30, 2026
d498f20
feat(plugins): install plugins from npm
ashx-j Oct 30, 2026
68ee54c
fix(plugins): second review fixes for installs, npm, the run hook and…
ashx-j Oct 30, 2026
29d0964
feat(plugins): server plugins can post notes into threads
ashx-j Oct 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe("ElectronProtocol", () => {
assert.equal(yield* Effect.promise(() => response.text()), "ok");
assert.include(
response.headers.get("content-security-policy") ?? "",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://clerk.t3.codes https://challenges.cloudflare.com",
);
assert.include(
response.headers.get("content-security-policy") ?? "",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInpu
"'self'",
"'unsafe-inline'",
"'wasm-unsafe-eval'",
// Web plugins run from object URLs: their bundles are fetched with the session's credentials.
"blob:",
...(clerkOrigin ? [clerkOrigin] : []),
"https://challenges.cloudflare.com",
];
Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@effect/vitest": "catalog:",
"@opencode-ai/sdk": "^1.3.15",
"@t3tools/contracts": "workspace:*",
"@t3tools/plugin-sdk": "workspace:*",
"@t3tools/provider-testing": "workspace:*",
"@t3tools/secret-provider-core": "workspace:*",
"@t3tools/secret-provider-definitions": "workspace:*",
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ describe("RPC authorization scopes", () => {
}
});

it("keeps enabling, reloading and configuring plugins, which runs their code, behind settings write", () => {
for (const method of [
WS_METHODS.pluginsSetEnabled,
WS_METHODS.pluginsReload,
WS_METHODS.pluginsSettingsUpdate,
]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthSettingsWriteScope);
}
});

it("rejects unknown RPC method names", () => {
for (const method of ["server.notRegistered", "toString", "constructor"]) {
expect(() => requiredScopeForRpcMethod(method)).toThrow(
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.deviceDetail]: AuthOrchestrationReadScope,
[WS_METHODS.deviceAction]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope,
// `plugins.invoke` also needs the operate scope for methods a plugin registered as writes;
// PluginHost checks that against the plugin's own declaration. Channels are reads.
[WS_METHODS.pluginsList]: AuthOrchestrationReadScope,
[WS_METHODS.pluginsSubscribeStates]: AuthOrchestrationReadScope,
[WS_METHODS.pluginsSettingsGet]: AuthOrchestrationReadScope,
[WS_METHODS.pluginsInvoke]: AuthOrchestrationReadScope,
[WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ export const make = Effect.gen(function* () {
threadSnooze: true,
threadForceStop: true,
environmentThemes: true,
plugins: true,
usageLimitSources: true,
providerWorkspaceSnapshotsOnDemand: true,
usagePriceOverrides: true,
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/environmentTheme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,41 @@ it.layer(NodeServices.layer)("environment theme", (it) => {
),
);

it.effect("adds plugin themes after the environment's own, which win on the same id", () =>
withEnvironmentThemes(
{ "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
Effect.gen(function* () {
const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const pluginThemes = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-themes-" });
yield* fs.writeFileString(
path.join(pluginThemes, "nightfall.json"),
encodeThemeFile({ ...NIGHTFALL_THEME, name: "Plugin Nightfall" }),
);
yield* fs.writeFileString(
path.join(pluginThemes, "shared-light.json"),
encodeThemeFile(SHARED_THEME),
);

yield* environmentTheme.setPluginThemeDirectories([pluginThemes]);
assert.deepEqual(
(yield* currentThemes).map((theme) => [theme.id, theme.name]),
[
["nightfall", "Nightfall"],
["shared-light", "Shared Light"],
],
);

yield* environmentTheme.setPluginThemeDirectories([]);
assert.deepEqual(
(yield* currentThemes).map((theme) => theme.id),
["nightfall"],
);
}),
),
);

it.effect("never replays a set older than the snapshot it started from", () =>
withEnvironmentThemes(
{ "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) },
Expand Down
29 changes: 26 additions & 3 deletions apps/server/src/environmentTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export class EnvironmentThemeService extends Context.Service<

/** Subscribes before reading to retain changes during connection. */
readonly streamChanges: Stream.Stream<ReadonlyArray<EnvironmentTheme>>;

/**
* Theme folders of active plugins, read after the environment's own folder. The first theme
* with an id wins, so a plugin cannot replace a theme the user published.
*/
readonly setPluginThemeDirectories: (directories: ReadonlyArray<string>) => Effect.Effect<void>;
}
>()("t3/environmentTheme/EnvironmentThemeService") {}

Expand Down Expand Up @@ -158,16 +164,26 @@ const make = Effect.gen(function* () {
// Sequence numbers let subscribers drop queued events older than their initial snapshot.
const changes = yield* PubSub.sliding<PublishedThemes>(1);
const published = yield* Ref.make<PublishedThemes>({ seq: 0, themes: [] });
const pluginDirectories = yield* Ref.make<ReadonlyArray<string>>([]);
// Serialize read/compare/publish so slow older reads cannot publish with newer sequence numbers.
const refreshSemaphore = yield* Semaphore.make(1);
const watcherScope = yield* Scope.make("sequential");
yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void));

const refresh = refreshSemaphore.withPermits(1)(
Effect.gen(function* () {
const themes = yield* readPublishedThemes(environmentThemesDir).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
);
const themes: Array<EnvironmentTheme> = [];
const ids = new Set<string>();
for (const directory of [environmentThemesDir, ...(yield* Ref.get(pluginDirectories))]) {
const found = yield* readPublishedThemes(directory).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
);
for (const theme of found) {
if (ids.has(theme.id)) continue;
ids.add(theme.id);
themes.push(theme);
}
}
// Compare the whole value so new fields cannot silently miss republishes.
const [changed, next] = yield* Ref.modify(
published,
Expand Down Expand Up @@ -198,6 +214,13 @@ const make = Effect.gen(function* () {

return {
current: Effect.map(refresh, (state) => state.themes),
setPluginThemeDirectories: Effect.fn("EnvironmentThemeService.setPluginThemeDirectories")(
function* (directories) {
const previous = yield* Ref.getAndSet(pluginDirectories, directories);
if (Equal.equals(previous, directories)) return;
yield* refresh;
},
),
get streamChanges() {
return Stream.unwrap(
Effect.gen(function* () {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
return redirectUrl.toString();
}

const authenticateRawRouteWithScope =
export const authenticateRawRouteWithScope =
/* Request instrumentation already owns the span; keep this helper in that context. */
Effect.fnUntraced(function* (
scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,
Expand Down
54 changes: 52 additions & 2 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as HtmlRender from "../htmlRender/HtmlRender.ts";
import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as PluginMcpTools from "../plugins/PluginMcpTools.ts";
import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
import {
type AgentThread,
Expand Down Expand Up @@ -1140,6 +1141,53 @@ export const layerDeviceToolkit = Layer.mergeAll(
layerDeviceScreenshotRegistration,
);

const isJsonObject = Schema.is(Schema.JsonObject);

const pluginToolResult = (output: Schema.Json) =>
new McpSchema.CallToolResult({
isError: false,
...(isJsonObject(output) ? { structuredContent: output } : {}),
content: [{ type: "text", text: JSON.stringify(output) }],
});

const pluginToolFailure = (error: { readonly message: string }) =>
new McpSchema.CallToolResult({ isError: true, content: [{ type: "text", text: error.message }] });

/**
* Plugins add tools while the server runs, so they register here directly instead of through
* a toolkit. Kept out of `layerTools`: with no plugin installed there are none to measure.
*/
export const layerPluginTools = Layer.effectDiscard(
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const plugins = yield* PluginMcpTools.PluginMcpTools;
const threads = yield* ThreadManagementService.ThreadManagementService;
yield* plugins.attach((entry) => {
const call = McpToolAccess.dynamic(entry.access, entry.call);
return server.addTool({
tool: new McpSchema.Tool({
name: entry.name,
description: entry.description,
inputSchema: entry.inputSchema,
annotations: { readOnlyHint: entry.access === "read" },
}),
annotations: Context.empty(),
handle: (payload) =>
Effect.withFiber((fiber) =>
call(isJsonObject(payload) ? payload : {}).pipe(
Effect.provideService(
McpInvocationContext.McpInvocationContext,
Context.getUnsafe(fiber.context, McpInvocationContext.McpInvocationContext),
),
Effect.provideService(ThreadManagementService.ThreadManagementService, threads),
Effect.match({ onFailure: pluginToolFailure, onSuccess: pluginToolResult }),
),
),
});
});
}),
).pipe(Layer.provide(McpServer.McpServer.layer));

export const layerMcpTransport = McpServer.layerHttp({
name: "T3 Code",
version: packageJson.version,
Expand All @@ -1148,7 +1196,7 @@ export const layerMcpTransport = McpServer.layerHttp({
allowSessionTermination: true,
}).pipe(Layer.provide(layerMcpAuthMiddleware));

/** Every tool the T3 MCP server advertises. AgentContextBudget.test.ts measures this set. */
/** Every built-in tool the T3 MCP server advertises. AgentContextBudget.test.ts measures this set. */
export const layerTools = Layer.mergeAll(
layerPreviewToolkit,
layerOrchestratorToolkit,
Expand All @@ -1163,4 +1211,6 @@ export const layerTools = Layer.mergeAll(
layerHtmlToolkit,
);

export const layer = layerTools.pipe(Layer.provideMerge(layerMcpTransport));
export const layer = Layer.merge(layerTools, layerPluginTools).pipe(
Layer.provideMerge(layerMcpTransport),
);
17 changes: 17 additions & 0 deletions apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,23 @@ export const writesWithCapability = <P, A, E, R>(handle: (params: P) => Effect.E
() => writingCaller,
);

/**
* For tools whose access is data, registered while the server runs (plugin tools): `read`
* checks like `reads`; `write` like `writesEnvironment`, because a plugin tool can do anything
* the plugin can, and its handler never sees the caller's limits. Returns the checked handler.
*/
export const dynamic = <P, A, E, R>(
access: "read" | "write",
handle: (params: P) => Effect.Effect<A, E, R>,
) => {
const declaration: Declaration<
(params: P) => Effect.Effect<A, E | OrchestratorMcpFailure, R | WritingServices>
> = access === "read" ? reads(handle) : writesEnvironment((params: P) => handle(params));
return checkedHandler(declaration);
};

type WritingServices = Effect.Services<typeof writingCaller>;

export const writesThreads = <P, A, E, R>(
threads: (params: P) => ReadonlyArray<ThreadId | undefined>,
handle: (params: P) => Effect.Effect<A, E, R>,
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ const RPC_AGGREGATES = {
[WS_METHODS.serverRefreshUsageRates]: "server",
[WS_METHODS.serverSignalProcess]: "server",
[WS_METHODS.serverReportClientActivity]: "server",
[WS_METHODS.pluginsList]: "plugins",
[WS_METHODS.pluginsSubscribeStates]: "plugins",
[WS_METHODS.pluginsSetEnabled]: "plugins",
[WS_METHODS.pluginsReload]: "plugins",
[WS_METHODS.pluginsInstall]: "plugins",
[WS_METHODS.pluginsUninstall]: "plugins",
[WS_METHODS.pluginsUpdate]: "plugins",
[WS_METHODS.pluginsSettingsGet]: "plugins",
[WS_METHODS.pluginsSettingsUpdate]: "plugins",
[WS_METHODS.pluginsInvoke]: "plugins",
[WS_METHODS.pluginsSubscribe]: "plugins",
[WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks",
[WS_METHODS.scheduledTasksUpsert]: "scheduledTasks",
[WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,63 @@ it.effect("marks a never-visited thread's first completion unread", () =>
}).pipe(Effect.provide(layerTest)),
);

it.effect("files a plugin note under the latest finished run without starting a run", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const threadId = ThreadId.make("thread:plugin-note");
yield* orchestrator.dispatch({
type: "thread.create",
commandId: CommandId.make("create-plugin-note"),
threadId,
projectId: ProjectId.make("project:plugin-note"),
title: "Plugin note",
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
createdBy: "user",
creationSource: "web",
});
const dispatch = (id: string) =>
orchestrator.dispatch({
type: "message.dispatch",
commandId: CommandId.make(id),
threadId,
messageId: MessageId.make(id),
text: "Run in the background",
attachments: [],
dispatchMode: { type: "defer_start" },
createdBy: "agent",
creationSource: "mcp",
});
yield* dispatch("plugin-note-finished");
const finished = (yield* projections.getThreadProjection(threadId)).runs[0]!;
yield* orchestrator.dispatch({
type: "run.interrupt",
commandId: CommandId.make("interrupt-plugin-note"),
threadId,
runId: finished.id,
});
// A later run that hasn't finished must not receive the note.
yield* dispatch("plugin-note-pending");
yield* orchestrator.dispatch({
type: "thread.notification.record",
commandId: CommandId.make("record-plugin-note"),
threadId,
turnItemId: TurnItemId.make("plugin-note"),
notification: { source: { kind: "system" }, outcome: "updated", summary: "Digest: done" },
});
const projection = yield* projections.getThreadProjection(threadId);
assert.equal(projection.runs.length, 2);
const finishedItems = projection.turnItems.filter((item) => item.runId === finished.id);
const note = finishedItems.find((item) => item.id === "plugin-note");
assert.equal(note?.type, "notification");
assert.equal(note?.ordinal, Math.max(...finishedItems.map((item) => item.ordinal)));
}).pipe(Effect.provide(layerTest)),
);

const completeAndSnoozeThread = Effect.fn("test.completeAndSnoozeThread")(function* (
name: string,
snoozeMinutes: number,
Expand Down
Loading