docs(opencode): sketch plugin redesign

This commit is contained in:
Dax Raad
2026-04-17 16:29:02 -04:00
parent 4ca6961f7f
commit 5a026d74ce
11 changed files with 1182 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
# Plugin architecture
This is a working note for reorganizing plugin code while the codebase migrates to Effect.
## Current shape
The main problem is that one conceptual system is split across a few large modules with overlapping responsibilities.
```mermaid
flowchart TD
A[ConfigPlugin origins] --> B[plugin/shared.ts]
A --> C[plugin/loader.ts]
A --> D[plugin/install.ts]
B --> C
B --> E[plugin/index.ts server runtime]
B --> F[tui/plugin/runtime.ts TUI runtime]
B --> D
C --> E
C --> F
D --> G[cli/cmd/plug.ts]
D --> F
H[plugin/meta.ts] --> F
I[npm/index.ts] --> B
I --> C
I --> D
style B fill:#3a2f1f,stroke:#c98a2b,color:#fff
style C fill:#3a2f1f,stroke:#c98a2b,color:#fff
style D fill:#3a2f1f,stroke:#c98a2b,color:#fff
style E fill:#4a1f1f,stroke:#d46a6a,color:#fff
style F fill:#4a1f1f,stroke:#d46a6a,color:#fff
```
### What is mixed together today
- `src/plugin/shared.ts`
spec parsing, package reading, entry resolution, compatibility checks, theme discovery, module validation, id resolution
- `src/plugin/loader.ts`
plan building, target resolution, import, retry rules, reporting hooks
- `src/plugin/install.ts`
install wrapper, manifest inspection, config patching, file locking
- `src/plugin/index.ts`
server plugin runtime, hook loading, config fanout, event subscription
- `src/cli/cmd/tui/plugin/runtime.ts`
TUI runtime state, loading, activation, API adaptation, theme sync, install flow, pending state, process singleton
- `src/plugin/meta.ts`
file-backed mutable plugin metadata store
## Target shape
The redesign should split stateless plugin plumbing from stateful runtimes.
```mermaid
flowchart TD
subgraph Pure[Pure helpers]
S1[plugin/spec.ts]
S2[plugin/module.ts]
S3[plugin/manifest.ts]
end
subgraph Effects[Effect functions]
E1[plugin/package.ts]
E2[plugin/external.ts]
E3[plugin/install.ts]
T2[tui/plugin/theme.ts]
T3[tui/plugin/api.ts]
T4[tui/plugin/scope.ts]
T5[tui/plugin/activation.ts]
end
subgraph Services[Effect services]
SV1[plugin/meta-store.ts PluginMetaStore.Service]
SV2[plugin/server.ts PluginServer.Service]
SV3[tui/plugin/manager.ts TuiPluginManager.Service]
end
Cfg[ConfigPlugin Origins] --> S1
S1 --> E1
S1 --> E2
S2 --> E2
S3 --> E3
E1 --> E2
E1 --> E3
E2 --> SV2
E2 --> SV3
T2 --> SV3
T3 --> SV3
T4 --> SV3
T5 --> SV3
SV1 --> SV3
E3 --> CLI[cli/cmd/plug.ts]
E3 --> SV3
style Pure fill:#1f3a2a,stroke:#4fa06b,color:#fff
style Effects fill:#1f2f4a,stroke:#5c8fda,color:#fff
style Services fill:#3b1f4a,stroke:#b070d6,color:#fff
```
## Module boundaries
### Pure helpers
- `src/plugin/spec.ts`
parse specifiers, detect npm vs file, normalize ids
- `src/plugin/module.ts`
validate exported module shape, extract `id`, read v1 server or TUI modules
- `src/plugin/manifest.ts`
derive package capabilities from package metadata
These should not touch the filesystem or global state.
### Effect functions
- `src/plugin/package.ts`
read `package.json`, check compatibility, read theme files
- `src/plugin/external.ts`
resolve targets, resolve entrypoints, import modules, retry local file plugins after dependency prep
- `src/plugin/install.ts`
shared install and config-patch workflow used by CLI and TUI
- `src/cli/cmd/tui/plugin/theme.ts`
sync and persist themes
- `src/cli/cmd/tui/plugin/api.ts`
adapt host API to plugin API
- `src/cli/cmd/tui/plugin/scope.ts`
lifecycle resource helpers
- `src/cli/cmd/tui/plugin/activation.ts`
activate and deactivate one plugin entry
These are composable functions that return `Effect`, but do not own long-lived mutable state.
### Services
- `PluginMetaStore.Service`
owns the metadata file and lock-backed updates
- `PluginServer.Service`
owns loaded server hooks and bus subscription state per project/worktree via `InstanceState`
- `TuiPluginManager.Service`
owns loaded TUI entries, enabled state, pending installs, and activation lifecycle
## Runtime split
### Server side
```mermaid
flowchart LR
A[PluginServer.Service] --> B[build plugin input]
A --> C[load internal plugins]
A --> D[load external plugins]
D --> E[plugin/external.ts]
A --> F[notify config]
A --> G[subscribe bus events]
```
### TUI side
```mermaid
flowchart LR
A[TuiPluginManager.Service] --> B[load internal entries]
A --> C[load external entries]
C --> D[plugin/external.ts]
A --> E[track plugin metadata]
E --> F[PluginMetaStore.Service]
A --> G[activate and deactivate]
G --> H[tui/plugin/activation.ts]
A --> I[sync themes]
I --> J[tui/plugin/theme.ts]
A --> K[install and configure plugin]
K --> L[plugin/install.ts]
```
## Design rules
- Keep orchestration readable at the top level.
- Put state in services, not module globals.
- Prefer typed results over callback-driven reporting.
- Share install/configure workflow between CLI and TUI.
- Keep plugin discovery parallel, but keep activation and hook registration sequential.
- Preserve the special cases that already matter:
theme-only TUI plugins, legacy server plugins, local file-plugin retry after dependency prep.
## Suggested migration order
1. Split `shared.ts` into `spec.ts`, `module.ts`, `manifest.ts`, and `package.ts` without changing behavior.
2. Replace `loader.ts` with flat exports in `external.ts` and return typed result values instead of report callbacks.
3. Collapse duplicated install flow into one shared `plugin/install.ts` workflow used by CLI and TUI.
4. Convert `meta.ts` into `PluginMetaStore.Service`.
5. Shrink `plugin/index.ts` into a thin `PluginServer.Service` composition root.
6. Break up `tui/plugin/runtime.ts` and move its mutable runtime state into `TuiPluginManager.Service`.

