Skip to content

feat: runtime plugin system — capability host, marketplace, install lifecycle (server + web)#3699

Closed
ccdwyer wants to merge 75 commits into
pingdotgg:mainfrom
ccdwyer:feat/thread-owner
Closed

feat: runtime plugin system — capability host, marketplace, install lifecycle (server + web)#3699
ccdwyer wants to merge 75 commits into
pingdotgg:mainfrom
ccdwyer:feat/thread-owner

Conversation

@ccdwyer

@ccdwyer ccdwyer commented Jul 5, 2026

Copy link
Copy Markdown

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):

  1. Thread ownershipprojection_threads.owner (default user), so plugin-created agent threads are attributable and filterable without disturbing existing flows.
  2. Server host + transport + scopes — per-plugin lifecycle (verify → import → register → migrate → recover → mount), a WS RPC envelope (plugins.call/subscribe) with a plugins:manage scope, and plugin-aware auth scopes bound atomically to each method.
  3. Curated capability façadesdatabase (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.
  4. Web host + extension pointsPluginUiHost loads/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.
  5. Marketplace + install lifecycle — sources, catalog browse, install-consent dialog (shows every requested capability), install/enable/disable/uninstall/upgrade, streaming-safe download with sha256 + gzip-bomb protection, single-writer lockfile, safe-mode crash counter.
  6. Example fixture — an in-repo hello-board plugin 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) and textGeneration.generateBoardProposal (structured generation under a per-provider no-tool posture — the model provably cannot run tools or write files).

Testing

  • Unit tests across capabilities, transport, scopes, install lifecycle, and the no-tool text-generation postures.
  • The in-repo HelloBoardFixture install-and-drive integration test exercises the real module loader → runtime build → RPC dispatch path.
  • The example plugin was separately verified end-to-end against the real runtime (all 46 methods installed + dispatched), and the no-tool board-proposal path confirmed against a real model.

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

  • Full-trust-in-process is the security model: the façades defend a well-behaved plugin against malicious data (traversal, SSRF, prompt-only egress), not a sandbox against malicious plugin code — consistent with a curated marketplace + install consent. Documented inline.
  • Rebased onto latest 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: derived pluginsDir, 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 use satisfiesScope so full standard-client grants implicitly satisfy plugin scopes without listing them verbatim; pairing links and sessions persist the wider scope set.

