From 02726b82be8650629bd77662e9a5d97763fc3244 Mon Sep 17 00:00:00 2001 From: Bonanza Date: Mon, 27 Jul 2026 19:45:44 -0700 Subject: [PATCH 01/29] feat(verify): SDK 3.0.0 d.ts mirror + freshness pin + F1 knowledge gate + CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reference/plugin-api/: byte-verbatim mirror of the published @appos.space/plugin-types@3.0.0 tarball's 9 dist/*.d.ts (maps dropped); generated INDEX.md records version, dist.integrity sha512, per-file sha256, and the regeneration command; old single-file plugin-api.d.ts (2950-line 2.4.0-fn50 snapshot) deleted - scripts/check-sdk-freshness.sh: check mode (registry dist.integrity vs committed .sdk-integrity pin + tarball-byte sha512 + per-file byte-equality + INDEX consistency; exits 0 today) and --update regeneration mode - scripts/verify-knowledge.mjs: ts-fence type-check against the pinned SDK + exported-name-set diff (mirror vs installed) + stale-identifier denylist + count-string consistency; three-tier scan matrix (teaching full scan / migration guide fences-only / compiled/** excluded); currently RED (184 findings) over the unrefreshed corpus BY DESIGN — proves the F1 drift class is detected before fn-165.2/.3 refresh content - package.json + package-lock.json: exact pins (typescript 5.9.3; @appos.space/plugin-types, plugin-utils, view-builders all 3.0.0) - .github/workflows/verify.yml: first CI for this repo (npm ci + both gates on PR/push; commented slot for fn-165.4 check-compiled-freshness) - README: knowledge-verification section + duplicate-clone retirement note Task: fn-165-ship-the-9-wave-plugin-ecosystem-sdk.1 --- .github/workflows/verify.yml | 43 + .sdk-integrity | 3 + README.md | 14 + package-lock.json | 54 + package.json | 17 + .../reference/plugin-api.d.ts | 2950 ----------------- .../reference/plugin-api/INDEX.md | 27 + .../reference/plugin-api/colors.d.ts | 14 + .../reference/plugin-api/core.d.ts | 418 +++ .../reference/plugin-api/fonts.d.ts | 5 + .../reference/plugin-api/icons.d.ts | 9 + .../reference/plugin-api/index.d.ts | 17 + .../plugin-api/namespaces-core-plugins.d.ts | 878 +++++ .../reference/plugin-api/namespaces.d.ts | 1340 ++++++++ .../reference/plugin-api/permissions.d.ts | 49 + .../reference/plugin-api/views.d.ts | 154 + scripts/check-sdk-freshness.sh | 248 ++ scripts/verify-knowledge.mjs | 409 +++ 18 files changed, 3699 insertions(+), 2950 deletions(-) create mode 100644 .github/workflows/verify.yml create mode 100644 .sdk-integrity create mode 100644 package-lock.json create mode 100644 package.json delete mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api.d.ts create mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/INDEX.md create mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/colors.d.ts create mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/core.d.ts create mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/fonts.d.ts create mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/icons.d.ts create mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/index.d.ts create mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/namespaces-core-plugins.d.ts create mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/namespaces.d.ts create mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/permissions.d.ts create mode 100644 plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/views.d.ts create mode 100755 scripts/check-sdk-freshness.sh create mode 100755 scripts/verify-knowledge.mjs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..a84c2b1 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,43 @@ +# verify.yml — fn-165.1 (R2): knowledge-drift gate. +# +# This repo had zero CI before fn-165 — a gate nobody runs is not a gate. +# Runs on every PR + push to main: +# 1. check-sdk-freshness.sh — bundled d.ts mirror is byte-equal to the +# PUBLISHED @appos.space/plugin-types tarball pinned in .sdk-integrity +# 2. verify-knowledge.mjs — teaching surfaces type-check against the +# pinned SDK (fence compile + exported-name-set diff + stale-identifier +# denylist + count-string consistency) +# +# Deterministic diagnostics: `npm ci` against the committed package-lock.json +# (typescript + @appos.space/* pinned EXACTLY in devDependencies). +name: verify + +on: + pull_request: + push: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install pinned toolchain (npm ci) + run: npm ci + + - name: SDK mirror freshness (integrity pin + byte-equality vs npm tarball) + run: scripts/check-sdk-freshness.sh + + - name: Knowledge verification (fence type-check + denylist + counts) + run: node scripts/verify-knowledge.mjs + + # fn-165.4 slot: compiled-artifact freshness (sources changed without + # compiled regen?). Added when the compile pipeline lands: + # - name: Compiled-artifact freshness + # run: scripts/check-compiled-freshness.sh diff --git a/.sdk-integrity b/.sdk-integrity new file mode 100644 index 0000000..5fb64ed --- /dev/null +++ b/.sdk-integrity @@ -0,0 +1,3 @@ +package=@appos.space/plugin-types +version=3.0.0 +integrity=sha512-Sw6Ro/MNq07cn/idU1d+ccyjjf0vAYrr09Mh/O3vnHXqrgjpg1l5YO7lpCMHqLoxJAxw3pRXD1pcLDJ1GMhT7g== diff --git a/README.md b/README.md index 251ca38..1579b88 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,20 @@ claude --plugin-dir /path/to/appos-dev-plugin/plugins/appos-dev - `pipeShellToWebPanel` lives on `ctx.ui`, NOT `ctx.shell` (stale docs are wrong) - Install path: `~/Library/Application Support/AppOS/plugins/{plugin-id}/` (see `/appos-dev:deploy`) +## Knowledge verification (CI) + +Every teaching surface in this repo is gated against the PUBLISHED `@appos.space/plugin-types` package (`.github/workflows/verify.yml` runs both checks on PR via `npm ci`): + +```bash +npm ci # install the exact-pinned toolchain +scripts/check-sdk-freshness.sh # bundled d.ts mirror byte-equal to the npm tarball +node scripts/verify-knowledge.mjs # fence type-check + stale-identifier denylist + count consistency +``` + +The bundled type reference is `plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/` — a byte-verbatim mirror of the published tarball's `dist/*.d.ts` files. Its `INDEX.md` records the `dist.integrity` pin, per-file sha256 hashes, and the regeneration command (`scripts/check-sdk-freshness.sh --update`). + +> Maintainer note (2026-07): the canonical local clone of this repo is `~/Documents/GitHub/AppOS/appos-dev-plugin`. The historical duplicate clone at `~/Documents/GitHub/appos-dev-plugin` is retired — tombstoned with a `RETIRED.md` pointing here — so edits land in exactly one working copy. + ## License MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1e00bb1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "appos-dev-plugin-verify", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appos-dev-plugin-verify", + "devDependencies": { + "@appos.space/plugin-types": "3.0.0", + "@appos.space/plugin-utils": "3.0.0", + "@appos.space/view-builders": "3.0.0", + "typescript": "5.9.3" + } + }, + "node_modules/@appos.space/plugin-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@appos.space/plugin-types/-/plugin-types-3.0.0.tgz", + "integrity": "sha512-Sw6Ro/MNq07cn/idU1d+ccyjjf0vAYrr09Mh/O3vnHXqrgjpg1l5YO7lpCMHqLoxJAxw3pRXD1pcLDJ1GMhT7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@appos.space/plugin-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@appos.space/plugin-utils/-/plugin-utils-3.0.0.tgz", + "integrity": "sha512-BNALQHi826yyOQSE//khllcG/xOq9wvkcfSCjvm95wuzGWtgb1x5vBHnMuzy4GZJHGxs371csFM/qKOwPVwTJQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@appos.space/view-builders": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@appos.space/view-builders/-/view-builders-3.0.0.tgz", + "integrity": "sha512-8ypcL8zh+xanew0/3frx4tqKRdphYM297GMDbV7AeV1Z/m0JAs1yAkwm1FfLcIK4ZGypxSbBkhXbFGnYNRRCPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@appos.space/plugin-types": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b4c0617 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "appos-dev-plugin-verify", + "private": true, + "description": "Verification tooling for the appos-dev Claude Code plugin knowledge base (fn-165). Not a published package — devDependencies pin the exact SDK + compiler used by scripts/verify-knowledge.mjs and scripts/check-sdk-freshness.sh.", + "type": "module", + "scripts": { + "verify": "node scripts/verify-knowledge.mjs", + "freshness": "scripts/check-sdk-freshness.sh", + "check": "npm run freshness && npm run verify" + }, + "devDependencies": { + "@appos.space/plugin-types": "3.0.0", + "@appos.space/plugin-utils": "3.0.0", + "@appos.space/view-builders": "3.0.0", + "typescript": "5.9.3" + } +} diff --git a/plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api.d.ts b/plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api.d.ts deleted file mode 100644 index 49971f5..0000000 --- a/plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api.d.ts +++ /dev/null @@ -1,2950 +0,0 @@ -/** - * AppOS Plugin API Type Definitions (Phase 2b-iii) - * - * These types define the contract between plugins and the host application. - * Plugins receive a `PluginContext` object in their `activate(context)` function. - * - * @version 2.4.0-fn50 - * - * Note: WindowSnapshot includes a reserved `plugins: []` field for future - * per-window plugin state persistence. Plugin authors should not - * depend on workspace persistence until the API is stabilized. - */ - -// ============================================================================ -// Data Types -// ============================================================================ - -/** - * Describes a file or directory visible to plugins. - * - * This is a bridge-specific DTO — it does NOT expose internal FileItem details - * like icon names or sort keys. All fields are JSON-safe. - */ -interface PluginFileDescriptor { - /** File URL as a string (e.g., "file:///Users/alice/Documents/readme.md"). */ - url: string; - /** File name including extension (e.g., "readme.md"). */ - name: string; - /** Whether this item is a directory. */ - isDirectory: boolean; - /** File size in bytes, or null for directories. */ - size: number | null; - /** Modification date in ISO 8601 format, or null if unavailable. */ - modificationDate: string | null; - /** Whether the file is hidden. */ - isHidden: boolean; - /** Lowercase file extension without the dot, or null for items without extension. */ - fileExtension: string | null; -} - -/** - * JSON-based view descriptor for plugin UI contributions. - * - * Plugins submit UI as JSON descriptors rather than arbitrary views. - * The host maps these to a fixed set of SwiftUI component templates. - */ -interface ViewDescriptor { - /** The type of UI element. */ - type: "text" | "image" | "hstack" | "vstack" | "button" | "section" | "divider" | "spacer" | "scroll" | "label" | "badge" | "list" | "listItem" | "textField" | "progress" | "remoteImage" | "grid"; - /** Child descriptors for container types (hstack, vstack, section, scroll, list). */ - children?: ViewDescriptor[]; - /** - * Type-specific properties. - * - `text`: `content` (string), `font` (string), `width` (number, fixed pt), `align` ("leading"|"trailing"|"center"), `mono` (boolean, .monospacedDigit()), `tooltip` (string) - * - `button`: `title` (string), `action` (string), `width` (number, fixed pt), `tooltip` (string, shown on hover — width-constrained buttons also get hover background) - * - `section`: `id` (string), `title` (string), `icon` (string SF symbol), `badge` (string), `isExpanded` (boolean) - * - `scroll`: `axes` ("horizontal" | "vertical") - * - `label`: `title` (string), `icon` (string SF symbol), `font` (string) - * - `badge`: `text` or `content` (string), `color` (hex or system color name) - * - `listItem`: `title` (string), `subtitle` (string), `icon` (string), `iconColor` (string), `trailing` (string), `action` (string), - * `menuActions` (JSON string of [{title, icon?, action, destructive?}] — native right-click context menu. Use "---" title for divider). - * When `children` are present, they render as fixed-width trailing columns (replacing `trailing` badge). - * Use text/button children with `width` set for aligned sidebar columns. - * Title shows native tooltip on hover for full text visibility. - * - `spacer`: `minLength` (number) - * - `progress`: `value` (number 0.0-1.0, omit for indeterminate), `label` (string), `style` ("bar" | "circular", default: "bar") - * - `remoteImage`: `url` (string, file:// only in Phase 1), `width` (number), `height` (number), `cornerRadius` (number), `maxDimension` (number, default 512 — max pixel size for downsampling) - * - `grid`: `columns` (number, default 3), `spacing` (number, default 8). Children render as grid items in a LazyVGrid with flexible columns - * - * Column alignment example (File Stats): - * ```json - * { "type": "listItem", "properties": { "title": ".json", "icon": "doc" }, - * "children": [ - * { "type": "text", "properties": { "content": "1", "width": 36, "align": "trailing", "font": "caption", "mono": true } }, - * { "type": "text", "properties": { "content": "(4.8%)", "width": 54, "align": "trailing", "font": "caption" } } - * ] - * } - * ``` - */ - properties?: Record; -} - -/** - * Options for command registration. - */ -interface CommandOptions { - /** Human-readable title for the command. */ - title: string; - /** SF Symbol name for the command icon. */ - icon?: string; - /** Keyboard shortcut string (e.g., "cmd+shift+t"). */ - shortcut?: string; - /** Condition when the command is available (e.g., "isDirectory"). Phase 1b: stored but not evaluated. */ - condition?: string; - /** The handler function called when the command is executed. */ - handler: () => void | Promise; -} - -/** - * Options for structured panel registration. - */ -interface PanelOptions { - /** Display title for the panel. */ - title: string; - /** SF Symbol name for the panel icon. */ - icon?: string; - /** Target region: "sidebar" or "pane". Default: "sidebar". */ - target?: "sidebar" | "pane"; - /** Position hint: "top" or "bottom" (for sidebar). Default: "bottom". */ - position?: "top" | "bottom"; - /** JSON view descriptor for the panel content. Accepts "view" or "content" key. */ - view: ViewDescriptor; - /** Handler function invoked for interactive elements. */ - handler?: (action: string) => void | Promise; - /** Priority for ordering (100+ for plugins, lower = higher priority). */ - priority?: number; - /** - * Whether the panel should auto-show in a pane tab on first registration. - * Only applies when `target` is `"pane"`. Subsequent content updates - * (re-calling `registerPanel` with the same ID) do NOT auto-show. - * Default: `true`. - */ - autoShow?: boolean; -} - -/** - * Options for activity bar item registration. - */ -interface ActivityBarItemOptions { - /** Display title for the item. */ - title: string; - /** SF Symbol name for the item icon. */ - icon: string; - /** Position hint: "top" or "bottom". Default: "bottom". */ - position?: "top" | "bottom"; - /** JSON view descriptor for the item content. */ - view?: ViewDescriptor; - /** Handler function invoked when the item is clicked. */ - handler?: (action: string) => void | Promise; - /** Priority for ordering (100+ for plugins). */ - priority?: number; -} - -/** - * Options for structured activity view registration. - */ -interface ActivityViewOptions { - /** Display title for the activity view. */ - title: string; - /** SF Symbol name for the activity icon. */ - icon: string; - /** Optional badge text. */ - badge?: string; - /** - * JSON view descriptor for the sidebar content. - * Optional when `linkedPanel` is set — the activity bar icon opens a pane - * tab instead of (or in addition to) showing sidebar content. - */ - view?: ViewDescriptor; - /** Handler function invoked for interactive elements within the view. */ - handler?: (action: string) => void | Promise; - /** Priority for ordering (100+ for plugins). */ - priority?: number; - /** - * Links this activity bar icon to a registered pane panel. - * When set, clicking the icon opens or focuses the linked panel - * in a pane tab instead of switching the sidebar. - * - * The value is the short panel ID (without plugin prefix). - * It is automatically qualified with `{pluginId}.` at runtime. - * - * If the linked panel is not yet registered when clicked, a warning - * is logged and the click is a no-op. - * - * @example - * ```js - * context.ui.registerPanel("my-pane", { - * title: "My Pane", - * target: "pane", - * view: { type: "text", properties: { content: "Hello" } } - * }); - * context.ui.registerActivityView("my-activity", { - * title: "My Plugin", - * icon: "puzzlepiece", - * linkedPanel: "my-pane" - * }); - * ``` - */ - linkedPanel?: string; -} - -/** - * Options for status bar item registration. - */ -interface StatusBarItemOptions { - /** Display text for the item. */ - text: string; - /** SF Symbol name for the item icon. */ - icon?: string; - /** Position hint: "left" or "right". Default: "right". */ - position?: "left" | "right"; - /** JSON view descriptor for the item content. */ - view?: ViewDescriptor; - /** Handler function invoked when the item is clicked. */ - handler?: (action: string) => void | Promise; - /** Priority for ordering (100+ for plugins). */ - priority?: number; -} - -/** - * Options for context menu item registration. - */ -interface ContextMenuItemOptions { - /** Display title for the menu item. */ - title: string; - /** SF Symbol name for the menu item icon. */ - icon?: string; - /** Condition type for when to show: "always" (default), "isDirectory", "isFile". */ - condition?: "always" | "isDirectory" | "isFile"; - /** Handler function invoked when the item is selected. */ - handler: () => void | Promise; -} - -/** - * Options for toolbar item registration. - */ -interface ToolbarItemOptions { - /** Display title for the item. */ - title: string; - /** SF Symbol name for the item icon. */ - icon: string; - /** Handler function invoked when the item is clicked. */ - handler: () => void | Promise; -} - -/** - * Options for file row annotation registration. - */ -interface FileRowAnnotationOptions { - /** Display title for the annotation. */ - title?: string; - /** SF Symbol name for the annotation icon. */ - icon?: string; - /** Handler function invoked to determine annotation state for a file. */ - handler: (fileDescriptor: PluginFileDescriptor) => any; -} - -/** - * Options for showing a notification toast. - */ -interface NotificationOptions { - /** Notification message text. */ - message: string; - /** Notification type: "info", "success", "warning", "error". Default: "info". */ - type?: "info" | "success" | "warning" | "error"; - /** Duration in seconds before auto-dismiss. Default: 2. */ - duration?: number; -} - -/** - * Options for showing a modal sheet. - * - * The entire options object is converted to a view descriptor dictionary. - * Include a "view" or "content" key with a ViewDescriptor, or pass - * view descriptor properties directly. - */ -interface SheetOptions { - /** Sheet title. */ - title: string; - /** JSON view descriptor for the sheet content. */ - view?: ViewDescriptor; - /** Whether the sheet is dismissable by clicking outside. Default: true. */ - dismissable?: boolean; -} - -/** - * Options for content panel registration. - */ -interface PanelOptions { - /** Display title for the panel. */ - title: string; - /** SF Symbol name for the panel icon. */ - icon?: string; - /** Position hint: "top" or "bottom". Default: "bottom". */ - position?: "top" | "bottom"; - /** JSON view descriptor for the panel content. */ - view: ViewDescriptor; - /** Handler function invoked for interactive elements. */ - handler?: (action: string) => void | Promise; - /** Priority for ordering (100+ for plugins). */ - priority?: number; -} - -/** - * File operation types that can be hooked. - */ -type FileOperationType = - | "copy" - | "move" - | "delete" - | "rename" - | "createFile" - | "createDirectory" - | "writeFile"; - -/** - * A single operation in a `fileOps.batch()` call. - */ -type BatchOperation = - | { type: "copy"; sources: string[]; dest: string } - | { type: "move"; sources: string[]; dest: string } - | { type: "delete"; urls: string[]; trash?: boolean } - | { type: "rename"; url: string; newName: string } - | { type: "createDirectory"; parentUrl: string; name: string } - | { type: "createFile"; parentUrl: string; name: string; contents?: string }; - -/** - * Information passed to before/after hook handlers. - * - * The coordinator sends `{ type, paths }` for before-hooks and - * `{ type, paths, success, error?, ...extras }` for after-hooks. - */ -interface FileOperationEvent { - /** The type of operation. */ - type: FileOperationType; - /** File URL strings involved in the operation. */ - paths: string[]; - /** Whether the operation succeeded (after-hooks only). */ - success?: boolean; - /** Error message if the operation failed (after-hooks only). */ - error?: string; - /** New URL after rename/create operations (after-hooks only). */ - newUrl?: string; - /** Created/target URL for createFile/createDirectory (after-hooks only). */ - url?: string; -} - -/** - * Result from a before-hook handler to cancel an operation. - * - * Phase 1b behavior: returning ANY non-null/non-undefined object cancels - * the operation. The `reason` field is read for display purposes. - * Return `undefined` or `null` to allow the operation to proceed. - */ -interface BeforeHookResult { - /** Reason for cancellation (shown to the user/calling plugin). */ - reason?: string; -} - -// ============================================================================ -// Dependency Management Types -// ============================================================================ - -/** - * Classifies a dependency as either a system binary or another plugin. - */ -type DependencyType = "system" | "plugin"; - -/** - * The resolved installation state of a single dependency. - * - * - `"not_found"` — Binary or plugin not found on the system. - * - `"installed"` — Found with a detected version string (see `installedVersion`). - * - `"installed_version_unknown"` — Found but version could not be detected. - * - `"permission_denied"` — The `shell.execute` permission was not granted. - * - `"command_not_allowed"` — The `check.command` is not in `shellCommands` allowlist. - */ -type InstallationState = - | "not_found" - | "installed" - | "installed_version_unknown" - | "permission_denied" - | "command_not_allowed"; - -/** - * The resolved status of a single declared dependency (system or plugin). - * - * Returned by `lifecycle.getDependencyStatus()` and - * `lifecycle.recheckDependencies()`. Matches the Swift `DependencyStatus` - * struct with custom Codable flattening of `InstallationState`. - */ -interface DependencyStatus { - /** Human-readable name of the dependency. */ - name: string; - /** Whether this is a system binary or plugin dependency. */ - type: DependencyType; - /** Whether this dependency is required for the plugin to function. */ - required: boolean; - /** Whether the dependency constraint is fully satisfied. */ - satisfied: boolean; - /** The resolved installation state. */ - state: InstallationState; - /** Detected version string. Present only when `state === "installed"`. */ - installedVersion?: string; - /** Minimum version constraint from the manifest. Undefined when no `minVersion` declared. */ - requiredVersion?: string; - /** Human-readable install hint (e.g., "brew install yt-dlp"). */ - installHint?: string; - /** URL to installation instructions. */ - installUrl?: string; - /** Human-readable description of the dependency's purpose. */ - description?: string; - /** Reason why the dependency is unsatisfied. Present only when `satisfied === false`. */ - unsatisfiedReason?: string; - /** Causal chain for transitive dependencies. Present for required transitive deps (e.g., `["required by com.foo.bar"]`). */ - causalChain?: string[]; -} - -// ============================================================================ -// Dependency Manifest Types -// ============================================================================ - -/** - * How to probe for a system binary dependency. - */ -interface SystemDependencyCheck { - /** Command name to execute (first argv element). */ - command: string; - /** Arguments passed after the command. */ - args?: string[]; - /** Regex with one capture group to extract the version from stdout. */ - versionPattern?: string; -} - -/** - * A declared dependency on a system binary (e.g., yt-dlp, ffmpeg, git). - * - * Declared in the `dependencies.system` array of `plugin.json`. - * The host runs `check.command` + `check.args` at activation time to probe - * binary presence. Requires `shell.execute` permission and `shellCommands` - * allowlist entry for the `check.command`. - */ -interface SystemDependency { - /** Human-readable name of the dependency (e.g., "yt-dlp"). */ - name: string; - /** Probe configuration for detecting the binary. */ - check: SystemDependencyCheck; - /** Minimum version constraint string. */ - minVersion?: string; - /** Whether this dependency is required. Default: `true`. */ - required?: boolean; - /** Human-readable install hint (e.g., "brew install yt-dlp"). */ - installHint?: string; - /** URL to installation instructions. Must start with `https://` or `http://`. */ - installUrl?: string; - /** Human-readable description of the dependency's purpose. */ - description?: string; -} - -/** - * A declared dependency on another plugin. - * - * Declared in the `dependencies.plugins` array of `plugin.json`. - */ -interface ManifestPluginDependency { - /** The plugin ID of the dependency (e.g., "com.community.shared-utils"). */ - id: string; - /** Minimum version constraint (semver). */ - minVersion?: string; - /** Whether this dependency is required. Default: `true`. */ - required?: boolean; -} - -/** - * A declared dependency on another plugin (alias for ManifestPluginDependency). - * - * This alias matches the spec/API naming convention. The full name - * `ManifestPluginDependency` disambiguates from the internal Swift - * `PluginDependency` struct which uses different field semantics. - */ -type PluginDependency = ManifestPluginDependency; - -/** - * Dependencies section of `plugin.json`. - * - * Plugins declare system binary and/or plugin dependencies here. - * The host resolves these at activation time and reports status via - * `lifecycle.getDependencyStatus()`. - */ -interface PluginDependencies { - /** System binary dependencies (e.g., yt-dlp, ffmpeg). */ - system?: SystemDependency[]; - /** Plugin dependencies (other AppOS plugins). */ - plugins?: PluginDependency[]; -} - -// ============================================================================ -// Plugin Context API -// ============================================================================ - -/** - * The main plugin context object passed to `activate(context)`. - * - * Provides access to all Phase 1b API namespaces: file operations, - * commands, UI contributions, storage, and settings. - */ -interface PluginContext { - /** Lifecycle hooks (dependency notifications). */ - readonly lifecycle: LifecycleAPI; - /** Command registration and execution. */ - readonly commands: CommandsAPI; - /** File system operations. */ - readonly fileOps: FileOpsAPI; - /** UI contribution methods. */ - readonly ui: UIAPI; - /** Scoped key-value storage. */ - readonly storage: StorageAPI; - /** Plugin settings from manifest. */ - readonly settings: SettingsAPI; - /** Extension point declaration and contribution (Phase 2a). */ - readonly extensionPoints: ExtensionPointsAPI; - /** Data contract exposure and querying (Phase 2a). */ - readonly dataContracts: DataContractsAPI; - /** Inter-plugin event channels (Phase 2a). */ - readonly interPluginEvents: InterPluginEventsAPI; - /** Smart folder filter type registration and evaluation. */ - readonly smartFolders: SmartFoldersAPI; - /** - * File preview registry queries and programmatic preview triggering. - * Note: `registerProvider` is CorePlugin-only in v1 — JS callers receive ContractValidationError. - */ - readonly preview: PreviewAPI; - /** Host event subscriptions ( +). */ - readonly events: HostEventsAPI; - /** Network fetch and download. Requires `network.outbound` or `network.unrestricted`. */ - readonly network: NetworkAPI; - /** Shell command execution. Requires `shell.execute`. */ - readonly shell: ShellAPI; - /** System clipboard read/write. Requires `clipboard.read` / `clipboard.write`. */ - readonly clipboard: ClipboardAPI; - /** Keyboard shortcut registration. Requires `ui.shortcuts`. */ - readonly shortcuts: ShortcutsAPI; - /** Theme registration and activation. Requires `ui.themes` for mutations. */ - readonly themes: ThemesAPI; - /** Workspace template management. Requires `workspaces`. */ - readonly workspaces: WorkspacesAPI; - /** Plugin cache with memory + disk tiers and TTL. Requires `cache`. */ - readonly cache: PluginCacheAPI; - /** Toast, HUD, confirmation, and progress feedback. Requires `feedback`. */ - readonly feedback: PluginFeedbackAPI; - /** OAuth 2.0 + PKCE authorization. Requires `oauth`. */ - readonly oauth: PluginOAuthAPI; - /** Menu bar NSStatusItem management. Requires `menubar`. */ - readonly menubar: PluginMenuBarAPI; -} - -// ============================================================================ -// Host Events API ( +) -// ============================================================================ - -/** - * Payload delivered to `fileOps.operationCompleted` subscribers. - * - * Fires once per operation in a `fileOps.batch()` call (success AND failure), - * enabling plugins to observe all outcomes without polling. - */ -interface FileOpCompletedPayload { - /** The operation type. Note: `writeFile` is not a batch-supported type; only the listed values appear. */ - type: "copy" | "move" | "delete" | "rename" | "createDirectory" | "createFile"; - /** - * Affected URL strings. - * - * **On success** — mirrors the hook-path composition: - * - `copy`/`move`: `[...sources, destDir, ...computedPerSourceDestinations]` - * - `delete`: `[...urls]` - * - `rename`: `[oldUrl, newUrl]` - * - `createDirectory`/`createFile`: `[parentUrl, createdUrl]` - * - * **On failure** — contains only the raw input paths (no computed destinations), - * because the operation may have failed before any destination path was validated. - * - `copy`/`move`: `[...sources, destDir]` (no per-source destinations) - * - `delete`: `[...urls]` - * - `rename`: `[oldUrl]` - * - `createDirectory`/`createFile`: `[parentUrl]` - */ - paths: string[]; - /** Whether the operation succeeded. */ - success: boolean; - /** Error message if the operation failed; absent on success. */ - error?: string; - /** Plugin ID that initiated the operation. */ - initiatorPluginId: string; -} - -/** - * Host event subscriptions (`context.events`). - * - * Supported events: - * - * | Event | Permission | Payload | - * |-------|-----------|---------| - * | `navigation.directoryChanged` | `filesystem.read` or `filesystem.readAll` | `{paneId, oldUrl, newUrl, windowId?}` | - * | `navigation.paneActivated` | `filesystem.read` | `{paneId, windowId?}` | - * | `selection.changed` | `filesystem.read` | `{paneId, selectedPaths, windowId?}` | - * | `smartFolder.evaluated` | `filesystem.read` | `{folderId, resultCount, windowId?}` | - * | `app.willQuit` | none | `{}` | - * | `app.didBecomeActive` | none | `{}` | - * | `plugin.activated` | none | `{pluginId}` | - * | `plugin.deactivated` | none | `{pluginId}` | - * | `fileOps.operationCompleted` | `filesystem.readAll` | `FileOpCompletedPayload` | - * | `oauth.tokenRefreshed` | `oauth` | `{pluginId, provider}` | - * | `oauth.tokenRevoked` | `oauth` | `{pluginId, provider}` | - * | `menubar.clicked` | `menubar` | `{pluginId}` | - * | `menubar.popoverOpened` | `menubar` | `{pluginId}` | - * | `menubar.popoverClosed` | `menubar` | `{pluginId}` | - * | `store.pluginInstalled` | none | `{pluginId, version}` | - * | `store.pluginUpdated` | none | `{pluginId, fromVersion, toVersion}` | - * | `store.pluginUninstalled` | none | `{pluginId}` | - * - * **Window scoping:** Per-window events (`navigation.*`, `selection.*`, `smartFolder.*`) - * include an optional `windowId` (UUID string) identifying the source window. Plugins receive - * events from ALL windows — filter by `windowId` if needed. `windowId` is `undefined` for - * app-wide events and may be `undefined` during the transition period. - */ -interface HostEventsAPI { - /** - * Subscribes to a host event. - * @param eventName - One of the supported event names listed above. - * @param handler - Callback invoked with the event payload. - * @returns Subscription token; pass to `unsubscribe()` to cancel. - */ - subscribe(eventName: string, handler: (payload: unknown) => void): string; - - /** - * Cancels a host event subscription. - * @param token - Token returned by `subscribe()`. - */ - unsubscribe(token: string): void; -} - -// ============================================================================ -// Lifecycle API -// ============================================================================ - -interface LifecycleAPI { - /** - * Registers a handler called when a dependency becomes available. - * @param depId - The dependency plugin ID. - * @param handler - Callback invoked when the dependency activates. - */ - onDependencyAvailable(depId: string, handler: () => void): void; - - /** - * Registers a handler called when a dependency becomes unavailable. - * @param depId - The dependency plugin ID. - * @param handler - Callback invoked when the dependency deactivates. - */ - onDependencyUnavailable(depId: string, handler: () => void): void; - - /** - * Returns the current status of all declared dependencies (system + plugin). - * - * Each entry includes installation state, version info, and satisfaction status. - * System dependencies are probed via `check.command`; plugin dependencies are - * resolved from the host's plugin registry. - * - * @returns Promise resolving to an array of dependency status objects. - */ - getDependencyStatus(): Promise; - - /** - * Re-runs both system and plugin dependency checks and updates state. - * - * Call after the user installs a missing system dependency (e.g., `brew install yt-dlp`). - * If all required deps become satisfied, the plugin transitions from degraded to active. - * Fires `onDependencyStatusChanged` handlers if any status changed. - * - * @returns Promise resolving to the updated dependency status array. - */ - recheckDependencies(): Promise; - - /** - * Registers a handler called when any dependency status changes. - * - * Fires on: - * - Host-side `recheckDependencies()` (manual or SwiftUI-triggered) - * - Plugin-side `recheckDependencies()` (JS bridge call) - * - Dependency plugin lifecycle changes (activate, deactivate, degraded) - * - * @param handler - Callback receiving the full updated status array. - * @returns A token string that can be used to identify the registration. - */ - onDependencyStatusChanged(handler: (statuses: DependencyStatus[]) => void): string; -} - -// ============================================================================ -// Commands API -// ============================================================================ - -interface CommandsAPI { - /** - * Registers a command. - * - * The plugin passes a SHORT ID (e.g., "myCmd"). The bridge auto-prefixes - * with `{pluginId}.` to form the full ID (e.g., "com.myplugin.myCmd"). - * - * @param id - Short command ID (will be prefixed with plugin ID). - * @param handlerOrOptions - Either a handler function (backward compat) or CommandOptions. - */ - register(id: string, handlerOrOptions: (() => void | Promise) | CommandOptions): void; - - /** - * Executes a command by ID. - * - * Allowed targets: own commands, core commands (`com.2panez.*`). - * Cross-plugin execution is blocked in Phase 1b. - * - * @param id - Command ID (short own, full own, or full core). - * @param args - Optional arguments (reserved for future use). - * @returns Promise that resolves when the command completes. - */ - execute(id: string, args?: Record): Promise; - - /** - * Returns the calling plugin's own registered commands as short IDs. - * @returns Array of short command ID strings. - */ - getRegistered(): string[]; - - /** - * Registers a handler called after a command executes. - * - * Own-namespace commands ONLY. Subscribing to core (`com.2panez.*`) or - * other plugins' commands is rejected with an error. - * - * @param id - Short command ID (own namespace only). - * @param handler - Callback with execution details. - * @returns Subscription ID for cancellation. - */ - onCommandExecuted(id: string, handler: (details: Record) => void): string; -} - -// ============================================================================ -// File Operations API -// ============================================================================ - -interface FileOpsAPI { - /** - * Returns file descriptors for the active pane's selected files. - * Requires: `filesystem.read` - */ - getSelectedFiles(): Promise; - - /** - * Returns the URL string of the active pane's current directory. - * Requires: `filesystem.read` - */ - getActiveDirectory(): Promise; - - /** - * Returns the URL string of the specified pane's directory. - * Requires: `filesystem.read` - * @param paneId - "left" or "right" - */ - getPaneDirectory(paneId: string): Promise; - - /** - * Lists directory contents. - * Requires: `filesystem.read` (pane dirs only) or `filesystem.readAll` (any path) - * @param url - Directory URL string. - */ - listDirectory(url: string): Promise; - - /** - * Returns metadata for a single file. - * Requires: `filesystem.read` or `filesystem.readAll` - * @param url - File URL string. - */ - getFileInfo(url: string): Promise; - - /** - * Reads a text file. - * Requires: `filesystem.read` or `filesystem.readAll` - * @param url - File URL string. - * @param encoding - Text encoding (e.g., "utf-8"). Default: "utf-8". - */ - readFile(url: string, encoding?: string): Promise; - - /** - * Reads a file as a base64-encoded string. - * Requires: `filesystem.read` or `filesystem.readAll` - * - * To decode in JavaScript: - * ```js - * const base64 = await context.fileOps.readFileData(url); - * const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); - * ``` - * - * @param url - File URL string. - * @returns Base64-encoded string of the file contents. - */ - readFileData(url: string): Promise; - - /** - * Copies files to a destination directory. - * Requires: `filesystem.write` or `filesystem.writeAll` - * @param sources - Array of source file URL strings. - * @param dest - Destination directory URL string. - */ - copy(sources: string[], dest: string): Promise; - - /** - * Moves files to a destination directory. - * Requires: `filesystem.write` or `filesystem.writeAll` - * @param sources - Array of source file URL strings. - * @param dest - Destination directory URL string. - */ - move(sources: string[], dest: string): Promise; - - /** - * Deletes files. - * Requires: `filesystem.write` or `filesystem.writeAll` - * @param urls - Array of file URL strings to delete. - * @param trash - If true, move to Trash; if false, permanently delete. Default: true. - */ - delete(urls: string[], trash?: boolean): Promise; - - /** - * Renames a file. - * Requires: `filesystem.write` or `filesystem.writeAll` - * @param url - File URL string to rename. - * @param newName - The new file name. - * @returns The new URL string after renaming. - */ - rename(url: string, newName: string): Promise; - - /** - * Creates a new directory. - * Requires: `filesystem.write` or `filesystem.writeAll` - * @param parentUrl - Parent directory URL string. - * @param name - Name for the new directory. - * @returns The URL string of the created directory. - */ - createDirectory(parentUrl: string, name: string): Promise; - - /** - * Creates a new file with optional initial contents. - * Requires: `filesystem.write` or `filesystem.writeAll` - * @param parentUrl - Parent directory URL string. - * @param name - Name for the new file. - * @param contents - Optional initial text content. - * @returns The URL string of the created file. - */ - createFile(parentUrl: string, name: string, contents?: string): Promise; - - /** - * Writes text content to an existing file. - * Requires: `filesystem.write` or `filesystem.writeAll` - * @param url - File URL string. - * @param contents - Text content to write. - * @param encoding - Text encoding. Default: "utf-8". - */ - writeFile(url: string, contents: string, encoding?: string): Promise; - - /** - * Watches a directory for changes. - * Requires: `filesystem.watch` (pane-root containment enforced) - * @param url - Directory URL string to watch. - * @param handler - Callback with changed file paths. - * @returns Subscription ID for cancellation via `unwatchDirectory`. - */ - watchDirectory(url: string, handler: (changedPaths: string[]) => void): string; - - /** - * Watches a directory for changes with configurable debounce and recursion. - * Requires: `filesystem.watch` (pane-root containment enforced) - * - * Backward-compatible alternative to `watchDirectory` for plugins that need - * fine-grained control over event delivery. - * - * @param url - Directory URL string to watch. - * @param options - Optional configuration. - * - `debounceMs` — FSEventStream latency in milliseconds. Range: 0–5000. - * Default: 500 (0.5 s, matching `watchDirectory` behavior). - * 200 ms is recommended for interactive plugins; 500 ms for background. - * - `recursive` — When `false`, only immediate children of the watched - * directory trigger the callback. Default: `true`. - * @param handler - Callback with changed file paths. - * @returns Subscription ID for cancellation via `unwatchDirectory`. - */ - watchDirectoryWithOptions( - url: string, - options: { debounceMs?: number; recursive?: boolean }, - handler: (changedPaths: string[]) => void - ): string; - - /** - * Executes multiple file operations as a batch. - * Requires: `filesystem.write` or `filesystem.writeAll` - * - * Operations execute sequentially in array order. On individual failure, - * execution continues (partial failure). Before/after hooks fire per - * operation. Maximum 1000 operations per call. - * - * Supported operation types and their fields: - * - `{ type: "copy", sources: string[], dest: string }` - * - `{ type: "move", sources: string[], dest: string }` - * - `{ type: "delete", urls: string[], trash?: boolean }` (trash defaults to true) - * - `{ type: "rename", url: string, newName: string }` - * - `{ type: "createDirectory", parentUrl: string, name: string }` - * - `{ type: "createFile", parentUrl: string, name: string, contents?: string }` - * - * @param operations - Array of operation descriptors. - * @returns Array of per-operation results: `{ index, success, error? }`. - */ - batch( - operations: BatchOperation[] - ): Promise>; - - /** - * Registers a before-operation hook for write operations. - * Requires: `filesystem.write` or `filesystem.writeAll` - * - * The initiating plugin's own hook is SKIPPED. - * Each handler has a 2-second timeout; global 10-second budget. - * - * @param type - Operation type(s) to hook. - * @param handler - Callback that may return `{ reason?: string }` (or any non-null object) to cancel. - * @returns Subscription ID for cancellation via `removeBeforeHook`. - */ - onBeforeOperation( - type: FileOperationType | FileOperationType[], - handler: (event: FileOperationEvent) => BeforeHookResult | void - ): string; - - /** - * Registers an after-operation hook for write operations. - * Requires: `filesystem.read` or `filesystem.readAll` - * - * Fire-and-forget: no timeout, no cancellation capability. - * - * @param type - Operation type(s) to hook. - * @param handler - Callback with operation result. - * @returns Subscription ID for cancellation via `removeAfterHook`. - */ - onAfterOperation( - type: FileOperationType | FileOperationType[], - handler: (event: FileOperationEvent) => void - ): string; - - /** - * Cancels a directory watcher subscription. - * @param subscriptionId - ID returned by `watchDirectory`. - */ - unwatchDirectory(subscriptionId: string): void; - - /** - * Removes a before-hook subscription. - * @param subscriptionId - ID returned by `onBeforeOperation`. - */ - removeBeforeHook(subscriptionId: string): void; - - /** - * Removes an after-hook subscription. - * @param subscriptionId - ID returned by `onAfterOperation`. - */ - removeAfterHook(subscriptionId: string): void; -} - -// ============================================================================ -// UI API -// ============================================================================ - -interface UIAPI { - /** - * Registers a structured panel (sidebar or pane). - * Requires: `ui.sidebar` - * @param id - Unique panel identifier. - * @param options - Panel configuration. - * @returns Registration ID for updates or cleanup. - */ - registerPanel(id: string, options: PanelOptions): string; - - /** - * Updates an existing structured panel's content. - * Requires: `ui.sidebar` - * @param id - The ID used during registration. - * @param options - Partial panel configuration (usually requires just `view`). - * @returns Registration ID. - */ - updatePanel(id: string, options: Partial): string; - - /** - * Registers a sidebar panel. - * @deprecated Use `registerPanel` with `target: "sidebar"` instead. - * Requires: `ui.sidebar` - * @param id - Unique panel identifier. - * @param options - Panel configuration. - * @returns Registration ID. - */ - registerSidebarPanel(id: string, options: PanelOptions): string; - - /** - * Registers an activity bar item. - * Requires: `ui.sidebar` - * @param id - Unique item identifier. - * @param options - Item configuration. - * @returns SlotToken ID for cleanup. - */ - registerActivityBarItem(id: string, options: ActivityBarItemOptions): string; - - /** - * Registers a structured activity sidebar view. - * Requires: `ui.sidebar` - * @param id - Unique view identifier. - * @param options - View configuration. - * @returns Registration ID for updates or cleanup. - */ - registerActivityView(id: string, options: ActivityViewOptions): string; - - /** - * Registers a status bar item. - * Requires: `ui.statusBar` - * @param id - Unique item identifier. - * @param options - Item configuration. - * @returns SlotToken ID for cleanup. - */ - registerStatusBarItem(id: string, options: StatusBarItemOptions): string; - - /** - * Unregisters an existing slot-based UI contribution (sidebar panel, activity bar item, status bar item). - * @param tokenId The unique token ID returned by the registration method. - */ - unregister(tokenId: string): void; - - /** - * Registers a content panel. - * Requires: `ui.sidebar` - * @param id - Unique panel identifier. - * @param options - Panel configuration. - * @returns SlotToken ID for cleanup. - */ - showPanel(id: string, options: PanelOptions): string; - - /** - * Registers a context menu item. - * Requires: `ui.contextMenu` - * @param id - Unique item identifier. - * @param options - Menu item configuration. - * @returns Registration ID for cleanup. - */ - registerContextMenuItem(id: string, options: ContextMenuItemOptions): string; - - /** - * Registers a toolbar item. - * Requires: `ui.sidebar` (per grouping) - * @param id - Unique item identifier. - * @param options - Toolbar item configuration. - * @returns Registration ID for cleanup. - */ - registerToolbarItem(id: string, options: ToolbarItemOptions): string; - - /** - * Registers a file row annotation provider. - * Requires: `ui.sidebar` - * @param id - Unique annotation identifier. - * @param options - Annotation configuration. - * @returns Registration ID for cleanup. - */ - registerFileRowAnnotation(id: string, options: FileRowAnnotationOptions): string; - - /** - * Shows a notification toast. - * Requires: `ui.notifications` - * Fire-and-forget: no return value. - * @param options - Notification configuration. - */ - showNotification(options: NotificationOptions): void; - - /** - * Shows a modal sheet. - * Requires: `ui.sheets` - * Fire-and-forget: no return value. - * @param options - Sheet configuration. - */ - showSheet(options: SheetOptions): void; - - /** - * Sets the quick filter text on the active pane's file list. - * - * This activates the same filter bar as typing in the file browser. - * Pass an empty string to clear the filter. - * - * No additional permission required beyond `ui.sidebar`. - * @param text - Filter text (e.g. ".json", "readme"). Empty string clears. - */ - setQuickFilter(text: string): void; - - /** - * Opens or focuses a pane tab that renders a previously registered panel. - * - * If the panel is already open in either pane, the existing tab is focused - * and its pane is activated (de-duplication). Otherwise a new tab is created - * in the target pane. - * - * No additional permission required — the panel must already be registered - * via `registerPanel` (which requires `ui.sidebar`). - * - * @param id - Short panel identifier (without plugin prefix). Automatically - * qualified with `{pluginId}.` at runtime. - * @param options - Optional configuration for the new tab. - * @param options.title - Override title for the tab (default: panel title from registry). - * @param options.pane - Target pane: `"left"`, `"right"`, or omit for active pane. - * @returns The short panel ID (same as the `id` argument). - */ - showPaneTab(id: string, options?: { title?: string; pane?: "left" | "right" }): string; - - /** - * Closes any open pane tabs for the given panel ID across both panes. - * - * If the closed tab was the last tab in a pane, a fallback file browser - * tab is created automatically. - * - * No additional permission required — the panel must already be registered - * via `registerPanel` (which requires `ui.sidebar`). - * - * @param id - Short panel identifier (without plugin prefix). Automatically - * qualified with `{pluginId}.` at runtime. - * @returns The short panel ID (same as the `id` argument). - */ - hidePaneTab(id: string): string; - - /** - * Opens a file in an in-pane viewer tab. - * - * Supports images (PNG, JPEG, SVG, GIF), PDFs, and text/source code files. - * Unknown file types show a placeholder with "Open with Default App" button. - * Directories are automatically converted to file browser tabs. - * - * **Permission**: Requires `filesystem.read` or `filesystem.readAll`. - * - `filesystem.read`: file must be within current pane roots (containment check). - * - `filesystem.readAll`: bypasses pane-root containment. - * - * **De-duplication**: If a viewer tab for the same file URL already exists - * in either pane, focuses the existing tab instead of creating a duplicate. - * - * @param url - A `file://` URL string pointing to the file. Non-file URLs - * are rejected with a JS error. - * @param options - Optional configuration. - * @param options.pane - Target pane: `"left"`, `"right"`, or omit for active pane. - * @param options.mode - Reserved for future use. Currently only `"view"` is supported. - */ - openInPane(url: string, options?: { pane?: "left" | "right"; mode?: "view" }): void; - - /** - * Opens a terminal tab at the given working directory. - * - * The terminal runs an interactive shell (zsh) rooted at the specified directory. - * Supports tab completion, ANSI colors, and cursor movement. - * - * **Permission**: Requires `filesystem.read` or `filesystem.readAll`. - * - `filesystem.read`: directory must be within current pane roots (containment check). - * - `filesystem.readAll`: bypasses pane-root containment. - * - * **De-duplication**: If a terminal tab for the same working directory already - * exists in either pane, focuses the existing tab instead of creating a duplicate. - * - * @param workingDirectory - A `file://` URL string pointing to a directory. - * Non-file URLs or non-directory paths are rejected with a JS error. - * @param options - Optional configuration. - * @param options.pane - Target pane: `"left"`, `"right"`, or omit for active pane. - */ - openTerminal(workingDirectory: string, options?: { pane?: "left" | "right" }): void; - - /** - * Opens a code editor tab for the given file URL. - * - * Uses Monaco editor with syntax highlighting, line numbers, and save support. - * Files larger than 5 MB open a warning view instead of the Monaco editor. - * - * **Permission**: Requires `filesystem.read` or `filesystem.readAll`. - * - `filesystem.read`: file must be within current pane roots (containment check). - * - `filesystem.readAll`: bypasses pane-root containment. - * - * **De-duplication**: If an editor tab for the same file URL already exists - * in either pane, focuses the existing tab instead of creating a duplicate. - * - * @param url - A `file://` URL string pointing to the file. Non-file URLs - * are rejected with a JS error. - * @param options - Optional configuration. - * @param options.pane - Target pane: `"left"`, `"right"`, or omit for active pane. - */ - openEditor(url: string, options?: { pane?: "left" | "right" }): void; - - /** - * Opens a web browser tab for the given URL. - * - * Embeds a WKWebView with a compact navigation toolbar (URL bar, back/forward/reload). - * Supports `http://`, `https://`, and `file://` URLs. - * - * **Permission (scheme-based):** - * - `http://` / `https://` — no special permission required (foreground UI action). - * - `file://` — requires `filesystem.read` or `filesystem.readAll`. - * - `filesystem.read`: file must be within current pane roots (containment check). - * - `filesystem.readAll`: bypasses pane-root containment. - * - File must exist and must not be a directory. - * - Other schemes are rejected with a JS error. - * - * **De-duplication**: If a browser tab for the same canonical URL already exists - * in either pane (checking live navigated URL, not just initial URL), focuses the - * existing tab instead of creating a duplicate. - * - * @param url - A URL string (`http://`, `https://`, or `file://`). - * @param options - Optional configuration. - * @param options.pane - Target pane: `"left"`, `"right"`, or omit for active pane. - */ - openWebView(url: string, options?: { pane?: "left" | "right" }): void; - - /** - * Opens a markdown preview tab for the given file URL. - * - * Renders markdown as styled HTML in a WKWebView with live file watching. - * Supports headings, paragraphs, lists, code blocks, tables, blockquotes, - * links, images, and inline formatting. System light/dark theme via CSS. - * - * **Permission**: Requires `filesystem.read` or `filesystem.readAll`. - * - `filesystem.read`: file must be within current pane roots (containment check). - * - `filesystem.readAll`: bypasses pane-root containment. - * - * **De-duplication**: If a markdown preview tab for the same file URL already - * exists in either pane, focuses the existing tab instead of creating a duplicate. - * - * @param url - A `file://` URL string pointing to a markdown file. Non-file URLs - * are rejected with a JS error. - * @param options - Optional configuration. - * @param options.pane - Target pane: `"left"`, `"right"`, or omit for active pane. - */ - openMarkdownPreview(url: string, options?: { pane?: "left" | "right" }): void; - - /** - * Opens a new AI chat pane tab with optional prefill. - * - * **Permission**: `ui.aiChat` - * - * **Prefill-only**: This method sets up the AI chat pane with the given - * connector, system prompt, and file context, but does NOT auto-send. - * The user must manually press Send. This prevents plugins from triggering - * unbounded API costs. - * - * Plugins never have access to the AI API key. - * - * **Connector validation**: The `connector` option is validated via - * `AIConnectorRegistry.isKnownConnector` (not `isReady`). The pane opens - * even if the API key is not configured, showing the setup flow. - * - * **Context files**: Each `file://` URL in the `context` array requires - * `filesystem.read` permission with pane-root containment (or `filesystem.readAll`). - * Files must exist, must not be directories, must be text-like (UTType - * conforming to `.text` or `.sourceCode`), and must contain valid UTF-8. - * Each file is capped at 100KB. - * - * @param options - Optional configuration. - * @param options.pane - Target pane: `"left"`, `"right"`, or omit for active pane. - * @param options.connector - Connector ID (e.g., `"claude-api"`). Defaults to the - * registry default if omitted. - * @param options.systemPrompt - System prompt to prefill in the chat session. - * @param options.context - Array of `file://` URL strings to attach as context. - */ - openAIChat(options?: { - pane?: "left" | "right"; - connector?: string; - systemPrompt?: string; - context?: string[]; - }): void; - - // ----: WebView Panel API ---- - - /** - * Registers a WebView panel definition. - * - * **Synchronous** — registers the panel metadata; WKWebView instances are - * created lazily when pane tabs are opened. - * - * **Permission**: `ui.webPanel` - * - * **Limits**: 2 web panels per plugin, 6 total across all plugins. - * - * @param id - Short panel identifier (auto-prefixed with `{pluginId}.`). - * @param options - Panel configuration. - * - * @example - * ```javascript - * context.ui.registerWebPanel("output", { - * title: "Build Output", - * icon: "terminal", - * htmlPath: "panels/output.html", - * }); - * ``` - * - * @since 1.6.0 - */ - registerWebPanel(id: string, options: WebPanelOptions): void; - - /** - * Posts a JSON message to active WebView instances of a panel. - * - * Broadcasts to all instances by default. Pass `options.instanceId` to - * target a specific instance. Messages are delivered via - * `window.twopanez._emit(data)` in the WebView. - * - * **Permission**: `ui.webPanel` - * - * Maximum message size: 1MB. Messages exceeding this are rejected. - * - * @param panelId - Short panel identifier (auto-prefixed with `{pluginId}.`). - * @param message - JSON-serializable data to send. - * @param options - Optional targeting. - * - * @since 1.6.0 - */ - postToWebPanel(panelId: string, message: any, options?: { instanceId?: string }): void; - - /** - * Registers a fire-and-forget message handler for messages sent from the - * WebView via `window.twopanez.send(data)`. - * - * The handler receives a {@link WebPanelMessage} envelope. - * Only one handler per panelId; calling again replaces the previous handler. - * - * **Permission**: `ui.webPanel` - * - * @param panelId - Short panel identifier (auto-prefixed with `{pluginId}.`). - * @param handler - Callback invoked on the plugin queue with the message envelope. - * - * @since 1.6.0 - */ - onWebPanelMessage(panelId: string, handler: (message: WebPanelMessage) => void): void; - - /** - * Registers a request/response handler for messages sent from the WebView - * via `window.twopanez.request(data)`. - * - * The handler receives a {@link WebPanelMessage} envelope and must return - * a value or a Promise. If a Promise is returned, it is awaited with a 10s - * timeout. The resolved value is sent back to the WebView as the return - * value of `window.twopanez.request()`. - * - * Only one handler per panelId; calling again replaces the previous handler. - * - * **Permission**: `ui.webPanel` - * - * @param panelId - Short panel identifier (auto-prefixed with `{pluginId}.`). - * @param handler - Callback that returns a value or Promise. - * - * @since 1.6.0 - */ - onWebPanelRequest(panelId: string, handler: (message: WebPanelMessage) => any | Promise): void; - - /** - * Convenience method that pipes shell output to WebView panel instances. - * - * Executes a shell command (same as `context.shell.execute()`) and forwards - * each `onData` chunk to all instances of the given panel via - * `window.twopanez._emit(chunk)`. Returns the final `ShellExecuteResult` - * as a Promise. - * - * **Permission**: `ui.webPanel` + `shell.execute` - * - * @param panelId - Short panel identifier (auto-prefixed with `{pluginId}.`). - * @param shellOptions - Shell execution options (same as `shell.execute()`). - * @returns The final shell execution result. - * - * @example - * ```javascript - * const result = await context.ui.pipeShellToWebPanel("output", { - * command: "make", - * args: ["build"], - * cwd: projectRoot, - * }); - * ``` - * - * @since 1.6.0 - */ - pipeShellToWebPanel(panelId: string, shellOptions: ShellExecuteOptions): Promise; -} - -// ============================================================================ -// WebView Panel Types -// ============================================================================ - -/** - * Configuration for `registerWebPanel()`. - * - * @since 1.6.0 - */ -interface WebPanelOptions { - /** Display title for the panel tab. */ - title: string; - - /** SF Symbol name for the tab icon. */ - icon?: string; - - /** - * Relative path to the HTML file within the plugin bundle. - * Must not be absolute or contain `..`. - */ - htmlPath: string; - - /** - * Preferred width in points for the panel. - * Currently stored for future floating/popover use — pane tabs use the pane's full width. - */ - width?: number; - - /** - * Whether the WebView is allowed to navigate away from the initial page. - * Default: `false`. - */ - allowNavigation?: boolean; -} - -/** - * CSS Custom Properties injected into WebView panels. - * - * The host automatically injects CSS custom properties into every plugin WebView - * ` ``` ### CSS custom properties -The host injects CSS custom properties into every plugin WebView, mapped to the app's design system. They update at runtime when the theme changes (no page reload needed): +The host injects design tokens into every plugin WebView; they update live +on theme change: | Property | Description | Default value | |---|---|---| @@ -371,38 +789,30 @@ body { ## Streaming shell output -`ctx.shell.execute()` supports an `onData` callback for real-time streaming output. When provided, chunks are delivered as the process writes to stdout/stderr. The final Promise still resolves with the full buffered result (subject to 10MB truncation), but `onData` sees all data including bytes beyond the truncation threshold. - -### ShellDataChunk +`ctx.shell.execute()` supports an `onData` callback for real-time +streaming. The final Promise still resolves with the buffered result +(10MB truncation); `onData` sees all data including bytes beyond the +threshold. `ShellDataChunk`: `{ stream: "stdout" | "stderr", data: string, +bytesTotal: number }`. ```ts -interface ShellDataChunk { - stream: "stdout" | "stderr"; // which pipe this chunk came from - data: string; // UTF-8 decoded text (may contain partial lines) - bytesTotal: number; // running total of bytes on this stream -} -``` - -### Buffered vs streaming patterns +import { urlToPath } from '@appos.space/plugin-utils'; -**Buffered (default)** — omit `onData`. The Promise resolves with `{ exitCode, stdout, stderr }` after process exit: +declare const outputDir: string; +declare function updateProgress(fraction: number): void; -```ts -// T1 plugins: cwd must be within active pane roots +// Buffered (default) — T1 plugins: cwd must be within active pane roots const activeDir = await ctx.fileOps.getActiveDirectory(); -const result = await ctx.shell.execute({ +const version = await ctx.shell.execute({ command: 'yt-dlp', args: ['--version'], cwd: activeDir ? urlToPath(activeDir) : undefined, }); -console.log(result.stdout.trim()); // "2024.08.06" -``` +console.log(version.stdout.trim()); -**Streaming** — provide `onData` for real-time progress: - -```ts +// Streaming — provide onData for real-time progress await ctx.shell.execute({ command: 'yt-dlp', - args: ['--ignore-config', '--progress', '--newline', url], + args: ['--ignore-config', '--progress', '--newline', 'https://example.com/video'], cwd: outputDir, onData: (chunk) => { if (chunk.stream === 'stdout') { @@ -413,42 +823,48 @@ await ctx.shell.execute({ }); ``` -Chunks arrive on the plugin's serial queue. If `onData` throws, the error is logged but the process continues — streaming is best-effort. Order is preserved per-stream but stdout/stderr interleaving is OS-dependent. +Chunks arrive on the plugin's serial queue; if `onData` throws, the error +is logged and streaming continues (best-effort). Order is preserved +per-stream; stdout/stderr interleaving is OS-dependent. ## Shell security tiers -Shell execution is governed by a three-tier security model: - | Tier | Name | When | CWD restriction | Denied patterns | Allowlist | |---|---|---|---|---|---| | T0 | none | No `shell.execute` declared | N/A (calls rejected) | N/A | N/A | -| T1 | contained | JS plugins with `shell.execute` but no filesystem-wide perms | CWD must be within active pane roots. `cwd` is **required** (omitting throws). | Enforced: destructive commands (`rm -rf`, `dd`, `shutdown`, etc.) are blocked. Shell metacharacter patterns (`$()`, backticks, pipe-to-shell) are checked when the command is a shell interpreter (`sh`, `bash`, `zsh`). | Enforced | -| T2 | uncontained | Core-swift plugins or JS with `filesystem.readAll`/`writeAll` | No CWD restriction | Skipped | Enforced | +| T1 | contained | JS plugins with `shell.execute` but no filesystem-wide perms | CWD must be within active pane roots; `cwd` is **required** (omitting throws) | Enforced: destructive commands (`rm -rf`, `dd`, `shutdown`, ...) blocked; metacharacter patterns checked when the command is a shell interpreter | Enforced | +| T2 | uncontained | Core-swift plugins or JS with `filesystem.readAll`/`writeAll` | None | Skipped | Enforced | -**Allowlist**: All tiers enforce the `shellCommands` allowlist from `plugin.json`. Only commands listed there can be executed. +All tiers enforce the `shellCommands` allowlist. `shellDeniedPatterns` +adds custom regex guards, merged with (never replacing) the built-ins. -### `shellDeniedPatterns` (manifest field) +## `@appos.space/plugin-utils` — and the `ActionHandler` name collision -Plugins may declare `shellDeniedPatterns: string[]` in `plugin.json` to add custom regex guards. These are **merged** with the built-in defaults (never replacing them). Invalid regexes are logged and skipped at parse time. +Pure runtime helpers: `urlToPath`, `pathToUrl`, `fileExtension`, +`isTextFile`, `formatSize`, `formatDate`, `truncate`, `generateId`, +`simpleHash`, `debounce`, `throttle`, `createActionRouter`. -```json -{ - "shellDeniedPatterns": [ - "\\bsudo\\b", - "--recursive.*--force" - ] -} -``` +> **Disambiguation:** `plugin-utils` exports +> `type ActionHandler = (arg: string) => void | Promise` — the +> handler type for `createActionRouter`, which routes **ViewDescriptor +> action strings** (`'open:entry-1'`) from panel `handler` callbacks. It is +> NOT an fn-89 public-action handler — those receive +> `(exec: ActionExecutionContext)`. Same word, two different planes; don't +> pass one where the other is expected. ## Where to find exact signatures -Read `plugin-api.d.ts` in this directory — it's a consolidated snapshot (~2950 lines) containing: -- `PluginContext` interface with all 22 namespace properties -- All namespace interfaces (`UIAPI`, `ShellAPI`, `WorkspacesAPI`, `PluginFeedbackAPI`, etc.) -- `ViewDescriptor` interface with the 17-type discriminated union -- Dependency types (`SystemDependency`, `DependencyStatus`, `PluginDependencies`) -- WebView panel types (`WebPanelOptions`, `WebPanelMessage`, CSS custom property docs) -- Shell types (`ShellExecuteOptions`, `ShellDataChunk`, `ShellExecuteResult`) -- Workspace types (`WorkspaceTemplate`, `WorkspaceTemplateTabSlot`, `WorkspaceTemplatePaneConfig`) +Read the `plugin-api/` mirror in this directory (byte-verbatim from the +published tarball; `INDEX.md` records version + integrity): + +- `core.d.ts` — `PluginContext` (every namespace property + metadata) +- `namespaces.d.ts` — the original host namespace interfaces + option types +- `namespaces-core-plugins.d.ts` — the core-plugin wave interfaces + (`ActionsAPI`, `SchedulerAPI`, `NotificationsAPI`, ...) +- `permissions.d.ts` — `CanonicalPermissionScope`, `LegacyPermissionScope`, + `PermissionEntry` +- `views.d.ts` — the ViewDescriptor discriminated union + `MenuAction` +- `colors.d.ts` / `fonts.d.ts` / `icons.d.ts` — design-token unions -For patterns, read `patterns.md` in this directory or the flagship `appos-plugin-ytdlp` (https://github.com/appos/appos-plugin-ytdlp) directly. +For patterns, read `patterns.md`; for 2.x migration, +`migration-2.x-to-3.0.md`. diff --git a/plugins/appos-dev/skills/appos-plugin-dev/reference/migration-2.x-to-3.0.md b/plugins/appos-dev/skills/appos-plugin-dev/reference/migration-2.x-to-3.0.md new file mode 100644 index 0000000..98a9d41 --- /dev/null +++ b/plugins/appos-dev/skills/appos-plugin-dev/reference/migration-2.x-to-3.0.md @@ -0,0 +1,272 @@ +# Migrating AppOS plugins: SDK 2.x → 3.0.0 + + + +`@appos.space/plugin-types` 3.0.0 is the breaking sync to the AppOS 1.0.0 +host surface. A 2.4.x plugin usually keeps *running* unchanged (the host +runtime did not remove the legacy bridge surface), but it stops *compiling* +against 3.0.0 — and two behavioral contracts changed shape. There are six +break classes. Work through them in order. + +## TL;DR checklist + +1. Rename every namespace type to its `API` spelling (table below). +2. Import every SDK type — 3.0.0 ships **no ambient globals**. +3. Action handlers now receive an **execution context** — read input via `exec.input`. +4. `registerWebPanel` / `onWebPanelMessage` / `onWebPanelRequest` return **string tokens** — capture them. +5. Change `interface` → `type` for anything you assert `exec.input` (or other `AnyJSONValue`) to. +6. Bump dependency pins `^2.4.0` → `^3.0.0` — and ignore the stale README inside the 3.0.0 tarball. + +--- + +## 1. The rename rule (apply this FIRST) + +**Every namespace interface is now `API`.** The 2.x-era +`PluginAPI` and `Namespace` spellings are gone — there are no +deprecated aliases, the old names simply do not exist in 3.0.0, so every +reference is a hard `TS2304` / `TS2305` error. + +| 2.x spelling | 3.0.0 spelling | +|---|---| +| `PluginCacheAPI` | `CacheAPI` | +| `PluginFeedbackAPI` | `FeedbackAPI` | +| `PluginOAuthAPI` | `OAuthAPI` | +| `PluginMenuBarAPI` | `MenubarAPI` | +| `HostEventsAPI` | `EventsAPI` | +| `ActionsNamespace` (and every other `Namespace`) | `ActionsAPI` (that namespace's `API`) | + +Before (2.x): + +```ts no-verify +import type { PluginCacheAPI, HostEventsAPI } from '@appos.space/plugin-types'; + +function wireCache(cache: PluginCacheAPI, events: HostEventsAPI): void { + // ... +} +``` + +After (3.0.0): + +```ts +import type { CacheAPI, EventsAPI } from '@appos.space/plugin-types'; + +function wireCache(cache: CacheAPI, events: EventsAPI): void { + // ... +} +``` + +Mechanical fix: grep your `src/` for `Plugin[A-Z]\w*API`, `HostEventsAPI`, +and `\w+Namespace` and rename per the table. If a name isn't in the table, +check the 3.0.0 exports: `grep -n "export interface" reference/plugin-api/namespaces.d.ts`. + +## 2. No ambient globals — import everything + +2.x builds commonly leaned on a triple-slash reference or a copied global +d.ts, so `PluginContext` (and friends) resolved without imports, and some +scaffolds declared `activate` ambiently: + +```ts no-verify +/// + +declare function activate(ctx: PluginContext): Promise; +``` + +3.0.0 is a plain ESM declaration package: **nothing is ambient**. The +failure signal is `TS2304: Cannot find name 'PluginContext'` (or any other +SDK type name) the moment you compile. Fix: `import type` every SDK name +you mention, and keep assigning your entry points onto `globalThis` (that +part is a host runtime contract, not a type-level one — unchanged): + +```ts +import type { PluginContext } from '@appos.space/plugin-types'; + +async function activate(ctx: PluginContext): Promise { + // ... +} + +async function deactivate(): Promise { + // ... +} + +;(globalThis as any).activate = activate; +;(globalThis as any).deactivate = deactivate; +``` + +Keep `verbatimModuleSyntax: true` in tsconfig (unchanged from 2.x): the +package is declaration-only, so value-position imports of it must never +survive to the bundler. + +## 3. Action handlers receive an execution context + +The fn-89 action fabric is the reason 3.0.0 exists. In the published 3.0.0 +contract, the handler you pass to `ctx.actions.register(def, handler)` is +invoked with a single `ActionExecutionContext` argument — the validated +input plus invocation metadata — NOT the raw input value: + +```ts no-verify +// 2.x-era shape: handler received the raw input value directly +await ctx.actions.register(def, async (input) => { + const url = (input as { url: string }).url; // ← input WAS the payload + return { ok: true, url }; +}); +``` + +This break is **source-breaking-but-runtime-correcting**: an untyped +`(input) => ...` handler still compiles at the call site (the parameter is +contextually typed as `ActionExecutionContext`), but at runtime that +parameter has always been the execution context on AppOS 1.0.0 hosts — so +2.x-typed code that treated it as the payload was reading the wrong object. +Explicitly-typed legacy handlers (e.g. `(input: DownloadUrlInput) => ...`) +fail loudly with `TS2345` instead. Either way, the fix is the same — read +the payload via `exec.input`: + +```ts +import type { ActionExecutionContext } from '@appos.space/plugin-types'; + +type DownloadUrlInput = { url: string; format?: string }; + +await ctx.actions.register( + { id: 'downloadUrl', title: 'Download Media URL' }, + async (exec: ActionExecutionContext) => { + // exec = { invocationId, source, input, sourceId? } + const input = exec.input as DownloadUrlInput; + return { enqueued: true, url: input.url }; + }, +); +``` + +`exec.source` is the `InvocationSource` union +(`"user" | "plugin" | "agent" | "recipe" | "sequence" | "system"`) — use it +when an action must behave differently for agent-driven invocations. + +## 4. WebPanel registrations now return string tokens + +In 2.x the d.ts typed `registerWebPanel`, `onWebPanelMessage`, and +`onWebPanelRequest` as `void`, so no 2.x code captured their results: + +```ts no-verify +// 2.x: nothing to capture (typed void) +ctx.ui.registerWebPanel('download', { title: 'Downloads', htmlPath: 'webview/download/index.html' }); +ctx.ui.onWebPanelMessage('download', handleMessage); +``` + +3.0.0 types what the host actually returns: a registration id / handler +token (`string`). This is **silently non-breaking at runtime** — your old +code keeps working — but every uncaptured token is a registration you can +never dispose deterministically, which leaks across deactivate/activate +cycles. Capture the tokens and thread them into your disposable tracking: + +```ts +const disposables: Array<() => void | Promise> = []; + +const panelToken = ctx.ui.registerWebPanel('download', { + title: 'Downloads', + htmlPath: 'webview/download/index.html', +}); +disposables.push(() => ctx.ui.unregister(panelToken)); + +const messageToken = ctx.ui.onWebPanelMessage('download', (envelope) => { + // envelope: { data, instanceId, windowId, paneId } +}); +void messageToken; // one handler per panelId — re-registering replaces it; + // keep the token for symmetry and debugging. +``` + +## 5. `interface` → `type` for `exec.input` assertion targets + +`exec.input` is typed `AnyJSONValue`. A TypeScript **type alias** object +type gets an implicit index signature, so `exec.input as MyInput` is a +legal assertion. An **interface** does not — asserting to one fails with +`TS2352` because the compiler can't see the interface as JSON-compatible. + +This is exactly the break `appos-plugin-ytdlp` hits at +`src/actions/register-actions.ts` under 3.0.0, where its input shape is +declared as an interface: + +```ts no-verify +interface DownloadUrlInput { // ← interface: no implicit index signature + url: string; + format?: string; +} + +const input = exec.input as DownloadUrlInput; +// error TS2352: Conversion of type 'AnyJSONValue' to type 'DownloadUrlInput' +// may be a mistake because neither type sufficiently overlaps with the other. +``` + +Fix — declare JSON-shaped inputs as `type` aliases: + +```ts +import type { ActionExecutionContext } from '@appos.space/plugin-types'; + +type DownloadUrlInput = { + url: string; + format?: string; +}; + +declare const exec: ActionExecutionContext; +const input = exec.input as DownloadUrlInput; // ✓ compiles +``` + +Do NOT paper over it with `exec.input as unknown as DownloadUrlInput` — +that silences every future shape drift too. The one-word `interface` → +`type` change is the correct fix (functionally identical for plain data +shapes). + +## 6. Dependency pins — and the stale tarball README + +Bump every `@appos.space/*` pin from the 2.x line to the 3.x line in +`package.json`: + +```json +{ + "devDependencies": { + "@appos.space/plugin-types": "^3.0.0" + }, + "dependencies": { + "@appos.space/plugin-utils": "^3.0.0", + "@appos.space/view-builders": "^3.0.0" + } +} +``` + +(2.x pins looked like `"@appos.space/plugin-types": "^2.4.0"` — grep your +manifest for `^2.` under `@appos.space/`.) + +**Known issue:** the README packed inside the published +`@appos.space/plugin-types@3.0.0` tarball is stale — it predates the 3.0.0 +surface (old namespace/permission counts, and it demos APIs under old +spellings). npm packages are immutable, so it stays that way for the whole +3.0.0 line. Trust the `dist/*.d.ts` type declarations (mirrored +byte-verbatim in `reference/plugin-api/` here), not the package README. + +--- + +## What did NOT change + +Do not "migrate" these — they are live host contracts, identical in 2.x +and 3.0.0: + +- `window.twopanez` WebView bridge (`send` / `request` / `onMessage` / + `instanceId` / `windowId` / `paneId`) and the `--twopanez-*` CSS custom + properties. The `twopanez` spelling is the wire contract; renaming it in + code breaks every panel. +- The install path `~/Library/Application Support/AppOS/plugins//`. +- `globalThis.activate` / `globalThis.deactivate` entry points (IIFE, es2020). +- `minHostVersion` semantics: it is compared against the HOST app version + (`1.0.0`), never the SDK version. Setting it to `"3.0.0"` because you saw + that in the SDK is the same landmine as `"2.4.0"` was. +- The 17-type `ViewDescriptor` union and `@appos.space/view-builders` + helper names. diff --git a/plugins/appos-dev/skills/appos-plugin-dev/reference/patterns.md b/plugins/appos-dev/skills/appos-plugin-dev/reference/patterns.md index fb360be..e4bcc1f 100644 --- a/plugins/appos-dev/skills/appos-plugin-dev/reference/patterns.md +++ b/plugins/appos-dev/skills/appos-plugin-dev/reference/patterns.md @@ -1,23 +1,39 @@ -# Patterns — from appos-plugin-ytdlp - -Working patterns extracted from the flagship `appos-plugin-ytdlp` (https://github.com/appos/appos-plugin-ytdlp). Every snippet here is shipped in a real plugin — when in doubt, open the source file referenced at the top of each section. Prefer a local clone; otherwise fetch raw files from `https://raw.githubusercontent.com/appos/appos-plugin-ytdlp/main/` (the repo is public as of AppOS launch), or fall back to https://docs.appos.space, which carries the same canonical patterns. +# Patterns — canonical AppOS plugin shapes + +Working patterns extracted from the flagship `appos-plugin-ytdlp` +(https://github.com/appos/appos-plugin-ytdlp — public; prefer a local +clone, or raw files from +`https://raw.githubusercontent.com/appos/appos-plugin-ytdlp/main/`), +updated to the SDK 3.0.0 surface. Where ytdlp's shipped source still +predates 3.0.0 (it pins the 2.4 line — its own migration is tracked), the +snippet here shows the 3.0.0-correct form; the *structure* still mirrors +the shipped plugin. Snippets stub external helpers with `declare` lines so +each block stands alone — replace the stubs with your real modules. +Fallback docs: https://docs.appos.space. ## 1. Entry point + disposables **File**: `src/main.ts` -The canonical activate/deactivate shape. Push every disposer into `disposables[]` as it's created; drain in reverse on deactivate. +The canonical activate/deactivate shape. Push every disposer into +`disposables[]` as it's created; drain in reverse on deactivate. ```ts import type { PluginContext } from '@appos.space/plugin-types'; -import { registerDownloadPanel } from './panels/download-panel.js'; -import { registerLibraryPanel } from './panels/library-panel.js'; + +// These live in sibling modules (src/state.ts, src/panels/*.ts, ...): +declare function initState(ctx: PluginContext): Promise; +declare function initPaths(ctx: PluginContext): Promise; +declare function registerDownloadPanel(ctx: PluginContext): Promise<() => void>; +declare function registerLibraryPanel(ctx: PluginContext): Promise<() => void>; +declare function registerWorkspace(ctx: PluginContext): Promise<() => void>; +declare function registerMenubar(ctx: PluginContext): Promise<() => void>; +declare function flushState(): void; const disposables: Array<() => void | Promise> = []; async function activate(ctx: PluginContext): Promise { - // ctx.pluginId is runtime-injected; see extension-api.md ambient declaration - console.log(`[${ctx.pluginId}] activating`); + console.log(`[${ctx.pluginId}] activating`); // metadata scalars are typed in 3.0.0 await initState(ctx); await initPaths(ctx); @@ -48,20 +64,228 @@ async function deactivate(): Promise { (globalThis as unknown as { deactivate: typeof deactivate }).deactivate = deactivate; ``` -**Why globalThis and not ESM export**: the IIFE bundle runs the entire file once; the host reads `globalThis.activate` and `globalThis.deactivate` after evaluation. ESM exports disappear inside the IIFE closure. +**Why globalThis and not ESM export**: the IIFE bundle runs the entire file +once; the host reads `globalThis.activate` and `globalThis.deactivate` +after evaluation. ESM exports disappear inside the IIFE closure. -## 2. WebView panel registration +## 2. Public action with execution context (fn-89) -**File**: `src/panels/download-panel.ts` +**File**: `src/actions/register-actions.ts` + +The 3.0.0 action contract: the handler receives ONE argument — an +`ActionExecutionContext` — and reads the validated payload via +`exec.input`. Declare the input shape as a **`type` alias** (an +`interface` fails the `exec.input as X` assertion with TS2352). + +```ts +import type { ActionExecutionContext, PluginContext } from '@appos.space/plugin-types'; + +type DownloadUrlInput = { url: string; format?: string }; + +declare function enqueueDownload(url: string, format?: string): Promise; + +export async function registerActions(ctx: PluginContext): Promise<() => Promise> { + const token = await ctx.actions.register( + { + id: 'downloadUrl', + title: 'Download Media URL', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string' }, + format: { type: 'string', enum: ['best', 'mp4', 'mp3'] }, + }, + required: ['url'], + additionalProperties: false, + }, + outputSchema: { + type: 'object', + properties: { + enqueuedIds: { type: 'array', items: { type: 'string' } }, + }, + required: ['enqueuedIds'], + }, + visibility: ['api', 'agent', 'automation'], + approval: 'auto', + }, + async (exec: ActionExecutionContext) => { + const input = exec.input as DownloadUrlInput; + const url = input.url.trim(); + if (!/^https?:\/\//.test(url)) { + throw new Error('Invalid media URL'); + } + const enqueuedIds = await enqueueDownload(url, input.format); + if (enqueuedIds.length === 0) { + // Fail the receipt with an actionable message. + throw new Error('URL was not enqueued — check plugin settings.'); + } + return { enqueuedIds }; + }, + ); + return async () => { await ctx.actions.unregister(token); }; +} +``` + +**Key points**: +- `exec.input` is the schema-validated payload — the handler NEVER receives + the raw input as its parameter. `exec` also carries `invocationId`, + `source` (the `InvocationSource` union: `"user" | "plugin" | "agent" | + "recipe" | "sequence" | "system"`), and optional `sourceId`. +- Throwing fails the invocation receipt with your message — throw + actionable errors, not raw internals. +- `visibility: ['agent']` makes the action available to AI agents as a + tool; write `inputSchema` descriptions as if an LLM will read them (it + will). +- `register` resolves to a handle token — keep it for `unregister` in your + dispose path. + +## 3. `extensions[]` + runtime dual registration (fn-163 workaround) + +**Files**: `plugin.json` + `src/main.ts` + +Declare actions in the manifest so they are visible in catalogs and +manifest scans — AND bind the executable at runtime. Manifest-declared +actions currently never reach discovery on their own (host bug fn-163), so +ship BOTH, exactly as `appos-plugin-ytdlp` does. + + +```json +{ + "extensions": [ + { + "extensionPoint": "actions.definition", + "contribution": { + "id": "clear-completed", + "displayName": "Clear Completed Downloads", + "inputSchema": { "type": "object" }, + "visibility": ["palette", "automation"], + "approval": "auto" + } + } + ] +} +``` + +```ts +// Runtime side of the dual registration. For palette-style actions that +// already exist as commands, project the command into the action catalog: +ctx.commands.register('clear-completed', async () => { + // ... clear the queue ... +}); +const actionToken = await ctx.actions.registerFromCommand('clear-completed', { + title: 'Clear Completed Downloads', + visibility: ['user', 'automation'], +}); +void actionToken; // thread into disposables +``` + +For input-bearing actions, the runtime half is a full +`ctx.actions.register(def, handler)` (pattern 2) whose `def` mirrors the +manifest contribution. Other extension points (notification channels, +recipes/sequences definitions, surface contributions) do NOT need this +workaround — their manifest path works; only ACTION definitions do. + +## 4. Notification emit with routing-first mindset (fn-97) + +**File**: `src/notify.ts` + +`ctx.notifications.emit` never picks a channel — the user's routing rules +and the host filter chain decide delivery. Manifest prerequisites (the +validator enforces this chain): `notifications.emit` + `actions.invoke` +permissions AND a dependency on `space.appos.core.notifications`. + +```json +{ + "permissions": ["notifications.emit", "actions.invoke"], + "dependencies": { + "plugins": [ + { "id": "space.appos.core.notifications", "required": true } + ] + } +} +``` + +```ts +import type { PluginContext } from '@appos.space/plugin-types'; + +export async function notifyDownloadComplete(ctx: PluginContext, filename: string): Promise { + try { + const handle = await ctx.notifications.emit({ + level: 'info', + title: 'Download complete', + body: `${filename} finished downloading.`, + category: 'downloads', + metadata: { filename }, + }); + void handle.notificationId; // keep if you may cancel() later + } catch (err) { + // Notifications are best-effort UX — never let emit failure break + // the operation that triggered it. + const name = err instanceof Error ? err.constructor.name : 'unknown'; + console.error(`[notify] emit failed (${name})`); + } +} +``` + +**Key points**: +- Set a stable `category` — users route on it (`downloads`, `errors`, ...). +- `cancel(notificationId)` returns `boolean` uniformly (`false` collapses + missing/foreign/terminal — anti-enumeration); it does not throw for those. +- Use `ctx.feedback.toast/hud` for always-local, in-app feedback; + `ctx.notifications.emit` for user-routable events (may leave the machine + via webhook channels). + +## 5. Scheduled job owning its lifecycle (fn-90) + +**File**: `src/maintenance.ts` + +Schedule on activation, cancel on dispose. All methods take the +owner-scoped `token` returned by `scheduleJob`. Requires +`scheduler.job.own`. ```ts import type { PluginContext } from '@appos.space/plugin-types'; -import { parseInbound } from '../types/webview-messages.js'; + +export async function scheduleNightlySweep(ctx: PluginContext): Promise<() => Promise> { + const { token } = await ctx.scheduler.scheduleJob({ + name: 'nightly-sweep', + trigger: { kind: 'cron', expression: '0 3 * * *' }, // DST-safe cron + action: { kind: 'action', actionId: 'clear-completed', input: {} }, + catchupStrategy: 'skip', // don't replay missed windows on relaunch + }); + + return async () => { + try { await ctx.scheduler.cancel(token); } catch { /* already gone */ } + }; +} +``` + +**Key points**: +- The `action.actionId` must be a registered fn-89 action (pattern 2/3) — + the scheduler dispatches through the action pipeline, so receipts, + rate limits, and approval policy all apply. +- `catchupStrategy`: `'skip'` | `'runOnce'` | `'runAll'` — choose + explicitly; `'runAll'` after a week offline can flood. +- `triggerNow(token)` is the debug/"Run now" path — same dispatch pipeline. +- `history(token, limit?)` + `nextFire(token)` power a status UI cheaply. + +## 6. WebView panel registration + +**File**: `src/panels/download-panel.ts` + +```ts +import type { PluginContext, WebPanelMessage } from '@appos.space/plugin-types'; + +type PanelInboundMessage = { v: 1; type: string }; + +// src/types/webview-messages.ts (pattern 7): +declare function parseInbound(data: unknown): PanelInboundMessage | null; +declare function handle(ctx: PluginContext, msg: PanelInboundMessage, envelope: WebPanelMessage): Promise; export function registerDownloadPanel(ctx: PluginContext): () => void { let disposed = false; - ctx.ui.registerWebPanel('download', { + const panelToken = ctx.ui.registerWebPanel('download', { title: 'Downloads', icon: 'arrow.down.circle', htmlPath: 'webview/download/index.html', @@ -79,20 +303,35 @@ export function registerDownloadPanel(ctx: PluginContext): () => void { }); }); - return () => { disposed = true; }; + return () => { + disposed = true; + ctx.ui.unregister(panelToken); + }; } ``` **Key points**: -- `onWebPanelMessage` has no disposer; use a `disposed` flag inside the handler closure -- Wrap every handler in `Promise.resolve().then(...).catch(...)` so sync throws and async rejections both land in the same error path -- Never log raw `err.message` — it may contain URLs, credentials, or user input. Log the error constructor name only. +- `registerWebPanel` returns a registration id — capture it and pass it to + `ctx.ui.unregister` in your disposer (3.0.0 types this; 2.x hid it). +- `onWebPanelMessage` allows one handler per panelId (re-registering + replaces it); use a `disposed` flag inside the closure as the guard. +- Wrap every handler in `Promise.resolve().then(...).catch(...)` so sync + throws and async rejections land in the same error path. +- Never log raw `err.message` — it may contain URLs, credentials, or user + input. Log the error constructor name only. -## 3. Typed message protocol +## 7. Typed message protocol **File**: `src/types/webview-messages.ts` ```ts +export type QueueEntry = { + id: string; + url: string; + status: 'queued' | 'active' | 'complete' | 'failed'; +}; +export type ProbeMetadata = { title: string; durationSeconds?: number }; + export type PanelInboundMessage = | { v: 1; type: 'probe-url'; probeId: string; url: string } | { v: 1; type: 'queue-download'; requestId: string; url: string; format: string } @@ -100,7 +339,7 @@ export type PanelInboundMessage = export type PanelOutboundMessage = | { v: 1; type: 'state-update'; queue: QueueEntry[] } - | { v: 1; type: 'probe-result'; probeId: string; metadata: Metadata } + | { v: 1; type: 'probe-result'; probeId: string; metadata: ProbeMetadata } | { v: 1; type: 'enqueue-ack'; requestId: string; ok: boolean; error?: string }; export function parseInbound(data: unknown): PanelInboundMessage | null { @@ -112,13 +351,18 @@ export function parseInbound(data: unknown): PanelInboundMessage | null { } ``` -Always version with `v: 1` and correlate request/response with a unique ID (`probeId`, `requestId`). WebView panels can have multiple instances — responses broadcast via `postToWebPanel` fan out to all of them, so without correlation IDs, responses get cross-applied. +Always version with `v: 1` and correlate request/response with a unique ID +(`probeId`, `requestId`). WebView panels can have multiple instances — +responses broadcast via `postToWebPanel` fan out to all of them, so +without correlation IDs, responses get cross-applied. -## 4. Throttled broadcast with JSC fallback +## 8. Throttled broadcast with JSC fallback **File**: `src/panels/download-panel.ts` ```ts +declare const state: { getQueue(): unknown[] }; + let throttleTimer: ReturnType | undefined; let lastBroadcast = 0; @@ -147,15 +391,22 @@ function broadcastQueue(): void { }, remaining); } } + +void broadcastQueue; ``` -**Why not use `throttle` from `@appos.space/plugin-utils`**: it calls `setTimeout` unconditionally. JSC may not inject timers, so roll a version that degrades to synchronous broadcasts. The 100ms target gives ~10 Hz updates — the sweet spot for progress UIs. +**Why not use `throttle` from `@appos.space/plugin-utils`**: it calls +`setTimeout` unconditionally. JSC may not inject timers, so roll a version +that degrades to synchronous broadcasts. The 100ms target gives ~10 Hz +updates — the sweet spot for progress UIs. -## 5. pipeShellToWebPanel wrapper +## 9. pipeShellToWebPanel wrapper **File**: `src/services/downloader.ts` ```ts +import type { PluginContext } from '@appos.space/plugin-types'; + async function runYtDlp(ctx: PluginContext, url: string, outputDir: string): Promise { const result = await ctx.ui.pipeShellToWebPanel('download', { command: 'yt-dlp', @@ -171,11 +422,12 @@ async function runYtDlp(ctx: PluginContext, url: string, outputDir: string): Pro }); if (result.exitCode !== 0) { - const errName = 'YtDlpExit'; - console.error(`[downloader] yt-dlp failed (${errName})`); + console.error('[downloader] yt-dlp failed (YtDlpExit)'); // Never log result.stderr raw — may contain URLs } } + +void runYtDlp; ``` **Gotchas**: @@ -183,9 +435,10 @@ async function runYtDlp(ctx: PluginContext, url: string, outputDir: string): Pro - 120s hard cap; long jobs need resume loops with `--continue` - `cwd` must be absolute and tilde-expanded; T1 sandbox rejects relative paths and `~` - Always pass `--ignore-config` or the tool's equivalent -- Chunks fan out to all panel instances; filter with `envelope.instanceId` if you need per-instance isolation +- Chunks fan out to all panel instances; filter with `envelope.instanceId` + if you need per-instance isolation -## 6. Workspace template registration +## 10. Workspace template registration **File**: `src/workspace/template.ts` @@ -219,8 +472,8 @@ export async function registerWorkspace(ctx: PluginContext): Promise<() => void> }, }); - // Note: workspace registration only. Apply is done unconditionally at the - // end of activate(), NOT here, NOT gated on a first-run cache flag. + // Note: registration only. Apply is done unconditionally at the END of + // activate(), NOT here, NOT gated on a first-run cache flag. // No explicit unregister needed — ephemeral templates clean on deactivation. return () => {}; } @@ -229,18 +482,17 @@ export async function registerWorkspace(ctx: PluginContext): Promise<() => void> Then, at the end of `activate()`: ```ts +declare const WORKSPACE_ID: string; + // Step N (last): apply workspace so the user sees the UI immediately. // activate() runs once per host launch, so this is effectively once-per-launch. -// The user can still switch workspaces manually after activation and we won't -// override them again until next launch. // -// IMPORTANT: apply() returns false (not an error) when no browser window is -// focused — e.g. when the plugin first activates from the Settings sheet. -// Always fall back to showPaneTab() so the user sees something. +// IMPORTANT: apply() resolves false (not an error) when no browser window +// is focused — e.g. when the plugin first activates from the Settings +// sheet. Always fall back to showPaneTab() so the user sees something. try { const applied = await ctx.workspaces.apply(WORKSPACE_ID); if (!applied) { - // No browser window was frontmost (Settings was focused, etc.) try { ctx.ui.showPaneTab('download', { title: 'Downloads', pane: 'left' }); } catch { /* ok */ } } } catch (err) { @@ -250,11 +502,19 @@ try { > **DO NOT use an `applyIfFirstRun(ctx)` / `cache.get('initialized')` gate.** > -> Gating workspace apply behind a cache flag is the #1 cause of "plugin installed but no UI is visible". On first launch it works; on every subsequent launch the user is left in whatever workspace they were in before, with no reliable way to discover the plugin's panels. Apply unconditionally in `activate()` and move on. +> Gating workspace apply behind a cache flag is the #1 cause of "plugin +> installed but no UI is visible". On first launch it works; on every +> subsequent launch the user is left in whatever workspace they were in +> before, with no reliable way to discover the plugin's panels. Apply +> unconditionally in `activate()` and move on. -**Panel-open commands**: `ctx.ui.showPaneTab(panelId, options?)` focuses an existing tab if present, or creates a new tab if none exists. Use `workspaces.apply()` before `showPaneTab` when the command depends on the full dual-pane layout: +**Panel-open commands**: `ctx.ui.showPaneTab(panelId, options?)` focuses an +existing tab or creates one. Use `workspaces.apply()` first when the +command depends on the full dual-pane layout: ```ts +declare const WORKSPACE_ID: string; + ctx.commands.register('open-download-panel', { title: 'Open yt-dlp Downloader', handler: async () => { @@ -264,9 +524,10 @@ ctx.commands.register('open-download-panel', { }); ``` -**Note**: `ctx.cache.get` returns the **deserialized** value (no JSON.parse). Pass `persist: true` for durability across restarts. +**Note**: `ctx.cache.get` returns the **deserialized** value (no +JSON.parse). Pass `persist: true` for durability across restarts. -## 7. Menubar registration with popover content +## 11. Menubar registration with popover content **File**: `src/menubar/menubar.ts` @@ -274,6 +535,9 @@ ctx.commands.register('open-download-panel', { import type { PluginContext } from '@appos.space/plugin-types'; import { vstack, section, listItem, button } from '@appos.space/view-builders'; +declare const state: { getQueue(): unknown[]; subscribe(listener: () => void): () => void }; +declare const WORKSPACE_ID: string; + export async function registerMenubar(ctx: PluginContext): Promise<() => void> { await ctx.menubar.register({ icon: 'arrow.down.circle' }); @@ -294,13 +558,13 @@ export async function registerMenubar(ctx: PluginContext): Promise<() => void> { const clickToken = ctx.events.subscribe('menubar.clicked', async () => { if (unsubscribed) return; try { await ctx.workspaces.apply(WORKSPACE_ID); } catch { /* may not exist yet */ } - try { ctx.ui.showPaneTab('download', { title: 'Downloads', pane: 'left' }); } catch { /* workspace apply already surfaced it */ } + try { ctx.ui.showPaneTab('download', { title: 'Downloads', pane: 'left' }); } catch { /* ok */ } }); // Update badge AND popover content as queue changes const unsubscribeQueue = state.subscribe(() => { const count = state.getQueue().length; - ctx.menubar.setBadge(count > 0 ? count : 0); + void ctx.menubar.setBadge(count > 0 ? count : 0); ctx.menubar.setContent(buildPopoverContent()).catch(() => {}); }); @@ -313,22 +577,31 @@ export async function registerMenubar(ctx: PluginContext): Promise<() => void> { } ``` -**Popover content is mandatory**: the host shows a popover when the menubar icon is clicked. Without `setContent()`, it says "No content". Always call `setContent()` after `register()` and update it reactively alongside `setBadge()`. +**Popover content is mandatory**: without `setContent()`, the popover says +"No content" — `register()` + `setBadge()` + `menubar.clicked` all work +fine without it, so it's easy to miss. Update it reactively alongside +`setBadge()`. -**Transactional init**: if any step fails after `register` succeeds, call `remove()` in the catch so the menu bar doesn't leak a dangling item on activation failure. +**Transactional init**: if any step fails after `register` succeeds, call +`remove()` in the catch so the menu bar doesn't leak a dangling item. -## 8. Smart folder filter with closure capture +## 12. Smart folder filter with closure capture **File**: `src/smart-folders/filters.ts` ```ts import type { PluginContext } from '@appos.space/plugin-types'; -export async function registerFilters(ctx: PluginContext, state: State): Promise<() => void> { +type LibraryState = { + favorites: Array<{ url: string }>; + subscribe(listener: () => void): () => void; +}; + +export async function registerFilters(ctx: PluginContext, state: LibraryState): Promise<() => void> { let favoritesByUrl: Map = new Map(); const rebuild = () => { - favoritesByUrl = new Map(state.favorites.map((f) => [f.url, true])); + favoritesByUrl = new Map(state.favorites.map((f) => [f.url, true])); }; rebuild(); @@ -354,9 +627,12 @@ export async function registerFilters(ctx: PluginContext, state: State): Promise } ``` -**Why synchronous `evaluate`**: smart folder filters are called once per file during directory traversal. They must be cheap and cannot await. Build a lookup structure (Map, Set) on state change and capture it in the closure. The callback receives `{ url: string, metadata: Record }` — NOT a `PluginFileDescriptor`. +**Why synchronous `evaluate`**: smart folder filters are called once per +file during directory traversal. They must be cheap and cannot await. +Build a lookup structure (Map, Set) on state change and capture it in the +closure. The callback receives `{ url, metadata }` — nothing else. -## 9. Dependency status handling +## 13. Dependency status handling **File**: `src/main.ts` @@ -374,17 +650,28 @@ const depToken = ctx.lifecycle.onDependencyStatusChanged((statuses) => { installHint: ytDlp?.installHint, }); }); +void depToken; // Note: no matching unsubscribe API exists for lifecycle tokens yet; // the subscription auto-cleans on plugin deactivation. ``` -`ctx.lifecycle.getDependencyStatus()` and `ctx.lifecycle.recheckDependencies()` are host-wired and safe to call — `appos-plugin-ytdlp` uses both in production (`src/main.ts` does the initial `getDependencyStatus()` read after subscribing; the panels call `recheckDependencies()` from their "Re-check" buttons). Subscribe FIRST, then read, so no update can slip between the read and the subscription. +`ctx.lifecycle.getDependencyStatus()` and +`ctx.lifecycle.recheckDependencies()` are host-wired and safe to call — +`appos-plugin-ytdlp` uses both in production (the initial +`getDependencyStatus()` read happens after subscribing; the panels call +`recheckDependencies()` from their "Re-check" buttons). Subscribe FIRST, +then read, so no update can slip between the read and the subscription. -If a required dependency is missing, show a "degraded banner" in the webview with the install hint. Don't refuse to load the plugin — the host already handles hard failures. +If a required dependency is missing, show a "degraded banner" in the +webview with the install hint. Don't refuse to load the plugin — the host +already handles hard failures. -## 10. Settings read with fallback +## 14. Settings read with fallback ```ts +import type { PluginContext } from '@appos.space/plugin-types'; +import { urlToPath } from '@appos.space/plugin-utils'; + async function getOutputDir(ctx: PluginContext): Promise { const raw = ctx.settings.get('outputDir'); if (typeof raw === 'string' && raw.length > 0) return raw; @@ -393,30 +680,51 @@ async function getOutputDir(ctx: PluginContext): Promise { if (activeDir) return urlToPath(activeDir); throw new Error('outputDir setting is required when no active directory'); } + +void getOutputDir; ``` -`ctx.settings.get(key)` returns `unknown` — always check the type before using the value. Prefer explicit defaults in code over relying on the manifest `default` field (which also works, but is a weaker guarantee). +`ctx.settings.get(key)` returns `unknown` — always check the type before +using the value. Prefer explicit defaults in code over relying on the +manifest `default` field (which also works, but is a weaker guarantee). -## 11. Handler action routing (ViewDescriptor) +## 15. Handler action routing (ViewDescriptor) -For ViewDescriptor-based panels (not used in ytdlp, but valid for simpler plugins), use short semantic action prefixes: +For ViewDescriptor-based panels, use short semantic action prefixes: ```ts -handler: (action: string) => { +declare function refresh(): void; +declare function addSelected(): void; +declare function activateEntry(id: string): void; +declare function openFile(id: string): void; +declare function revealInFinder(id: string): void; +declare function removeItem(id: string): void; + +const handler = (action: string): void => { if (action === 'refresh') refresh(); if (action === 'add-selected') addSelected(); - if (action.startsWith('select:')) activate(action.substring(7)); + if (action.startsWith('select:')) activateEntry(action.substring(7)); if (action.startsWith('open:')) openFile(action.substring(5)); if (action.startsWith('reveal:')) revealInFinder(action.substring(7)); if (action.startsWith('remove:')) removeItem(action.substring(7)); -} +}; + +void handler; ``` -**Don't repeat the noun**: `"remove:"` is better than `"remove-collection:"`. The handler already knows its context. +**Don't repeat the noun**: `"remove:"` is better than `"remove-collection:"`. +The handler already knows its context. (This `(action: string) => ...` +handler is the `ActionHandler` shape from `@appos.space/plugin-utils` — a +ViewDescriptor action-string router, unrelated to fn-89 action handlers.) -## 12. menuActions on listItem +## 16. menuActions on listItem ```ts +import type { ListItemDescriptor, MenuAction } from '@appos.space/plugin-types'; +import { encodeMenuActions } from '@appos.space/view-builders'; + +declare const item: { url: string; name: string; subtitle: string }; + const menu: MenuAction[] = [ { title: 'Open', icon: 'doc', action: `open:${item.url}` }, { title: 'Reveal in Finder', icon: 'folder', action: `reveal:${item.url}` }, @@ -424,25 +732,29 @@ const menu: MenuAction[] = [ { title: 'Remove', icon: 'trash', action: `remove:${item.url}`, destructive: true }, ]; -const listItem: ListItemDescriptor = { +const row: ListItemDescriptor = { type: 'listItem', properties: { title: item.name, subtitle: item.subtitle, icon: 'doc.fill', action: `select:${item.url}`, - menuActions: JSON.stringify(menu), // MUST be a JSON STRING + menuActions: encodeMenuActions(menu), // MUST be a JSON STRING }, }; + +void row; ``` **Key rules**: -- `menuActions` is a **JSON string**, always `JSON.stringify()` the array +- `menuActions` is a **JSON string** — build it with `encodeMenuActions()` + (or `JSON.stringify(menu)`, identical output) - Dividers are plain objects with `title: '---'` - Destructive actions get `destructive: true` and are placed last -- Always ship `menuActions` on every listable item — this is the single most important UX pattern for plugin authors +- Always ship `menuActions` on every listable item — the single most + important UX pattern for plugin authors -## 13. Build script (canonical) +## 17. Build script (canonical) **File**: `build.mjs` @@ -471,9 +783,11 @@ if (isWatch) { } ``` -**Invoked as**: `npm run build` or `node build.mjs`. The esbuild API wins over `npx esbuild ...` because watch mode is cleaner and the script survives across platforms. +**Invoked as**: `npm run build` or `node build.mjs`. The esbuild API wins +over `npx esbuild ...` because watch mode is cleaner and the script +survives across platforms. -## 14. tsconfig (mandatory flags) +## 18. tsconfig (mandatory flags) ```json { @@ -495,9 +809,12 @@ if (isWatch) { } ``` -**`verbatimModuleSyntax: true` is mandatory** for any plugin importing from `@appos.space/plugin-types`. Without it, TypeScript emits runtime `require` / `import` calls that look up a non-existent module in the bundler. The plugin silently fails to activate. +**`verbatimModuleSyntax: true` is mandatory** for any plugin importing +from `@appos.space/plugin-types`. Without it, TypeScript emits runtime +`require` / `import` calls that look up a non-existent module in the +bundler. The plugin silently fails to activate. -## 15. Deploy (rsync with --delete-excluded) +## 19. Deploy (rsync with --delete-excluded) ```bash rsync -av --delete --delete-excluded \ @@ -520,9 +837,11 @@ rsync -av --delete --delete-excluded \ "$PLUGIN_ROOT/" "$INSTALL_DIR/" ``` -**Critical**: `--delete-excluded` removes files added to the exclude list after a previous deploy. Without it, a file you excluded today but copied yesterday stays on the destination forever. The first time you add `.mcp.json` to the exclude list, you need `--delete-excluded` for it to actually disappear from the install directory. +**Critical**: `--delete-excluded` removes files added to the exclude list +after a previous deploy. Without it, a file you excluded today but copied +yesterday stays on the destination forever. -## 16. minHostVersion landmine +## 20. minHostVersion landmine ```json { @@ -532,7 +851,13 @@ rsync -av --delete --delete-excluded \ } ``` -**ALWAYS** default `minHostVersion` to `"1.0.0"`. The host compares this against its `CFBundleShortVersionString` (currently `1.0.0`), NOT the SDK package version (`2.4.x`). Setting `minHostVersion` to `"2.4.0"` because you saw that number in `@appos.space/plugin-types/package.json` will cause `DependencyResolver.swift` to silently reject the plugin before it reaches the Settings → Plugins sheet. No error dialog, no log entry you'll think to check. +**ALWAYS** default `minHostVersion` to `"1.0.0"`. The host compares this +against its `CFBundleShortVersionString` (currently `1.0.0`), NOT the SDK +package version. Setting `minHostVersion` to the SDK's version number +because you saw it in `@appos.space/plugin-types/package.json` will cause +the host's dependency resolver to silently reject the plugin before it +reaches the Settings → Plugins sheet. No error dialog, no log entry you'll +think to check. To verify the actual host version: @@ -540,9 +865,10 @@ To verify the actual host version: defaults read /Applications/AppOS.app/Contents/Info.plist CFBundleShortVersionString ``` -## 17. WebView panel with plugin-to-webview messaging +## 21. WebView panel with plugin-to-webview messaging -**Full plugin structure** showing `plugin.json` + `src/main.ts` + `webview/main/` with external JS/CSS (CSP-compliant). +**Full plugin structure** showing `plugin.json` + `src/main.ts` + +`webview/main/` with external JS/CSS (CSP-compliant). ### plugin.json @@ -570,12 +896,13 @@ const disposables: Array<() => void | Promise> = []; async function activate(ctx: PluginContext): Promise { // Register with SHORT id — runtime auto-prefixes to {pluginId}.main-panel - ctx.ui.registerWebPanel('main-panel', { + const panelToken = ctx.ui.registerWebPanel('main-panel', { title: 'My Tools', icon: 'wrench', htmlPath: 'webview/main/index.html', allowNavigation: false, }); + disposables.push(() => ctx.ui.unregister(panelToken)); ctx.ui.onWebPanelMessage('main-panel', (envelope) => { if (typeof envelope.data !== 'object' || envelope.data === null) return; @@ -725,19 +1052,31 @@ checkStatus(); ``` **Key points:** -- Register the SHORT id `main-panel` — runtime auto-prefixes to `{pluginId}.main-panel` -- All JS and CSS are external files (CSP blocks inline ` - + ``` ### CSS custom properties -The host injects CSS custom properties into every plugin WebView, mapped to the app's design system. They update at runtime when the theme changes (no page reload needed): +The host injects design tokens into every plugin WebView; they update live +on theme change: | Property | Description | Default value | |---|---|---| @@ -936,38 +1323,30 @@ body { ## Streaming shell output -`ctx.shell.execute()` supports an `onData` callback for real-time streaming output. When provided, chunks are delivered as the process writes to stdout/stderr. The final Promise still resolves with the full buffered result (subject to 10MB truncation), but `onData` sees all data including bytes beyond the truncation threshold. - -### ShellDataChunk +`ctx.shell.execute()` supports an `onData` callback for real-time +streaming. The final Promise still resolves with the buffered result +(10MB truncation); `onData` sees all data including bytes beyond the +threshold. `ShellDataChunk`: `{ stream: "stdout" | "stderr", data: string, +bytesTotal: number }`. ```ts -interface ShellDataChunk { - stream: "stdout" | "stderr"; // which pipe this chunk came from - data: string; // UTF-8 decoded text (may contain partial lines) - bytesTotal: number; // running total of bytes on this stream -} -``` - -### Buffered vs streaming patterns +import { urlToPath } from '@appos.space/plugin-utils'; -**Buffered (default)** — omit `onData`. The Promise resolves with `{ exitCode, stdout, stderr }` after process exit: +declare const outputDir: string; +declare function updateProgress(fraction: number): void; -```ts -// T1 plugins: cwd must be within active pane roots +// Buffered (default) — T1 plugins: cwd must be within active pane roots const activeDir = await ctx.fileOps.getActiveDirectory(); -const result = await ctx.shell.execute({ +const version = await ctx.shell.execute({ command: 'yt-dlp', args: ['--version'], cwd: activeDir ? urlToPath(activeDir) : undefined, }); -console.log(result.stdout.trim()); // "2024.08.06" -``` +console.log(version.stdout.trim()); -**Streaming** — provide `onData` for real-time progress: - -```ts +// Streaming — provide onData for real-time progress await ctx.shell.execute({ command: 'yt-dlp', - args: ['--ignore-config', '--progress', '--newline', url], + args: ['--ignore-config', '--progress', '--newline', 'https://example.com/video'], cwd: outputDir, onData: (chunk) => { if (chunk.stream === 'stdout') { @@ -978,70 +1357,92 @@ await ctx.shell.execute({ }); ``` -Chunks arrive on the plugin's serial queue. If `onData` throws, the error is logged but the process continues — streaming is best-effort. Order is preserved per-stream but stdout/stderr interleaving is OS-dependent. +Chunks arrive on the plugin's serial queue; if `onData` throws, the error +is logged and streaming continues (best-effort). Order is preserved +per-stream; stdout/stderr interleaving is OS-dependent. ## Shell security tiers -Shell execution is governed by a three-tier security model: - | Tier | Name | When | CWD restriction | Denied patterns | Allowlist | |---|---|---|---|---|---| | T0 | none | No `shell.execute` declared | N/A (calls rejected) | N/A | N/A | -| T1 | contained | JS plugins with `shell.execute` but no filesystem-wide perms | CWD must be within active pane roots. `cwd` is **required** (omitting throws). | Enforced: destructive commands (`rm -rf`, `dd`, `shutdown`, etc.) are blocked. Shell metacharacter patterns (`$()`, backticks, pipe-to-shell) are checked when the command is a shell interpreter (`sh`, `bash`, `zsh`). | Enforced | -| T2 | uncontained | Core-swift plugins or JS with `filesystem.readAll`/`writeAll` | No CWD restriction | Skipped | Enforced | +| T1 | contained | JS plugins with `shell.execute` but no filesystem-wide perms | CWD must be within active pane roots; `cwd` is **required** (omitting throws) | Enforced: destructive commands (`rm -rf`, `dd`, `shutdown`, ...) blocked; metacharacter patterns checked when the command is a shell interpreter | Enforced | +| T2 | uncontained | Core-swift plugins or JS with `filesystem.readAll`/`writeAll` | None | Skipped | Enforced | -**Allowlist**: All tiers enforce the `shellCommands` allowlist from `plugin.json`. Only commands listed there can be executed. +All tiers enforce the `shellCommands` allowlist. `shellDeniedPatterns` +adds custom regex guards, merged with (never replacing) the built-ins. -### `shellDeniedPatterns` (manifest field) +## `@appos.space/plugin-utils` — and the `ActionHandler` name collision -Plugins may declare `shellDeniedPatterns: string[]` in `plugin.json` to add custom regex guards. These are **merged** with the built-in defaults (never replacing them). Invalid regexes are logged and skipped at parse time. +Pure runtime helpers: `urlToPath`, `pathToUrl`, `fileExtension`, +`isTextFile`, `formatSize`, `formatDate`, `truncate`, `generateId`, +`simpleHash`, `debounce`, `throttle`, `createActionRouter`. -```json -{ - "shellDeniedPatterns": [ - "\\bsudo\\b", - "--recursive.*--force" - ] -} -``` +> **Disambiguation:** `plugin-utils` exports +> `type ActionHandler = (arg: string) => void | Promise` — the +> handler type for `createActionRouter`, which routes **ViewDescriptor +> action strings** (`'open:entry-1'`) from panel `handler` callbacks. It is +> NOT an fn-89 public-action handler — those receive +> `(exec: ActionExecutionContext)`. Same word, two different planes; don't +> pass one where the other is expected. ## Where to find exact signatures -Read `plugin-api.d.ts` in this directory — it's a consolidated snapshot (~2950 lines) containing: -- `PluginContext` interface with all 22 namespace properties -- All namespace interfaces (`UIAPI`, `ShellAPI`, `WorkspacesAPI`, `PluginFeedbackAPI`, etc.) -- `ViewDescriptor` interface with the 17-type discriminated union -- Dependency types (`SystemDependency`, `DependencyStatus`, `PluginDependencies`) -- WebView panel types (`WebPanelOptions`, `WebPanelMessage`, CSS custom property docs) -- Shell types (`ShellExecuteOptions`, `ShellDataChunk`, `ShellExecuteResult`) -- Workspace types (`WorkspaceTemplate`, `WorkspaceTemplateTabSlot`, `WorkspaceTemplatePaneConfig`) +Read the `plugin-api/` mirror in this directory (byte-verbatim from the +published tarball; `INDEX.md` records version + integrity): -For patterns, read `patterns.md` in this directory or the flagship `appos-plugin-ytdlp` (https://github.com/appos/appos-plugin-ytdlp) directly. +- `core.d.ts` — `PluginContext` (every namespace property + metadata) +- `namespaces.d.ts` — the original host namespace interfaces + option types +- `namespaces-core-plugins.d.ts` — the core-plugin wave interfaces + (`ActionsAPI`, `SchedulerAPI`, `NotificationsAPI`, ...) +- `permissions.d.ts` — `CanonicalPermissionScope`, `LegacyPermissionScope`, + `PermissionEntry` +- `views.d.ts` — the ViewDescriptor discriminated union + `MenuAction` +- `colors.d.ts` / `fonts.d.ts` / `icons.d.ts` — design-token unions + +For patterns, read `patterns.md`; for 2.x migration, +`migration-2.x-to-3.0.md`. --- ## Code Patterns from Reference Plugins -# Patterns — from appos-plugin-ytdlp +# Patterns — canonical AppOS plugin shapes -Working patterns extracted from the flagship `appos-plugin-ytdlp` (https://github.com/appos/appos-plugin-ytdlp). Every snippet here is shipped in a real plugin — when in doubt, open the source file referenced at the top of each section. Prefer a local clone; otherwise fetch raw files from `https://raw.githubusercontent.com/appos/appos-plugin-ytdlp/main/` (the repo is public as of AppOS launch), or fall back to https://docs.appos.space, which carries the same canonical patterns. +Working patterns extracted from the flagship `appos-plugin-ytdlp` +(https://github.com/appos/appos-plugin-ytdlp — public; prefer a local +clone, or raw files from +`https://raw.githubusercontent.com/appos/appos-plugin-ytdlp/main/`), +updated to the SDK 3.0.0 surface. Where ytdlp's shipped source still +predates 3.0.0 (it pins the 2.4 line — its own migration is tracked), the +snippet here shows the 3.0.0-correct form; the *structure* still mirrors +the shipped plugin. Snippets stub external helpers with `declare` lines so +each block stands alone — replace the stubs with your real modules. +Fallback docs: https://docs.appos.space. ## 1. Entry point + disposables **File**: `src/main.ts` -The canonical activate/deactivate shape. Push every disposer into `disposables[]` as it's created; drain in reverse on deactivate. +The canonical activate/deactivate shape. Push every disposer into +`disposables[]` as it's created; drain in reverse on deactivate. ```ts import type { PluginContext } from '@appos.space/plugin-types'; -import { registerDownloadPanel } from './panels/download-panel.js'; -import { registerLibraryPanel } from './panels/library-panel.js'; + +// These live in sibling modules (src/state.ts, src/panels/*.ts, ...): +declare function initState(ctx: PluginContext): Promise; +declare function initPaths(ctx: PluginContext): Promise; +declare function registerDownloadPanel(ctx: PluginContext): Promise<() => void>; +declare function registerLibraryPanel(ctx: PluginContext): Promise<() => void>; +declare function registerWorkspace(ctx: PluginContext): Promise<() => void>; +declare function registerMenubar(ctx: PluginContext): Promise<() => void>; +declare function flushState(): void; const disposables: Array<() => void | Promise> = []; async function activate(ctx: PluginContext): Promise { - // ctx.pluginId is runtime-injected; see extension-api.md ambient declaration - console.log(`[${ctx.pluginId}] activating`); + console.log(`[${ctx.pluginId}] activating`); // metadata scalars are typed in 3.0.0 await initState(ctx); await initPaths(ctx); @@ -1072,27 +1473,235 @@ async function deactivate(): Promise { (globalThis as unknown as { deactivate: typeof deactivate }).deactivate = deactivate; ``` -**Why globalThis and not ESM export**: the IIFE bundle runs the entire file once; the host reads `globalThis.activate` and `globalThis.deactivate` after evaluation. ESM exports disappear inside the IIFE closure. +**Why globalThis and not ESM export**: the IIFE bundle runs the entire file +once; the host reads `globalThis.activate` and `globalThis.deactivate` +after evaluation. ESM exports disappear inside the IIFE closure. -## 2. WebView panel registration +## 2. Public action with execution context (fn-89) -**File**: `src/panels/download-panel.ts` +**File**: `src/actions/register-actions.ts` + +The 3.0.0 action contract: the handler receives ONE argument — an +`ActionExecutionContext` — and reads the validated payload via +`exec.input`. Declare the input shape as a **`type` alias** (an +`interface` fails the `exec.input as X` assertion with TS2352). + +```ts +import type { ActionExecutionContext, PluginContext } from '@appos.space/plugin-types'; + +type DownloadUrlInput = { url: string; format?: string }; + +declare function enqueueDownload(url: string, format?: string): Promise; + +export async function registerActions(ctx: PluginContext): Promise<() => Promise> { + const token = await ctx.actions.register( + { + id: 'downloadUrl', + title: 'Download Media URL', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string' }, + format: { type: 'string', enum: ['best', 'mp4', 'mp3'] }, + }, + required: ['url'], + additionalProperties: false, + }, + outputSchema: { + type: 'object', + properties: { + enqueuedIds: { type: 'array', items: { type: 'string' } }, + }, + required: ['enqueuedIds'], + }, + visibility: ['api', 'agent', 'automation'], + approval: 'auto', + }, + async (exec: ActionExecutionContext) => { + const input = exec.input as DownloadUrlInput; + const url = input.url.trim(); + if (!/^https?:\/\//.test(url)) { + throw new Error('Invalid media URL'); + } + const enqueuedIds = await enqueueDownload(url, input.format); + if (enqueuedIds.length === 0) { + // Fail the receipt with an actionable message. + throw new Error('URL was not enqueued — check plugin settings.'); + } + return { enqueuedIds }; + }, + ); + return async () => { await ctx.actions.unregister(token); }; +} +``` + +**Key points**: +- `exec.input` is the schema-validated payload — the handler NEVER receives + the raw input as its parameter. `exec` also carries `invocationId`, + `source` (the `InvocationSource` union: `"user" | "plugin" | "agent" | + "recipe" | "sequence" | "system"`), and optional `sourceId`. +- Throwing fails the invocation receipt with your message — throw + actionable errors, not raw internals. +- `visibility: ['agent']` makes the action available to AI agents as a + tool; write `inputSchema` descriptions as if an LLM will read them (it + will). +- `register` resolves to a handle token — keep it for `unregister` in your + dispose path. + +## 3. `extensions[]` + runtime dual registration (fn-163 workaround) + +**Files**: `plugin.json` + `src/main.ts` + +Declare actions in the manifest so they are visible in catalogs and +manifest scans — AND bind the executable at runtime. Manifest-declared +actions currently never reach discovery on their own (host bug fn-163), so +ship BOTH, exactly as `appos-plugin-ytdlp` does. + + +```json +{ + "extensions": [ + { + "extensionPoint": "actions.definition", + "contribution": { + "id": "clear-completed", + "displayName": "Clear Completed Downloads", + "inputSchema": { "type": "object" }, + "visibility": ["palette", "automation"], + "approval": "auto" + } + } + ] +} +``` + +```ts +// Runtime side of the dual registration. For palette-style actions that +// already exist as commands, project the command into the action catalog: +ctx.commands.register('clear-completed', async () => { + // ... clear the queue ... +}); +const actionToken = await ctx.actions.registerFromCommand('clear-completed', { + title: 'Clear Completed Downloads', + visibility: ['user', 'automation'], +}); +void actionToken; // thread into disposables +``` + +For input-bearing actions, the runtime half is a full +`ctx.actions.register(def, handler)` (pattern 2) whose `def` mirrors the +manifest contribution. Other extension points (notification channels, +recipes/sequences definitions, surface contributions) do NOT need this +workaround — their manifest path works; only ACTION definitions do. + +## 4. Notification emit with routing-first mindset (fn-97) + +**File**: `src/notify.ts` + +`ctx.notifications.emit` never picks a channel — the user's routing rules +and the host filter chain decide delivery. Manifest prerequisites (the +validator enforces this chain): `notifications.emit` + `actions.invoke` +permissions AND a dependency on `space.appos.core.notifications`. + +```json +{ + "permissions": ["notifications.emit", "actions.invoke"], + "dependencies": { + "plugins": [ + { "id": "space.appos.core.notifications", "required": true } + ] + } +} +``` ```ts import type { PluginContext } from '@appos.space/plugin-types'; -import { parseInbound } from '../types/webview-messages.js'; + +export async function notifyDownloadComplete(ctx: PluginContext, filename: string): Promise { + try { + const handle = await ctx.notifications.emit({ + level: 'info', + title: 'Download complete', + body: `${filename} finished downloading.`, + category: 'downloads', + metadata: { filename }, + }); + void handle.notificationId; // keep if you may cancel() later + } catch (err) { + // Notifications are best-effort UX — never let emit failure break + // the operation that triggered it. + const name = err instanceof Error ? err.constructor.name : 'unknown'; + console.error(`[notify] emit failed (${name})`); + } +} +``` + +**Key points**: +- Set a stable `category` — users route on it (`downloads`, `errors`, ...). +- `cancel(notificationId)` returns `boolean` uniformly (`false` collapses + missing/foreign/terminal — anti-enumeration); it does not throw for those. +- Use `ctx.feedback.toast/hud` for always-local, in-app feedback; + `ctx.notifications.emit` for user-routable events (may leave the machine + via webhook channels). + +## 5. Scheduled job owning its lifecycle (fn-90) + +**File**: `src/maintenance.ts` + +Schedule on activation, cancel on dispose. All methods take the +owner-scoped `token` returned by `scheduleJob`. Requires +`scheduler.job.own`. + +```ts +import type { PluginContext } from '@appos.space/plugin-types'; + +export async function scheduleNightlySweep(ctx: PluginContext): Promise<() => Promise> { + const { token } = await ctx.scheduler.scheduleJob({ + name: 'nightly-sweep', + trigger: { kind: 'cron', expression: '0 3 * * *' }, // DST-safe cron + action: { kind: 'action', actionId: 'clear-completed', input: {} }, + catchupStrategy: 'skip', // don't replay missed windows on relaunch + }); + + return async () => { + try { await ctx.scheduler.cancel(token); } catch { /* already gone */ } + }; +} +``` + +**Key points**: +- The `action.actionId` must be a registered fn-89 action (pattern 2/3) — + the scheduler dispatches through the action pipeline, so receipts, + rate limits, and approval policy all apply. +- `catchupStrategy`: `'skip'` | `'runOnce'` | `'runAll'` — choose + explicitly; `'runAll'` after a week offline can flood. +- `triggerNow(token)` is the debug/"Run now" path — same dispatch pipeline. +- `history(token, limit?)` + `nextFire(token)` power a status UI cheaply. + +## 6. WebView panel registration + +**File**: `src/panels/download-panel.ts` + +```ts +import type { PluginContext, WebPanelMessage } from '@appos.space/plugin-types'; + +type PanelInboundMessage = { v: 1; type: string }; + +// src/types/webview-messages.ts (pattern 7): +declare function parseInbound(data: unknown): PanelInboundMessage | null; +declare function handle(ctx: PluginContext, msg: PanelInboundMessage, envelope: WebPanelMessage): Promise; export function registerDownloadPanel(ctx: PluginContext): () => void { let disposed = false; - ctx.ui.registerWebPanel('download', { + const panelToken = ctx.ui.registerWebPanel('download', { title: 'Downloads', icon: 'arrow.down.circle', htmlPath: 'webview/download/index.html', allowNavigation: false, }); - ctx.ui.onWebPanelMessage('download', (envelope) => { + const messageToken = ctx.ui.onWebPanelMessage('download', (envelope) => { if (disposed) return; const msg = parseInbound(envelope.data); if (!msg) return; @@ -1102,21 +1711,41 @@ export function registerDownloadPanel(ctx: PluginContext): () => void { console.error(`[download] Handler "${msg.type}" failed (${name})`); }); }); + void messageToken; // no handler-unregister API — see key points below - return () => { disposed = true; }; + return () => { + disposed = true; + ctx.ui.unregister(panelToken); + }; } ``` **Key points**: -- `onWebPanelMessage` has no disposer; use a `disposed` flag inside the handler closure -- Wrap every handler in `Promise.resolve().then(...).catch(...)` so sync throws and async rejections both land in the same error path -- Never log raw `err.message` — it may contain URLs, credentials, or user input. Log the error constructor name only. - -## 3. Typed message protocol +- `registerWebPanel` returns a registration id — capture it and pass it to + `ctx.ui.unregister` in your disposer (3.0.0 types this; 2.x hid it). +- `onWebPanelMessage` / `onWebPanelRequest` also return tokens, but the + host exposes NO handler-unregister API — `ctx.ui.unregister` takes only + slot-based contribution ids (panels, toolbar items, status bar items), + not handler tokens. Capture the token for identification/debugging; the + `disposed` flag is the actual disposal mechanism. One handler per + panelId — re-registering replaces the previous handler. +- Wrap every handler in `Promise.resolve().then(...).catch(...)` so sync + throws and async rejections land in the same error path. +- Never log raw `err.message` — it may contain URLs, credentials, or user + input. Log the error constructor name only. + +## 7. Typed message protocol **File**: `src/types/webview-messages.ts` ```ts +export type QueueEntry = { + id: string; + url: string; + status: 'queued' | 'active' | 'complete' | 'failed'; +}; +export type ProbeMetadata = { title: string; durationSeconds?: number }; + export type PanelInboundMessage = | { v: 1; type: 'probe-url'; probeId: string; url: string } | { v: 1; type: 'queue-download'; requestId: string; url: string; format: string } @@ -1124,7 +1753,7 @@ export type PanelInboundMessage = export type PanelOutboundMessage = | { v: 1; type: 'state-update'; queue: QueueEntry[] } - | { v: 1; type: 'probe-result'; probeId: string; metadata: Metadata } + | { v: 1; type: 'probe-result'; probeId: string; metadata: ProbeMetadata } | { v: 1; type: 'enqueue-ack'; requestId: string; ok: boolean; error?: string }; export function parseInbound(data: unknown): PanelInboundMessage | null { @@ -1136,13 +1765,18 @@ export function parseInbound(data: unknown): PanelInboundMessage | null { } ``` -Always version with `v: 1` and correlate request/response with a unique ID (`probeId`, `requestId`). WebView panels can have multiple instances — responses broadcast via `postToWebPanel` fan out to all of them, so without correlation IDs, responses get cross-applied. +Always version with `v: 1` and correlate request/response with a unique ID +(`probeId`, `requestId`). WebView panels can have multiple instances — +responses broadcast via `postToWebPanel` fan out to all of them, so +without correlation IDs, responses get cross-applied. -## 4. Throttled broadcast with JSC fallback +## 8. Throttled broadcast with JSC fallback **File**: `src/panels/download-panel.ts` ```ts +declare const state: { getQueue(): unknown[] }; + let throttleTimer: ReturnType | undefined; let lastBroadcast = 0; @@ -1171,15 +1805,22 @@ function broadcastQueue(): void { }, remaining); } } + +void broadcastQueue; ``` -**Why not use `throttle` from `@appos.space/plugin-utils`**: it calls `setTimeout` unconditionally. JSC may not inject timers, so roll a version that degrades to synchronous broadcasts. The 100ms target gives ~10 Hz updates — the sweet spot for progress UIs. +**Why not use `throttle` from `@appos.space/plugin-utils`**: it calls +`setTimeout` unconditionally. JSC may not inject timers, so roll a version +that degrades to synchronous broadcasts. The 100ms target gives ~10 Hz +updates — the sweet spot for progress UIs. -## 5. pipeShellToWebPanel wrapper +## 9. pipeShellToWebPanel wrapper **File**: `src/services/downloader.ts` ```ts +import type { PluginContext } from '@appos.space/plugin-types'; + async function runYtDlp(ctx: PluginContext, url: string, outputDir: string): Promise { const result = await ctx.ui.pipeShellToWebPanel('download', { command: 'yt-dlp', @@ -1195,11 +1836,12 @@ async function runYtDlp(ctx: PluginContext, url: string, outputDir: string): Pro }); if (result.exitCode !== 0) { - const errName = 'YtDlpExit'; - console.error(`[downloader] yt-dlp failed (${errName})`); + console.error('[downloader] yt-dlp failed (YtDlpExit)'); // Never log result.stderr raw — may contain URLs } } + +void runYtDlp; ``` **Gotchas**: @@ -1207,9 +1849,10 @@ async function runYtDlp(ctx: PluginContext, url: string, outputDir: string): Pro - 120s hard cap; long jobs need resume loops with `--continue` - `cwd` must be absolute and tilde-expanded; T1 sandbox rejects relative paths and `~` - Always pass `--ignore-config` or the tool's equivalent -- Chunks fan out to all panel instances; filter with `envelope.instanceId` if you need per-instance isolation +- Chunks fan out to all panel instances; filter with `envelope.instanceId` + if you need per-instance isolation -## 6. Workspace template registration +## 10. Workspace template registration **File**: `src/workspace/template.ts` @@ -1243,8 +1886,8 @@ export async function registerWorkspace(ctx: PluginContext): Promise<() => void> }, }); - // Note: workspace registration only. Apply is done unconditionally at the - // end of activate(), NOT here, NOT gated on a first-run cache flag. + // Note: registration only. Apply is done unconditionally at the END of + // activate(), NOT here, NOT gated on a first-run cache flag. // No explicit unregister needed — ephemeral templates clean on deactivation. return () => {}; } @@ -1253,12 +1896,19 @@ export async function registerWorkspace(ctx: PluginContext): Promise<() => void> Then, at the end of `activate()`: ```ts +declare const WORKSPACE_ID: string; + // Step N (last): apply workspace so the user sees the UI immediately. // activate() runs once per host launch, so this is effectively once-per-launch. -// The user can still switch workspaces manually after activation and we won't -// override them again until next launch. +// +// IMPORTANT: apply() resolves false (not an error) when no browser window +// is focused — e.g. when the plugin first activates from the Settings +// sheet. Always fall back to showPaneTab() so the user sees something. try { - await ctx.workspaces.apply(WORKSPACE_ID); + const applied = await ctx.workspaces.apply(WORKSPACE_ID); + if (!applied) { + try { ctx.ui.showPaneTab('download', { title: 'Downloads', pane: 'left' }); } catch { /* ok */ } + } } catch (err) { console.warn('[my-plugin] workspaces.apply on activation failed:', err); } @@ -1266,11 +1916,19 @@ try { > **DO NOT use an `applyIfFirstRun(ctx)` / `cache.get('initialized')` gate.** > -> Gating workspace apply behind a cache flag is the #1 cause of "plugin installed but no UI is visible". On first launch it works; on every subsequent launch the user is left in whatever workspace they were in before, with no reliable way to discover the plugin's panels. Apply unconditionally in `activate()` and move on. +> Gating workspace apply behind a cache flag is the #1 cause of "plugin +> installed but no UI is visible". On first launch it works; on every +> subsequent launch the user is left in whatever workspace they were in +> before, with no reliable way to discover the plugin's panels. Apply +> unconditionally in `activate()` and move on. -**Panel-open commands**: `ctx.ui.showPaneTab(panelId, options?)` focuses an existing tab if present, or creates a new tab if none exists. Use `workspaces.apply()` before `showPaneTab` when the command depends on the full dual-pane layout: +**Panel-open commands**: `ctx.ui.showPaneTab(panelId, options?)` focuses an +existing tab or creates one. Use `workspaces.apply()` first when the +command depends on the full dual-pane layout: ```ts +declare const WORKSPACE_ID: string; + ctx.commands.register('open-download-panel', { title: 'Open yt-dlp Downloader', handler: async () => { @@ -1280,9 +1938,10 @@ ctx.commands.register('open-download-panel', { }); ``` -**Note**: `ctx.cache.get` returns the **deserialized** value (no JSON.parse). Pass `persist: true` for durability across restarts. +**Note**: `ctx.cache.get` returns the **deserialized** value (no +JSON.parse). Pass `persist: true` for durability across restarts. -## 7. Menubar registration with popover content +## 11. Menubar registration with popover content **File**: `src/menubar/menubar.ts` @@ -1290,6 +1949,9 @@ ctx.commands.register('open-download-panel', { import type { PluginContext } from '@appos.space/plugin-types'; import { vstack, section, listItem, button } from '@appos.space/view-builders'; +declare const state: { getQueue(): unknown[]; subscribe(listener: () => void): () => void }; +declare const WORKSPACE_ID: string; + export async function registerMenubar(ctx: PluginContext): Promise<() => void> { await ctx.menubar.register({ icon: 'arrow.down.circle' }); @@ -1310,13 +1972,13 @@ export async function registerMenubar(ctx: PluginContext): Promise<() => void> { const clickToken = ctx.events.subscribe('menubar.clicked', async () => { if (unsubscribed) return; try { await ctx.workspaces.apply(WORKSPACE_ID); } catch { /* may not exist yet */ } - try { ctx.ui.showPaneTab('download', { title: 'Downloads', pane: 'left' }); } catch { /* workspace apply already surfaced it */ } + try { ctx.ui.showPaneTab('download', { title: 'Downloads', pane: 'left' }); } catch { /* ok */ } }); // Update badge AND popover content as queue changes const unsubscribeQueue = state.subscribe(() => { const count = state.getQueue().length; - ctx.menubar.setBadge(count > 0 ? count : 0); + void ctx.menubar.setBadge(count > 0 ? count : 0); ctx.menubar.setContent(buildPopoverContent()).catch(() => {}); }); @@ -1329,22 +1991,31 @@ export async function registerMenubar(ctx: PluginContext): Promise<() => void> { } ``` -**Popover content is mandatory**: the host shows a popover when the menubar icon is clicked. Without `setContent()`, it says "No content". Always call `setContent()` after `register()` and update it reactively alongside `setBadge()`. +**Popover content is mandatory**: without `setContent()`, the popover says +"No content" — `register()` + `setBadge()` + `menubar.clicked` all work +fine without it, so it's easy to miss. Update it reactively alongside +`setBadge()`. -**Transactional init**: if any step fails after `register` succeeds, call `remove()` in the catch so the menu bar doesn't leak a dangling item on activation failure. +**Transactional init**: if any step fails after `register` succeeds, call +`remove()` in the catch so the menu bar doesn't leak a dangling item. -## 8. Smart folder filter with closure capture +## 12. Smart folder filter with closure capture **File**: `src/smart-folders/filters.ts` ```ts import type { PluginContext } from '@appos.space/plugin-types'; -export async function registerFilters(ctx: PluginContext, state: State): Promise<() => void> { +type LibraryState = { + favorites: Array<{ url: string }>; + subscribe(listener: () => void): () => void; +}; + +export async function registerFilters(ctx: PluginContext, state: LibraryState): Promise<() => void> { let favoritesByUrl: Map = new Map(); const rebuild = () => { - favoritesByUrl = new Map(state.favorites.map((f) => [f.url, true])); + favoritesByUrl = new Map(state.favorites.map((f) => [f.url, true])); }; rebuild(); @@ -1370,9 +2041,12 @@ export async function registerFilters(ctx: PluginContext, state: State): Promise } ``` -**Why synchronous `evaluate`**: smart folder filters are called once per file during directory traversal. They must be cheap and cannot await. Build a lookup structure (Map, Set) on state change and capture it in the closure. The callback receives `{ url: string, metadata: Record }` — NOT a `PluginFileDescriptor`. +**Why synchronous `evaluate`**: smart folder filters are called once per +file during directory traversal. They must be cheap and cannot await. +Build a lookup structure (Map, Set) on state change and capture it in the +closure. The callback receives `{ url, metadata }` — nothing else. -## 9. Dependency status handling +## 13. Dependency status handling **File**: `src/main.ts` @@ -1390,17 +2064,28 @@ const depToken = ctx.lifecycle.onDependencyStatusChanged((statuses) => { installHint: ytDlp?.installHint, }); }); +void depToken; // Note: no matching unsubscribe API exists for lifecycle tokens yet; // the subscription auto-cleans on plugin deactivation. ``` -`ctx.lifecycle.getDependencyStatus()` and `ctx.lifecycle.recheckDependencies()` are host-wired and safe to call — `appos-plugin-ytdlp` uses both in production (`src/main.ts` does the initial `getDependencyStatus()` read after subscribing; the panels call `recheckDependencies()` from their "Re-check" buttons). Subscribe FIRST, then read, so no update can slip between the read and the subscription. +`ctx.lifecycle.getDependencyStatus()` and +`ctx.lifecycle.recheckDependencies()` are host-wired and safe to call — +`appos-plugin-ytdlp` uses both in production (the initial +`getDependencyStatus()` read happens after subscribing; the panels call +`recheckDependencies()` from their "Re-check" buttons). Subscribe FIRST, +then read, so no update can slip between the read and the subscription. -If a required dependency is missing, show a "degraded banner" in the webview with the install hint. Don't refuse to load the plugin — the host already handles hard failures. +If a required dependency is missing, show a "degraded banner" in the +webview with the install hint. Don't refuse to load the plugin — the host +already handles hard failures. -## 10. Settings read with fallback +## 14. Settings read with fallback ```ts +import type { PluginContext } from '@appos.space/plugin-types'; +import { urlToPath } from '@appos.space/plugin-utils'; + async function getOutputDir(ctx: PluginContext): Promise { const raw = ctx.settings.get('outputDir'); if (typeof raw === 'string' && raw.length > 0) return raw; @@ -1409,30 +2094,51 @@ async function getOutputDir(ctx: PluginContext): Promise { if (activeDir) return urlToPath(activeDir); throw new Error('outputDir setting is required when no active directory'); } + +void getOutputDir; ``` -`ctx.settings.get(key)` returns `unknown` — always check the type before using the value. Prefer explicit defaults in code over relying on the manifest `default` field (which also works, but is a weaker guarantee). +`ctx.settings.get(key)` returns `unknown` — always check the type before +using the value. Prefer explicit defaults in code over relying on the +manifest `default` field (which also works, but is a weaker guarantee). -## 11. Handler action routing (ViewDescriptor) +## 15. Handler action routing (ViewDescriptor) -For ViewDescriptor-based panels (not used in ytdlp, but valid for simpler plugins), use short semantic action prefixes: +For ViewDescriptor-based panels, use short semantic action prefixes: ```ts -handler: (action: string) => { +declare function refresh(): void; +declare function addSelected(): void; +declare function activateEntry(id: string): void; +declare function openFile(id: string): void; +declare function revealInFinder(id: string): void; +declare function removeItem(id: string): void; + +const handler = (action: string): void => { if (action === 'refresh') refresh(); if (action === 'add-selected') addSelected(); - if (action.startsWith('select:')) activate(action.substring(7)); + if (action.startsWith('select:')) activateEntry(action.substring(7)); if (action.startsWith('open:')) openFile(action.substring(5)); if (action.startsWith('reveal:')) revealInFinder(action.substring(7)); if (action.startsWith('remove:')) removeItem(action.substring(7)); -} +}; + +void handler; ``` -**Don't repeat the noun**: `"remove:"` is better than `"remove-collection:"`. The handler already knows its context. +**Don't repeat the noun**: `"remove:"` is better than `"remove-collection:"`. +The handler already knows its context. (This `(action: string) => ...` +handler is the `ActionHandler` shape from `@appos.space/plugin-utils` — a +ViewDescriptor action-string router, unrelated to fn-89 action handlers.) -## 12. menuActions on listItem +## 16. menuActions on listItem ```ts +import type { ListItemDescriptor, MenuAction } from '@appos.space/plugin-types'; +import { encodeMenuActions } from '@appos.space/view-builders'; + +declare const item: { url: string; name: string; subtitle: string }; + const menu: MenuAction[] = [ { title: 'Open', icon: 'doc', action: `open:${item.url}` }, { title: 'Reveal in Finder', icon: 'folder', action: `reveal:${item.url}` }, @@ -1440,25 +2146,29 @@ const menu: MenuAction[] = [ { title: 'Remove', icon: 'trash', action: `remove:${item.url}`, destructive: true }, ]; -const listItem: ListItemDescriptor = { +const row: ListItemDescriptor = { type: 'listItem', properties: { title: item.name, subtitle: item.subtitle, icon: 'doc.fill', action: `select:${item.url}`, - menuActions: JSON.stringify(menu), // MUST be a JSON STRING + menuActions: encodeMenuActions(menu), // MUST be a JSON STRING }, }; + +void row; ``` **Key rules**: -- `menuActions` is a **JSON string**, always `JSON.stringify()` the array +- `menuActions` is a **JSON string** — build it with `encodeMenuActions()` + (or `JSON.stringify(menu)`, identical output) - Dividers are plain objects with `title: '---'` - Destructive actions get `destructive: true` and are placed last -- Always ship `menuActions` on every listable item — this is the single most important UX pattern for plugin authors +- Always ship `menuActions` on every listable item — the single most + important UX pattern for plugin authors -## 13. Build script (canonical) +## 17. Build script (canonical) **File**: `build.mjs` @@ -1487,9 +2197,11 @@ if (isWatch) { } ``` -**Invoked as**: `npm run build` or `node build.mjs`. The esbuild API wins over `npx esbuild ...` because watch mode is cleaner and the script survives across platforms. +**Invoked as**: `npm run build` or `node build.mjs`. The esbuild API wins +over `npx esbuild ...` because watch mode is cleaner and the script +survives across platforms. -## 14. tsconfig (mandatory flags) +## 18. tsconfig (mandatory flags) ```json { @@ -1511,9 +2223,12 @@ if (isWatch) { } ``` -**`verbatimModuleSyntax: true` is mandatory** for any plugin importing from `@appos.space/plugin-types`. Without it, TypeScript emits runtime `require` / `import` calls that look up a non-existent module in the bundler. The plugin silently fails to activate. +**`verbatimModuleSyntax: true` is mandatory** for any plugin importing +from `@appos.space/plugin-types`. Without it, TypeScript emits runtime +`require` / `import` calls that look up a non-existent module in the +bundler. The plugin silently fails to activate. -## 15. Deploy (rsync with --delete-excluded) +## 19. Deploy (rsync with --delete-excluded) ```bash rsync -av --delete --delete-excluded \ @@ -1536,9 +2251,11 @@ rsync -av --delete --delete-excluded \ "$PLUGIN_ROOT/" "$INSTALL_DIR/" ``` -**Critical**: `--delete-excluded` removes files added to the exclude list after a previous deploy. Without it, a file you excluded today but copied yesterday stays on the destination forever. The first time you add `.mcp.json` to the exclude list, you need `--delete-excluded` for it to actually disappear from the install directory. +**Critical**: `--delete-excluded` removes files added to the exclude list +after a previous deploy. Without it, a file you excluded today but copied +yesterday stays on the destination forever. -## 16. minHostVersion landmine +## 20. minHostVersion landmine ```json { @@ -1548,7 +2265,13 @@ rsync -av --delete --delete-excluded \ } ``` -**ALWAYS** default `minHostVersion` to `"1.0.0"`. The host compares this against its `CFBundleShortVersionString` (currently `1.0.0`), NOT the SDK package version (`2.4.x`). Setting `minHostVersion` to `"2.4.0"` because you saw that number in `@appos.space/plugin-types/package.json` will cause `DependencyResolver.swift` to silently reject the plugin before it reaches the Settings → Plugins sheet. No error dialog, no log entry you'll think to check. +**ALWAYS** default `minHostVersion` to `"1.0.0"`. The host compares this +against its `CFBundleShortVersionString` (currently `1.0.0`), NOT the SDK +package version. Setting `minHostVersion` to the SDK's version number +because you saw it in `@appos.space/plugin-types/package.json` will cause +the host's dependency resolver to silently reject the plugin before it +reaches the Settings → Plugins sheet. No error dialog, no log entry you'll +think to check. To verify the actual host version: @@ -1556,9 +2279,10 @@ To verify the actual host version: defaults read /Applications/AppOS.app/Contents/Info.plist CFBundleShortVersionString ``` -## 17. WebView panel with plugin-to-webview messaging +## 21. WebView panel with plugin-to-webview messaging -**Full plugin structure** showing `plugin.json` + `src/main.ts` + `webview/main/` with external JS/CSS (CSP-compliant). +**Full plugin structure** showing `plugin.json` + `src/main.ts` + +`webview/main/` with external JS/CSS (CSP-compliant). ### plugin.json @@ -1583,32 +2307,44 @@ import type { PluginContext } from '@appos.space/plugin-types'; import { urlToPath } from '@appos.space/plugin-utils'; const disposables: Array<() => void | Promise> = []; +let disposed = false; async function activate(ctx: PluginContext): Promise { // Register with SHORT id — runtime auto-prefixes to {pluginId}.main-panel - ctx.ui.registerWebPanel('main-panel', { + const panelToken = ctx.ui.registerWebPanel('main-panel', { title: 'My Tools', icon: 'wrench', htmlPath: 'webview/main/index.html', allowNavigation: false, }); - - ctx.ui.onWebPanelMessage('main-panel', (envelope) => { + disposables.push(() => ctx.ui.unregister(panelToken)); + // Handler guard: disposables drain in REVERSE, so this flips BEFORE the + // panel unregisters — queued messages can't slip through mid-teardown. + disposables.push(() => { disposed = true; }); + + // SECURITY: messages are SEMANTIC intents, never shell-shaped. The + // webview may only ask for named operations; the plugin hardcodes the + // command + argv per intent. NEVER forward a command or argv array + // from webview input into ctx.shell.execute — a compromised or buggy + // panel could then drive any allowlisted binary with arbitrary flags. + const messageToken = ctx.ui.onWebPanelMessage('main-panel', (envelope) => { + if (disposed) return; if (typeof envelope.data !== 'object' || envelope.data === null) return; const raw = envelope.data as Record; if (raw.v !== 1 || typeof raw.type !== 'string') return; - if (raw.type === 'run-command') { - if (typeof raw.command !== 'string') return; - const args = Array.isArray(raw.args) ? raw.args.filter((a): a is string => typeof a === 'string') : []; - Promise.resolve().then(() => runCommand(ctx, raw.command as string, args)).catch((err) => { + if (raw.type === 'show-version') { + Promise.resolve().then(() => showVersion(ctx)).catch((err) => { const name = err instanceof Error ? err.constructor.name : 'unknown'; - console.error(`[mytools] runCommand failed (${name})`); + console.error(`[mytools] showVersion failed (${name})`); }); } + // Unknown message types are dropped — no generic fallthrough. }); + void messageToken; // no handler-unregister API (pattern 6) - ctx.ui.onWebPanelRequest('main-panel', async (envelope) => { + const requestToken = ctx.ui.onWebPanelRequest('main-panel', async (envelope) => { + if (disposed) return { v: 1, type: 'error', message: 'plugin disposed' }; if (typeof envelope.data !== 'object' || envelope.data === null) { return { v: 1, type: 'error', message: 'invalid payload' }; } @@ -1621,9 +2357,13 @@ async function activate(ctx: PluginContext): Promise { } return { v: 1, type: 'error', message: 'unknown request' }; }); + void requestToken; // no handler-unregister API (pattern 6) } -async function runCommand(ctx: PluginContext, command: string, args: string[]): Promise { +// One function per intent: command + argv are HARDCODED here, never taken +// from the webview message. +async function showVersion(ctx: PluginContext): Promise { + if (disposed) return; // re-check: dispatch is async, disposal may have raced // T1 plugins must use cwd within active pane roots const activeDir = await ctx.fileOps.getActiveDirectory(); const cwd = activeDir ? urlToPath(activeDir) : undefined; @@ -1632,8 +2372,8 @@ async function runCommand(ctx: PluginContext, command: string, args: string[]): ctx.ui.postToWebPanel('main-panel', { v: 1, type: 'started' }); const result = await ctx.shell.execute({ - command, - args, + command: 'mytool', // fixed binary (must be in shellCommands) + args: ['--version'], // fixed argv for this intent cwd, onData: (chunk) => { ctx.ui.postToWebPanel('main-panel', { @@ -1728,8 +2468,8 @@ window.twopanez.onMessage((msg) => { }); runBtn.addEventListener('click', () => { - // Fire-and-forget message to plugin - window.twopanez.send({ v: 1, type: 'run-command', command: 'mytool', args: ['--version'] }); + // Fire-and-forget SEMANTIC intent — the plugin decides what to execute. + window.twopanez.send({ v: 1, type: 'show-version' }); }); // Request/response example @@ -1741,19 +2481,34 @@ checkStatus(); ``` **Key points:** -- Register the SHORT id `main-panel` — runtime auto-prefixes to `{pluginId}.main-panel` -- All JS and CSS are external files (CSP blocks inline `