View File

@@ -0,0 +1,56 @@
# plug
Type-only sketch for a cleaner plugin architecture.
This folder is intentionally not wired into the application yet.
It exists so the shape of a redesign is easy to inspect without mixing design work with runtime changes.
## Files
- `common.ts`
shared helper types used by the rest of the sketch
- `spec.ts`
plugin declaration and normalization types
- `package.ts`
package metadata and capability inspection types
- `module.ts`
imported module shape and validation types
- `external.ts`
external plugin load pipeline types
- `meta.ts`
plugin metadata store types
- `install.ts`
install and config patch workflow types
- `server.ts`
server plugin service types
- `tui.ts`
TUI plugin manager service types
## Reading order
If you want the sketch to build up from small concepts to runtime orchestration, read the files in this order:
1. `spec.ts`
Start here for the basic nouns: plugin kinds, sources, declarations, config origins, and normalized candidates.
2. `package.ts`
Next read how package metadata is described after a plugin target has been resolved.
3. `module.ts`
Then read the imported module shapes and validation results for v1 and legacy modules.
4. `external.ts`
This is the shared external loading pipeline that connects spec parsing, package inspection, and module import.
5. `meta.ts`
Read this next to see what state should be persisted across runs and why it belongs behind a service.
6. `install.ts`
This describes the install, manifest, and config patch workflow shared by CLI and TUI.
7. `server.ts`
Read the server runtime service after the lower-level pipeline files, since it mainly composes those pieces.
8. `tui.ts`
Read this last because it has the largest runtime surface and depends on most of the earlier concepts.
9. `common.ts`
This file is only shared utility typing. You can skim it first or ignore it until you see a helper type you want to expand.
## Intent
- Stateful parts are described as service interfaces.
- Stateless parts are described as function types returning `Effect`.
- The comments explain what each type is for and where it would sit in the architecture.

View File

@@ -0,0 +1,48 @@
/**
* Small helper alias used throughout this folder so the design signatures stay readable.
*
* These files are only a type sketch, so we reference the Effect type without creating any runtime code.
*/
export type Fx<Success, Error = never, Requirements = never> = import("effect").Effect.Effect<
Success,
Error,
Requirements
>
/**
* Standard success result shape used by the sketch files.
*
* The current plugin code already uses similar tagged unions in a few places.
*/
export type Ok<Value> = {
ok: true
value: Value
}
/**
* Standard failure result shape used by the sketch files.
*
* `code` stays machine-friendly while the extra data explains the specific failure.
*/
export type Failure<Code extends string, Data> = {
ok: false
code: Code
} & Data
/**
* Generic loaded module namespace.
*
* Dynamic imports return a namespace object, and the next stage decides whether that namespace is a
* v1 module, a legacy server export set, or an invalid module.
*/
export type ModuleNamespace = Record<string, unknown>
/**
* Shared shape for objects that carry both a configured spec and its resolved on-disk target.
*/
export interface SpecTarget {
/** The original normalized plugin spec, for example `pkg@1.2.3` or `file:///.../plugin.ts`. */
readonly spec: string
/** The resolved install location or local file URL that later stages work against. */
readonly target: string
}