Threads gain an owner column (default user, 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.html responses inject plugin host head HTML for shared web singletons. DB migration 034 adds plugin_migrations tracking.

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

  • Introduces a full plugin subsystem on the server: host 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
  • Adds a @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 generation
  • Exposes plugin management over WebSocket RPC (list, call, subscribe, installBegin/Confirm/Abort, setEnabled, uninstall, upgradeBegin/Confirm, checkUpdates, source CRUD) with scope-based authorization via the new satisfiesScope helper
  • Extends the auth scope model with plugins:manage and per-plugin plugin:<id>:{read|operate} scopes; standard-client token holders implicitly satisfy plugin scopes without explicit enumeration
  • Adds plugin UI hosting in the web app: import-map injection into index.html, dynamic route and settings rendering, sidebar sections, project actions, and command palette integration
  • Introduces thread owner field (migration 033) so plugin-owned threads are excluded from user-facing lists and agent awareness relay
  • Adds generateBoardProposal to all text-generation providers, running Claude/Codex in a strict no-tool posture from a scoped temp directory; Cursor and Grok explicitly reject the operation
  • Risk: large surface area added in a single PR; ESM loader hook registration (node:module.register) is best-effort and emits a warning when unavailable rather than failing hard

Macroscope summarized b2e4df3.

ccdwyer added 30 commits July 5, 2026 05:17
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
Comment thread apps/server/src/plugins/PluginMarketplace.ts
Comment thread apps/server/src/plugins/PluginLockfileStore.ts Outdated
…#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

@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 finding on the reviewed changes. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment on lines +136 to +139
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 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

Comment thread apps/server/src/plugins/PluginHost.ts
Comment thread apps/server/src/plugins/PluginHost.ts
Comment thread apps/server/src/plugins/PluginHost.ts Outdated
Comment thread apps/server/src/plugins/PluginHost.ts
Comment thread apps/server/src/plugins/PluginManagementRpcHandlers.ts Outdated
Comment thread apps/server/src/plugins/PluginLockfileStore.ts Outdated
Comment thread apps/server/src/plugins/PluginInstaller.ts Outdated
Comment thread apps/server/src/plugins/PluginInstaller.ts
…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
@ccdwyer

ccdwyer commented Jul 5, 2026

Copy link
Copy Markdown
Author

Thanks for the continued review — round-3 fixes pushed in b416af370. Mapping each item to its change:

Lifecycle / interrupt-safety

  • Same-version upgrade destroyed the live pluginbeginUpgrade now rejects a same-version request before staging, so the destructive move can never run against the live version dir.
  • loadPlugin persisted failed on interrupt-only teardown — a clean shutdown / concurrent lifecycle change now propagates the interruption and leaves persisted state intact. Shared a new handleLoadFailureCause across the hot-activation and pre-scope start paths so all three call sites behave identically (matching the round-2 resolveRegistration interrupt handling). Genuine errors still persist failed.
  • Runtime could go live after a concurrent disable/uninstall — re-check lockfile state immediately before registry.put; if no longer active, abort via Effect.interrupt (closes the scope → removes any partial HTTP registration, skips the put + active publish).
  • Hung register()/recover() could stall startup — both bounded by a 30s Effect.timeout (T3_PLUGIN_HOST_REGISTER_TIMEOUT_MS override); a timeout flows into the normal markFailure path.
  • uninstall published disableddeactivatePlugin now re-reads and publishes the actually-persisted state (pending-remove vs disabled).

Install / lockfile

  • stageTarball decompress leak — wrapped in the same staging-dir cleanup the sibling steps use.
  • Advisory-lock finalizer could delete a foreign lock — the lock now carries a per-holder pid:uuid owner token; the finalizer only removes a lock still carrying its own token, so a reclaimer's now-valid lock is never clobbered by a slow prior holder.
  • addSource didn't rewrite a legacy credentialed URL — a matched duplicate source's URL is rewritten to the credential-stripped canonical form (opaque sourceId preserved).

Capability API

  • available() used Effect.die — now a typed Effect.fail(PluginCapabilityUnavailable), so a plugin calling an undeclared capability can catch/degrade instead of crashing on a defect (SDK already types the failure channel).
  • observeThread snapshot→live race — now subscribes the filtered live event stream into a bounded (256, back-pressured) queue before reading the snapshot, then emits snapshot followed by buffered events filtered to sequence > snapshotSequence (dedups anything already in the snapshot). The engine commits the projection update inside the txn before publishing to the domain-event PubSub, so this closes the gap; a hard scheduler-independent guarantee would need a subscription-readiness signal from streamDomainEvents (an engine-level change ws.ts's subscribeThread also lacks) — documented inline rather than touching ws.ts.

Pairing-link plugin scopes (display-only)

  • Widened AuthPairingLink.scopes AuthEnvironmentScopesAuthScopes (propagates to the pairingLinkUpserted event + AuthAccessSnapshot.pairingLinks); PairingGrantStore list/emit views pass plugin scopes through instead of filtering them, and the web AccessScopeSummary renders plugin:<id>:* verbatim. The grant/consumption/security path is untouched.

All green: full 18-package typecheck, apps/server src/plugins (127 tests) + src/auth suites. New regression tests added for the upgrade guard, decompress cleanup, lock-ownership finalizer, credentialed-URL rewrite, pending-remove publish, typed-capability catch, and the observeThread dedup.

Comment thread apps/server/src/plugins/PluginHost.ts
Comment thread apps/server/src/plugins/capabilities/AgentsCapability.ts
Comment thread apps/server/src/plugins/PluginHost.ts
Comment thread apps/server/src/plugins/PluginInstaller.ts
Comment thread apps/server/src/plugins/PluginLockfileStore.ts
Comment thread apps/server/src/plugins/capabilities/AgentsCapability.ts
Comment thread apps/server/src/plugins/capabilities/AgentsCapability.ts Outdated
…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
@ccdwyer

ccdwyer commented Jul 5, 2026

Copy link
Copy Markdown
Author

Round-4 fixes pushed in e0f6b6240. Mapping each finding to its change (full 18-package typecheck + apps/server src/plugins/src/auth = 179 tests, all green; the two subtlest fixes were mutation-verified to fail on the old code):

Lifecycle

  • start-loop aborted on one plugin's interrupt (High) — this was a regression from round-3: propagating an interrupt-only cause is right for a single on-demand activation, but inside the startup loop the pre-registry.put state re-check firing Effect.interrupt for one plugin skipped all remaining plugins. The loop now logs and continues on an interrupt-only cause (non-interrupt causes still go through handleLoadFailureCause); a genuine host-shutdown interrupt still stops the loop via re-interrupt + the trailing ignoreCause. The single-plugin activatePlugin path is unchanged. New two-plugin test proves a later plugin still activates after one is interrupted.
  • Activation published a stale active — the success path now re-reads and publishes the actually-persisted lockfile state (mirroring the round-3 deactivate fix), so a concurrent disable/uninstall landing after registry.put isn't contradicted.

Install / lockfile

  • compareSemver mis-ordered prerelease/build metadata — replaced with a semver.org §11-correct comparator (release outranks prerelease, numeric identifiers compared numerically and ranked below alphanumeric, longer identifier set wins on tie, build metadata ignored), so checkUpdates can't advertise a wrong "latest" (1.0.0-rc.10 vs rc.2, rc.1 vs 1.0.0). Unit-tested across the full §11 chain.
  • openLock partial-write orphaned the lock — if writeAll/sync fails or is interrupted after the wx create, the file is now removed, so it can't block all lockfile mutations until STALE_LOCK_MS. Tested with an injected FS whose writeAll fails.

Agents capability

  • requireOwnedThread conflated not-found with ownership — a missing thread now yields AgentsThreadNotFoundError, a real owner mismatch yields AgentsThreadOwnershipError, owned-by-us still passes. The guard is not weakened; callers' not-found branches are now reachable.
  • startTurn dropped bootstrap when it created the thread — it now forwards the bootstrap with only createThread stripped, so the first turn keeps its prepareWorktree/runSetupScript.
  • turnAliases leaked on a failed start for an existing thread — the error path now always drops the pending alias, and only rolls back a self-created thread when applicable. (Mutation-verified.)
  • observeThread snapshot read race — the detail and the global snapshotSequence are now read sequentially, sequence first, so the dedup threshold can never exceed what the detail reflects → no dropped updates. A bounded duplicate within the read window is idempotent on the revision-gated client. Being simultaneously gap-free and dup-free isn't attainable without a per-thread snapshot sequence (getThreadDetailById exposes none), so this deliberately chooses at-least-once — a lost update is worse than an idempotent re-render — matching how core subscribeThread tolerates duplicate thread-detail events. Documented inline.

Comment thread apps/server/src/plugins/PluginHost.ts Outdated
Comment thread apps/server/src/plugins/PluginHost.ts
Comment thread apps/server/src/plugins/PluginHost.ts
…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
@ccdwyer

ccdwyer commented Jul 5, 2026

Copy link
Copy Markdown
Author

Round-5 fixes pushed in 9ae34eee0 (full 18-package typecheck + apps/server src/plugins/src/auth green; the marker-clear regression is mutation-verified — removing it fails the new test).

All three findings trace back to the interrupt handling from the prior rounds, and the root cause of the first two is the same: Effect.interrupt was overloaded for both a per-plugin self-cancel and a genuine host-shutdown interruption, so they couldn't be told apart. The fix introduces a typed sentinel:

  • [both bots] Interrupt-only teardown left activatingSince stale → a cleanly-cancelled activation was miscounted as a crash by reconcilePendingState on the next start(), bumping crashCount toward failed. Both the interrupt-only teardown and the new sentinel teardown now clear activation.activatingSince (preserving state/crashCount/lastError) — a clean cancellation isn't a crash. A genuine hard crash mid-activation still leaves the marker set (no teardown ran), so safe-mode's crash-loop protection is unchanged.
  • [Cursor] The start-loop swallow also caught host-shutdown interrupts → the pre-put lifecycle re-check now fails with an internal typed PluginActivationCanceled sentinel instead of Effect.interrupt. So the loop continues only on that sentinel (one plugin's concurrent-disable doesn't skip the rest), while a genuine interrupt-only cause is re-raised through handleLoadFailureCause and propagates out of the loop — the trailing ignoreCause ends start promptly on shutdown instead of plodding through the remaining plugins.
  • [Macroscope, secondary] turnAliases unbounded for turns started but never awaited → bounded by a generous FIFO cap (4096). Normal turns prune on terminal read well before the cap, so eviction only affects long-abandoned un-awaited turns (a later awaitTurn then degrades to the not-found path — not a crash — only under pathological >cap concurrent un-awaited turns).

Cancel/interrupt matrix now: (a) concurrent disable during activation → sentinel → marker cleared, no failed, crashCount untouched, loop continues; (b) host shutdown mid-activation → interrupt-only → marker cleared, re-raised, loop stops promptly; (c) genuine activation error → markFailure (unchanged).

Comment thread apps/server/src/plugins/PluginHost.ts Outdated
Comment thread apps/server/src/plugins/capabilities/AgentsCapability.ts Outdated

@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.

Effect service review: one error-modeling convention violation found in PluginLockfileStore.ts.

Posted via Macroscope — Effect Service Conventions

Comment thread apps/server/src/plugins/PluginLockfileStore.ts Outdated
Comment thread apps/server/src/plugins/PluginHost.ts
…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
@ccdwyer

ccdwyer commented Jul 5, 2026

Copy link
Copy Markdown
Author

Round-6 fixes pushed in 67ca9e848 (full 18-package typecheck + apps/server src/plugins/src/auth green; the cause predicates are locked by unit tests over hand-built mixed causes).

  • [both bots] The activation-cancel check was "contains" when it must be "only" — and interrupts must win. Scope.close can produce a mixed cause (the PluginActivationCanceled sentinel combined with a finalizer/teardown error, or with a fiber interrupt), and the round-5 .some(...) check folded those into the benign-cancel branch — silently dropping a real teardown failure and masking a shutdown interrupt. Replaced with two predicates: causeContainsInterrupt (Cause.hasInterrupts) checked first → re-raise so shutdown stops promptly even when mixed; then causeIsActivationCanceledOnly (every reason is the sentinel) → benign cancel; then everything else — including the sentinel combined with a teardown error — goes to markFailure and is never dropped. Both predicates are exported and unit-tested (sentinel-only → cancel; sentinel+die → failed; sentinel+interrupt → re-raised; pure interrupt → re-raised).
  • [Macroscope] startTurn idempotent-retry alias leak. The engine dedups dispatch by commandId, so a 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, so a later awaitTurn couldn't correlate and timed out. When a commandId is supplied, turnId/messageId are now derived deterministically from it (sha256, opaque, same id prefixes — no raw commandId leak), so a retry resolves the same alias the first call persisted. The no-commandId path is unchanged. Tested: same-commandId retry returns identical ids and the real-engine dedup leaves one turn whose pendingMessageId equals the returned messageId.
  • [Effect Service Conventions] 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, and the redundant detail field is dropped — matching the sibling read/write/lock errors.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread apps/server/src/plugins/capabilities/AgentsCapability.ts Outdated

@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 convention finding in the plugin HTTP client capability. See inline comment.

Posted via Macroscope — Effect Service Conventions

Comment thread apps/server/src/plugins/capabilities/HttpClientCapability.ts Outdated
Comment thread apps/server/src/plugins/capabilities/AgentsCapability.ts
Comment thread apps/server/src/plugins/PluginHost.ts Outdated
Comment thread apps/server/src/plugins/PluginHost.ts Outdated
… 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
@ccdwyer

ccdwyer commented Jul 5, 2026

Copy link
Copy Markdown
Author

Round-7 fixes pushed in b2e4df306 (full 18-package typecheck + apps/server src/plugins/src/auth = 191 tests green).

Activation concurrency (both High):

  • activatePlugin had no single-flight — two concurrent calls both observed an empty registry, both ran loadPlugin, and registry.put overwrote the first runtime's entry, orphaning its scope (forked services / terminal finalizers / HTTP routes stayed live but unreachable). Now serialized per pluginId by a single-permit semaphore, get-or-created atomically under a SynchronizedRef<HashMap<pluginId, Semaphore>>, with the registry.get double-check inside the lock. deactivatePlugin shares the same per-plugin lock so activate/deactivate can't interleave (neither calls the other under the lock → no deadlock).
  • activatePlugin swallowed a lockfile read failure — a transient/corrupt read was replaced with an empty lockfile, so it early-returned as "success" while the plugin never activated (installs/enables looked done but weren't). The error now propagates (signature widened; PluginInstaller.setEnabled maps it to an activation-failed error). A genuinely missing lockfile is still EMPTY_PLUGIN_LOCKFILE via readLockfile, so only real read/parse errors surface.

Agents capability:

  • awaitTerminalTurn subscribe/read race (High)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, 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 (located via the deterministic turnId's existing alias).

Conventions: HttpClientError's transport-failure message now uses a stable reason instead of echoing cause.message (the underlying error stays on data.cause).


A few older Bugbot threads re-surfaced this round — these are already addressed in the current tree (they date from the pre-round-1 e3a2cb60f review):

  • "Pairing check ignores implicit scopes" → the pairingCredential handler uses satisfiesScope (honors the implicit standard-client marker), not verbatim .has, since round 1.
  • "observeThread misses live events"observeThread subscribes into a bounded buffer before reading the snapshot (round 3), then replays filtered by sequence.
  • "Healthy window counts as crash" → the success path clears activatingSince immediately via markActivated(false); it does not linger until the healthy-delay job.

)
.pipe(Effect.mapError(lockfileError));
orphanedVersionDir = null;
yield* dropStage(stageToken);

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/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.

@ccdwyer

ccdwyer commented Jul 6, 2026

Copy link
Copy Markdown
Author

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 Effect.exit interrupt bug, an actually-broken DNS pin, a gh exit-code merge-safety bug, and ~25 more), all fixed and folded in. Closing this in favor of:

  1. Plugin system (1/8): thread ownership #3725 — thread ownership (standalone)
  2. Plugin system (2/8): host foundation + plugin SDK #3726 — host foundation + plugin SDK
  3. Plugin system (3/8): RPC transport + plugin-aware auth scopes #3727 — RPC transport + auth scopes
  4. Plugin system (4/8): mechanical capability façades #3728 — mechanical capability façades
  5. Plugin system (5/8): agents + vcs capabilities #3729 — agents + vcs capabilities
  6. Plugin system (6/8): web host + extension points #3730 — web host + extension points
  7. Plugin system (7/8): marketplace + install lifecycle #3731 — marketplace + install lifecycle
  8. Plugin system (8/8): filesystem/httpClient capabilities + hardening #3732 — filesystem/httpClient + hardening

Merge bottom-up (start with #3725). Thanks for the 7 rounds of Bugbot/Macroscope review here — every finding is carried forward into the stack.

https://claude.ai/code/session_011H86UHL1RPuWBX3LBTUPsk

@ccdwyer ccdwyer closed this Jul 6, 2026
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