feat: runtime plugin system — capability host, marketplace, install lifecycle (server + web)#3699
feat: runtime plugin system — capability host, marketplace, install lifecycle (server + web)#3699ccdwyer wants to merge 75 commits into
Conversation
Threads carry an owner ("user" by default, or "plugin:<id>") so future
runtime plugins can create system threads that stay out of user-facing
thread lists and relay publishes while remaining fetchable by id.
- Migration 033: projection_threads.owner TEXT NOT NULL DEFAULT 'user'
- ThreadCreatedPayload/OrchestrationThread gain optional owner
(decoding default "user"; wire-backward-compatible)
- Decider/projector/projection pipeline propagate owner
- ProjectionSnapshotQuery user-facing list/aggregate/count queries
filter owner = 'user'; id-keyed lookups intentionally unfiltered
- AgentAwarenessRelay skips publishing non-user threads
- Equivalence + exclusion tests across migration, projector, snapshot
query, and relay paths
Implemented by GPT-5.5 via codex exec (assembly-line slice 1).
Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
- getCommandReadModel now reads an unfiltered thread list: the decider must see plugin-owned threads or commands/events on them fail after a restart (Grok review MUST; getSnapshot stays owner-filtered) - Tighten ThreadOwner plugin-id grammar to the manifest id grammar (lowercase/digits/hyphens, 2-41 chars) - Pin encode direction: decoded legacy payloads re-encode with explicit owner "user" - Document relay behavior when the owner lookup returns no row - Revert unrelated projector.test.ts refactor to keep the diff reviewable Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Runtime plugin infrastructure for the server, behind a lockfile at <stateDir>/plugins/plugins.json. No transport or capability facades yet. - @t3tools/contracts/plugin: manifest schema (fail-closed), plugin id grammar, capability literals, hostApi version + range matcher, lockfile schema with staged-upgrade + safe-mode fields - @t3tools/plugin-sdk: definePlugin, PluginHostApi capability interfaces, registration descriptor types - apps/server/src/plugins/: lockfile store (atomic writes, in-process semaphore + advisory file lock), plugin migrator (per-plugin versioning in plugin_migrations, sqlite_master prefix gate incl. trigger/view body checks, downgrade refusal), module loader (containment-checked dynamic import, module.register resolve hook sharing the host effect instance), runtime registry, PluginHost lifecycle (pending-state application, crash-loop safe mode, hostApi gating, per-plugin Scope, T3_NO_PLUGINS) - Migration 034: plugin_migrations table - Host starts after signalCommandReady; zero-plugin boot is a single missing-lockfile check Implemented by GPT-5.5 via codex exec (assembly-line slice 2a-1). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Migration gate hardening (Claude + Grok reviews): - Detect DROPPED objects: a migration removing any object outside the plugin namespace is now a violation (previously invisible - the diff only inspected the after-snapshot) - Reject ATTACH DATABASE (PRAGMA database_list, ignoring built-in main/temp) with best-effort DETACH so a rogue attach cannot persist on the shared connection - Reject newly created TEMP objects (before/after sqlite_temp_master diff so pre-existing temp state cannot false-positive) - Empty migration list is a no-op, not a downgrade - Tests: drop-core, rename-out-of-namespace, ATTACH, TEMP, empty-list Also: - Loader resolves entries against the realpath'd plugin dir - APP_VERSION from package.json (was a hardcoded literal) - Service restart backoff capped at 30s (Schedule.either) Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Generic transport for runtime-plugin methods over the existing WS RPC stack; no management RPCs, web changes, or capability facades yet. - Contracts: plugins.list/call/subscribe in WsRpcGroup; PluginRpcError (not-found/not-ready/unauthorized/invalid-method/internal) and PluginInfo; new core scope plugins:manage; PluginScope pattern (plugin:<id>:read|operate) + satisfiesScope with the documented implicit-standard rule (a full standard-client grant set satisfies any plugin scope; restricted tokens need explicit grants) - Server: PluginRpcDispatcher enforcing per-plugin scope + readiness from registered descriptors (static RPC_REQUIRED_SCOPE entries stay orchestration:read as the transport baseline - two-layer check); defects contained and attributed, socket survives plugin bugs; ws.ts authorize helpers now use satisfiesScope; token-exchange, session store, and pairing grants accept AuthScope; public access-management DTOs keep exposing core scopes only - Client runtime: listPlugins/callPlugin/subscribePlugin helpers Implemented by GPT-5.5 via codex exec (assembly-line slice 2a-2). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
- Legacy-session marker rule (Claude review MUST): sessions persisted before plugins:manage joined AuthStandardClientScopes hold only the original five scopes; a frozen AuthStandardClientMarkerScopes bundle now drives the implicit rules so pre-upgrade standard clients keep plugin access and plugins:manage without re-pairing or migration. Tests cover legacy-bundle satisfaction, partial-marker rejection, and that the marker implies no unrelated core scopes. - Document the deliberate invalid-method/unauthorized error-order disclosure in PluginRpcDispatcher (Grok review SHOULD). Rejected review findings (evidence in debate dir): scope-filter sites are display-DTO-only (grant consumption reads persisted scopes unfiltered); the token-exchange allow-list is a strict superset of the old parser's (upstream never had workflow scopes). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Eight of the ten capability facades, each a curated wrapper over an existing core service, handed to plugins only when declared in the manifest (undeclared accessors keep the typed defect; agents/vcs stubs remain for the next slice). - database: execute/withTransaction (namespace by convention, documented) - secrets: get/set/delete/list under an enforced plugin:<id>: prefix - environments.read: environment id/descriptor + project reads - projections.read: thread/turn/message/activity reads, capped - textGeneration: one-shot generation - sourceControl: provider detection + the GitHub CLI operations that exist upstream (checks/reviews/merge omitted - no backing) - terminals: PTY spawn/observe/sendInput/kill - http: PluginHttpRegistry + /hooks/plugins/:pluginId/* route layer (:param matcher, public/token auth via satisfiesScope, body caps, attributed 500s, no-oracle 404s), routes registered at activation and removed on plugin scope close Implemented by GPT-5.5 via codex exec (assembly-line slice 2a-3a). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
- Secret names must match a safe path-segment grammar (Claude review): the backing store maps keys to file paths, so separators/colons/ traversal in a name are now rejected before they reach it - Plugin HTTP body read is incremental with a hard cap (Claude review): replaces buffer-then-check, bounding memory on public webhook routes; oversize -> 413, malformed -> 400 - Terminals capability tracks live sessions and closes them on plugin scope teardown (Grok review): a leaked PTY/process can no longer outlive its plugin on any exit path; finalizer registered before plugin code runs - Malformed percent-escapes in a route path degrade to no-match/404 instead of a caught defect -> 500 on a public route (Grok review) - Tests: invalid secret-name rejection, terminal leak-on-shutdown, decode-throw no-match, route removal on scope close Documented (not changed) for v1: matcher registration-order precedence, post-fetch projection caps, no HEAD/OPTIONS synthesis. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Completes the plugin capability surface. Both handed to plugins only when declared in the manifest. agents: built entirely on the orchestration command plane (OrchestrationEngineService.dispatch + streamDomainEvents + projection reads), never ProviderService, so plugin-driven turns are indistinguishable from core turns and inherit dedup, decider invariants, session resume, and approval routing. Security core: threads are created with owner "plugin:<id>" INJECTED by the host (never from plugin input), and every thread-scoped operation verifies ownership before it dispatches or reads. listInstances, createThread, startTurn (with owner-injected bootstrap create + best-effort rollback if turn-start fails), observeThread, awaitTurn (session-local turn handle, documented), listPendingRequests, respond/interrupt/stop/delete. vcs: Git + checkpoint facade over GitVcsDriver/CheckpointStore. Absolute-path validated; worktree create/remove/list; branch/commit; merge with conflicts returned as a value; working-tree and ref diffs; push; checkpoint create/has/restore/delete (the store has no list). Reviewed by Claude + Grok (SHIP); applied: alias-map pruning on terminal resolution, bootstrap-create rollback, and the session-local turn-handle docs. Implemented by GPT-5.5 via codex exec (assembly-line slice 2a-3b). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Lets a dynamically-imported plugin web bundle resolve the host's
singleton react / react-dom / effect / @effect/atom-react /
@t3tools/plugin-sdk-web and load same-origin. No UI host or plugin
routes yet; zero-plugin behavior is unchanged.
- PluginWebRoutes: serves /plugins/:id/:version/{web,assets}/* from the
plugin dir (per-segment decode, ../sep/null rejection, web|assets
allowlist, double-realpath containment, lockfile version gate,
immutable cache, nosniff) and /plugin-host/*.js shim modules
- shared/pluginHostWeb: the import map + data-driven shim generation
from static export-name manifests, and an idempotent index.html head
injection; http.ts injects into both static branches (index.html
only), a Vite transformIndexHtml gives dev parity
- apps/web publishes the singletons on globalThis.__T3_PLUGIN_HOST__ as
the first statement of main.tsx, exposes whenPluginHostReady
- @t3tools/plugin-sdk-web: barrel re-exporting the design system,
ChatMarkdown, the pickers, and the atom adapter, built with react and
effect external
Reviewed by Claude + Grok. Applied: a shim-export drift guard that
diffs every shim list against the real host singleton object, an
all-modules shim-identity test, and prominent docs that plugin web
bundles must import effect from the barrel (subpaths are not in the
import map - a deliberate v1 constraint).
Implemented by GPT-5.5 via codex exec (assembly-line slice 2b-1).
Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Whitespace-only; applies the repo formatter to the hand-applied slice-2a-3b review fixes. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Consumes the web plumbing to dynamically load each active plugin's web bundle, hand it a UI registration context, and render its routes, sidebar sections, and settings pages. The plugin list refreshes live on a new plugins.stateChanged lifecycle event. No marketplace/install UI yet; zero-plugin behavior is unchanged. - PluginUiHost: after whenPluginHostReady, imports active hasWeb plugins and runs their register(ctx); per-plugin failure is contained (shell survives, plugin marked failed); a single-flight guard prevents overlapping syncs from double-importing; inactive plugins are pruned from the renderable registry - state/plugins: pluginListAtom off subscribeServerLifecycle, refreshing on plugins.stateChanged; per-plugin pluginRpc (call/subscribe) - server: PluginHost publishes plugins.stateChanged on active/failed/ disabled transitions via the lifecycle event stream - splat routes for plugin app pages and settings pages; PluginSidebar Sections (renders nothing when no plugins); extensible settings nav - plugin-sdk-web: defineWebPlugin + the registration context types Reviewed by Claude + Grok. MUST fixed: registered components are rendered with createElement (own React fiber), not called as functions - a function call ran plugin hooks on the host route's fiber and broke the Rules of Hooks. SHOULD fixed: single-flight sync guard. Implemented by GPT-5.5 via codex exec (assembly-line slice 2b-2). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
The server side of the marketplace: fetch indexes and run the full install/upgrade/uninstall/enable lifecycle as compile-time management RPCs under the plugins:manage scope. No web UI yet. - PluginMarketplace: resolve + fetch + parse marketplace.json (HTTPS or owner/repo shorthand; file:// only under T3_PLUGIN_DEV), cached, byte capped, per-source error isolation; tarball URL scheme gated the same way before any download - PluginInstaller: download -> verify sha256 -> safe-extract (hand- written gunzip + tar parser, no new dependency) -> validate manifest -> stage under a TTL token -> confirm moves atomically and hot- activates; upgrade stages pending-upgrade; uninstall stages pending-remove; setEnabled; checkUpdates - PluginManagementRpcHandlers wired into ws.ts (all plugins:manage) + server.ts layers; client-runtime typed helpers - Tar extraction rejects traversal, absolute paths, links, unsupported entries, oversize, and decompression bombs; malicious-tar tests Reviewed by Claude + Grok. MUST fixes: streaming gunzip with a hard output cap so a decompression bomb is aborted mid-inflate instead of OOMing; remove any pre-existing destination before the staging rename and clean up the stage token on a failed confirm. SHOULD fix: id collision is checked against the real DB table prefix (p_<id-with-underscores>_) shared with the migrator, so distinct ids like chat/chatbot are no longer falsely rejected. Implemented by GPT-5.5 via codex exec (assembly-line slice 2c-1). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Completes the plugin system: a user-facing marketplace, an in-repo fixture that exercises the whole stack end to end, and an authoring guide. - Plugins settings page: add/remove sources, browse catalogs, install with a capability-consent dialog (shows the host-owned capability descriptions before confirm; cancel aborts the staged token), and an installed list with enable/disable, uninstall (with remove-data), and check-for-updates/upgrade; pending states prompt a relaunch - Management-command atoms over the existing plugins:manage RPCs; the installed list stays driven by pluginListAtom (live refresh) - fixtures/hello-board: a full-stack fixture plugin (database migration + RPC on the server, a route + sidebar section on the web) with an esbuild build producing a tarball + sha256 + marketplace.json - Server integration test: add file:// source -> catalog -> install -> activate -> RPC round-trip -> asserts the p_hello_board_notes table, with every non-declared capability mocked to fail - docs/plugins.md authoring guide Reviewed by Claude + Grok (SHIP, no blockers): consent flow is abort-safe, the test symlink harness is isolated from the production loader, and the component-rendering fiber fix is preserved. Implemented by GPT-5.5 via codex exec (assembly-line slice 2c-2). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Extends the plugin capability set so a plugin can read/write its project workspace and make outbound HTTPS requests - the two gaps found when assessing the workflows-plugin conversion. Also surfaces plugin commands in the host command palette. - filesystem: workspace-scoped, realpath-contained to project workspace roots (read live) + worktrees the plugin creates via vcs (tracked in a per-plugin PluginWorkspaceGrants holder; granted after createWorktree, revoked after removeWorktree). Enforcement: checks strictly precede mutation, writes go through O_NOFOLLOW to a target built from the validated real parent (O_EXCL|O_NOFOLLOW for exclusive create), remove is no-follow, rename is same-root/no-overwrite, and plugin-facing error messages carry only the supplied root/relativePath. Full surface: read/write/capped-read/exists/stat/list/recursive-list/mkdir/remove/ rename/listRoots + an SDK writeFileAtomic helper. - httpClient: HTTPS-only outbound via the lifted OutboundUrlValidator (dns.lookup all-addresses, full special-use + metadata blocks, IPv4- mapped/NAT64 forms) with the connection pinned to a validated address (custom Node lookup) to close DNS rebinding; redirects surfaced not followed; response + request-body + timeout caps; header control-char rejection. http:// only to loopback under T3_PLUGIN_DEV. - Command palette contributes a plugins group from the plugin command registry; a throwing command is contained; zero-plugin palette unchanged. - Manifest gating + consent descriptions; fixture declares and exercises both capabilities in the server integration test. Reviewed by Claude + Grok (SHIP, no MUSTs; the 4 spec security MUSTs verified implemented). Applied Grok SHOULDs: request-body size cap and header CRLF sanitization. Implemented by GPT-5.5 via codex exec (assembly-line, workflows-plugin sub-project 0). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Fixture-style plugin package mirroring fixtures/hello-board: manifest (capabilities ["database"], server-only entry), server-only build.mjs, definePlugin acquiring the database capability and registering the schema migration. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…on (slice A1a) Ports the fork's consolidated workflow schema (033_WorkflowSchema.ts) into the plugin's own PluginMigration: 24 tables + 24 indexes, every object renamed to the migrator-required prefix p_workflow_boards_. Drops workflow_notification_outbox (notifications deferred, v1) and the core-owned projection_threads ALTER; folds the 4 dispatch_outbox ALTER columns inline. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…(slice A1a) Real end-to-end install (build tarball -> file source -> beginInstall -> confirmInstall -> activate -> migrate) asserting the 24 namespaced tables + 24 indexes, zero legacy names, exact per-table column counts, critical constraints (folded dispatch_outbox columns, key DEFAULT/UNIQUE, partial pr_state index), and the recorded plugin_migrations row. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…nabler) Additive: plugins whose ported code uses tagged-template SQL / composable fragments / withTransaction get the raw client. Grants no new power (execute already allows arbitrary SQL under the full-trust runtime; the p_<id>_ gate is migration-time only). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Copies the fork's pure-Schema contracts/workflow.ts, retargets its two base imports to @t3tools/contracts, adds @t3tools/contracts + @t3tools/shared + json-logic-js deps (all genuinely imported by the ported core). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…lice A1b) Event store, projection pipeline, read model, committer + supporting services (BoardRegistry/PredicateEvaluator/SaveLocks/Ids/BoardEvents) ported onto p_workflow_boards_* via the RENAME_MAP. NotificationSink no-op seam replaces the notification-outbox enqueue (gate byte-identical to fork); the two read-model notification-outbox DELETEs removed. WorkflowCoreLive provides SqlClient from database.client. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…ema + NotificationSink fake (slice A1b) The fork's ~6k lines of core tests ported: harness swapped to run the plugin's own migration on in-memory SQLite, all SQL literals renamed, and the committer's notification-outbox assertions rewritten against a capturing NotificationSink fake (one notify per needs-you transition). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…d getMessageById (A2.0) Additive, back-compat: startTurn accepts optional caller-supplied messageId/commandId (omitted => generated as before), giving idempotent dispatch via the orchestration command-receipt dedup. projections.read gains getMessageById. Enables the plugin turn loop to key durability on the caller messageId (=> pendingMessageId on the projected turn). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…urable key) The durable dispatch key is the caller messageId (=> pendingMessageId); add a message_id column to p_workflow_boards_dispatch_outbox (final-shape edit of the initial migration, pre-distribution). Install test column count 17 -> 18. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…rve/recover on agents capability (A2 crux) Rebuilds the agent turn loop on the agents capability, replacing ProviderDispatchOutbox + TurnStateReader + ProviderResponsePort + CapturedStepOutputReader. Dispatch state machine reserved->start_requested->projected->terminal keyed on the durable messageId (persist-before-dispatch, crash-safe; deterministic commandId => idempotent re-dispatch). Event-driven observe (awaitTurn / re-observe after restart, 30m timeout); turn-correlated captured output (pendingMessageId->assistantMessageId); ported pending-request stale/ resolved exclusion; recovery re-correlates on pendingMessageId with a projection-lag budget and terminal-first settlement. ELIMINATES both direct UPDATE projection_turns core-table writes (stranded turns => plugin-authoritative state + best-effort interrupt). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…ability (A2) 13 tests over a faithful in-memory fake agents/projections capability: happy path, crash-window idempotency, restart-resume (re-observe), projection-lag resume vs abandon-after-budget, two-turns-on-one-thread correlation, approval/user-input parity, terminal-first vs stale pending, fail-closed pending reads, stranded handling. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…re filesystem capability (A3a support) isPendingRequestLive lets the engine confirm a provider user-input request is still live before responding (restores the fork's answer-liveness guard on the A2 port). The plugin now declares the filesystem capability (recovery reads board files through it, not raw effect/FileSystem). Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…b/A2 (A3a engine core) WorkflowEngine (admission/WIP/routing/transitions/dependencies/orchestration) + WorkflowRecovery + trivial deps (ApprovalGate/ScriptCancelRegistry/RoutingContextBuilder/ StepUsageReader) + StepExecutor seam + StubStepExecutor + the engine's unlocked-ops driver (WorkflowSourceCommitter) + service-interface closure. Provider-service call sites rewritten onto WorkflowAgentPort (messageId-keyed); DurableApprovalResume is approval-only; recovery drops both projection_turns core writes and completes terminal-turn steps after recoverPending (crash-window fix). Recovery board reads go through the filesystem capability. A3b supplies the real StepExecutor + step-type impls. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…ites (A3a) ~9k LOC of engine + recovery tests ported onto the A1b in-memory harness + StubStepExecutor + fake WorkflowAgentPort/merge/PR ports, all SQL literals renamed. Re-homed the recovery terminal-dispatch-completion test (proves the crash-window fix), preload/stranded/script-run recovery, and the engine-command cases (unknown-ticket move, answer validation, message edit, blocked/failed routing) that the first port pass had dropped. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
- auth: honor implicit scope satisfaction (satisfiesScope) instead of verbatim membership in the pairing-credential delegation check and the bootstrap→ access-token exchange, so plugin scopes / plugins:manage held implicitly by a standard-client grant/session are accepted (administrative scopes still need explicit membership). - marketplace: strip embedded credentials from canonical source URLs so https://user:pw@host isn't persisted in the lockfile or echoed via listSources. - lockfile: re-check the advisory lock's mtime immediately before reclaiming a stale lock, so a concurrently-refreshed lock isn't clobbered. - management: run removeSource's used-by check inside the lockfile single-writer critical section. - host: clear activatingSince on successful activation (preserving crashCount accumulation), so a benign restart within the healthy window isn't counted as an interrupted activation. - migrator: clarify the p_<id>_* schema-object gate is a convention, not a data-mutation sandbox (full-trust model). Verified: root typecheck + auth/plugins suites (163) green. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
…#3699) Plugin lifecycle (PluginHost): - loadPlugin now removes the registry entry on activation failure, so a closed-scope plugin with an unresolved readiness Deferred is no longer reported active. - updateFailure only flips "active" → "failed"; a concurrently-requested disable/uninstall/upgrade is preserved (and markFailure publishes the state actually persisted). - resolveRegistration lets fiber interruption propagate instead of persisting a spurious "failed" on clean shutdown. - staged-upgrade promotion resets activation health (crashCount 0, lastError null) so a new build doesn't inherit the old one's crash count. Install lifecycle (PluginInstaller): - confirmInstall/confirmUpgrade remove the moved version dir if the lockfile write fails before the entry is committed (armed only for that window; never a pre-existing dir). - uninstall now deactivates the running runtime. Marketplace / lockfile: - findVersion wraps resolveTarballUrl in Effect.try so a bad tarball URL is a typed failure, not a defect. - source dedupe compares canonical URLs so a stripped re-add matches a legacy credentialed row. - stale-lock acquire tolerates NotFound on the age-check stat (lock freed meanwhile) and retries instead of failing. Left for a human decision: surfacing plugin scopes in the pairing-link auth UI requires widening the AuthPairingLink contract + pairingLinkUpserted event (env-scopes → full scopes) across server + client-runtime + web/mobile — the consumption/security path already reads full scopes, only the display filters. Verified: root typecheck + plugins/auth suites (166) green. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
There was a problem hiding this comment.
One Effect service convention finding on the reviewed changes. See inline comment.
Posted via Macroscope — Effect Service Conventions
| new PluginRegistrationError({ | ||
| pluginId, | ||
| detail: Cause.pretty(cause), | ||
| }), |
There was a problem hiding this comment.
When wrapping a real register() failure, PluginRegistrationError discards the underlying error: detail is set to Cause.pretty(cause) and message (line 92) is then built from that stringified cause. Per the error conventions, the immediate failure should be preserved as cause and the wrapper message derived only from stable structural attributes—not from a stringified/pretty-printed cause. Cause.pretty also copies unbounded defect/stack text into a caller-visible field.
Every other error in this package (e.g. PluginModuleLoadError, PluginLockfileLockError, PluginLockfileWriteError) already carries cause: Schema.Defect(). Add a cause field to PluginRegistrationError and construct it here with the real cause plus a stable detail (e.g. "plugin register() failed"), leaving detail for the pure validation constructions.
Posted via Macroscope — Effect Service Conventions
…rship, typed capability failure, plugin scope surfacing Server host / lifecycle: - loadPlugin no longer persists "failed" on interrupt-only teardown (clean shutdown / concurrent lifecycle change); shared handleLoadFailureCause across the hot-activation and pre-scope start paths, matching the activation-exit branch - Re-check lifecycle state immediately before registry.put; abort via interrupt so a concurrently disabled/uninstalled plugin's runtime never goes live - Bound plugin-controlled register()/recover() with a 30s timeout (T3_PLUGIN_HOST_REGISTER_TIMEOUT_MS) so a hung plugin fails the normal way - deactivatePlugin publishes the actually-persisted state (pending-remove vs disabled) instead of a hardcoded "disabled" - unavailable() capability access is now a typed Effect.fail(PluginCapabilityUnavailable) (was Effect.die) so plugins can catch/degrade instead of crashing on a defect Install / lockfile: - beginUpgrade rejects a same-version upgrade before staging, so the destructive move can never run against the live version dir - stageTarball decompress failure now cleans the staging dir (was leaked) - Advisory lock carries a per-holder pid:uuid owner token; the finalizer only removes a lock still carrying its own token, so a reclaimer's valid lock is never clobbered by a slow prior holder - addSource rewrites a matched legacy credentialed source URL to the stripped form Agents capability: - observeThread subscribes the filtered live event stream into a bounded queue BEFORE reading the snapshot, then emits snapshot + events filtered by sequence > snapshotSequence — closes the snapshot→live gap (residual documented) Auth (plugin scope surfacing, display-only): - AuthPairingLink.scopes widened AuthEnvironmentScopes → AuthScopes, propagating to the pairingLinkUpserted event + AuthAccessSnapshot.pairingLinks - PairingGrantStore list/emit views pass plugin scopes through instead of filtering (consumption/grant path untouched); web AccessScopeSummary renders plugin:<id>:* verbatim Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
|
Thanks for the continued review — round-3 fixes pushed in Lifecycle / interrupt-safety
Install / lockfile
Capability API
Pairing-link plugin scopes (display-only)
All green: full 18-package typecheck, |
…precedence, lock/alias leaks, ownership semantics Server host / lifecycle: - start's per-plugin loop no longer aborts on one plugin's interrupt-only cause (e.g. the pre-put state re-check firing Effect.interrupt); it logs and continues so remaining plugins still activate. A real host-shutdown interrupt still stops the loop via re-interrupt + the trailing ignoreCause. [High] - Successful activation publishes the actually-persisted lockfile state instead of a hardcoded "active", so a concurrent disable/uninstall (deactivate running after registry.put but before the publish) isn't contradicted. Install: - compareSemver replaced with a semver.org §11-correct comparator: numeric core, release outranks prerelease, prerelease identifiers compared numerically / ASCII with numeric ranking below alpha, longer identifier set wins on tie, build metadata ignored. Fixes checkUpdates advertising the wrong "latest" (e.g. 1.0.0-rc.10 vs rc.2, rc.1 vs 1.0.0). Exported for unit testing. Lockfile: - openLock removes the freshly-created lock file if writeAll/sync fails or is interrupted mid-write, so a partial-write (ENOSPC/IO) can't orphan a lock that blocks all mutations until STALE_LOCK_MS. Agents capability: - requireOwnedThread now fails AgentsThreadNotFoundError for a missing thread and AgentsThreadOwnershipError only for a real owner mismatch (guard unchanged: only an owned-by-us thread passes), so callers' not-found branches are reachable. - startTurn, when it creates the thread itself, forwards the bootstrap with only createThread stripped (prepareWorktree/runSetupScript preserved) instead of dropping the whole object, so the first turn still gets its worktree/setup prep. - A failed turn-start always drops its pending turnAliases entry (previously leaked for an existing thread), rolling back a self-created thread only when applicable. - observeThread reads snapshotSequence before the thread detail (sequentially, not concurrently) so the global dedup threshold never exceeds what the detail reflects — no dropped updates; a bounded in-window duplicate is idempotent on the revision-gated client (deliberate at-least-once; exact dedup needs a per-thread snapshot sequence getThreadDetailById doesn't expose). turnAliases is now an injectable (defaulted) parameter of makeAgentsCapability so a failed-start leak is deterministically assertable; production is unaffected. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
|
Round-4 fixes pushed in Lifecycle
Install / lockfile
Agents capability
|
…ctivatingSince clear, bounded turnAliases Interrupt/cancel handling (PluginHost): - Introduced an internal typed sentinel PluginActivationCanceled so a self-cancel (the pre-put lifecycle re-check aborting activation when a concurrent disable/uninstall flipped the lockfile) is distinguishable from a genuine fiber interruption (host shutdown). The pre-put re-check now fails with the sentinel instead of Effect.interrupt. - Interrupt-only teardown AND sentinel-cancel teardown now clear activation.activatingSince (preserving state/crashCount/lastError) — a clean cancellation is not a crash, so reconcile on the next start no longer miscounts it, bumps crashCount, and eventually forces "failed". [both bots] - The start per-plugin loop continues on a sentinel-cancel (remaining plugins still activate) but routes everything else through handleLoadFailureCause, which RE-RAISES a genuine interrupt-only cause so it propagates out of the loop and the trailing ignoreCause ends start promptly on shutdown instead of plodding through the rest of the plugins. [Cursor] - handleLoadFailureCause gains the sentinel disposition (log + swallow, no markFailure) alongside interrupt-only (re-raise) and genuine error (markFailure). Agents capability: - turnAliases now bounded by a generous FIFO cap (maxTurnAliases=4096, injectable for tests). Normal turns prune on terminal read well before the cap, so eviction only affects long-abandoned turns started but never awaited — bounding what was previously unbounded growth over the plugin process lifetime. [Macroscope] Tests: cancelled-activation clears the marker without a crash bump (mutation-verified it fails without the clear); sentinel-cancel continues the start loop; a genuine register() interrupt stops the loop; turnAliases FIFO eviction under a small cap. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
|
Round-5 fixes pushed in All three findings trace back to the interrupt handling from the prior rounds, and the root cause of the first two is the same:
Cancel/interrupt matrix now: (a) concurrent disable during activation → sentinel → marker cleared, no |
There was a problem hiding this comment.
Effect service review: one error-modeling convention violation found in PluginLockfileStore.ts.
Posted via Macroscope — Effect Service Conventions
…stic idempotent turn ids, structural lockfile error message Interrupt/cancel handling (PluginHost) — mixed-cause bug from round 5: - Scope.close can produce a MIXED cause combining the PluginActivationCanceled sentinel with a finalizer/teardown error or a fiber interrupt. The round-5 "contains the sentinel" check folded those into the benign-cancel branch, silently dropping a real teardown failure and masking a shutdown interrupt. - Replaced with two exported predicates: causeIsActivationCanceledOnly (every reason is the sentinel — strict) and causeContainsInterrupt (Cause.hasInterrupts — contains any interrupt). All three ladders now check interrupt-containing FIRST (re-raise so shutdown stops promptly, even when mixed), then exclusively-sentinel (benign cancel), then genuine error INCLUDING the sentinel combined with a teardown error (persist "failed" — never dropped). Agents capability — idempotent-retry alias leak: - The engine dedups dispatch by commandId, so a startTurn retry with the same caller-supplied commandId persists no new turn, but the old code returned fresh random turnId/messageId and aliased to a messageId that was never persisted → a later awaitTurn could not correlate and timed out. When a commandId is supplied, turnId/messageId are now derived deterministically from it (sha256, opaque, same id prefixes) so a retry resolves the same alias the first call persisted. The no-commandId path is unchanged. Lockfile: - PluginLockfileCorruptError.message now derives from the stable path only (was echoing String(cause), which could leak corrupt lockfile contents); the underlying failure is still preserved on cause. Dropped the redundant detail field, matching the sibling Read/Write/Lock errors. Tests: unit tests over hand-built causes lock the only-vs-contains + interrupt-wins semantics; a same-commandId retry returns identical ids and correlates (real-engine dedup leaves one turn whose pendingMessageId == the returned messageId); the corrupt error message excludes the raw cause text. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
|
Round-6 fixes pushed in
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 67ca9e8. Configure here.
There was a problem hiding this comment.
One convention finding in the plugin HTTP client capability. See inline comment.
Posted via Macroscope — Effect Service Conventions
… lockfile read failure, awaitTurn race, retry-alias reuse, stable http error PluginHost — activation concurrency (both High): - activatePlugin is now single-flight per pluginId: a per-plugin single-permit semaphore (get-or-created atomically under a SynchronizedRef of pluginId→Semaphore) wraps the body, so two concurrent calls no longer both loadPlugin and leak the first runtime's scope (forked services / terminal finalizers / HTTP routes). The registry.get double-check stays inside the lock; deactivatePlugin shares the same lock so activate/deactivate for one plugin can't interleave (no nesting → no deadlock). - activatePlugin no longer swallows a lockfile read/parse failure into an empty lockfile (which made it silently succeed without loading — callers treated a failed install/enable as done). The error now propagates; the signature is widened and PluginInstaller.setEnabled maps it to an activation-failed management error. A genuinely missing lockfile is still EMPTY_PLUGIN_LOCKFILE via readLockfile. Agents capability: - awaitTerminalTurn: forkScoped returns before the watcher subscribes, so a turn that reached terminal in that gap produced no waking event and awaitTurn hung to the timeout. Now races the event-driven wake against a bounded re-poll (TERMINAL_POLL_INTERVAL) so a missed event still makes progress; the outer timeoutOption still bounds it. - startTurn idempotent-retry (follow-up to round 6): a retry with the same commandId but no messageId derived a fresh id and overwrote the alias, breaking correlation when the first call supplied an explicit messageId. It now reuses the messageId the first call persisted (found via the deterministic turnId's existing alias). Http client: - HttpClientError transport-failure message now uses a stable reason instead of echoing cause.message; the underlying error stays on data.cause. Tests: single-flight (concurrent activate loads once), read-failure propagation, awaitTurn resolves via re-poll under TestClock, retry reuses the first messageId, stable http reason. Claude-Session: https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
|
Round-7 fixes pushed in Activation concurrency (both High):
Agents capability:
Conventions: A few older Bugbot threads re-surfaced this round — these are already addressed in the current tree (they date from the pre-round-1
|
| ) | ||
| .pipe(Effect.mapError(lockfileError)); | ||
| orphanedVersionDir = null; | ||
| yield* dropStage(stageToken); |
There was a problem hiding this comment.
🟡 Medium plugins/PluginInstaller.ts:824
In confirmInstall, the lockfile entry is committed with state: "active" before host.activatePlugin() runs. When activatePlugin fails, the error is mapped to activation-failed and returned, but the lockfile entry is left in the active state even though no runtime was started — the plugin is recorded as running when it is not. Consider updating the lockfile entry to reflect the activation failure (for example, transitioning to an inactive/error state) before returning the error.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/plugins/PluginInstaller.ts around line 824:
In `confirmInstall`, the lockfile entry is committed with `state: "active"` before `host.activatePlugin()` runs. When `activatePlugin` fails, the error is mapped to `activation-failed` and returned, but the lockfile entry is left in the `active` state even though no runtime was started — the plugin is recorded as running when it is not. Consider updating the lockfile entry to reflect the activation failure (for example, transitioning to an inactive/error state) before returning the error.
|
Superseded by an 8-PR stacked series — the same runtime plugin system, re-sliced into small, dependency-ordered, individually-green PRs for easier review. Before submission each slice was re-reviewed by a four-model tribunal (GPT-5.5, Grok, GLM-5.2, Fable) that traced execution rather than the diff — surfacing a batch of real issues (SSRF-via-redirect, a stderr credential leak, a shell-stream visibility leak, an
Merge bottom-up (start with #3725). Thanks for the 7 rounds of Bugbot/Macroscope review here — every finding is carried forward into the stack. |

Summary
Adds a runtime plugin system to t3code — install third-party features (server + web/Electron) at runtime from a marketplace, sandboxed behind curated, capability-scoped host APIs. No fork required to extend the app.
A plugin is a signed tarball with a manifest declaring the capabilities it needs. The host serves its web bundle same-origin behind an import map (sharing React/Effect/atoms as singletons), runs its server module in an isolated Effect scope, and exposes only the host APIs the manifest declared — validated at install time with an explicit consent step.
What's included
Built as reviewable slices (happy to land these as a stacked series if you'd prefer — see "Splitting" below):
projection_threads.owner(defaultuser), so plugin-created agent threads are attributable and filterable without disturbing existing flows.plugins.call/subscribe) with aplugins:managescope, and plugin-aware auth scopes bound atomically to each method.database(plugin-prefixed tables gated at migration time),filesystem(workspace-scoped, realpath-contained),httpClient(HTTPS-only, DNS-pinned SSRF guard),secrets,vcs,sourceControl,agents(owner-injected, built on the orchestration engine — never raw provider access),terminals,projections.read,environments.read,textGeneration. Nothing raw is handed out; each façade is a narrow, auditable surface.PluginUiHostloads/renders plugin web bundles; plugins can contribute routes, sidebar sections, settings pages, command-palette entries, and per-project sidebar actions. Plugins ship their own compiled CSS isolated in a lowest-priority cascade layer.hello-boardplugin plus a real install-and-drive integration test that installs it through the real installer and round-trips an RPC.Real-world example plugin
The plugin APIs are proven by porting a substantial existing feature — a board-as-state-machine workflow engine — into a standalone installable plugin with 46 RPC methods (boards, tickets, agent-driven lanes, work-source sync, webhooks, self-improve proposals), built entirely on the capability APIs above:
👉 https://github.com/ccdwyer/workflow-boards-plugin
It's distributed separately (not in this PR) so the core stays generic; it's the reference for what these APIs can carry. Two capability methods here exist to serve it and are generic-additive in the same spirit as the rest:
vcs.diffRefToWorkingTree(diff an arbitrary base ref against the working tree) andtextGeneration.generateBoardProposal(structured generation under a per-provider no-tool posture — the model provably cannot run tools or write files).Testing
HelloBoardFixtureinstall-and-drive integration test exercises the real module loader → runtime build → RPC dispatch path.Splitting
This is a large PR because a plugin system touches many layers at once. If you'd rather review incrementally, I can restructure it as a stack: (1) thread ownership → (2) server host + transport + scopes + capabilities → (3) web host + extension points → (4) marketplace + install + fixture. Just say the word.
Notes
main.https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk
Note
High Risk
Changes authentication scope satisfaction and token exchange, adds a full-trust in-process plugin host with outbound HTTP, and alters thread visibility in projections—security- and data-visibility sensitive areas.
Overview
Introduces a server-side plugin runtime wired to
@t3tools/plugin-sdk: derivedpluginsDir, lockfile/catalog/host lifecycle (activate, migrations, capability gating, crash/safe-mode behavior), HTTP route registry, outbound SSRF validation for plugin HTTP clients, and a hello-board install-and-RPC integration test.Auth moves from environment-only scopes to
AuthScope(core +plugin:<id>:read|operate+plugins:manage). Token exchange and pairing delegation usesatisfiesScopeso full standard-client grants implicitly satisfy plugin scopes without listing them verbatim; pairing links and sessions persist the wider scope set.Threads gain an
ownercolumn (defaultuser, migration 033). Plugin-owned threads (plugin:…) stay in the command read model but are hidden from user-facing projections (counts, shell snapshots, startup thread pick) while remaining reachable by id.Static
index.htmlresponses inject plugin host head HTML for shared web singletons. DB migration 034 addsplugin_migrationstracking.Reviewed by Cursor Bugbot for commit b2e4df3. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add runtime plugin system with capability host, marketplace, and install lifecycle
PluginHost), runtime registry, lockfile store, catalog, marketplace, installer, module loader with ESM singleton hooks, RPC dispatcher, HTTP route serving, and per-plugin SQLite migrations@t3tools/plugin-sdk(server) and@t3tools/plugin-sdk-web(web) package exposing plugin capabilities: agents, VCS, terminals, database, filesystem, HTTP client, secrets, environments, source control, and text generationlist,call,subscribe,installBegin/Confirm/Abort,setEnabled,uninstall,upgradeBegin/Confirm,checkUpdates, source CRUD) with scope-based authorization via the newsatisfiesScopehelperplugins:manageand per-pluginplugin:<id>:{read|operate}scopes; standard-client token holders implicitly satisfy plugin scopes without explicit enumerationindex.html, dynamic route and settings rendering, sidebar sections, project actions, and command palette integrationownerfield (migration 033) so plugin-owned threads are excluded from user-facing lists and agent awareness relaygenerateBoardProposalto all text-generation providers, running Claude/Codex in a strict no-tool posture from a scoped temp directory; Cursor and Grok explicitly reject the operationnode:module.register) is best-effort and emits a warning when unavailable rather than failing hardMacroscope summarized b2e4df3.