View File

@@ -0,0 +1,123 @@
import type { Candidate, Kind, Options, Origin, Source } from "./spec"
import type { Fx, ModuleNamespace, SpecTarget } from "./common"
import type { PackageRecord } from "./package"
/**
* Normalized external plugin plan before any filesystem or npm work happens.
*/
export interface Plan {
/** Original normalized string spec. */
readonly spec: string
/** Optional inline config tuple payload. */
readonly options: Options | undefined
/** Whether the package is deprecated because the functionality is now built in. */
readonly deprecated: boolean
}
/**
* External plugin that has been resolved to a concrete on-disk entrypoint.
*/
export interface Resolved extends SpecTarget {
/** Options to forward when the plugin is instantiated. */
readonly options: Options | undefined
/** Whether the plugin came from a file path or npm install. */
readonly source: Source
/** JavaScript module entrypoint that can be dynamically imported. */
readonly entry: string
/** Loaded package metadata when a package.json exists. */
readonly pkg: PackageRecord | undefined
}
/**
* External plugin target that was found but does not expose the requested runtime entrypoint.
*
* This is a first-class result because TUI still cares about theme-only packages.
*/
export interface MissingEntrypoint extends SpecTarget {
/** Options to forward if some later stage still wants to keep the plugin record. */
readonly options: Options | undefined
/** Whether the target came from a file path or npm install. */
readonly source: Source
/** Loaded package metadata when a package.json exists. */
readonly pkg: PackageRecord | undefined
/** Human-readable explanation of what was missing. */
readonly message: string
}
/**
* Resolved plugin whose module has been imported successfully.
*/
export interface Loaded extends Resolved {
/** Raw dynamic import namespace. */
readonly module: ModuleNamespace
}
/**
* Pipeline stages where a plugin load can fail.
*/
export type FailureStage = "install" | "entry" | "compatibility" | "load"
/**
* External load failure record.
*
* This replaces the current callback-heavy report object with an explicit value.
*/
export interface Failure {
/** Which configured plugin was being processed. */
readonly candidate: Candidate
/** Which pass failed. */
readonly stage: FailureStage
/** Whether the failure happened during the retry-after-dependencies pass. */
readonly retry: boolean
/** Underlying failure object. */
readonly error: unknown
/** Best-known resolution details when the failure happened after entry resolution. */
readonly resolved: Resolved | undefined
}
/**
* Result of processing one configured external plugin.
*/
export type AttemptResult =
| {
/** Successfully resolved and imported plugin module. */
readonly type: "loaded"
readonly origin: Origin
readonly retry: boolean
readonly value: Loaded
}
| {
/** Target exists but does not expose the requested runtime entrypoint. */
readonly type: "missing"
readonly origin: Origin
readonly retry: boolean
readonly value: MissingEntrypoint
}
| {
/** Operational failure during install, resolution, compatibility, or import. */
readonly type: "failed"
readonly value: Failure
}
/**
* Input to the external loading workflow.
*/
export interface LoadRequest {
/** Ordered merged config origins to process. */
readonly items: readonly Origin[]
/** Which runtime is asking for plugins. */
readonly kind: Kind
/**
* Optional dependency-prep effect.
*
* If provided, file plugins that failed during the first pass may be retried after this effect completes.
*/
readonly waitForDependencies: Fx<void> | undefined
}
/**
* Intended signature for the shared external loader.
*
* The return value preserves order and gives callers explicit success, missing-entry, and failure results.
*/
export type LoadExternal = (request: LoadRequest) => Fx<readonly AttemptResult[]>

View File

@@ -0,0 +1,147 @@
import type { Origin } from "./spec"
import type { Failure, Fx, Ok } from "./common"
/**
* How a config patch changed one file.
*/
export type PatchMode = "noop" | "add" | "replace"
/**
* Why a package is considered to target one runtime.
*/
export type TargetReason = "server-export" | "tui-export" | "package-main" | "themes"
/**
* One runtime target inferred from an installed package.
*/
export interface Target {
/** Which runtime should receive a config entry. */
readonly kind: "server" | "tui"
/** Optional default options to write alongside the plugin spec. */
readonly options: Record<string, unknown> | undefined
/** Why this runtime target was inferred from package metadata. */
readonly reason: TargetReason
}
/**
* High-level package manifest summary returned by plugin inspection.
*/
export interface Manifest {
/** Installed or resolved target used to inspect the package. */
readonly target: string
/** Runtime targets inferred from that package. */
readonly targets: readonly Target[]
}
/**
* Context needed when patching config files after install.
*/
export interface PatchRequest {
/** Plugin spec to add or replace. */
readonly spec: string
/** Runtime targets that should be written into config. */
readonly targets: readonly Target[]
/** Whether an existing npm package entry may be replaced by package name. */
readonly force: boolean
/** Whether the write should go to the global config directory. */
readonly global: boolean
/** VCS hint used to decide whether the worktree root should own the local config write. */
readonly vcs: string | undefined
/** Current worktree path. */
readonly worktree: string
/** Current working directory. */
readonly directory: string
/** Optional explicit global config directory override. */
readonly config: string | undefined
}
/**
* One config file mutation performed during patching.
*/
export interface PatchItem {
/** Which runtime file was touched. */
readonly kind: "server" | "tui"
/** Whether the plugin row was added, replaced, or already present. */
readonly mode: PatchMode
/** Concrete config file path that was inspected or written. */
readonly file: string
}
/**
* Final result of a successful config patch operation.
*/
export interface PatchSuccess {
/** Directory that owned the config write. */
readonly dir: string
/** Per-runtime write details. */
readonly items: readonly PatchItem[]
}
/**
* Shared error union for install and config patch workflows.
*/
export type InstallFailure =
| Failure<"install_failed", { error: unknown }>
| Failure<"manifest_read_failed", { file: string; error: unknown }>
| Failure<"manifest_no_targets", { file: string }>
| Failure<"invalid_json", { kind: "server" | "tui"; file: string; line: number; col: number; parse: string }>
| Failure<"patch_failed", { kind: "server" | "tui"; error: unknown }>
/**
* Current low-level install result shape.
*/
export type InstallResult = Ok<{ target: string }>
/**
* Current low-level manifest inspection result shape.
*/
export type ManifestResult = Ok<{ manifest: Manifest }> | InstallFailure
/**
* Current low-level patch result shape.
*/
export type PatchResult = Ok<PatchSuccess> | InstallFailure
/**
* High-level request for the full install-and-configure workflow.
*/
export interface InstallAndConfigureRequest {
/** Raw package spec entered by the user. */
readonly spec: string
/** Whether the installed plugin should be written to global config. */
readonly global: boolean
/** Whether an already-configured npm package may be replaced by package name. */
readonly force: boolean
/** Current working directory. */
readonly directory: string
/** Current worktree root. */
readonly worktree: string
/** VCS hint used when picking the local config directory. */
readonly vcs: string | undefined
/** Optional explicit global config directory override. */
readonly config: string | undefined
}
/**
* Result of the full install-and-configure workflow.
*
* The optional `origin` field is useful for the TUI runtime, which wants to track newly configured
* plugins before they are actually activated.
*/
export interface InstallAndConfigureSuccess {
/** Resolved install target returned by the package install step. */
readonly target: string
/** Inferred package manifest. */
readonly manifest: Manifest
/** Config patch details. */
readonly patch: PatchSuccess
/** Newly configured TUI origin when the package exposed a TUI target. */
readonly origin: Origin | undefined
}
/**
* Intended shared workflow used by both `opencode plug` and the TUI runtime.
*/
export type InstallAndConfigure = (
request: InstallAndConfigureRequest,
) => Fx<InstallAndConfigureSuccess, InstallFailure>

View File

@@ -0,0 +1,102 @@
import type { Fx } from "./common"
/**
* External sources tracked in the metadata file.
*
* Internal plugins are intentionally excluded because the metadata file is for user-configurable external plugins.
*/
export type Source = "file" | "npm"
/**
* Persisted information about one installed theme file provided by a TUI plugin.
*/
export interface ThemeEntry {
/** Original theme file inside the plugin package or local plugin root. */
readonly src: string
/** Final persisted destination in the user's themes directory. */
readonly dest: string
/** Source file modification time used to detect changes. */
readonly mtime: number | undefined
/** Source file size used to detect changes. */
readonly size: number | undefined
}
/**
* Persisted metadata about one external plugin id.
*/
export interface Entry {
/** Logical runtime id. */
readonly id: string
/** Whether this record describes a file plugin or npm plugin. */
readonly source: Source
/** Normalized config spec that produced the record. */
readonly spec: string
/** Resolved install directory or file target. */
readonly target: string
/** Requested npm version or range from the original spec, when relevant. */
readonly requested: string | undefined
/** Installed package version, when relevant. */
readonly version: string | undefined
/** Modified time for local file plugins, when relevant. */
readonly modified: number | undefined
/** First time this plugin id was seen. */
readonly first_time: number
/** Most recent time this plugin id was loaded. */
readonly last_time: number
/** Most recent time the record fingerprint changed. */
readonly time_changed: number
/** Number of times the plugin has been loaded. */
readonly load_count: number
/** Compact identity used to decide whether the plugin changed between runs. */
readonly fingerprint: string
/** Theme files installed on behalf of this plugin. */
readonly themes: Readonly<Record<string, ThemeEntry>> | undefined
}
/**
* How the latest touch compared with the previously persisted record.
*/
export type TouchState = "first" | "updated" | "same"
/**
* Input required to update metadata for one external plugin load.
*/
export interface TouchInput {
/** Normalized config spec. */
readonly spec: string
/** Resolved install target or file target. */
readonly target: string
/** Logical runtime id. */
readonly id: string
}
/**
* Result returned after updating metadata for one plugin.
*/
export interface TouchResult {
/** Whether the plugin is new, changed, or unchanged compared with the previous record. */
readonly state: TouchState
/** The full persisted entry after the update. */
readonly entry: Entry
}
/**
* Entire on-disk metadata store keyed by runtime plugin id.
*/
export type Store = Readonly<Record<string, Entry>>
/**
* Stateful service responsible for the plugin metadata file.
*
* This is a strong service boundary because it owns a lock, persistence format, and mutation rules.
*/
export interface Interface {
/** Update metadata for one plugin load. */
readonly touch: (input: TouchInput) => Fx<TouchResult>
/** Update metadata for many plugin loads in a single locked write. */
readonly touchMany: (input: readonly TouchInput[]) => Fx<readonly TouchResult[]>
/** Persist one installed theme record under an existing plugin id. */
readonly setTheme: (input: { id: string; name: string; theme: ThemeEntry }) => Fx<void>
/** Read the entire current metadata store. */
readonly list: () => Fx<Store>
}

View File

@@ -0,0 +1,87 @@
import type { Plugin as ServerPluginFactory, PluginModule as ServerPluginModule } from "@opencode-ai/plugin"
import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { ModuleNamespace } from "./common"
/**
* Validation mode for imported plugin modules.
*
* `strict` means the module must clearly match the expected target shape.
* `detect` means the loader is probing for a v1 module before falling back to older compatibility paths.
*/
export type ValidationMode = "strict" | "detect"
/**
* Current v1 server module shape.
*
* A v1 module is target-exclusive, so it must not export both `server` and `tui` from the same default export.
*/
export type V1ServerModule = {
/** Optional logical plugin id. npm plugins may omit this and fall back to package name. */
readonly id?: string
/** Server plugin factory used to create hook handlers. */
readonly server: ServerPluginModule["server"]
/** Explicitly absent to make the target-exclusive shape obvious. */
readonly tui?: never
}
/**
* Current v1 TUI module shape.
*/
export type V1TuiModule = {
/** Optional logical plugin id. */
readonly id?: string
/** TUI plugin factory used to register commands, routes, and UI hooks. */
readonly tui: TuiPluginModule["tui"]
/** Explicitly absent to make the target-exclusive shape obvious. */
readonly server?: never
}
/**
* Union of the currently supported v1 module shapes.
*/
export type V1Module = V1ServerModule | V1TuiModule
/**
* Older server plugin export styles still supported for backward compatibility.
*
* These only exist on the server side.
*/
export type LegacyServerExport =
| ServerPluginFactory
| {
readonly server: ServerPluginFactory
}
/**
* Result of validating an imported module namespace.
*/
export type ValidationResult =
| {
/** Namespace matched the modern v1 shape. */
readonly type: "v1"
/** Parsed target-exclusive default export. */
readonly module: V1Module
}
| {
/** Namespace did not match v1 but did contain legacy server plugin exports. */
readonly type: "legacy-server"
/** Distinct legacy server plugin factories extracted from the namespace. */
readonly exports: readonly LegacyServerExport[]
}
/**
* Intended signature for the module validation function.
*
* This stays separate from the external loader so module-shape rules are easy to inspect on their own.
*/
export type ValidateModule = (
namespace: ModuleNamespace,
input: {
/** Human-readable spec used in error messages. */
readonly spec: string
/** Which runtime is being validated. */
readonly kind: "server" | "tui"
/** Whether this is a hard validation pass or a soft probe. */
readonly mode: ValidationMode
},
) => ValidationResult | undefined

