Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
},
{
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -1231,6 +1233,8 @@ export const layerAttachmentToolkit = toolkitRegistration(
AttachmentHandlers.layer,
);

const layerPluginToolkit = toolkitRegistration(PluginToolkit, PluginHandlers.layer);

export const layerPullRequestsToolkit = toolkitRegistration(
PullRequestsToolkit,
PullRequestsHandlers.layer,
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/builtInToolNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export const BUILT_IN_MCP_TOOL_NAMES: ReadonlyArray<string> = [
"t3_group_resource",
"t3_group_update",
"t3_pending_request",
"t3_plugin_list",
"t3_plugin_reload",
"t3_preview_close",
"t3_preview_list",
"t3_project_clone",
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/mcp/toolkits/plugins/handlers.ts
Original file line number Diff line number Diff line change
@@ -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)),
});
63 changes: 63 additions & 0 deletions apps/server/src/mcp/toolkits/plugins/tools.ts
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/worktree/registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)({}),
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`)
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<never>());
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1821,6 +1845,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray<string> = [
"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.
Expand Down
Loading
Loading