From 5a026d74ce9efb4d405923d512b398aa282aedb1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 17 Apr 2026 16:29:02 -0400 Subject: [PATCH] docs(opencode): sketch plugin redesign --- .../opencode/specs/plugin-architecture.md | 193 ++++++++++++++++++ packages/opencode/src/plug/README.md | 56 +++++ packages/opencode/src/plug/common.ts | 48 +++++ packages/opencode/src/plug/external.ts | 123 +++++++++++ packages/opencode/src/plug/install.ts | 147 +++++++++++++ packages/opencode/src/plug/meta.ts | 102 +++++++++ packages/opencode/src/plug/module.ts | 87 ++++++++ packages/opencode/src/plug/package.ts | 84 ++++++++ packages/opencode/src/plug/server.ts | 93 +++++++++ packages/opencode/src/plug/spec.ts | 91 +++++++++ packages/opencode/src/plug/tui.ts | 158 ++++++++++++++ 11 files changed, 1182 insertions(+) create mode 100644 packages/opencode/specs/plugin-architecture.md create mode 100644 packages/opencode/src/plug/README.md create mode 100644 packages/opencode/src/plug/common.ts create mode 100644 packages/opencode/src/plug/external.ts create mode 100644 packages/opencode/src/plug/install.ts create mode 100644 packages/opencode/src/plug/meta.ts create mode 100644 packages/opencode/src/plug/module.ts create mode 100644 packages/opencode/src/plug/package.ts create mode 100644 packages/opencode/src/plug/server.ts create mode 100644 packages/opencode/src/plug/spec.ts create mode 100644 packages/opencode/src/plug/tui.ts diff --git a/packages/opencode/specs/plugin-architecture.md b/packages/opencode/specs/plugin-architecture.md new file mode 100644 index 0000000000..9cef785a8a --- /dev/null +++ b/packages/opencode/specs/plugin-architecture.md @@ -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`. diff --git a/packages/opencode/src/plug/README.md b/packages/opencode/src/plug/README.md new file mode 100644 index 0000000000..6ab69bcb4b --- /dev/null +++ b/packages/opencode/src/plug/README.md @@ -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. diff --git a/packages/opencode/src/plug/common.ts b/packages/opencode/src/plug/common.ts new file mode 100644 index 0000000000..b7d6b63acf --- /dev/null +++ b/packages/opencode/src/plug/common.ts @@ -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 = 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 = { + 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 = { + 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 + +/** + * 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 +} diff --git a/packages/opencode/src/plug/external.ts b/packages/opencode/src/plug/external.ts new file mode 100644 index 0000000000..67a7a2ba43 --- /dev/null +++ b/packages/opencode/src/plug/external.ts @@ -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 | 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 diff --git a/packages/opencode/src/plug/install.ts b/packages/opencode/src/plug/install.ts new file mode 100644 index 0000000000..e2abf03d47 --- /dev/null +++ b/packages/opencode/src/plug/install.ts @@ -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 | 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 | 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 diff --git a/packages/opencode/src/plug/meta.ts b/packages/opencode/src/plug/meta.ts new file mode 100644 index 0000000000..8d9021a149 --- /dev/null +++ b/packages/opencode/src/plug/meta.ts @@ -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> | 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> + +/** + * 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 + /** Update metadata for many plugin loads in a single locked write. */ + readonly touchMany: (input: readonly TouchInput[]) => Fx + /** Persist one installed theme record under an existing plugin id. */ + readonly setTheme: (input: { id: string; name: string; theme: ThemeEntry }) => Fx + /** Read the entire current metadata store. */ + readonly list: () => Fx +} diff --git a/packages/opencode/src/plug/module.ts b/packages/opencode/src/plug/module.ts new file mode 100644 index 0000000000..8a27c3517c --- /dev/null +++ b/packages/opencode/src/plug/module.ts @@ -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 diff --git a/packages/opencode/src/plug/package.ts b/packages/opencode/src/plug/package.ts new file mode 100644 index 0000000000..3dd9afb794 --- /dev/null +++ b/packages/opencode/src/plug/package.ts @@ -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 + +/** + * 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 | 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 +} diff --git a/packages/opencode/src/plug/server.ts b/packages/opencode/src/plug/server.ts new file mode 100644 index 0000000000..afd7ce9500 --- /dev/null +++ b/packages/opencode/src/plug/server.ts @@ -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`. + */ +export type TriggerName = { + [Name in keyof Hooks]-?: NonNullable extends (input: infer _Input, output: infer _Output) => Promise + ? Name + : never +}[keyof Hooks] + +/** + * Input type for one triggerable hook name. + */ +export type TriggerInput = Parameters[Name]>[0] + +/** + * Output accumulator type for one triggerable hook name. + */ +export type TriggerOutput = Parameters[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 + +/** + * 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 + /** Return the currently loaded hook objects. */ + readonly list: () => Fx + /** Trigger one hook name across loaded plugins while preserving plugin order. */ + readonly trigger: ( + name: Name, + input: TriggerInput, + output: TriggerOutput, + ) => Fx> +} diff --git a/packages/opencode/src/plug/spec.ts b/packages/opencode/src/plug/spec.ts new file mode 100644 index 0000000000..af86f0dc74 --- /dev/null +++ b/packages/opencode/src/plug/spec.ts @@ -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 +} diff --git a/packages/opencode/src/plug/tui.ts b/packages/opencode/src/plug/tui.ts new file mode 100644 index 0000000000..30338d1da8 --- /dev/null +++ b/packages/opencode/src/plug/tui.ts @@ -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 | 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 +} + +/** + * 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> + /** 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 + /** Newly installed plugin origins waiting to be added to the live runtime. */ + readonly pending: ReadonlyMap +} + +/** + * 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 + /** Return current status rows for the TUI plugin UI. */ + readonly list: () => Fx + /** Enable one plugin id. */ + readonly activate: (id: string) => Fx + /** Disable one plugin id. */ + readonly deactivate: (id: string) => Fx + /** Add one already-configured plugin spec to the live runtime. */ + readonly add: (spec: string) => Fx + /** Install a package and patch config, returning the current TUI-facing result type. */ + readonly install: (spec: string, options: { global?: boolean } | undefined) => Fx + /** Dispose all active plugins in reverse order. */ + readonly dispose: () => Fx +} + +/** + * 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 + +/** + * Stateless helper signature for deactivating one plugin entry. + */ +export type DeactivateEntry = (input: { + state: State + plugin: PluginEntry + persist: boolean +}) => Fx + +/** + * Stateless helper signature for syncing theme files for one plugin entry. + */ +export type SyncThemes = (plugin: PluginEntry) => Fx