View File

@@ -0,0 +1,84 @@
import type { Kind, Source } from "./spec"
/**
* Raw package.json object for a plugin package.
*
* The real implementation would read this from disk and then derive higher-level capability types
* from it.
*/
export type PackageJson = Record<string, unknown>
/**
* Package metadata resolved from a plugin target on disk.
*/
export interface PackageRecord {
/** Root directory that owns the package.json. */
readonly dir: string
/** Absolute path to the package.json file. */
readonly pkg: string
/** Parsed package.json content. */
readonly json: PackageJson
}
/**
* Why a runtime target was inferred from a package.
*
* This makes install and diagnostics code easier to read because the source of a capability is explicit.
*/
export type CapabilityReason = "server-export" | "tui-export" | "package-main" | "themes"
/**
* One runtime capability inferred from package metadata.
*/
export interface Capability {
/** Which runtime this capability belongs to. */
readonly kind: Kind
/** Why this capability exists. */
readonly reason: CapabilityReason
/** Optional default config written during install when the package provides it. */
readonly options: Record<string, unknown> | undefined
}
/**
* Summary of theme assets declared by a package.
*/
export interface ThemeManifest {
/** Directory that relative theme paths should be resolved from. */
readonly root: string
/** Package-relative theme files that survived validation. */
readonly files: readonly string[]
}
/**
* Compatibility information declared by an npm package.
*/
export interface Compatibility {
/** Whether the compatibility gate was actually checked. */
readonly checked: boolean
/** The running opencode version used during the check. */
readonly runtimeVersion: string
/** The declared `engines.opencode` range, if any. */
readonly range: string | undefined
}
/**
* Package inspection result after a target has been resolved.
*/
export interface PackageResolution {
/** Normalized plugin spec. */
readonly spec: string
/** External source kind. */
readonly source: Source
/** Resolved install directory or file URL. */
readonly target: string
/** Loaded package metadata when a package.json exists. */
readonly pkg: PackageRecord | undefined
/** Entrypoint picked for the requested runtime target, if one exists. */
readonly entry: string | undefined
/** Runtime capabilities inferred from the package metadata. */
readonly capabilities: readonly Capability[]
/** Validated theme manifest when the package declares `oc-themes`. */
readonly themes: ThemeManifest | undefined
/** Compatibility info for npm packages. */
readonly compatibility: Compatibility | undefined
}

View File

@@ -0,0 +1,93 @@
import type { Hooks, Plugin as ServerPluginFactory, PluginInput } from "@opencode-ai/plugin"
import type { Fx } from "./common"
import type { Loaded } from "./external"
import type { Source } from "./spec"
/**
* Built-in server plugin that ships with the app and does not go through the external loader.
*/
export interface InternalPlugin {
/** Stable id used for diagnostics and duplicate detection. */
readonly id: string
/** Factory that creates one `Hooks` object when the plugin service starts. */
readonly plugin: ServerPluginFactory
}
/**
* One successfully instantiated server plugin hook set.
*/
export interface HookEntry {
/** Stable runtime id. */
readonly id: string
/** Normalized spec that produced this hook set. */
readonly spec: string
/** Whether the plugin came from npm, a file path, or built-in code. */
readonly source: Source | "internal"
/** Hook handlers returned by the plugin factory. */
readonly hooks: Hooks
}
/**
* Stateful data owned by the server plugin runtime per project/worktree instance.
*/
export interface State {
/** Fully initialized hook sets in the order they should run. */
readonly loaded: readonly HookEntry[]
/** Flat hook list kept for the existing trigger API. */
readonly hooks: readonly Hooks[]
}
/**
* Hook names that follow the trigger pattern `(input, output) => Promise<void>`.
*/
export type TriggerName = {
[Name in keyof Hooks]-?: NonNullable<Hooks[Name]> extends (input: infer _Input, output: infer _Output) => Promise<void>
? Name
: never
}[keyof Hooks]
/**
* Input type for one triggerable hook name.
*/
export type TriggerInput<Name extends TriggerName> = Parameters<Required<Hooks>[Name]>[0]
/**
* Output accumulator type for one triggerable hook name.
*/
export type TriggerOutput<Name extends TriggerName> = Parameters<Required<Hooks>[Name]>[1]
/**
* Context assembled before any server plugins are instantiated.
*/
export interface RuntimeContext {
/** Rich plugin input passed to every server plugin factory. */
readonly input: PluginInput
/** Whether external plugins should be skipped because the runtime is in pure mode. */
readonly pure: boolean
}
/**
* Stateless adapter that turns a loaded external module into one or more server hook sets.
*
* The return type is plural because legacy server modules can still expose multiple plugin factories.
*/
export type ApplyLoaded = (load: Loaded, input: PluginInput) => Fx<readonly Hooks[]>
/**
* Public service surface for the server plugin runtime.
*
* The implementation would be backed by `InstanceState` because loaded hooks and bus subscriptions are
* scoped to one project/worktree instance.
*/
export interface Interface {
/** Ensure the per-instance plugin state has been initialized. */
readonly init: () => Fx<void>
/** Return the currently loaded hook objects. */
readonly list: () => Fx<readonly Hooks[]>
/** Trigger one hook name across loaded plugins while preserving plugin order. */
readonly trigger: <Name extends TriggerName>(
name: Name,
input: TriggerInput<Name>,
output: TriggerOutput<Name>,
) => Fx<TriggerOutput<Name>>
}

