Skip to content

Plugin system (6/8): web host + extension points#3730

Draft
ccdwyer wants to merge 7 commits into
pingdotgg:mainfrom
ccdwyer:wb/06-web-host
Draft

Plugin system (6/8): web host + extension points#3730
ccdwyer wants to merge 7 commits into
pingdotgg:mainfrom
ccdwyer:wb/06-web-host

Conversation

@ccdwyer

@ccdwyer ccdwyer commented Jul 6, 2026

Copy link
Copy Markdown

What Changed

The web/Electron plugin host. The server serves plugin web bundles same-origin under /plugins/:id/:version/{web,assets}/* (per-segment decode + ../sep/null reject + realpath containment + version gate) behind an import map sharing React/Effect/atoms as host singletons; PluginUiHost loads and renders plugin bundles; plugins contribute routes, sidebar sections, settings pages, command-palette entries, and per-project actions. @t3tools/plugin-sdk-web + @t3tools/shared carry the shared surface.

Why

Part 6 of 8 of a runtime plugin system (see #3725 for the overview). Builds on #3729. This is the client-side host that lets a plugin ship UI that renders inside the app, isolated in its own error boundary and CSS cascade layer.

Review — four-model tribunal (GPT-5.5 · Grok · GLM-5.2 · Fable)

Fable verified the untrusted-path file server + XSS posture are solid.

  • PluginSurfaceErrorBoundary never reset (MUST): one plugin's crash poisoned every plugin surface on the same route → key + resetKey remount.
  • PluginId.make threw on a bad URL param (MUST): a typo'd plugin URL hit the root error screen → non-throwing parse routes to the existing not-found view.
  • Transient import failure permanently disabled a plugin → retries on the next sync; externals.ts omitted the SDK packages (broken plugin builds) → aligned + drift guard; eager effect-barrel retention → −104 KB off the eager bundle; plugin-sdk-webapps/web/src circular dep documented as a virtual compile-time surface.

UI Changes

This part is host infrastructure: it renders whatever a plugin contributes, so there is no standalone screen to screenshot — a plugin surface only appears once a plugin is installed (which needs part 7 / the marketplace). Extension points wire into the existing sidebar, settings nav, and command palette without changing their default appearance when no plugin is present.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • Screenshots for UI changes (no visible UI without an installed plugin — see note above; happy to add a demo capture on request)
  • Video for animation/interaction (N/A)

https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk

Threads carry an owner (user or plugin:<id>); plugin-owned threads are
hidden from user-facing projections, activity relays, and client thread
state. Includes migration 033 and the decider/projector/read-model wiring.

Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 602ff240-6dfa-4990-b6e1-2f542db68561

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the vouch:unvouched PR author is not yet trusted in the VOUCHED list. label Jul 6, 2026
@github-actions github-actions Bot added the size:XXL 1,000+ changed lines (additions + deletions). label Jul 6, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One Effect service-convention issue on the new plugin error modeling. Other new service modules (canonical imports → errors → Context.Service → make → layer order, namespace effect imports, inline service interfaces, Schema.TaggedErrorClass with real cause: Schema.Defect(), direct Schema.is predicates) look consistent with the conventions.

Posted via Macroscope — Effect Service Conventions

Comment on lines +118 to +124
Effect.catchCause((cause) =>
Effect.fail(
new PluginRegistrationError({
pluginId,
detail: Cause.pretty(cause),
}),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When wrapping a real failure here, the immediate cause is stringified into detail (Cause.pretty(cause)) and the structured cause is dropped. Per the error conventions, a wrapper must preserve the underlying error as cause and derive its message from stable structural attributes — not stuff arbitrary (third-party plugin) defect text into a caller/log-visible detail field.

Give PluginRegistrationError an optional cause and keep it out of the message. The existing validation call sites (invalid RPC scope …, duplicate RPC method …) are pure domain errors with no cause and stay as-is:

export class PluginRegistrationError extends Schema.TaggedErrorClass<PluginRegistrationError>()(
  "PluginRegistrationError",
  { pluginId: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()) },
) {
  override get message(): string {
    return `Plugin ${this.pluginId} returned an invalid registration: ${this.detail}`;
  }
}

Then preserve the cause at this boundary instead of stringifying it:

    Effect.catchCause((cause) =>
      Effect.fail(
        new PluginRegistrationError({
          pluginId,
          detail: "register() failed",
          cause: Cause.squash(cause),
        }),
      ),
    ),

Posted via Macroscope — Effect Service Conventions

turnAliases.set(String(turnId), { threadId: request.threadId, messageId });
// Do NOT forward bootstrap.createThread into turn-start: the thread now
// exists, and the decider would ignore it anyway.
const turnBootstrap = createdThread ? undefined : bootstrap;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High capabilities/AgentsCapability.ts:337

When startTurn creates the thread first, it sets turnBootstrap = undefined, which drops the entire bootstrap object before dispatching thread.turn.start. Non-createThread bootstrap fields such as prepareWorktree and runSetupScript are silently discarded, so plugin callers requesting a bootstrapped worktree/setup on the first turn get a normal turn start and the preparation never runs. Consider stripping only createThread from bootstrap instead of discarding the whole object.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/capabilities/AgentsCapability.ts around line 337:

When `startTurn` creates the thread first, it sets `turnBootstrap = undefined`, which drops the entire `bootstrap` object before dispatching `thread.turn.start`. Non-`createThread` bootstrap fields such as `prepareWorktree` and `runSetupScript` are silently discarded, so plugin callers requesting a bootstrapped worktree/setup on the first turn get a normal turn start and the preparation never runs. Consider stripping only `createThread` from `bootstrap` instead of discarding the whole object.

Comment thread apps/server/src/http.ts
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const staticRoot = path.resolve(staticDir);
const serveIndexHtml = (indexPath: string) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/http.ts:254

serveIndexHtml uses Effect.orElseSucceed(() => null) to convert readFile failures into a 404 Not Found, but these failures include permission errors and transient I/O errors — not just missing files. So a request for an existing index.html that the server cannot read returns a cacheable 404 instead of 500, while the same failure for a non-HTML asset at line 310 correctly returns 500. This masks runtime faults and gives clients the wrong status code. Consider distinguishing file-not-found from other read errors so that only genuine missing files produce 404.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/http.ts around line 254:

`serveIndexHtml` uses `Effect.orElseSucceed(() => null)` to convert `readFile` failures into a `404 Not Found`, but these failures include permission errors and transient I/O errors — not just missing files. So a request for an existing `index.html` that the server cannot read returns a cacheable `404` instead of `500`, while the same failure for a non-HTML asset at line 310 correctly returns `500`. This masks runtime faults and gives clients the wrong status code. Consider distinguishing `file-not-found` from other read errors so that only genuine missing files produce `404`.

readInstalledManifest(pluginId, entry).pipe(
Effect.map(
(manifest): PluginInfo => ({
id: manifest.id,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium plugins/PluginCatalog.ts:78

pluginInfoFromLockfileEntry sets id from manifest.id (the on-disk manifest.json) instead of the lockfile key pluginId. If the installed manifest is stale or tampered and its id differs from the lockfile entry, the returned PluginInfo reports the wrong plugin ID and can duplicate another plugin's ID in the list. Consider using PluginId.make(pluginId) for the id field, consistent with fallbackPluginInfo.

Suggested change
id: manifest.id,
id: PluginId.make(pluginId),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginCatalog.ts around line 78:

`pluginInfoFromLockfileEntry` sets `id` from `manifest.id` (the on-disk `manifest.json`) instead of the lockfile key `pluginId`. If the installed manifest is stale or tampered and its `id` differs from the lockfile entry, the returned `PluginInfo` reports the wrong plugin ID and can duplicate another plugin's ID in the list. Consider using `PluginId.make(pluginId)` for the `id` field, consistent with `fallbackPluginInfo`.

) ?? null
);
}).pipe(
Effect.flatMap((row) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium capabilities/AgentsCapability.ts:200

readTerminalTurn deletes the turnAliases entry at line 204 as soon as the first waiter reads the terminal row. Because startTurn returns a session-local turnId alias (not a durable id), any later awaitTurn call for the same turn will miss in getByTurnId, fail to find the alias, and then in awaitTerminalTurn re-check after subscribe returns null — so it falls back to Deferred.await on a deferred that will never complete, timing out even though the turn already finished. Consider not pruning the alias until the turn is genuinely complete at the projection level and all waiters can resolve it, or keying the deferred so multiple waiters share one completion signal.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/capabilities/AgentsCapability.ts around line 200:

`readTerminalTurn` deletes the `turnAliases` entry at line 204 as soon as the first waiter reads the terminal row. Because `startTurn` returns a session-local `turnId` alias (not a durable id), any later `awaitTurn` call for the same turn will miss in `getByTurnId`, fail to find the alias, and then in `awaitTerminalTurn` re-check after subscribe returns `null` — so it falls back to `Deferred.await` on a deferred that will never complete, timing out even though the turn already finished. Consider not pruning the alias until the turn is genuinely complete at the projection level and all waiters can resolve it, or keying the deferred so multiple waiters share one completion signal.

Comment on lines +409 to +413
mutate<E>((lockfile) =>
Effect.gen(function* () {
const current = lockfile.plugins[id];
const nextPlugin = yield* fn({ lockfile, current });
const plugins = { ...lockfile.plugins };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium plugins/PluginLockfileStore.ts:409

updatePlugin passes the live lockfile object to the callback via PluginLockfileMutationContext. The callback's return value is only used to replace lockfile.plugins[id], but the rest of the lockfile (e.g. sources) is reused by reference. A callback that mutates nested objects in place — such as context.lockfile.sources.push(...) or mutating fields on context.current — will silently persist those changes even when it returns undefined or an unchanged plugin, corrupting unrelated lockfile state. Consider passing defensive (deep) copies of lockfile and current to the callback so in-place mutations cannot escape into the persisted lockfile.

    mutate<E>((lockfile) =>
      Effect.gen(function* () {
-        const current = lockfile.plugins[id];
-        const nextPlugin = yield* fn({ lockfile, current });
+        const current = lockfile.plugins[id];
+        const context: PluginLockfileMutationContext = {
+          lockfile: structuredClone(lockfile),
+          current: current === undefined ? undefined : structuredClone(current),
+        };
+        const nextPlugin = yield* fn(context);
        const plugins = { ...lockfile.plugins };
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginLockfileStore.ts around lines 409-413:

`updatePlugin` passes the live `lockfile` object to the callback via `PluginLockfileMutationContext`. The callback's return value is only used to replace `lockfile.plugins[id]`, but the rest of the lockfile (e.g. `sources`) is reused by reference. A callback that mutates nested objects in place — such as `context.lockfile.sources.push(...)` or mutating fields on `context.current` — will silently persist those changes even when it returns `undefined` or an unchanged plugin, corrupting unrelated lockfile state. Consider passing defensive (deep) copies of `lockfile` and `current` to the callback so in-place mutations cannot escape into the persisted lockfile.


const quoteShellArg = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;

const commandLine = (command: string, args: ReadonlyArray<string> | undefined) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium capabilities/TerminalsCapability.ts:11

commandLine wraps the command and every argument in POSIX single quotes, but on Windows the terminal falls back to cmd.exe, which treats single quotes as literal characters rather than delimiters. A command like C:\Program Files\tool.exe or any argument containing spaces gets written as 'C:\Program Files\tool.exe' ..., which cmd.exe fails to parse correctly. If supporting Windows hosts is intended, the escaping should branch on the target shell; otherwise consider documenting that commandLine is POSIX-only.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/capabilities/TerminalsCapability.ts around line 11:

`commandLine` wraps the command and every argument in POSIX single quotes, but on Windows the terminal falls back to `cmd.exe`, which treats single quotes as literal characters rather than delimiters. A command like `C:\Program Files\tool.exe` or any argument containing spaces gets written as `'C:\Program Files\tool.exe' ...`, which `cmd.exe` fails to parse correctly. If supporting Windows hosts is intended, the escaping should branch on the target shell; otherwise consider documenting that `commandLine` is POSIX-only.

Comment on lines +306 to +311
Effect.catchCause((cause) =>
input.logger.error("plugin service failed; restarting", {
service: input.service.name,
cause: Cause.pretty(cause),
}),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High plugins/PluginHost.ts:306

startService uses Effect.catchCause, which also catches Cause.Interrupt. Since the service fiber is started with Effect.forkScoped, closing the plugin scope during shutdown interrupts it, but the interruption is caught here and converted into a logged success. Effect.repeat then treats that as a restartable outcome and re-runs the service, causing restart/log spam or shutdown hangs during plugin teardown. Consider re-raising interruption instead of recovering from it — e.g., use Effect.catchCause only when the cause is not an interruption, or use a more targeted catch that excludes Cause.isInterrupt.

-  input.service.run({ pluginId: input.pluginId, logger: input.logger }).pipe(
-    Effect.catchCause((cause) =>
-      input.logger.error("plugin service failed; restarting", {
-        service: input.service.name,
-        cause: Cause.pretty(cause),
-      }),
-    ),
+  input.service.run({ pluginId: input.pluginId, logger: input.logger }).pipe(
+    Effect.catchCause((cause) =>
+      Cause.isInterrupt(cause)
+        ? Effect.failCause(cause)
+        : input.logger.error("plugin service failed; restarting", {
+            service: input.service.name,
+            cause: Cause.pretty(cause),
+          }),
+    ),
Also found in 1 other location(s)

apps/server/src/plugins/PluginRpcDispatcher.ts:129

mapPluginHandlerCause preserves interrupt-only causes via Effect.failCause(cause) at line 129. If a plugin RPC handler returns Effect.interrupt (or otherwise ends with an interrupt-only Cause), dispatcher.call() is interrupted instead of failing with a PluginRpcError. That breaks the declared Effect&lt;unknown, PluginRpcError&gt; contract and can make the RPC request terminate without any typed error response.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around lines 306-311:

`startService` uses `Effect.catchCause`, which also catches `Cause.Interrupt`. Since the service fiber is started with `Effect.forkScoped`, closing the plugin scope during shutdown interrupts it, but the interruption is caught here and converted into a logged success. `Effect.repeat` then treats that as a restartable outcome and re-runs the service, causing restart/log spam or shutdown hangs during plugin teardown. Consider re-raising interruption instead of recovering from it — e.g., use `Effect.catchCause` only when the cause is not an interruption, or use a more targeted catch that excludes `Cause.isInterrupt`.

Also found in 1 other location(s):
- apps/server/src/plugins/PluginRpcDispatcher.ts:129 -- `mapPluginHandlerCause` preserves interrupt-only causes via `Effect.failCause(cause)` at line 129. If a plugin RPC handler returns `Effect.interrupt` (or otherwise ends with an interrupt-only `Cause`), `dispatcher.call()` is interrupted instead of failing with a `PluginRpcError`. That breaks the declared `Effect<unknown, PluginRpcError>` contract and can make the RPC request terminate without any typed error response.

return row !== null && terminalState(row.state);
}

function pendingRequestFromActivity(activity: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium capabilities/AgentsCapability.ts:112

pendingRequestFromActivity returns every approval.requested and user-input.requested activity as a pending request without checking whether a later approval.resolved, user-input.resolved, or failure activity with the same requestId exists in the thread history. listPendingRequests maps over thread.value.activities and flat-maps each one through this function, so it returns already-resolved requests as if they were still open whenever the thread contains both the original request and its resolution event.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/capabilities/AgentsCapability.ts around line 112:

`pendingRequestFromActivity` returns every `approval.requested` and `user-input.requested` activity as a pending request without checking whether a later `approval.resolved`, `user-input.resolved`, or failure activity with the same `requestId` exists in the thread history. `listPendingRequests` maps over `thread.value.activities` and flat-maps each one through this function, so it returns already-resolved requests as if they were still open whenever the thread contains both the original request and its resolution event.

Comment on lines +218 to +228
yield* migration.up.pipe(
Effect.provideService(SqlClient.SqlClient, sql),
Effect.mapError(
(cause) =>
new PluginMigrationExecutionError({
pluginId,
version: migration.version,
cause,
}),
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium plugins/PluginMigrator.ts:218

migration.up is wrapped with Effect.mapError, which only catches typed errors. If a plugin migration dies (e.g. throws inside Effect.gen or uses Effect.die), the defect bypasses PluginMigrationExecutionError entirely and escapes as a raw cause, violating the PluginMigratorError contract and losing the pluginId/version context. Use Effect.catchAllCause (or Effect.sandbox/Effect.catchAllDefect) instead so both failures and defects are wrapped in PluginMigrationExecutionError.

            yield* migration.up.pipe(
              Effect.provideService(SqlClient.SqlClient, sql),
-              Effect.mapError(
-                (cause) =>
-                  new PluginMigrationExecutionError({
-                    pluginId,
-                    version: migration.version,
-                    cause,
-                  }),
-              ),
+              Effect.catchAllCause((cause) =>
+                Effect.fail(
+                  new PluginMigrationExecutionError({
+                    pluginId,
+                    version: migration.version,
+                    cause,
+                  }),
+                ),
+              ),
            );
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginMigrator.ts around lines 218-228:

`migration.up` is wrapped with `Effect.mapError`, which only catches typed errors. If a plugin migration dies (e.g. throws inside `Effect.gen` or uses `Effect.die`), the defect bypasses `PluginMigrationExecutionError` entirely and escapes as a raw cause, violating the `PluginMigratorError` contract and losing the `pluginId`/`version` context. Use `Effect.catchAllCause` (or `Effect.sandbox`/`Effect.catchAllDefect`) instead so both failures and defects are wrapped in `PluginMigrationExecutionError`.

detail: `object name must start with ${input.prefix}`,
});
}
if (entry.type !== "trigger" && entry.type !== "view") continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium plugins/PluginMigrator.ts:147

validateMigrationObjects skips namespace-reference checks for indexes because line 147 only inspects trigger and view entries. A plugin can create a plugin-prefixed index on a core table (e.g. CREATE INDEX p_demo_idx ON core_items(id)); the name passes the prefix check, and the loop continues without inspecting the index body, so the violation goes undetected. Consider extending the body-reference check to index (and any other object type whose SQL can reference a core table) rather than only trigger and view.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginMigrator.ts around line 147:

`validateMigrationObjects` skips namespace-reference checks for indexes because line 147 only inspects `trigger` and `view` entries. A plugin can create a plugin-prefixed index on a core table (e.g. `CREATE INDEX p_demo_idx ON core_items(id)`); the name passes the prefix check, and the loop continues without inspecting the index body, so the violation goes undetected. Consider extending the body-reference check to `index` (and any other object type whose SQL can reference a core table) rather than only `trigger` and `view`.

ccdwyer added 6 commits July 5, 2026 22:50
Clients could send thread.create with owner=plugin:* via the client wire
union and forge a hidden thread. Add an owner-less ClientThreadCreateCommand
and use it in the client-facing unions; owner stays server-injected.

Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
PluginHost lifecycle (discover, activate, deactivate), lockfile store,
per-plugin migrations (034), module loader + resolve hooks, plugin
manifest contract, and the @t3tools/plugin-sdk server package.

Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Plugin RPC dispatcher + catalog wiring over ws, plugin-aware auth scope
model (plugin:<id>:read|operate), session/pairing-grant scope plumbing,
and the client-runtime authorization surface for plugin scopes.

Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Database, secrets, terminals, environments-read, projections-read,
source-control, text-generation, and plugin HTTP route facades gated by
per-plugin scopes, plus the projection read-model support they sit on.

Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Durable agent turn dispatch/observe surface and vcs (git) capability
facade for plugins, wired into the plugin host API and server registry.

Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Serve plugin web bundles + import-map host shims from the server, load
and render plugin UI (routes, sidebar sections, settings pages) in the
web app via PluginUiHost, and ship the @t3tools/plugin-sdk-web package.

Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
projectsLength={projects.length}
/>

<PluginSidebarSections />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/Sidebar.tsx:3746

<PluginSidebarSections /> is rendered after <SidebarProjectsContent>, which closes the sidebar's only scrollable region. The plugin sections are therefore mounted outside the scroll area, so with enough projects or plugin items the plugin UI and/or the footer get pushed off-screen with no way to scroll to them, making plugin-contributed sidebar content inaccessible on smaller windows. Consider rendering <PluginSidebarSections /> inside the same SidebarContent/ScrollArea as the project list so it participates in the sidebar's scroll.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/Sidebar.tsx around line 3746:

`<PluginSidebarSections />` is rendered after `<SidebarProjectsContent>`, which closes the sidebar's only scrollable region. The plugin sections are therefore mounted outside the scroll area, so with enough projects or plugin items the plugin UI and/or the footer get pushed off-screen with no way to scroll to them, making plugin-contributed sidebar content inaccessible on smaller windows. Consider rendering `<PluginSidebarSections />` inside the same `SidebarContent`/`ScrollArea` as the project list so it participates in the sidebar's scroll.


const maxBodyBytes = bodyLimit(descriptor.maxBodyBytes);
const declaredLength = contentLength(request);
if (declaredLength !== null && declaredLength > maxBodyBytes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High plugins/PluginHttpRoutes.ts:173

When the Content-Length header exceeds maxBodyBytes, the route returns 413 without draining or destroying request.stream. Because HttpServerRequest.stream wraps Node's http.IncomingMessage, unread request bytes remain buffered on the socket, so a client can send oversized bodies on many keep-alive connections to pin sockets and exhaust memory. Consider consuming or destroying the request stream before returning the early 413 (and the 400 body-rejection path).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHttpRoutes.ts around line 173:

When the `Content-Length` header exceeds `maxBodyBytes`, the route returns `413` without draining or destroying `request.stream`. Because `HttpServerRequest.stream` wraps Node's `http.IncomingMessage`, unread request bytes remain buffered on the socket, so a client can send oversized bodies on many keep-alive connections to pin sockets and exhaust memory. Consider consuming or destroying the request stream before returning the early `413` (and the `400` body-rejection path).

return Effect.void;
}

const unavailable = (capability: string) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High plugins/PluginHost.ts:149

unavailable uses Effect.die to signal a missing capability, so a plugin calling yield* hostApi.vcs and handling it with Effect.catchTag("PluginCapabilityUnavailable", ...) never reaches its catch branch — the fiber dies with a defect instead. PluginHostApi advertises PluginCapabilityUnavailable as a recoverable typed failure, but Effect.die makes it an unrecoverable defect that bypasses typed error handlers. Consider using Effect.fail so the error matches the declared type.

Also found in 1 other location(s)

packages/plugin-sdk/src/index.ts:817

PluginHostApi advertises each capability property as Effect.Effect&lt;..., PluginCapabilityUnavailable&gt;, which implies plugins can recover from a missing capability through the normal error channel. The host implementation actually builds missing capabilities with Effect.die(new PluginCapabilityUnavailable(...)) in apps/server/src/plugins/PluginHost.ts, so yielding hostApi.agents/vcs/etc. terminates the fiber as a defect instead of failing with PluginCapabilityUnavailable. A plugin that follows this SDK contract and tries to handle the typed error with catchTag/catchAll will be unable to recover and its registration/service will fail unexpectedly.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginHost.ts around line 149:

`unavailable` uses `Effect.die` to signal a missing capability, so a plugin calling `yield* hostApi.vcs` and handling it with `Effect.catchTag("PluginCapabilityUnavailable", ...)` never reaches its catch branch — the fiber dies with a defect instead. `PluginHostApi` advertises `PluginCapabilityUnavailable` as a recoverable typed failure, but `Effect.die` makes it an unrecoverable defect that bypasses typed error handlers. Consider using `Effect.fail` so the error matches the declared type.

Also found in 1 other location(s):
- packages/plugin-sdk/src/index.ts:817 -- `PluginHostApi` advertises each capability property as `Effect.Effect<..., PluginCapabilityUnavailable>`, which implies plugins can recover from a missing capability through the normal error channel. The host implementation actually builds missing capabilities with `Effect.die(new PluginCapabilityUnavailable(...))` in `apps/server/src/plugins/PluginHost.ts`, so yielding `hostApi.agents`/`vcs`/etc. terminates the fiber as a defect instead of failing with `PluginCapabilityUnavailable`. A plugin that follows this SDK contract and tries to handle the typed error with `catchTag`/`catchAll` will be unable to recover and its registration/service will fail unexpectedly.

registerSidebarSection: (registration) => {
sidebarSections.push({ ...registration, pluginId: plugin.id });
},
registerSettingsPage: (registration) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium plugins/PluginUiHost.tsx:191

registerSettingsPage stores the id verbatim, but resolvePluginSettingsPageRegistration normalizes the lookup id via normalizePluginPath. When a plugin registers a settings page with an id like /general/ or foo//bar, the stored id is never normalized, so the resolver strips slashes/duplicates from the lookup key but never matches the stored value — the settings page always renders as not found after navigation. Consider normalizing registration.id in registerSettingsPage the same way registerRoute normalizes registration.path.

Also found in 1 other location(s)

apps/web/src/components/settings/SettingsSidebarNav.tsx:59

getSettingsNavItems builds plugin settings URLs from raw page.id at line 59, but the settings route later normalizes away empty slash segments before lookup (parseSettingsSplat / resolvePluginSettingsPageRegistration). If a plugin registers an id like &#34;/general/&#34; or &#34;foo//bar&#34;, the sidebar link navigates to /settings/&lt;plugin&gt;//general/ while lookup searches for normalized general; the registry still stores the original id, so the page renders the "Plugin settings not found" view and the sidebar entry is unusable.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/plugins/PluginUiHost.tsx around line 191:

`registerSettingsPage` stores the `id` verbatim, but `resolvePluginSettingsPageRegistration` normalizes the lookup id via `normalizePluginPath`. When a plugin registers a settings page with an id like `/general/` or `foo//bar`, the stored id is never normalized, so the resolver strips slashes/duplicates from the lookup key but never matches the stored value — the settings page always renders as not found after navigation. Consider normalizing `registration.id` in `registerSettingsPage` the same way `registerRoute` normalizes `registration.path`.

Also found in 1 other location(s):
- apps/web/src/components/settings/SettingsSidebarNav.tsx:59 -- `getSettingsNavItems` builds plugin settings URLs from raw `page.id` at line 59, but the settings route later normalizes away empty slash segments before lookup (`parseSettingsSplat` / `resolvePluginSettingsPageRegistration`). If a plugin registers an id like `"/general/"` or `"foo//bar"`, the sidebar link navigates to `/settings/<plugin>//general/` while lookup searches for normalized `general`; the registry still stores the original id, so the page renders the "Plugin settings not found" view and the sidebar entry is unusable.


const objectKey = (entry: SqliteMasterObject) => `${entry.type}:${entry.name}`;

const changedObjects = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium plugins/PluginMigrator.ts:96

changedObjects() compares only the sql text of objects present in the after snapshot. A migration that runs DROP TABLE core_victim; CREATE TABLE core_victim (...) with identical schema text will not appear in removedObjects() (the table exists afterward) nor in changedObjects() (the sql text is unchanged), so the drop-and-recreate of a core table goes undetected and the migration commits successfully despite destroying all rows in a non-plugin table.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginMigrator.ts around line 96:

`changedObjects()` compares only the `sql` text of objects present in the `after` snapshot. A migration that runs `DROP TABLE core_victim; CREATE TABLE core_victim (...)` with identical schema text will not appear in `removedObjects()` (the table exists afterward) nor in `changedObjects()` (the `sql` text is unchanged), so the drop-and-recreate of a core table goes undetected and the migration commits successfully despite destroying all rows in a non-plugin table.

);
});

const ensureHostSingletonResolution = registrationGate.withPermits(1)(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium plugins/PluginModuleLoader.ts:144

ensureHostSingletonResolution catches and logs every failure from registerHook at the Effect.catchCause block, then returns void as if registration succeeded. Callers like PluginHost.start proceed to loadServerEntry without the loader hook installed. When import("node:module"), hook-file access, or nodeModule.register(...) fails transiently, the latch (hostResolutionHookRegistered) stays false, but the caller has no way to know the hook is missing — it continues importing plugins unhooked, which can resolve duplicate or missing effect/SDK modules and leave plugins marked as failed. Consider propagating the error to callers instead of swallowing it, so they can decide whether to abort or retry before activating plugins.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginModuleLoader.ts around line 144:

`ensureHostSingletonResolution` catches and logs every failure from `registerHook` at the `Effect.catchCause` block, then returns `void` as if registration succeeded. Callers like `PluginHost.start` proceed to `loadServerEntry` without the loader hook installed. When `import("node:module")`, hook-file access, or `nodeModule.register(...)` fails transiently, the latch (`hostResolutionHookRegistered`) stays `false`, but the caller has no way to know the hook is missing — it continues importing plugins unhooked, which can resolve duplicate or missing `effect`/SDK modules and leave plugins marked as failed. Consider propagating the error to callers instead of swallowing it, so they can decide whether to abort or retry before activating plugins.

transform: (stream) => {
const loadPlugins = listPlugins().pipe(
Effect.map((result) => result.plugins),
Effect.catchCause((cause) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium state/plugins.ts:47

environmentPluginListResultAtom catches every listPlugins() failure and returns EMPTY_PLUGIN_LIST, so a transient RPC error is indistinguishable from a legitimately empty plugin list. Downstream, pluginListAtom exposes that empty array as a successful refresh, which causes existing plugin UI registrations to be deleted as though no plugins are active. A single network failure during a refresh silently unloads all plugin-provided routes, settings pages, and commands until another lifecycle event happens to trigger a reload. Consider preserving the previous successful result on failure (or propagating the error) instead of substituting an empty list.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/state/plugins.ts around line 47:

`environmentPluginListResultAtom` catches every `listPlugins()` failure and returns `EMPTY_PLUGIN_LIST`, so a transient RPC error is indistinguishable from a legitimately empty plugin list. Downstream, `pluginListAtom` exposes that empty array as a successful refresh, which causes existing plugin UI registrations to be deleted as though no plugins are active. A single network failure during a refresh silently unloads all plugin-provided routes, settings pages, and commands until another lifecycle event happens to trigger a reload. Consider preserving the previous successful result on failure (or propagating the error) instead of substituting an empty list.

requireAbsolute("worktreePath", request.worktreePath).pipe(
Effect.flatMap((cwd) =>
input.git.createRef({
cwd,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium capabilities/VcsCapability.ts:163

createBranch forwards request.branch directly to input.git.createRef, which runs git branch <refName> without --end-of-options. When branch is --show-current, git prints the current branch and exits successfully without creating anything, yet this method returns success as if a branch were created. Consider inserting --end-of-options before the user-supplied ref name so option-like values are treated as branch names rather than git flags.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/capabilities/VcsCapability.ts around line 163:

`createBranch` forwards `request.branch` directly to `input.git.createRef`, which runs `git branch <refName>` without `--end-of-options`. When `branch` is `--show-current`, git prints the current branch and exits successfully without creating anything, yet this method returns success as if a branch were created. Consider inserting `--end-of-options` before the user-supplied ref name so option-like values are treated as branch names rather than git flags.

yield* Effect.logDebug("Accepting commands");
yield* commandGate.signalCommandReady;
yield* Effect.logDebug("startup phase: starting plugin host");
yield* runStartupPhase("plugins.start", pluginHost.start);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/serverRuntimeStartup.ts:436

pluginHost.start is awaited synchronously in the startup sequence, so any plugin that hangs during import, migration, or recover() blocks the startup fiber indefinitely. The server then never reaches http.wait, never publishes the ready event, and never opens the browser or prints headless access info, even though the HTTP listener is already listening. Consider forking pluginHost.start (like keybindings.start and serverSettings.start above) so plugin activation cannot block command readiness and the ready event.

Suggested change
yield* runStartupPhase("plugins.start", pluginHost.start);
yield* runStartupPhase("plugins.start", pluginHost.start.pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to start plugin host", { cause }),
),
Effect.forkScoped,
));
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverRuntimeStartup.ts around line 436:

`pluginHost.start` is awaited synchronously in the startup sequence, so any plugin that hangs during import, migration, or `recover()` blocks the startup fiber indefinitely. The server then never reaches `http.wait`, never publishes the `ready` event, and never opens the browser or prints headless access info, even though the HTTP listener is already listening. Consider forking `pluginHost.start` (like `keybindings.start` and `serverSettings.start` above) so plugin activation cannot block command readiness and the `ready` event.

}
}

const pluginsToLoad = activeWebPlugins.filter((plugin) => !state.loaded.has(plugin.id));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium plugins/PluginUiHost.tsx:163

Once a plugin's import or register fails, syncPluginUiHostRegistrations stores it in state.loaded with failure set, but pluginsToLoad filters out any plugin already present in state.loaded. On every later sync the same active plugin id/version is skipped, so the plugin's UI stays disabled until a full reload or version change even though the transient failure may have resolved. Consider filtering pluginsToLoad against successful loads only (e.g. skip entries where failure === null) so failed plugins are retried.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/plugins/PluginUiHost.tsx around line 163:

Once a plugin's import or `register` fails, `syncPluginUiHostRegistrations` stores it in `state.loaded` with `failure` set, but `pluginsToLoad` filters out any plugin already present in `state.loaded`. On every later sync the same active plugin id/version is skipped, so the plugin's UI stays disabled until a full reload or version change even though the transient failure may have resolved. Consider filtering `pluginsToLoad` against successful loads only (e.g. skip entries where `failure === null`) so failed plugins are retried.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant