Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
60a9cb3
feat: add production plugin runtime
t3-code[bot] Aug 19, 2026
14cd84c
refactor: expose plugin runtime as effect service
t3-code[bot] Aug 19, 2026
431c7c7
fix: make plugin shutdown interruption safe
t3-code[bot] Aug 19, 2026
0f78d2b
fix: roll back interrupted plugin reconciliation
t3-code[bot] Aug 19, 2026
72d3a0a
fix: harden interrupted plugin callbacks
t3-code[bot] Aug 19, 2026
596bd97
feat: add plugin command catalog
t3-code[bot] Aug 19, 2026
12b8e9e
fix: harden plugin command catalog
t3-code[bot] Aug 19, 2026
ceb3640
fix: address plugin command review findings
t3-code[bot] Aug 19, 2026
4c1b5a6
fix: serialize plugin catalog publication
t3-code[bot] Aug 19, 2026
0422cae
feat: add plugin package lifecycle
t3-code[bot] Aug 19, 2026
69e4b12
fix: align plugin lifecycle effect errors
t3-code[bot] Aug 20, 2026
3b532e1
fix: keep plugin status failures scoped
t3-code[bot] Aug 20, 2026
0ebe3fe
chore: rerun flaky ci
t3-code[bot] Aug 20, 2026
7809007
refactor: defer mobile plugin commands
t3-code[bot] Aug 23, 2026
48c0e28
Merge branch 'feat/plugin-command-catalog' into feat/plugin-package-l…
t3-code[bot] Aug 23, 2026
890e002
feat: add plugin management settings
t3-code[bot] Aug 23, 2026
52a541f
fix: disable plugin actions during mutations
t3-code[bot] Aug 23, 2026
e0619c3
refactor: use shared plugin empty state
t3-code[bot] Aug 23, 2026
6a85281
fix: align plugin settings header actions
t3-code[bot] Aug 23, 2026
2849683
Merge remote-tracking branch 'origin/main' into feat/plugin-managemen…
t3-code[bot] Aug 23, 2026
376ea51
fix: align plugin loading state
t3-code[bot] Aug 23, 2026
691c592
fix: preserve plugin command error context
t3-code[bot] Aug 23, 2026
221e6d2
fix: isolate plugin package failures
t3-code[bot] Aug 23, 2026
527fd0d
Merge branch 'main' into feat/plugin-system
UtkarshUsername Aug 23, 2026
0d0a498
fix(web): refresh plugin status after failed lifecycle actions
UtkarshUsername Aug 23, 2026
cd758a2
Merge branch 'main' into feat/plugin-system
UtkarshUsername Aug 23, 2026
918c8bd
Merge branch 'main' into feat/plugin-system
UtkarshUsername Aug 23, 2026
3ccb065
fix: address plugin review findings
t3-code[bot] Aug 23, 2026
ea5647f
docs: correct plugin command surfaces
t3-code[bot] Aug 23, 2026
8dd04ec
feat: finish plugin dependency resolution
t3-code[bot] Aug 23, 2026
700d682
fix: harden plugin startup recovery
t3-code[bot] Aug 23, 2026
a8c3e8f
feat: add plugin capability broker
t3-code[bot] Aug 23, 2026
3741acb
fix: harden plugin capability boundaries
t3-code[bot] Aug 23, 2026
f8e2498
fix: bound plugin broker inputs
t3-code[bot] Aug 23, 2026
99646b6
fix: contain plugin permission badges
t3-code[bot] Aug 23, 2026
1229672
feat: isolate plugin execution
t3-code[bot] Aug 24, 2026
4aa61b8
fix: address plugin worker review
t3-code[bot] Aug 24, 2026
a8e98bd
fix: reject invalid worker results
t3-code[bot] Aug 24, 2026
410bf39
fix: preserve plugin error structure
t3-code[bot] Aug 24, 2026
5c53f8c
fix: split plugin mismatch errors
t3-code[bot] Aug 24, 2026
050f279
fix: retain worker disposal errors
t3-code[bot] Aug 24, 2026
ba752ad
fix: sanitize worker crash diagnostics
t3-code[bot] Aug 24, 2026
cd7c496
feat: add declarative plugin ui kit
t3-code[bot] Aug 24, 2026
a994f5e
fix: address declarative ui review
t3-code[bot] Aug 24, 2026
e9a598f
fix: harden declarative ui boundaries
t3-code[bot] Aug 24, 2026
9e1a501
fix: align declarative ui surfaces
t3-code[bot] Aug 24, 2026
4cb02c8
fix: finish declarative ui review
t3-code[bot] Aug 24, 2026
c25025c
fix: consolidate plugin ui treatments
t3-code[bot] Aug 24, 2026
68c92fb
fix: attach plugin composer drawer state
t3-code[bot] Aug 24, 2026
5501410
fix: keep plugin composer hooks stable
t3-code[bot] Aug 24, 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: 2 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
import { HomeRouteScreen } from "./features/home/HomeRouteScreen";
import { PluginUiMobileNotificationHost } from "./features/plugins/PluginUiMobileCards";
import { AddProjectDestinationRoute } from "./features/projects/AddProjectDestinationRoute";
import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute";
import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute";
Expand Down Expand Up @@ -394,6 +395,7 @@ function RootStackLayout(props: {
return (
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
<PluginUiMobileNotificationHost />
<ShowcaseCaptureCoordinator pathname={pathname} />
<ExistingThreadSettingsRouteProvider>
<AdaptiveWorkspaceLayout pathname={workspacePathname}>
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { buildHomeProjectScopes } from "./homeThreadList";
import { usePendingTaskListActions } from "./usePendingTaskListActions";
import { useThreadListActions } from "./useThreadListActions";
import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle";
import { PluginUiMobileCards } from "../plugins/PluginUiMobileCards";

/* ─── Route screen ───────────────────────────────────────────────────── */

Expand Down Expand Up @@ -170,6 +171,8 @@ export function HomeRouteScreen() {
onThreadSortOrderChange={setThreadSortOrder}
/>

<PluginUiMobileCards environmentId={selectedEnvironmentId} />

<HomeScreen
catalogState={catalogState}
environments={environments}
Expand Down
137 changes: 137 additions & 0 deletions apps/mobile/src/features/plugins/PluginUiMobileCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useAtomValue } from "@effect/atom-react";
import type { EnvironmentId, PluginUiCatalog, PluginUiNotification } from "@t3tools/contracts";
import { AsyncResult } from "effect/unstable/reactivity";
import * as Option from "effect/Option";
import { useEffect, useRef } from "react";
import { Alert, Pressable, ScrollView, View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { useWorkspaceState } from "../../state/workspace";

export function PluginUiMobileNotificationHost() {
const { environments } = useWorkspaceState();
return environments.map((environment) => (
<EnvironmentPluginUiMobileNotificationHost
key={environment.environmentId}
environmentId={environment.environmentId}
/>
));
}

function EnvironmentPluginUiMobileNotificationHost({
environmentId,
}: {
readonly environmentId: EnvironmentId;
}) {
const result = useAtomValue(
serverEnvironment.pluginUiNotifications({ environmentId, input: {} }),
);
const notification = Option.getOrNull(AsyncResult.value(result));
const shown = useRef<PluginUiNotification | null>(null);

useEffect(() => {
if (notification === null) return;
if (shown.current === notification) return;
shown.current = notification;
Alert.alert(notification.title, notification.message);
}, [notification]);

return null;
}

export function PluginUiMobileCards({
environmentId,
}: {
readonly environmentId: EnvironmentId | null;
}) {
if (environmentId === null) return null;
return <EnvironmentPluginUiMobileCards environmentId={environmentId} />;
}

function EnvironmentPluginUiMobileCards({
environmentId,
}: {
readonly environmentId: EnvironmentId;
}) {
const result = useAtomValue(serverEnvironment.pluginUi({ environmentId, input: {} }));
const catalog = Option.getOrNull(AsyncResult.value(result)) as PluginUiCatalog | null;
const invoke = useAtomCommand(serverEnvironment.invokePluginCommand, { reportFailure: false });
if (catalog === null) return null;

const packages = catalog.packages
.map((pluginPackage) => ({
pluginPackage,
cards: pluginPackage.cards.filter((card) => card.surfaces.includes("mobile")),
statuses: pluginPackage.statusItems.filter((item) => item.surfaces.includes("mobile")),
}))
.filter(({ cards, statuses }) => cards.length > 0 || statuses.length > 0);
if (packages.length === 0) return null;

return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-3 px-4 py-2"
accessibilityLabel="Plugin cards"
>
{packages.flatMap(({ pluginPackage, cards, statuses }) => [
...cards.map((card) => {
const action = card.actionId
? [...pluginPackage.composerActions, ...pluginPackage.contextualActions].find(
(candidate) => candidate.id === card.actionId,
)
: undefined;
return (
<Pressable
key={`${pluginPackage.pluginId}:${card.id}`}
disabled={action === undefined}
className="min-w-44 rounded-2xl border border-border bg-card p-4 active:opacity-70"
accessibilityRole={action === undefined ? "summary" : "button"}
accessibilityLabel={card.title}
onPress={
action === undefined
? undefined
: () => {
void invoke({
environmentId,
input: {
generation: catalog.generation,
id: action.commandId,
context: { cardId: card.id },
},
}).then((outcome) => {
if (outcome._tag === "Success") {
Alert.alert(card.title, outcome.value.message);
} else {
Alert.alert("Plugin action failed");
}
});
}
}
>
<Text className="text-sm font-t3-semibold text-foreground">{card.title}</Text>
{card.value ? (
<Text className="mt-1 text-2xl font-t3-bold text-foreground">{card.value}</Text>
) : null}
{card.description ? (
<Text className="mt-1 text-sm text-muted-foreground">{card.description}</Text>
) : null}
</Pressable>
);
}),
...statuses.map((status) => (
<View
key={`${pluginPackage.pluginId}:${status.id}`}
className="min-w-36 rounded-2xl border border-border bg-card p-4"
accessibilityLabel={`${status.label}: ${status.value}`}
>
<Text className="text-xs text-muted-foreground">{status.label}</Text>
<Text className="mt-1 text-base font-t3-semibold text-foreground">{status.value}</Text>
</View>
)),
])}
</ScrollView>
);
}
3 changes: 2 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"type": "module",
"scripts": {
"dev": "node --watch src/bin.ts",
"build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean",
"build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean && vp pack src/plugins/PluginWorkerRuntime.mjs --out-dir dist --no-clean",
"start": "node dist/bin.mjs",
"typecheck": "tsgo --noEmit",
"test": "vp test run"
Expand All @@ -38,6 +38,7 @@
"devDependencies": {
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/plugin-runtime": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"@t3tools/web": "workspace:*",
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,31 @@ describe("RPC authorization scopes", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope);
});

it("allows command discovery with read scope and invocation with operate scope", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.pluginCommandsList)).toBe(
AuthOrchestrationReadScope,
);
expect(requiredScopeForRpcMethod(WS_METHODS.subscribePluginCommands)).toBe(
AuthOrchestrationReadScope,
);
expect(requiredScopeForRpcMethod(WS_METHODS.pluginCommandsInvoke)).toBe(
AuthOrchestrationOperateScope,
);
expect(requiredScopeForRpcMethod(WS_METHODS.pluginUiList)).toBe(AuthOrchestrationReadScope);
expect(requiredScopeForRpcMethod(WS_METHODS.subscribePluginUi)).toBe(
AuthOrchestrationReadScope,
);
expect(requiredScopeForRpcMethod(WS_METHODS.subscribePluginUiNotifications)).toBe(
AuthOrchestrationReadScope,
);
expect(requiredScopeForRpcMethod(WS_METHODS.pluginUiSettingGet)).toBe(
AuthOrchestrationReadScope,
);
expect(requiredScopeForRpcMethod(WS_METHODS.pluginUiSettingSet)).toBe(
AuthOrchestrationOperateScope,
);
});

it("requires permission to operate on a thread before uploading feedback", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.providerUploadFeedback)).toBe(
AuthOrchestrationOperateScope,
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
[WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope,
[WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope,
[WS_METHODS.pluginCommandsList]: AuthOrchestrationReadScope,
[WS_METHODS.pluginCommandsInvoke]: AuthOrchestrationOperateScope,
[WS_METHODS.pluginUiList]: AuthOrchestrationReadScope,
[WS_METHODS.pluginUiSettingGet]: AuthOrchestrationReadScope,
[WS_METHODS.pluginUiSettingSet]: AuthOrchestrationOperateScope,
[WS_METHODS.pluginPackagesStatus]: AuthOrchestrationReadScope,
[WS_METHODS.pluginPackagesEnable]: AuthOrchestrationOperateScope,
[WS_METHODS.pluginPackagesDisable]: AuthOrchestrationOperateScope,
[WS_METHODS.pluginPackagesReload]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribePluginCommands]: AuthOrchestrationReadScope,
[WS_METHODS.subscribePluginUi]: AuthOrchestrationReadScope,
[WS_METHODS.subscribePluginUiNotifications]: AuthOrchestrationReadScope,
[WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope,
[WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
[WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
Expand Down
Loading
Loading