View File

@@ -0,0 +1,91 @@
import type {
Options as ConfigPluginOptions,
Origin as ConfigPluginOrigin,
Scope as ConfigPluginScope,
Spec as ConfigPluginSpec,
} from "@/config/plugin"
/**
* The two external plugin sources supported by the current system.
*
* `file` means a local path or `file://` URL.
* `npm` means an installable package spec.
*/
export type Source = "file" | "npm"
/**
* `internal` is used by the TUI runtime for built-in plugins that are shipped with the app.
*
* It is kept separate from `Source` because it is not part of the external plugin loading pipeline.
*/
export type RuntimeSource = Source | "internal"
/**
* The two runtime targets a package can expose.
*/
export type Kind = "server" | "tui"
/**
* Inline config tuple options forwarded to the plugin factory.
*/
export type Options = ConfigPluginOptions
/**
* Raw plugin config entry as it appears in `opencode.json` or `tui.json`.
*/
export type Input = ConfigPluginSpec
/**
* Config provenance attached to a plugin declaration after config merging.
*
* This answers "which config file declared this plugin?" so follow-up writes can go back to the
* right place.
*/
export type Origin = ConfigPluginOrigin
/**
* Whether a config origin should behave like a global or project-local plugin declaration.
*/
export type Scope = ConfigPluginScope
/**
* Parsed npm-style package identity.
*
* This is the identity used for dedupe and for better install error messages.
*/
export interface ParsedSpecifier {
/** Package name portion of the spec. For file specs this will usually just be the original string. */
readonly pkg: string
/** Version request portion of the spec. Bare package names typically normalize to `latest`. */
readonly version: string
}
/**
* Normalized external plugin declaration.
*
* This is the shape that downstream loading code should work with instead of raw config tuples.
*/
export interface Declared {
/** Original config provenance. */
readonly origin: Origin
/** Normalized string spec extracted from the raw config value. */
readonly spec: string
/** Optional config tuple payload that should be forwarded to the plugin factory. */
readonly options: Options | undefined
/** Whether this should be treated as a file plugin or npm plugin. */
readonly source: Source
/** Whether the package name maps to a built-in plugin and should therefore be ignored. */
readonly deprecated: boolean
}
/**
* Candidate item passed into the external load pipeline.
*
* The name exists to make it obvious that the plugin has not been resolved or imported yet.
*/
export interface Candidate {
/** Original config entry and provenance. */
readonly origin: Origin
/** Normalized declaration derived from that config entry. */
readonly declared: Declared
}

View File

@@ -0,0 +1,158 @@
import type {
TuiDispose,
TuiPlugin,
TuiPluginApi,
TuiPluginInstallResult,
TuiPluginMeta,
TuiPluginModule,
TuiPluginStatus,
} from "@opencode-ai/plugin/tui"
import type { Info as TuiConfigInfo } from "@/cli/cmd/tui/config/tui"
import type { HostPluginApi, HostSlots } from "@/cli/cmd/tui/plugin/slots"
import type { Origin, RuntimeSource } from "./spec"
import type { Fx } from "./common"
import type { ThemeEntry, TouchResult } from "./meta"
/**
* Loaded TUI plugin before it is wrapped in runtime state.
*/
export interface PluginLoad {
/** Inline config tuple payload forwarded to the plugin factory. */
readonly options: Record<string, unknown> | undefined
/** Normalized string spec. */
readonly spec: string
/** Resolved install target or file target. */
readonly target: string
/** Whether this load happened during the retry-after-dependencies pass. */
readonly retry: boolean
/** Whether this plugin is external or built in. */
readonly source: RuntimeSource
/** Stable runtime id. */
readonly id: string
/** Target-exclusive TUI module. */
readonly module: TuiPluginModule
/** Config provenance. Internal plugins still get a synthetic origin for consistency. */
readonly origin: Origin
/** Root used to resolve package-relative theme files. */
readonly theme_root: string
/** Valid theme files discovered from `oc-themes`. */
readonly theme_files: readonly string[]
}
/**
* One plugin-owned lifecycle scope.
*
* The current runtime models this manually with cleanup functions and an `AbortController`.
*/
export interface PluginScope {
/** Lifecycle object exposed to the plugin API. */
readonly lifecycle: TuiPluginApi["lifecycle"]
/** Register a plain cleanup callback and return an unregister function. */
readonly track: (fn: TuiDispose | undefined) => () => void
/** Dispose the scope and run cleanup callbacks in reverse order. */
readonly dispose: () => Promise<void>
}
/**
* One plugin entry tracked by the TUI manager.
*/
export interface PluginEntry {
/** Stable runtime id. */
readonly id: string
/** Raw load details used for diagnostics and reload decisions. */
readonly load: PluginLoad
/** Metadata exposed to plugin code and the UI. */
readonly meta: TuiPluginMeta
/** Persisted theme install records keyed by theme name. */
readonly themes: Readonly<Record<string, ThemeEntry>>
/** Concrete TUI plugin factory. */
readonly plugin: TuiPlugin
/** Current enabled state. */
readonly enabled: boolean
/** Live lifecycle scope when the plugin is active. */
readonly scope: PluginScope | undefined
}
/**
* Result of running one plugin cleanup callback.
*/
export type CleanupResult =
| { readonly type: "ok" }
| { readonly type: "error"; readonly error: unknown }
| { readonly type: "timeout" }
/**
* Stateful data owned by the TUI plugin manager.
*/
export interface State {
/** Current directory the runtime was initialized for. */
readonly directory: string
/** Host-side API exposed to plugins. */
readonly api: HostPluginApi
/** Slot registry used by TUI plugins. */
readonly slots: HostSlots
/** Ordered plugin list used for activation, disposal, and status display. */
readonly plugins: readonly PluginEntry[]
/** Fast lookup by plugin id. */
readonly plugins_by_id: ReadonlyMap<string, PluginEntry>
/** Newly installed plugin origins waiting to be added to the live runtime. */
readonly pending: ReadonlyMap<string, Origin>
}
/**
* Result of tracking newly loaded external plugins in the metadata store.
*/
export interface MetadataBatch {
/** Metadata touch results in the same order as the loaded plugins. */
readonly results: readonly TouchResult[]
}
/**
* Public service surface for the TUI plugin runtime.
*
* This would replace the current module-global singleton state with an explicit service.
*/
export interface Interface {
/** Initialize the runtime for one working directory and config snapshot. */
readonly init: (input: { api: HostPluginApi; config: TuiConfigInfo }) => Fx<void>
/** Return current status rows for the TUI plugin UI. */
readonly list: () => Fx<readonly TuiPluginStatus[]>
/** Enable one plugin id. */
readonly activate: (id: string) => Fx<boolean>
/** Disable one plugin id. */
readonly deactivate: (id: string) => Fx<boolean>
/** Add one already-configured plugin spec to the live runtime. */
readonly add: (spec: string) => Fx<boolean>
/** Install a package and patch config, returning the current TUI-facing result type. */
readonly install: (spec: string, options: { global?: boolean } | undefined) => Fx<TuiPluginInstallResult>
/** Dispose all active plugins in reverse order. */
readonly dispose: () => Fx<void>
}
/**
* Stateless helper signature for creating one active plugin scope.
*/
export type CreateScope = (input: { load: PluginLoad; id: string; disposeTimeoutMs: number }) => PluginScope
/**
* Stateless helper signature for activating one plugin entry.
*/
export type ActivateEntry = (input: {
state: State
plugin: PluginEntry
persist: boolean
}) => Fx<boolean>
/**
* Stateless helper signature for deactivating one plugin entry.
*/
export type DeactivateEntry = (input: {
state: State
plugin: PluginEntry
persist: boolean
}) => Fx<boolean>
/**
* Stateless helper signature for syncing theme files for one plugin entry.
*/
export type SyncThemes = (plugin: PluginEntry) => Fx<void>