diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6ca6052..318bdf7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,13 +6,13 @@ }, "metadata": { "description": "Create, build, test, and deploy AppOS plugins using the @appos.space SDK. Skills for the full plugin API, commands for scaffolding/building/deploying, and specialized agents for architecture and WebView panel implementation.", - "version": "2.0.1" + "version": "3.0.0" }, "plugins": [ { "name": "appos-dev", - "description": "Create, build, test, and deploy AppOS plugins using the @appos.space SDK. Skills for the full plugin API (22 namespaces, 34 permissions, WebView panels, workspaces, menubar, smart folders), commands for scaffolding/building/deploying, and specialized agents for architecture and WebView panel implementation. Canonical reference: appos-plugin-ytdlp.", - "version": "2.0.1", + "description": "Create, build, test, and deploy AppOS plugins using the @appos.space SDK 3.0.0. Skills for the full plugin API (the complete PluginContext surface — WebView panels, actions, notifications, scheduler, workspaces, menubar, smart folders, and the core-plugin namespaces — plus the canonical permission-scope model and extensions[] manifests), commands for scaffolding/building/deploying, and specialized agents for architecture, ViewDescriptor, and WebView panel implementation. Canonical reference: appos-plugin-ytdlp.", + "version": "3.0.0", "author": { "name": "AppOS", "url": "https://github.com/appos" diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..284e1bd --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,44 @@ +# 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) +# 3. check-compiled-freshness.sh (fn-165.4) — compiled/ artifacts match +# their manifest in BOTH directions (source changed without regen / +# compiled artifact edited directly) +# +# 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 + + - name: Compiled-artifact freshness (sources vs compiled/ manifest, both directions) + 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/CLAUDE.md b/CLAUDE.md index 8a4fa0a..b941a0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,18 +2,20 @@ Project instructions for Claude Code when working inside this repo. +> Note: per the Claude Code plugin docs, this root `CLAUDE.md` is NOT loaded as plugin context when the `appos-dev` plugin is installed — it exists for contributors editing THIS repo. Knowledge meant for plugin consumers belongs in the skills/agents/commands under `plugins/appos-dev/`. + ## What this is A Claude Code plugin that gives Claude Code the skills, commands, and agents needed to create, build, test, and deploy AppOS workspace manager plugins. Canonical references: the developer docs at https://docs.appos.space (always reachable) and the flagship plugin https://github.com/appos/appos-plugin-ytdlp (public as of AppOS launch) — the plugin that exercises every supported SDK feature. -This plugin is versioned at **2.x** (current: see `.claude-plugin/marketplace.json`) because it was fully rewritten to target the SDK+WebView flagship pattern. The legacy ViewDescriptor-only model is still supported but is no longer the primary pattern. +This plugin is versioned at **3.x** (current: see `.claude-plugin/marketplace.json`, the repo's ONLY manifest — both `metadata.version` and `plugins[0].version`), deliberately aligned with the `@appos.space` SDK major it teaches. That alignment is versioning convenience, NOT a `minHostVersion` — the host version stays `1.0.0` (see Conventions). The v2.x line was the SDK+WebView rewrite; the legacy ViewDescriptor-only model is still supported but is no longer the primary pattern. ## Repository layout ``` appos-dev-plugin/ ├── .claude-plugin/ -│ └── marketplace.json # Marketplace catalog — points at ./plugins/appos-dev +│ └── marketplace.json # Marketplace catalog (the ONLY manifest — no plugin.json exists) — points at ./plugins/appos-dev ├── plugins/ │ └── appos-dev/ # The actual Claude Code plugin │ ├── commands/ @@ -28,16 +30,24 @@ appos-dev-plugin/ │ ├── skills/ │ │ ├── appos-plugin-dev/ │ │ │ ├── SKILL.md # Main SDK+WebView skill -│ │ │ └── reference/ # Full API spec, patterns, type definitions +│ │ │ └── reference/ # API reference, patterns, plugin-api/ d.ts mirror (generated) │ │ ├── viewdescriptor-authoring/ │ │ │ └── SKILL.md # ViewDescriptor authoring skill │ │ └── webview-panels/ │ │ └── SKILL.md # Focused WebView authoring skill -│ └── compiled/ # Concatenated context artifacts for the AppOS host +│ └── compiled/ # GENERATED context artifacts for the AppOS host — do not hand-edit +├── scripts/ # verify-knowledge.mjs + check-sdk-freshness.sh + check-compiled-freshness.sh (CI gates) +├── package.json # exact-pinned toolchain for the gates (npm ci) +├── package-lock.json ├── README.md └── LICENSE ``` +### Generated content — provenance + +- `plugins/appos-dev/skills/appos-plugin-dev/reference/plugin-api/` is a byte-verbatim mirror of the published `@appos.space/plugin-types` tarball's `dist/*.d.ts`, regenerated with `scripts/check-sdk-freshness.sh --update` and verified against the npm registry on CI. Never edit by hand. +- `plugins/appos-dev/compiled/` is generated by the **AppOS-Desktop** repo's `scripts/compile-factory-context.sh`, which concatenates this repo's skill sources into the context artifacts the host bundles for its in-app AI features. Regenerate via that script (it writes both the Desktop-side copy and this repo's copy); the artifacts are validated by freshness manifests, not by `verify-knowledge.mjs`. + ## Key references Before editing anything, be aware of these external sources of truth: @@ -50,7 +60,7 @@ Before editing anything, be aware of these external sources of truth: ## Conventions - **Plugin IDs**: use `space.appos.*` for flagship plugins (the ones shipped with AppOS), `com.community.*` for community plugins, other reverse-domain for private/personal plugins. -- **minHostVersion**: always default to `"1.0.0"`. The single biggest cause of "plugin installed but not appearing in Settings" is conflating the SDK version (`2.4.x`) with the host version (`1.0.x`). The `appos-plugin-dev` skill has a "minHostVersion landmine" section; keep it prominent. +- **minHostVersion**: always default to `"1.0.0"`. The single biggest cause of "plugin installed but not appearing in Settings" is conflating the SDK version (`3.0.x` — or the older `2.4.x`) with the host version (`1.0.x`). The `appos-plugin-dev` skill has a "minHostVersion landmine" section; keep it prominent. - **tsconfig**: always include `verbatimModuleSyntax: true`. Without it, TypeScript emits broken runtime imports from the declaration-only `@appos.space/plugin-types` package. - **Entry point**: always `globalThis.activate = activate` + `globalThis.deactivate = deactivate`, never ESM `export`. ESM exports disappear inside the IIFE closure and the host can't find them. - **Parameter naming**: always `ctx`, never `pluginContext`. Matches `appos-plugin-ytdlp` and every reference plugin. @@ -62,6 +72,7 @@ Before editing anything, be aware of these external sources of truth: 2. If the SDK surface changed, check https://github.com/appos/plugin-sdk (`packages/plugin-types`) — or the published `@appos.space/plugin-types` npm package — for the current type definitions. 3. Keep examples copyable — prefer full working snippets over fragments. 4. When documenting a gotcha, include a `**Why**:` line with the root cause so future-you can judge edge cases. +5. Run the CI gates before committing: `npm ci` once, then `npm run check` — the same sequence CI runs: `scripts/check-sdk-freshness.sh` (mirror byte-equality), `node scripts/verify-knowledge.mjs` (type-checks every `ts` fence against the pinned SDK, plus a stale-identifier denylist and count-string consistency; opt-out convention documented in the script header), and `scripts/check-compiled-freshness.sh` (compiled/ artifacts match their manifest, both directions). ## When adding new commands/agents/skills diff --git a/README.md b/README.md index 251ca38..f554119 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,22 @@ A Claude Code plugin for creating, building, testing, and deploying [AppOS](https://appos.space) workspace manager plugins using the `@appos.space` SDK. -## What's new in v2.0 +## What's new in v3.0 -v2.0 is a full rewrite targeting the SDK+WebView flagship pattern used by `appos-plugin-ytdlp`. The legacy ViewDescriptor-only model is still supported but is no longer the primary pattern. Key changes: +v3.0 re-anchors every teaching surface on the published SDK 3.0.0 (`@appos.space/plugin-types@3.0.0`), the surface the shipped AppOS 1.0.0 host actually exposes. The plugin's own version is deliberately aligned with the SDK major it teaches (it is NOT a `minHostVersion` — that stays `"1.0.0"`). Key changes: -- **SDK-based scaffolding** — `new-plugin` now writes `package.json` with `@appos.space/plugin-types` (declaration-only types), `@appos.space/plugin-utils` (runtime helpers), and `@appos.space/view-builders` (typed view builders), plus a `tsconfig.json` with `verbatimModuleSyntax: true` and a `build.mjs` esbuild-API build script. -- **WebView panels are first-class** — new `webview-panels` skill covers `registerWebPanel`, the host-injected webview bridge, CSP constraints, typed message protocols, throttled broadcasts, and `pipeShellToWebPanel` for streaming CLI output directly to the UI. -- **22 namespaces, 34 permissions** — updated to match the current `@appos.space/plugin-types` surface. Adds `menubar`, `workspaces`, `smartFolders`, `cache`, `feedback`, `webview`, and more. -- **minHostVersion landmine documented** — the single most common "plugin won't appear in Settings" bug now has a prominent warning everywhere it matters. +- **Full 3.0.0 API surface** — 43 namespaces on `PluginContext` (of which 21 core-plugin namespaces: actions, palette, scheduler, vault, store, resources, tokens, bundles, entities, fields, ledger, views, surfaces, protocols, notifications, input, webhook, llm, recipes, sequences, fileSystem) and the 135-scope canonical permission model with 5 legacy aliases (deprecated). +- **`extensions[]` manifests** — manifest-declarative contributions to core-plugin extension points, including the required `actions.definition` dual-registration pattern: today the manifest entry is catalog/manifest metadata only (host bug fn-163), and the runtime `ctx.actions.register()` call is what provides discovery and execution — see `plugins/appos-dev/skills/appos-plugin-dev/reference/extension-api.md`. +- **Scaffold pins `^3.0.0`** — `new-plugin` scaffolds depend on the 3.x SDK line; the SDK main entry ships no ambient globals, so all types are imported from the packages (3.0.1+ adds one opt-in globals subpath typing the host-injected `URL`; scaffolds declare that surface locally in the `src/jsc-globals.ts` `declare global` module instead — a `.ts` module, so it stays type-checked even under the scaffold's `skipLibCheck: true`). +- **Byte-verbatim type mirror + drift gate** — the bundled d.ts reference is a generated mirror of the published npm tarball, and CI type-checks every fenced code example against it (see "Knowledge verification" below). + +## What was new in v2.0 + +v2.0 was a full rewrite targeting the SDK+WebView flagship pattern used by `appos-plugin-ytdlp`. The legacy ViewDescriptor-only model is still supported but is no longer the primary pattern. Key changes: + +- **SDK-based scaffolding** — `new-plugin` writes `package.json` with `@appos.space/plugin-types` (declaration-only types), `@appos.space/plugin-utils` (runtime helpers), and `@appos.space/view-builders` (typed view builders), plus a `tsconfig.json` with `verbatimModuleSyntax: true` and a `build.mjs` esbuild-API build script. +- **WebView panels are first-class** — the `webview-panels` skill covers `registerWebPanel`, the host-injected webview bridge, CSP constraints, typed message protocols, throttled broadcasts, and `pipeShellToWebPanel` for streaming CLI output directly to the UI. +- **minHostVersion landmine documented** — the single most common "plugin won't appear in Settings" bug has a prominent warning everywhere it matters. - **Canonical reference** — `appos-plugin-ytdlp` is the flagship plugin that exercises every supported SDK feature. Skills and agents point at it for ground truth. ## Features @@ -37,6 +45,30 @@ The plugin lives at `plugins/appos-dev` inside this repo (marketplace layout): claude --plugin-dir /path/to/appos-dev-plugin/plugins/appos-dev ``` +## Repository layout + +``` +appos-dev-plugin/ +├── .claude-plugin/ +│ └── marketplace.json # Marketplace catalog (the repo's ONLY manifest) — points at ./plugins/appos-dev +├── plugins/ +│ └── appos-dev/ # The actual Claude Code plugin +│ ├── commands/ # new-plugin, build, deploy, validate +│ ├── agents/ # plugin-architect, viewdescriptor-builder, webview-panel-builder +│ ├── skills/ +│ │ ├── appos-plugin-dev/ # Main SDK skill + reference/ (incl. plugin-api/ d.ts mirror) +│ │ ├── viewdescriptor-authoring/ +│ │ └── webview-panels/ +│ └── compiled/ # GENERATED context artifacts consumed by the AppOS host (do not hand-edit) +├── scripts/ # verify-knowledge.mjs, check-sdk-freshness.sh, check-compiled-freshness.sh +├── package.json # exact-pinned toolchain for the verification gate +├── README.md +├── CLAUDE.md # contributor instructions for THIS repo (not shipped as plugin context) +└── LICENSE +``` + +The `compiled/` artifacts are generated by the AppOS-Desktop repo's `scripts/compile-factory-context.sh` (which concatenates the skill sources for the host's in-app AI features) — regenerate them from that script rather than editing them; they are validated by freshness manifests, not by the knowledge gate. + ## Commands | Command | Description | @@ -51,6 +83,7 @@ claude --plugin-dir /path/to/appos-dev-plugin/plugins/appos-dev | Skill | Triggers on | |-------|-------------| | appos-plugin-dev | "AppOS plugin", "workspace manager plugin", PluginContext, SDK packages, workspaces, menubar | +| viewdescriptor-authoring | "ViewDescriptor", "sidebar panel UI", "listItem", "menuActions", "section with badge", column alignment | | webview-panels | "registerWebPanel", "postToWebPanel", "pipeShellToWebPanel", "bridge.js", "shell chunks", CSP, webview | ## Agents @@ -58,6 +91,7 @@ claude --plugin-dir /path/to/appos-dev-plugin/plugins/appos-dev | Agent | Purpose | |-------|---------| | plugin-architect | Designs plugin structure from requirements — maps APIs, permissions, rendering mode, settings | +| viewdescriptor-builder | Builds ViewDescriptor JSON trees — all 17 view types, columns, menuActions, empty/loading states | | webview-panel-builder | Builds WebView panels end-to-end — registration, HTML bundle, typed message protocol, pipeShellToWebPanel wiring | ## Prerequisites @@ -79,6 +113,27 @@ 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 all three checks on PR via `npm ci`). Reproduce CI locally with: + +```bash +npm ci # install the exact-pinned toolchain +npm run check # the full CI sequence — the three gates below, in order +``` + +`npm run check` expands to: + +```bash +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 +scripts/check-compiled-freshness.sh # compiled/ artifacts match their manifest, both directions +``` + +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..5eaf0fc --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "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", + "freshness:compiled": "scripts/check-compiled-freshness.sh", + "check": "npm run freshness && npm run verify && npm run freshness:compiled" + }, + "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/agents/plugin-architect.md b/plugins/appos-dev/agents/plugin-architect.md index a61cf80..3e6a37d 100644 --- a/plugins/appos-dev/agents/plugin-architect.md +++ b/plugins/appos-dev/agents/plugin-architect.md @@ -38,7 +38,7 @@ You are an AppOS plugin design specialist. You understand the full SDK surface ( Before responding, invoke these skills to load the current API surface and patterns: -- `appos-plugin-dev` — Full SDK pattern, 22 namespaces, 34 permissions, build/deploy, minHostVersion landmine +- `appos-plugin-dev` — Full SDK pattern: the complete `PluginContext` namespace surface, the canonical permission-scope model, build/deploy, minHostVersion landmine - `webview-panels` — WebView panel authoring when the plugin needs rich UI Also use Glob to find `**/reference/extension-api.md` and `**/reference/patterns.md` in the appos-dev plugin directory if they exist — they contain deeper API details. @@ -49,7 +49,7 @@ The canonical flagship reference is `appos-plugin-ytdlp` (https://github.com/app ### 1. Requirements analysis -When the user describes what they want to build, map their requirements to specific API namespaces. The SDK exposes 22 namespaces on `PluginContext` — use only the ones that are actually needed: +When the user describes what they want to build, map their requirements to specific API namespaces. The SDK exposes 43 namespaces on `PluginContext` as of SDK 3.0.0 (of which 21 core-plugin namespaces) — use only the ones that are actually needed: **Core UI** - `ui` — panels (`registerPanel`, `registerWebPanel`, `registerActivityView`), sidebar items, webview messaging (`postToWebPanel`, `onWebPanelMessage`, `pipeShellToWebPanel`), status bar, context menus, notifications, sheets, quick actions @@ -66,30 +66,65 @@ When the user describes what they want to build, map their requirements to speci - `cache` — Hybrid memory + SQLite persistence (`cache.get` deserializes; pass `persist: true` for durability) - `storage` — Key-value persistence including secure (keychain) entries - `settings` — Read user-configurable settings +- `store` — Durable Promise-shaped document/KV store (namespaced, quota-managed) +- `preview` — File preview registry queries + programmatic preview triggering **Execution** - `shell` — Execute allowed shell commands with streaming output - `network` — HTTP fetch and file download - `clipboard` — Read/write system clipboard +- `oauth` — OAuth 2.0 + PKCE authorization flows +- `vault` — Credential vault: store/use secrets without ever reading them back in plain text **Events & lifecycle** - `events` — Subscribe to navigation, pane activation, selection changes, app.willQuit, menubar.clicked -- `lifecycle` — Dependency availability notifications (`onDependencyStatusChanged`) +- `lifecycle` — Dependency availability notifications (`onDependencyStatusChanged`) + query/recheck APIs (`getDependencyStatus`, `recheckDependencies`) - `commands` — Register commands for the command palette and shortcuts **Feedback** -- `feedback` — Toasts, logs, confirmations, prompts +- `feedback` — Toasts (`toast`), HUD panels (`hud`/`updateHud`/`dismissHud`), NSAlert confirmation dialogs (`alert` — the ONLY confirm-gated method, requires `feedback.confirm`), system notifications (`systemNotification`), and adaptive routing (`notify`). There is NO `.log`, `.confirm`, or `.prompt` — do not design against them -**Inter-plugin** +**Inter-plugin (legacy tier)** - `extensionPoints` — Declare/contribute extension points for other plugins - `dataContracts` — Expose queryable data for other plugins - `interPluginEvents` — Pub/sub between plugins +**Actions & automation (core-plugin tier)** +- `actions` — Public Action Fabric: typed, schema-validated, policy-bearing public actions (`register`, `invoke`, `all`, `registerFromCommand`). Declare actions in the manifest `extensions[]` (`actions.definition`) too, but ALWAYS pair with the runtime registration — manifest-declared actions don't reach discovery on their own yet (host bug fn-163; see `skills/appos-plugin-dev/reference/extension-api.md`): today the manifest entry is catalog/manifest metadata, and the runtime `ctx.actions.register(...)` call is what makes the action discoverable and executable. +- `palette` — Command palette integration for public actions (`query`, `history`, `pin`) +- `scheduler` — Job scheduling engine: interval/cron/notification/fsEvents/calendar/power/network triggers, conditions, run history +- `recipes` / `sequences` — Author-declared multi-step plans (linear or LLM-agent) dispatched through the action fabric + +**Shared read plane (core-plugin tier)** +- `resources` — URI-addressable resource read plane (`workspace://active`, `pane://active`, `selection://active`, ...) with watch support +- `tokens` — Dotted-path token providers + `{{a.b.c}}` template resolution +- `bundles` — ContextBundle composition (frozen resource+token snapshots; distinct from `clipboard.bundles`) +- `entities` / `fields` — Entity resolution plane + plugin-attached fields (query, watch, upsert; computed fields) +- `views` / `surfaces` — Host-rendered Saved Views over entities; surface contributions are manifest-`extensions[]`-declared (runtime `surfaces` methods reject in v1) +- `ledger` — Execution/approval ledger reads (own receipts; shared with grant) + +**Channels & integration (core-plugin tier)** +- `notifications` — Outbound notifications: emit typed notifications, user-authored routing decides the channel (native, webhook, third-party) +- `input` — Inbound input channels: receive external messages/intents (webhooks, protocols, URL schemes) and reply +- `webhook` — Bidirectional HTTPS webhook gateway: register inbound routes, send/enqueue outbound deliveries +- `protocols` — Supervised sidecar subprocesses with stdio/JSON-RPC framing (MCP/LSP wrappers) +- `llm` — LLM provider verbs (`complete`, `stream`, `embed`, `vision`, `agent`) + provider/router contributor registries + +**Host-internal** +- `fileSystem` — Transfer-strategy provider stub; core-swift only, throws for JS plugins — do not design against it + ### 2. Permission mapping -Map each API usage to the minimal set of 34 permissions. Never over-permission. +Map each API usage to the minimal set of canonical permission scopes. Never over-permission. Do NOT rely on a memorized permission count — the canonical scope union grows with the host; look up the scope(s) per namespace in the `appos-plugin-dev` skill's permission reference (the SDK's `permissions.d.ts` / `schemas/plugin-v1.json` enum is authoritative). Classic namespaces map via the API→permission table in the skill; core-plugin namespaces each carry their own scope families (e.g. `actions.register` / `actions.invoke`, `notifications.emit`, `scheduler.job.own`, `vault.store` / `vault.read`, `llm.complete`). + +Five legacy alias spellings exist in the SDK's `LegacyPermissionScope` type union, but only ONE is backward-compatible: `network.fetch`, which the host normalizes to `network.outbound` at manifest parse time (tolerated — still recommend declaring `network.outbound` directly). The other four are DEAD: they pass schema validation but have no host-side entry, so the plugin installs "successfully" while the capability is silently never granted. When a design retains legacy names, REPLACE the four dead aliases with canonical scopes: + +- `network` → `network.outbound` +- `webview` → `ui.webPanel` +- `smartFolders` → `filesystem.read` (smart-folder filter registration runs under filesystem read) +- `shell.uncontained` → remove entirely; the uncontained tier is NOT declarable — the host infers it from `filesystem.readAll` -See the `appos-plugin-dev` skill for the full permission list and the API→permission table. +Never emit any of the five alias spellings in a design document's permission list. Host-behavior authority: the "Deprecated legacy aliases" table in the `appos-plugin-dev` skill's `reference/extension-api.md`. ### 3. Rendering mode decision @@ -156,7 +191,7 @@ ID: space.appos.{nameid} (or com.community.{nameid} for community) minHostVersion: 1.0.0 API Namespaces: ui, shell, cache, feedback, lifecycle -Permissions: ui.webPanel, webview, shell.execute, cache, feedback, feedback.confirm +Permissions: ui.webPanel, shell.execute, cache, feedback, feedback.confirm Shell Commands: yt-dlp, ffmpeg System Dependencies: - yt-dlp (required, brew install yt-dlp) diff --git a/plugins/appos-dev/agents/viewdescriptor-builder.md b/plugins/appos-dev/agents/viewdescriptor-builder.md index d8a3365..f3b4189 100644 --- a/plugins/appos-dev/agents/viewdescriptor-builder.md +++ b/plugins/appos-dev/agents/viewdescriptor-builder.md @@ -77,24 +77,33 @@ Build context menus as JSON arrays with: ### 4. Action handler routing Design the `handler` callback with short semantic prefixes. Don't repeat the noun: ```typescript -handler: (action: string) => { +declare function refresh(): void; +declare function addSelected(): void; +declare function createCollection(): void; +declare function activateItem(id: string): void; +declare function openFile(url: string): void; +declare function revealInFinder(url: string): void; +declare function removeItem(id: string): void; +declare function deleteItem(id: string): void; + +const handler = (action: string) => { // Simple actions: bare strings if (action === "refresh") refresh(); if (action === "add-selected") addSelected(); - if (action === "new-collection") create(); + if (action === "new-collection") createCollection(); // Parameterized: short prefix + value - if (action.startsWith("select:")) activate(action.substring(7)); + if (action.startsWith("select:")) activateItem(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)); if (action.startsWith("delete:")) deleteItem(action.substring(7)); -} +}; // BAD: "open-collection:", "delete-collection:" — redundant noun ``` ### 5. Output format Always produce complete, runnable TypeScript code that can be pasted directly into a plugin's `src/main.ts`. Include: -- The `ViewDescriptor` interface +- The `import type { ViewDescriptor } from '@appos.space/plugin-types'` import (the union type is published by the SDK — never hand-roll a local `ViewDescriptor` declaration; `@appos.space/view-builders` offers typed builder helpers on top of it) - The render function - The handler function - All helper functions needed @@ -105,4 +114,4 @@ Always produce complete, runnable TypeScript code that can be pasted directly in - Wrap outer content in `{ type: "scroll", children: [{ type: "vstack", children }] }` - Set `id` on sections for persistent collapse state - There are exactly 17 ViewDescriptor types — no others exist -- No HTML, WebView, or DOM — everything is native SwiftUI +- ViewDescriptor trees contain no HTML or DOM — everything renders as native SwiftUI. (This rule is scoped to the ViewDescriptor rendering mode: WebView panels are a separate, HTML-based rendering mode — see the `webview-panels` skill / `webview-panel-builder` agent.) diff --git a/plugins/appos-dev/agents/webview-panel-builder.md b/plugins/appos-dev/agents/webview-panel-builder.md index 7a54912..560373b 100644 --- a/plugins/appos-dev/agents/webview-panel-builder.md +++ b/plugins/appos-dev/agents/webview-panel-builder.md @@ -114,6 +114,10 @@ The bridge routes shell chunks to a separate listener bucket so protocol handler Write a typed discriminated union in `src/types/webview-messages.ts`: ```ts +// Domain payload types — define these for your plugin +export type QueueEntry = { id: string; url: string; progress: number }; +export type Metadata = Record; + export type PanelInboundMessage = | { v: 1; type: 'probe-url'; probeId: string; url: string } | { v: 1; type: 'queue-download'; requestId: string; url: string; format: string } @@ -137,20 +141,25 @@ export function parseInbound(data: unknown): PanelInboundMessage | null { ### 5. Wire the plugin-side handler -```ts -import { parseInbound } from '../types/webview-messages.js'; +```ts no-verify +// (multi-file fragment — the relative import resolves only inside a real plugin tree) +import type { PluginContext, WebPanelMessage } from '@appos.space/plugin-types'; +import { parseInbound, type PanelInboundMessage } from '../types/webview-messages.js'; + +declare function handle(ctx: PluginContext, msg: PanelInboundMessage, envelope: WebPanelMessage): Promise; let disposed = false; export function registerDownloadPanel(ctx: PluginContext): () => void { - ctx.ui.registerWebPanel('download', { + // SDK 3.0.0 types both calls as returning registration-token strings. + 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; @@ -166,11 +175,14 @@ export function registerDownloadPanel(ctx: PluginContext): () => void { } ``` -`onWebPanelMessage` returns `void`, not a disposer. Use a `disposed` flag inside the handler closure for cleanup. +Capture the string tokens per the 3.0.0 types, but do not build cleanup on their runtime values: the shipped 1.0.0 host returns `undefined` from both calls at runtime (host↔d.ts reconciliation is a known SDK follow-up), and it removes panels and message handlers automatically on plugin unload. Use the `disposed` flag for mid-life teardown; re-calling `onWebPanelMessage` for the same panel replaces the previous handler. ### 6. Wire pipeShellToWebPanel (for CLI wrappers) ```ts +declare const url: string; +declare const absoluteOutputDir: string; + const result = await ctx.ui.pipeShellToWebPanel('download', { command: 'yt-dlp', args: ['--ignore-config', '--newline', '--progress-template', '[progress]%(progress)j', url], @@ -184,20 +196,23 @@ const result = await ctx.ui.pipeShellToWebPanel('download', { - Hard 120-second timeout. For long jobs, use resume loops with `--continue`. - `cwd` must be absolute and tilde-expanded. T1 sandbox rejects relative paths. - Always pass `--ignore-config` (or equivalent) so ambient user config can't inject flags. -- Chunks fan out to every instance. Filter with `envelope.instanceId` if you need isolation. +- Chunks fan out to every instance and CANNOT be filtered per-instance: the chunk is `{ stream, data, bytesTotal }` with no instance identifier (`envelope.instanceId` exists only on WebView→plugin messages). For isolation, use `ctx.shell.execute({ onData })` and forward chunks yourself via `ctx.ui.postToWebPanel(panelId, msg, { instanceId })` targeting the initiating instance. ### 7. Throttle high-frequency broadcasts For progress updates, throttle to ~10 Hz: ```ts -let throttleTimer: ReturnType | undefined; +declare const state: { getQueue(): unknown[] }; + +// NonNullable because JSC may not inject timers — the guard below narrows +let throttleTimer: ReturnType> | undefined; let lastBroadcast = 0; function broadcastQueue(): void { - if (typeof setTimeout !== 'function') { + if (typeof setTimeout !== 'function' || typeof clearTimeout !== 'function') { // JSC may not inject timers — fall back to sync - ctx.ui.postToWebPanel('download', { v: 1, type: 'queue-update', entries: [...] }); + ctx.ui.postToWebPanel('download', { v: 1, type: 'queue-update', entries: state.getQueue() }); return; } const now = Date.now(); @@ -205,11 +220,11 @@ function broadcastQueue(): void { clearTimeout(throttleTimer); if (remaining <= 0) { lastBroadcast = now; - ctx.ui.postToWebPanel('download', { v: 1, type: 'queue-update', entries: [...] }); + ctx.ui.postToWebPanel('download', { v: 1, type: 'queue-update', entries: state.getQueue() }); } else { throttleTimer = setTimeout(() => { lastBroadcast = Date.now(); - ctx.ui.postToWebPanel('download', { v: 1, type: 'queue-update', entries: [...] }); + ctx.ui.postToWebPanel('download', { v: 1, type: 'queue-update', entries: state.getQueue() }); }, remaining); } } @@ -250,7 +265,7 @@ The output should include: - `webview//styles.css` - `webview//app.js` - `webview/shared/bridge.js` (if not already present) -- A note to the user about which permissions to add to `plugin.json` (`ui.webPanel`, `webview`, and `shell.execute` + `shellCommands` if using pipeShellToWebPanel) +- A note to the user about which permissions to add to `plugin.json` (`ui.webPanel`, plus `shell.execute` + `shellCommands` if using pipeShellToWebPanel — do NOT add the legacy `webview` alias: it has no host-side entry and is never granted) ### Key rules recap @@ -261,4 +276,4 @@ The output should include: - `pipeShellToWebPanel` lives on `ctx.ui`, not `ctx.shell` - Throttle high-frequency broadcasts with JSC timer fallback - Redact error messages before logging -- `onWebPanelMessage` has no disposer — use a `disposed` flag +- Capture the token strings `registerWebPanel` / `onWebPanelMessage` are typed to return (SDK 3.0.0), but use a `disposed` flag for mid-life teardown — the 1.0.0 host returns `undefined` at runtime and auto-cleans on plugin unload diff --git a/plugins/appos-dev/commands/build.md b/plugins/appos-dev/commands/build.md index b08016a..be37a87 100644 --- a/plugins/appos-dev/commands/build.md +++ b/plugins/appos-dev/commands/build.md @@ -82,6 +82,11 @@ grep -c "globalThis" "{plugin-root}/dist/main.js" Expect at least 2. If 0, the bundler tree-shook them away — check that `src/main.ts` ends with: ```ts +import type { PluginContext } from '@appos.space/plugin-types'; + +async function activate(ctx: PluginContext): Promise { /* ... your real activate ... */ } +async function deactivate(): Promise { /* ... your real deactivate ... */ } + (globalThis as unknown as { activate: typeof activate }).activate = activate; (globalThis as unknown as { deactivate: typeof deactivate }).deactivate = deactivate; ``` diff --git a/plugins/appos-dev/commands/deploy.md b/plugins/appos-dev/commands/deploy.md index c881e78..9a67da2 100644 --- a/plugins/appos-dev/commands/deploy.md +++ b/plugins/appos-dev/commands/deploy.md @@ -12,7 +12,7 @@ Deploy the built plugin to `~/Library/Application Support/AppOS/plugins/`. Locate `plugin.json` in the current directory or parent directories. Read it to get: - `id` (target install directory name) - `name` (for the report) -- `minHostVersion` — verify this is NOT set to an SDK version like `"2.4.0"`. If it is, STOP and warn the user: this is the minHostVersion landmine — it must be a host `CFBundleShortVersionString` (`"1.0.0"` is the safe default). See `appos-plugin-dev` skill → "minHostVersion landmine" section. +- `minHostVersion` — verify this is NOT set to an SDK version like `"3.0.0"` or `"2.4.0"`. If it is, STOP and warn the user: this is the minHostVersion landmine — it must be a host `CFBundleShortVersionString` (`"1.0.0"` is the safe default). See `appos-plugin-dev` skill → "minHostVersion landmine" section. ## 2. Verify the build exists diff --git a/plugins/appos-dev/commands/new-plugin.md b/plugins/appos-dev/commands/new-plugin.md index 1380bd4..70c71b6 100644 --- a/plugins/appos-dev/commands/new-plugin.md +++ b/plugins/appos-dev/commands/new-plugin.md @@ -42,7 +42,7 @@ These skills contain the canonical APIs, file layout, and gotchas. Do not procee ## 3. Plan permissions and APIs From the one-sentence description, decide: -- **Permissions** — start minimal. `ui.sidebar` for any panel, `ui.webPanel` + `webview` for WebView panels (BOTH required), `shell.execute` + `shellCommands: [...]` for CLI wrappers, `filesystem.read`/`filesystem.write` for file work, `cache` for persistence. +- **Permissions** — start minimal. `ui.sidebar` for any panel, `ui.webPanel` for WebView panels (do NOT add the legacy `webview` alias — it has no host-side entry, is never granted, and `/appos-dev:validate` flags it as an ERROR), `shell.execute` + `shellCommands: [...]` for CLI wrappers, `filesystem.read`/`filesystem.write` for file work, `cache` for persistence. - **System dependencies** — CLIs to probe on startup (with `check.command`, `check.args`, `versionPattern`, `minVersion`, `installHint`, `installUrl`). - **Settings** — `string`, `enum`, `bool`, or `number` keys shown in the plugin settings sheet. The manifest schema's settings type enum is `bool` — writing `boolean` fails `/appos-dev:validate` schema validation. - **Rendering mode** — confirms from step 1. Drives whether you create `webview/` or not. @@ -72,19 +72,21 @@ If WebView panels are needed, also create `$TARGET/webview/{panelId}/` and `$TAR "typecheck": "tsc --noEmit" }, "devDependencies": { - "@appos.space/plugin-types": "^2.4.0", + "@appos.space/plugin-types": "^3.0.0", "esbuild": "^0.20.0", "typescript": "^5.4.0" }, "dependencies": { - "@appos.space/plugin-utils": "^2.4.0", - "@appos.space/view-builders": "^2.4.0" + "@appos.space/plugin-utils": "^3.0.0", + "@appos.space/view-builders": "^3.0.0" } } ``` If the plugin doesn't use ViewDescriptor panels at all, you can drop `@appos.space/view-builders` from `dependencies`. If it doesn't need runtime helpers, drop `@appos.space/plugin-utils` too. **`@appos.space/plugin-types` stays in `devDependencies` always** — it's type-only. +If the plugin uses a WebView panel, step 10 extends the `typecheck` script to also cover `webview/` sources — leave it as written for now. +
SDK contributors only: developing against a local plugin-sdk checkout @@ -92,7 +94,7 @@ If you are working on the SDK itself, point the three `@appos.space/*` entries a
-## 6. Write tsconfig.json +## 6. Write tsconfig.json + src/jsc-globals.ts **MANDATORY**: `verbatimModuleSyntax: true` is required because `@appos.space/plugin-types` is declaration-only. Without this flag, TypeScript emits runtime `import` statements that try to resolve a non-existent module at runtime. @@ -109,14 +111,78 @@ If you are working on the SDK itself, point the three `@appos.space/*` entries a "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, - "lib": ["ES2020", "DOM"] + "types": [], + "lib": ["ES2022"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } ``` -Note `"lib": ["ES2020", "DOM"]` — the `DOM` entry is only needed if the plugin ships `webview/*.js` files it wants to typecheck. For pure plugin-side code, `"ES2020"` alone is fine. +Note `"lib": ["ES2022"]` — deliberately **no `DOM`**. Plugin-side code runs in JavaScriptCore: there is no `document`, no `window`, no browser `fetch` (use `ctx.network.fetch`), no guaranteed timers, and `URL` only on hosts that inject it (AppOS 1.1.0+ — and users can switch it off). Putting `DOM` in this lib would make all of those typecheck clean and then throw at runtime. `"types": []` closes the same door from the `node_modules` side: without it, any `@types/*` package installed later (most commonly `@types/node`, which rides in with many dev tools) is auto-included and silently injects `process` plus Node's timer globals into this program — `process.env`-using code would then pass `npm run typecheck` and throw in JavaScriptCore. The globals the runtime genuinely provides come from a scaffolded `declare global` module instead — write `src/jsc-globals.ts` (it is matched by `"include": ["src/**/*.ts"]`, so it is part of this program automatically): + +```ts +// src/jsc-globals.ts — ambient globals of the AppOS JavaScriptCore plugin +// runtime, written as a `declare global` .ts MODULE (the same pattern as the +// SDK's own globals source), NOT a .d.ts: `skipLibCheck: true` skips every +// .d.ts in the program — project-owned files included — so a typo inside a +// .d.ts version of this file would be silently suppressed and degrade +// console / timers / URL to error-`any`. A .ts module is always fully +// type-checked. JSC ships a native console; the host injects NO timers, so +// the timer globals are typed `| undefined` — an unguarded setTimeout(...) +// is a type error (TS2722) while a `typeof setTimeout === 'function'`- +// narrowed call compiles. Do not add DOM globals here: document/window/ +// browser fetch do not exist in the plugin runtime (use ctx.network.fetch). +export {}; + +declare global { + var console: { + log(...args: unknown[]): void; + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; + debug(...args: unknown[]): void; + trace(...args: unknown[]): void; + }; + var setTimeout: ((handler: (...args: unknown[]) => void, timeout?: number, ...args: unknown[]) => number) | undefined; + var clearTimeout: ((id: number | undefined) => void) | undefined; + var setInterval: ((handler: (...args: unknown[]) => void, timeout?: number, ...args: unknown[]) => number) | undefined; + var clearInterval: ((id: number | undefined) => void) | undefined; + // URL — AppOS hosts 1.1.0+ inject a native Foundation-bridged URL global + // (immutable v1 subset; NO searchParams — that getter THROWS at runtime, + // parse url.search manually). Older hosts, the appos.jsc.urlGlobal.disabled + // kill switch, and menu-bar contexts lack it, so it is typed `| undefined`: + // an unguarded `new URL(...)` is a type error (TS18048) while a + // `typeof URL === 'function'`-narrowed call compiles. Same surface as the + // SDK 3.0.1+ opt-in `@appos.space/plugin-types/globals` subpath (`var` + // matches its declaration) — if you pin SDK >=3.0.1 you may switch the + // tsconfig `types` array from `[]` to ["@appos.space/plugin-types/globals"] + // instead and DELETE this URL block (keeping both would double-declare URL). + interface URL { + readonly href: string; + readonly protocol: string; + readonly hostname: string; + readonly host: string; + readonly port: string; + readonly pathname: string; + readonly search: string; + readonly hash: string; + readonly origin: string; + readonly username: string; + readonly password: string; + toString(): string; + toJSON(): string; + } + interface URLConstructor { + new (url: string | URL, base?: string | URL): URL; + canParse(url: string | URL, base?: string | URL): boolean; + readonly prototype: URL; + } + var URL: URLConstructor | undefined; +} +``` + +With this pair, accidental browser-global use in `src/` fails `npm run typecheck` (`document` → TS2584, `window`/`fetch` → TS2304), an unguarded `setTimeout(...)` fails TS2722, an unguarded `new URL(...)` fails TS18048, and `typeof setTimeout === 'function'` / `typeof URL === 'function'`-guarded calls compile — matching what actually happens at runtime. `skipLibCheck: true` stays on here because this program pulls in the external SDK `.d.ts` from `node_modules` — and it can never mute the declarations above, because `src/jsc-globals.ts` is a `.ts` module, which `skipLibCheck` does not skip; the WebView config in step 10 sets it to `false` because its only declaration file is project-owned. This config deliberately covers `src/**/*.ts` ONLY: nothing under `webview/` is part of this program. WebView sources are a separate compilation world — DOM belongs exclusively to their `tsconfig.webview.json`, which step 10 writes and chains into `npm run typecheck`. ## 7. Write build.mjs @@ -156,7 +222,7 @@ if (isWatch) { ## 8. Write plugin.json -**LANDMINE**: `minHostVersion` refers to the host app's `CFBundleShortVersionString` (currently `1.0.0`), NOT the `@appos.space/plugin-types` SDK version. Defaulting to the SDK version (e.g. `"2.4.0"`) will cause `DependencyResolver.swift` to silently reject the plugin before it reaches the plugins sheet. **Always default to `"1.0.0"`.** +**LANDMINE**: `minHostVersion` refers to the host app's `CFBundleShortVersionString` (currently `1.0.0`), NOT the `@appos.space/plugin-types` SDK version. Defaulting to the SDK version (e.g. `"3.0.0"`, or the older `"2.4.0"`) will cause `DependencyResolver.swift` to silently reject the plugin before it reaches the plugins sheet. **Always default to `"1.0.0"`.** ```json { @@ -177,7 +243,32 @@ if (isWatch) { } ``` -Add permissions incrementally based on what the plugin actually does. If it uses a WebView panel, add BOTH `"ui.webPanel"` AND `"webview"`. If it uses a CLI, add `shell.execute`, `"shellCommands": ["your-tool"]`, and a `dependencies.system[]` entry with a check command and install hint. If it uses the menubar, add `"menubar"` and remember to call `ctx.menubar.setContent()` to populate the popover (without it, clicking shows "No content"). +Add permissions incrementally based on what the plugin actually does. If it uses a WebView panel, add `"ui.webPanel"` only — do NOT add the legacy `"webview"` alias (it passes schema validation but has no host-side entry, so it is silently never granted, and `/appos-dev:validate`'s legacy-alias post-check reports it as an ERROR). If it uses a CLI, add `shell.execute`, `"shellCommands": ["your-tool"]`, and a `dependencies.system[]` entry with a check command and install hint. If it uses the menubar, add `"menubar"` and remember to call `ctx.menubar.setContent()` to populate the popover (without it, clicking shows "No content"). + +### Optional: declare public actions via `extensions[]` + +If the plugin exposes public actions (command palette, automation), add an `extensions[]` array with `actions.definition` contributions (requires the `actions.register` permission): + +```json +"extensions": [ + { + "extensionPoint": "actions.definition", + "contribution": { + "id": "refresh-stats", + "displayName": "Refresh File Stats", + "description": "Re-scan the active directory.", + "inputSchema": { "type": "object" }, + "visibility": ["palette"], + "risk": "read", + "approval": "auto" + } + } +] +``` + +**Dual registration is required.** Manifest-declared actions don't reach discovery on their own yet (host bug fn-163; see `skills/appos-plugin-dev/reference/extension-api.md`): an `actions.definition` contribution alone currently never becomes palette-visible or invokable — no cold-start palette entry, no `ctx.actions.all()` stub, no Settings → Actions row. Today the manifest entry is catalog/manifest metadata (visible in catalogs and manifest scans), not runtime discovery. Pair EVERY `actions.definition` contribution with a runtime `ctx.actions.register(...)` or `ctx.actions.registerFromCommand(...)` call in `activate()` using the same id — the runtime registration is what makes the action discoverable and executable. Ship BOTH, exactly as `appos-plugin-ytdlp` does. + +**Removal marker**: when you retire an action, remove BOTH sites — the runtime `register()` call and the manifest contribution. A leftover manifest stub is stale catalog/manifest metadata today, and once fn-163 lands it would be replayed into discovery at every cold start as a permanently non-executable palette entry. ## 9. Write src/main.ts @@ -250,7 +341,10 @@ Minimal `index.html`: Then in `src/main.ts`, register the panel: ```ts -ctx.ui.registerWebPanel('{panelId}', { +const disposables: Array<() => void | Promise> = []; // declared once, in step 9 + +// SDK 3.0.0 types both calls as returning registration-token strings. +const panelToken = ctx.ui.registerWebPanel('{panelId}', { title: '{Panel Title}', icon: 'square.grid.2x2', // SF Symbol htmlPath: 'webview/{panelId}/index.html', @@ -258,14 +352,48 @@ ctx.ui.registerWebPanel('{panelId}', { }); let panelDisposed = false; -ctx.ui.onWebPanelMessage('{panelId}', (envelope) => { +const messageToken = ctx.ui.onWebPanelMessage('{panelId}', (envelope) => { if (panelDisposed) return; // handle messages from webview }); disposables.push(() => { panelDisposed = true; }); ``` -`onWebPanelMessage` does NOT return a disposer — never push its return value into `disposables`. Use a `disposed` flag as above. See the `webview-panels` skill → "Cleanup" section. +Capture the string tokens (`panelToken`, `messageToken`) per the 3.0.0 types, but do NOT build cleanup on their runtime values — the shipped 1.0.0 host returns `undefined` from both calls at runtime (host↔d.ts reconciliation is a known SDK follow-up), and it removes panels and message handlers automatically on plugin unload. Use the `disposed` flag as above for mid-life teardown; calling `onWebPanelMessage` again for the same panel replaces the previous handler. See the `webview-panels` skill → "Cleanup" section. + +### Wire webview sources into the typecheck + +The step-6 `tsconfig.json` checks `src/**/*.ts` only — without more wiring, nothing under `webview/` (neither the bridge declaration nor your panel `.js`) ever enters `npm run typecheck`, and a misspelled `window.twopanez` / `bridge` member fails silently at runtime. Write `tsconfig.webview.json` next to `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "allowJs": true, + "checkJs": true, + "skipLibCheck": false, + "forceConsistentCasingInFileNames": true, + "types": [], + "lib": ["ES2020", "DOM", "DOM.Iterable"] + }, + "include": ["webview/**/*"] +} +``` + +Then: + +1. Copy the `window.twopanez` ambient declaration from the `webview-panels` skill ("The bridge" section) to `webview/twopanez.d.ts` — it is what types the host-injected global for this config. +2. Update the `typecheck` script in `package.json` to run both worlds: `"typecheck": "tsc --noEmit && tsc -p tsconfig.webview.json"`. + +`skipLibCheck` is `false` here on purpose (unlike the step-6 config): the only declaration file this small program sees is the project-owned `webview/twopanez.d.ts`. With `skipLibCheck: true`, a broken or misspelled type inside that file is silently suppressed — `window.twopanez` degrades to an error-`any` and member typos like `window.twopanez.onMesage(...)` pass, which is exactly what this config exists to catch. With `false`, the corruption itself fails typecheck (`TS2552: Cannot find name 'TwopanezBrige'`). + +`"types": []` matters here for the same reason it does in the step-6 config: WKWebView is a browser, not Node. Without it, a later `@types/node` install (it rides in with many dev tools) is auto-included into this program too, and webview code using `process` or `Buffer` passes typecheck and then throws in WKWebView. The DOM globals this program genuinely needs come from `lib` (which `types` does not affect), so the explicitly selected DOM libraries above keep working. + +`checkJs` + `strict` means webview `.js` functions need JSDoc `@param`/`@returns` annotations — the skill's `bridge.js` already carries them; copy it as-is. Use `/** @param {any} x */` where typing isn't worth it. `/appos-dev:deploy` already excludes `tsconfig.*.json` from the rsync, so the extra config never ships with the plugin. ## 11. Build @@ -293,6 +421,14 @@ Also verify the manifest is well-formed and has `minHostVersion: "1.0.0"`: node -e "console.log(JSON.parse(require('fs').readFileSync('{target-directory}/plugin.json','utf8')).minHostVersion)" ``` +Run the typecheck — it must exit 0: + +```bash +cd "{target-directory}" && npm run typecheck +``` + +The typecheck models the real runtimes on both sides. Plugin side (`src/`): browser globals fail (`document` → TS2584, `window`/`fetch` → TS2304) and an unguarded `setTimeout(...)` fails TS2722 — if any of those fire, the code would have thrown in JavaScriptCore at runtime; fix the code (guard timers, use `ctx.network.fetch`) rather than adding `DOM` to the step-6 lib. For WebView plugins it also covers `webview/` via `tsconfig.webview.json` — a misspelled bridge member like `window.twopanez.onMesage(...)` fails there with `TS2551 … Did you mean 'onMessage'?` instead of silently doing nothing at runtime. + ## 13. Report Tell the user: diff --git a/plugins/appos-dev/commands/validate.md b/plugins/appos-dev/commands/validate.md index 7938586..ecbc7b2 100644 --- a/plugins/appos-dev/commands/validate.md +++ b/plugins/appos-dev/commands/validate.md @@ -20,21 +20,22 @@ Read `$PLUGIN_ROOT/plugin.json` and check: - **Runtime**: must be `"javascript"` - **Entrypoint**: must point at a JS file that exists (typically `"dist/main.js"`) - **Version**: valid semver (MAJOR.MINOR.PATCH) -- **minHostVersion**: **LANDMINE CHECK** — this must be the host app `CFBundleShortVersionString`, NOT the `@appos.space/plugin-types` SDK version. If it's set to `"2.0.0"`, `"2.1.0"`, `"2.2.0"`, `"2.3.0"`, `"2.4.0"`, or similar SDK-like values, flag it as an ERROR. Default safe value: `"1.0.0"`. Host version can be read via: +- **minHostVersion**: **LANDMINE CHECK** — this must be the host app `CFBundleShortVersionString`, NOT the `@appos.space/plugin-types` SDK version. If it's set to `"3.0.0"` (the current SDK major), `"2.0.0"`, `"2.1.0"`, `"2.2.0"`, `"2.3.0"`, `"2.4.0"`, or similar SDK-like values, flag it as an ERROR. Default safe value: `"1.0.0"`. Note the dev-plugin itself is versioned 3.0.0 (aligned with the SDK it teaches) — that number is ALSO not a valid `minHostVersion`. Host version can be read via: ```bash defaults read /Applications/AppOS.app/Contents/Info.plist CFBundleShortVersionString ``` + If a from-source/dev build of the host reports `0.0.0` (symptom: even `minHostVersion: "1.0.0"` plugins get rejected), the cause is a mis-generated Xcode project: xcodegen defaults version keys to a 2-component `"1.0"`, which fails the host's strict `X.Y.Z` semver parse and collapses to `0.0.0`. That's a host build issue (fixed in the host's `project.yml` version properties), not a plugin manifest issue — report it as such instead of telling the user to lower `minHostVersion`. - **Permissions**: validated by the manifest schema (next step). Do NOT hand-check against a hardcoded list — the authoritative permission set lives in the SDK's `schemas/plugin-v1.json` and grows with each SDK release. ### Schema validation (authoritative) Validate the whole manifest against the SDK's published JSON Schema. This checks required fields, field shapes, and the full permissions enum in one step. -**Preferred (no clone needed)** — fetch the schema from the public `appos/plugin-sdk` repo and validate with ajv: +**Preferred (no clone needed)** — fetch the schema from the public `appos/plugin-sdk` repo's `v3.0.0` release tag and validate with ajv: ```bash curl -fsSL -o /tmp/appos-plugin-v1.schema.json \ - https://raw.githubusercontent.com/appos/plugin-sdk/main/schemas/plugin-v1.json + https://raw.githubusercontent.com/appos/plugin-sdk/v3.0.0/schemas/plugin-v1.json npx --yes -p ajv-cli -p ajv-formats ajv validate \ --spec=draft2020 -c ajv-formats \ -s /tmp/appos-plugin-v1.schema.json -d "$PLUGIN_ROOT/plugin.json" @@ -42,7 +43,7 @@ npx --yes -p ajv-cli -p ajv-formats ajv validate \ Exit 0 with `plugin.json valid` = pass. On failure, ajv prints the offending JSON paths — e.g., an unknown permission string fails the `permissions` items enum. -> **Why `main` and not a pinned SDK release tag:** the schema anchor is chosen for *install-time* truth. The shipped AppOS host validates and gates permissions against its full current scope surface (`PermissionScope.allKnown` — the superset the `main` schema mirrors), so `main` matches what the host actually accepts. The `v2.4.0` tag's schema predates the core-plugin waves: it knows only 37 of the current 140 permission strings and rejects the `extensions` field entirely, so it falsely FAILS valid manifests — including the flagship `appos-plugin-ytdlp`, which declares `actions.register`, `actions.invoke`, and `notifications.emit`. (The published `@appos.space/plugin-types` npm package ships only `.d.ts` files — there is no per-release package schema to fetch.) Note the separate compile-time bound: the SDK version you compile against (`^2.4.0`) limits which *typed APIs* your TypeScript sees, not which manifest permissions the host accepts. If a future SDK `main` ever moves ahead of your installed host, cross-check `minHostVersion` guidance above — manifest validity is anchored to the host, not the npm package. +> **Why the `v3.0.0` tag:** the schema anchor is chosen for *install-time* truth. The shipped AppOS 1.0.0 host validates and gates permissions against its full current scope surface (`PermissionScope.allKnown`), and the SDK 3.0.0 release schema carries the full permission enum (135 canonical permission scopes + 5 legacy aliases (deprecated)) plus the `extensions` field for core-plugin extension contributions. **Caveat:** the 5 legacy aliases are schema-*tolerated*, not host-honored — only `network.fetch` has a host-side alias entry (normalized to `network.outbound` at manifest parse time); the other four (`network`, `smartFolders`, `webview`, `shell.uncontained`) grant nothing at install time, so an AJV pass alone is NOT install-time permission truth. Always run the legacy-alias post-check below after schema validation. Pin the tag for reproducibility; `main` currently carries the same schema, but tags don't move under you. Do NOT use the older `v2.4.0` tag — its schema predates the core-plugin waves: it knows only 37 of the current 140 permission strings and rejects the `extensions` field entirely, so it falsely FAILS valid manifests — including the flagship `appos-plugin-ytdlp`, which declares `actions.register`, `actions.invoke`, `notifications.emit`, and `extensions[]` contributions. (The published `@appos.space/plugin-types` npm package ships only `.d.ts` files — there is no per-release package schema to fetch from npm.) Note the separate compile-time bound: the SDK version you compile against (`^3.0.0`) limits which *typed APIs* your TypeScript sees, not which manifest permissions the host accepts. If a future SDK schema ever moves ahead of your installed host, cross-check the `minHostVersion` guidance above — manifest validity is anchored to the host, not the npm package. **Alternative (plugin-sdk clone available)** — validate offline with the clone's own validator, passing your manifest path as a positional argument: @@ -54,6 +55,19 @@ Exit 0 = pass; on failure it prints the offending JSON paths. This runs fully of If you're offline and have no clone, fall back to the structural checks above (required fields, ID format, minHostVersion landmine) and state in the report that the permission set could NOT be authoritatively verified. +### Legacy-alias post-check (required — a schema pass is not host truth) + +The schema tolerates 5 legacy aliases for compile-time compatibility, but the host's alias map implements only ONE of them — see the "Deprecated legacy aliases" table in the `appos-plugin-dev` skill's `reference/extension-api.md` (host-behavior authority) and `LegacyPermissionScope` in `reference/plugin-api/permissions.d.ts`. After schema validation passes (whichever path you used), re-scan the manifest's `permissions` array — both bare strings and `{ scope, reason }` object entries — and: + +- **ERROR — dead aliases.** These pass AJV but have NO host-side entry: they are silently never granted, so the plugin installs "successfully" yet lacks the capability at runtime. Flag each occurrence as an ERROR with its canonical replacement: + - `network` → replace with `network.outbound` + - `webview` → replace with `ui.webPanel` + - `smartFolders` → replace with `filesystem.read` (smart-folder filter registration runs under filesystem read) + - `shell.uncontained` → remove entirely; the uncontained tier is NOT declarable — the host infers it from `filesystem.readAll` +- **WARNING — tolerated but rename.** `network.fetch` is the one real alias: the host normalizes it to `network.outbound` at manifest parse time, so it works today, but flag it and recommend declaring `network.outbound` directly. + +A manifest declaring any of the four dead aliases must NOT be reported as an overall PASS on permissions. + If the manifest declares `shell.execute`, verify `"shellCommands"` is present and non-empty. The AppOS sandbox blocks any command not in that list. ## 3. SDK layout validation @@ -80,29 +94,42 @@ Search `src/main.ts` and any files it imports (`src/**/*.ts`) for API usage and | Source pattern | Required permission | |---|---| -| `ctx.ui.registerPanel`, `ctx.ui.registerActivityView`, `ctx.ui.registerFileRowAnnotation` | `ui.sidebar` | -| `ctx.ui.registerWebPanel`, `ctx.ui.postToWebPanel`, `ctx.ui.onWebPanelMessage`, `ctx.ui.pipeShellToWebPanel` | `ui.webPanel` + `webview` | +| `ctx.ui.registerPanel`, `.updatePanel`, `.showPanel`, `.registerActivityView`, `.registerActivityBarItem`, `.registerFileRowAnnotation`, `.registerSidebarPanel` (deprecated — migrate to `registerPanel`) | `ui.sidebar` | +| `ctx.ui.registerToolbarItem` | `ui.sidebar` — NOT `ui.toolbar`; that scope exists in the enum, but the 1.0.0 host gates toolbar registration under `ui.sidebar` (fn-7 grouping), so `ui.toolbar` alone will NOT admit this call | +| `ctx.ui.registerWebPanel`, `ctx.ui.postToWebPanel`, `ctx.ui.onWebPanelMessage`, `ctx.ui.onWebPanelRequest` | `ui.webPanel` (the `webview` alias is dead — never granted) | +| `ctx.ui.pipeShellToWebPanel` | BOTH `ui.webPanel` AND `shell.execute` — it streams into a WebView panel *and* spawns a shell process; every piped command must also be listed in `shellCommands` (step 7) | | `ctx.ui.registerStatusBarItem` | `ui.statusBar` | | `ctx.ui.registerContextMenuItem` | `ui.contextMenu` | | `ctx.ui.showNotification` | `ui.notifications` | | `ctx.ui.showSheet` | `ui.sheets` | -| `ctx.ui.registerQuickAction` | `ui.quickActions` | | `ctx.shortcuts.register` | `ui.shortcuts` | | `ctx.themes.registerTheme` | `ui.themes` | -| `ctx.fileOps.listDirectory`, `.readFile`, `.getActiveDirectory` | `filesystem.read` | -| `ctx.fileOps.createFile`, `.writeFile`, `.delete`, `.moveFile`, `.copyFile` | `filesystem.write` | -| `ctx.fileOps.watchDirectory` | `filesystem.watch` | -| `ctx.shell.execute`, `ctx.shell.pipeToWebPanel` (deprecated — use `ctx.ui.pipeShellToWebPanel`) | `shell.execute` | +| `ctx.fileOps.listDirectory`, `.readFile`, `.readFileData`, `.getFileInfo`, `.getActiveDirectory`, `.getPaneDirectory`, `.getSelectedFiles` | `filesystem.read` | +| `ctx.fileOps.createFile`, `.createDirectory`, `.writeFile`, `.delete`, `.rename`, `.copy`, `.move`, `.batch` | `filesystem.write` (the SDK 3.0.0 methods are `copy(sources, dest)` / `move(sources, dest)` — there are NO `.copyFile` / `.moveFile` spellings; `batch` takes copy/move/delete operations, all write-gated) | +| `ctx.fileOps.watchDirectory`, `.watchDirectoryWithOptions` | `filesystem.watch` | +| `ctx.shell.execute` | `shell.execute` (`ShellAPI` has ONLY `execute` in SDK 3.0.0 — any `ctx.shell.pipe*` spelling comes from stale docs and does not exist; flag such a call as an ERROR pointing to `ctx.ui.pipeShellToWebPanel`) | | `ctx.clipboard.read` | `clipboard.read` | | `ctx.clipboard.write` | `clipboard.write` | -| `ctx.network.fetch` | `network` or `network.outbound` | -| `ctx.cache.get`, `.set`, `.delete` | `cache` | -| `ctx.feedback.toast`, `.log` | `feedback` | -| `ctx.feedback.confirm`, `.prompt` | `feedback.confirm` | -| `ctx.workspaces.register`, `.apply`, `.list` | `workspaces` | -| `ctx.menubar.register`, `.setBadge`, `.remove` | `menubar` | -| `ctx.smartFolders.registerFilterType` | `smartFolders` | -| `ctx.storage.getSecure`, `.setSecure` | `keychain.plugin` | +| `ctx.network.fetch`, `.download`, `.executeRequest` | `network.outbound` (legacy `network.fetch` is normalized to it; bare `network` is dead — never granted) | +| `ctx.cache.get`, `.set`, `.remove`, `.clear`, `.has`, `.keys` | `cache` (the removal method is `remove` — `CacheAPI` has NO `.delete`) | +| `ctx.feedback.toast`, `.hud`, `.updateHud`, `.dismissHud`, `.systemNotification`, `.notify` | `feedback` | +| `ctx.feedback.alert` | `feedback.confirm` (`alert` is the ONLY confirm-gated method — `FeedbackAPI` has no `.confirm`, `.prompt`, or `.log`) | +| `ctx.workspaces.register`, `.apply`, `.list`, `.getActive`, `.onChange` | `workspaces` | +| `ctx.menubar.register`, `.update`, `.setBadge`, `.setContent`, `.remove` | `menubar` | +| `ctx.smartFolders.registerFilterType` | `filesystem.read` (the `smartFolders` alias is dead — never granted) | +| `ctx.storage.getSecure`, `.setSecure`, `.deleteSecure` | `keychain.plugin` | + +Every method spelling above is pinned to the SDK 3.0.0 mirror (`reference/plugin-api/namespaces.d.ts` in the `appos-plugin-dev` skill). If you encounter a classic-namespace call NOT in this table (e.g. the `ctx.ui.open*` family, whose gating is path-scoped and varies per method), verify the method exists in the mirror and look up its scope there — do NOT guess a spelling or scope. In particular, `ctx.ui.registerQuickAction` and a `ui.quickActions` scope do not exist in SDK 3.0.0; flag any occurrence of either as an ERROR, not as a permission mismatch. + +The table above covers the classic namespaces. Core-plugin namespaces (`ctx.actions`, `ctx.notifications`, `ctx.scheduler`, `ctx.vault`, `ctx.store`, `ctx.resources`, `ctx.entities`, `ctx.views`, `ctx.webhook`, `ctx.llm`, `ctx.recipes`, `ctx.sequences`, ...) each require their own scopes (e.g. `actions.register`, `actions.invoke`, `notifications.emit`, `scheduler.job.own`). Do NOT hand-check those against a memorized list — look the scope up per namespace in the `appos-plugin-dev` skill's permission reference (the schema validation in step 2 is the authoritative enum check). + +Also cross-check `extensions[]`: for each entry, look up the `extensionPoint`'s required permission scope in the per-extension-point documentation in the `appos-plugin-dev` skill's `reference/extension-api.md` (the `extensions[]` section plus the per-namespace scope notes), and verify the exact spelling against `CanonicalPermissionScope` in `reference/plugin-api/permissions.d.ts`. Do NOT derive the scope mechanically as `.register` — several contribution families use differently-shaped scopes, and a mechanical suffix both misses the real requirement and recommends a scope that does not exist. Contrasting examples: + +- `actions.definition` → `actions.register`, and `space.appos.core.notifications:channel` → `notifications.channel.register` (the `*.register` families, where the suffix happens to hold) +- `surfaces.contribution` → the per-surface scope `surfaces.contribute.` (e.g. `surfaces.contribute.sidebar.top`) — there is NO `surfaces.register` +- computed-field provider contributions (fn-93 entities) → `entities.computedField.provide` — a `*.provide` scope, not `*.register` + +If a declared scope is a valid canonical scope for that extension point per the reference, do not report it as missing merely because it lacks a `.register` suffix. Report any missing permissions (API used but not declared) or excess permissions (declared but not used). diff --git a/plugins/appos-dev/compiled/cli-chat-system-prompt.md b/plugins/appos-dev/compiled/cli-chat-system-prompt.md index 1d8f34a..2acc8fe 100644 --- a/plugins/appos-dev/compiled/cli-chat-system-prompt.md +++ b/plugins/appos-dev/compiled/cli-chat-system-prompt.md @@ -1,7 +1,7 @@ You are an app builder helping the user create a custom app for the AppOS platform. All apps are built as AppOS plugins using TypeScript and the AppOS Plugin API — this is the only way to implement functionality. -All user-facing features must be implemented through the plugin API surface: custom panes (sidebar panels or full-pane views), WebView panels for rich HTML user interfaces (forms, charts, media players), context menu items, keyboard shortcuts, activity bar buttons, commands, event listeners, smart folders, theme extensions, streaming shell output for real-time progress (onData callbacks), workspace templates for custom window layouts, and dependency declarations for CLI tool requirements (installHint, version checking). There is no other way for users to interact with the app. Always choose the most accessible UI surface for each feature — prefer activity bar buttons and keyboard shortcuts for primary actions, context menus for contextual actions, WebView panels for rich interactive content, and panes for content display. +All user-facing features must be implemented through the plugin API surface: custom panes (sidebar panels or full-pane views), WebView panels for rich HTML user interfaces (forms, charts, media players), context menu items, keyboard shortcuts, activity bar buttons, commands, event listeners, smart folders, theme extensions, streaming shell output for real-time progress (onData callbacks), workspace templates for custom window layouts, and dependency declarations for CLI tool requirements (installHint, version checking). Deeper platform behavior comes from the core-plugin surfaces: public actions (typed, approval-gated operations other plugins and agents can invoke), scheduler jobs (interval/cron/event triggers), notifications routed through user-configured channels, the clipboard history engine and context bundles, recipes/sequences for multi-step plans, and LLM calls through the platform's provider layer. There is no other way for users to interact with the app. Always choose the most accessible UI surface for each feature — prefer activity bar buttons and keyboard shortcuts for primary actions, context menus for contextual actions, WebView panels for rich interactive content, and panes for content display. -You have access to the full plugin API reference via the appos-dev plugin. Guide the user through describing what they want, then scaffold and build it using the plugin template and API. Keep responses conversational and concise. When the app is ready, let the user know they can deploy it. +Before writing or modifying ANY plugin code, invoke the `appos-plugin-dev` skill (installed in this workspace under `.claude/skills/`) — it defines the required scaffold → implement → validate workflow for AppOS plugins. You also have access to the full plugin API reference via the appos-dev plugin. Guide the user through describing what they want, then scaffold and build it using the plugin template and API. Keep responses conversational and concise. When the app is ready, let the user know they can deploy it. Do not mention terminal commands, slash commands, or technical CLI concepts — present everything as simple actions. diff --git a/plugins/appos-dev/compiled/manifest.json b/plugins/appos-dev/compiled/manifest.json new file mode 100644 index 0000000..e5fe3de --- /dev/null +++ b/plugins/appos-dev/compiled/manifest.json @@ -0,0 +1,21 @@ +{ + "schema": 1, + "artifacts": { + "cli-chat-system-prompt.md": "0ace5e39d569fac84ca22e244bfb5dae8242b14e059395894f16f712b1ecc170", + "plugin-factory-context.md": "229f7fc7d15a6650f0923cf33d88e0712ecbb20a184df24e22d26a2c34783720" + }, + "sources": { + "SKILL.md": "f4868a887ab7726ca8c6463b90953a1efb82712fb1a3df5e68888f9b43644cd6", + "reference/extension-api.md": "b37b24cd2a5e02345b361ee08aea29a885084ebfb4a8a73358036a91572c49e8", + "reference/patterns.md": "93fc7e840176ab625fe848fb6df92f1c98301b5e13811906696eca3be91822f9", + "reference/plugin-api/index.d.ts": "cbdd7a4aa96001d3fe664427ffc30039ce09ba80dca2eee32960c5c925c4d8d4", + "reference/plugin-api/core.d.ts": "3e951ef56e6148879fbe92d592399711f4bad5556f1549233ec3d32d0d9c49f9", + "reference/plugin-api/views.d.ts": "dc3ae9b751b1cce8541e269c8000001dd819622fe1d5dfdbee7c6ca88c06f551", + "reference/plugin-api/namespaces.d.ts": "17c3364ad01ecc2b341a487bc0f286afa11cb2471f1fddb3735b1b0379bdad17", + "reference/plugin-api/namespaces-core-plugins.d.ts": "1b6c76836a6cfc87a3699ab40b8f6fd19d2c88bc8d6f90cd4ca6604d236cb7f6", + "reference/plugin-api/permissions.d.ts": "cd947682aed902d4878e56a03cd864f930fdc65f61588cc9e7c132c28772ab18", + "reference/plugin-api/colors.d.ts": "dbabe48e8d4615db877035f422c2e3200b591641161037a01cda60e51ab0981d", + "reference/plugin-api/fonts.d.ts": "479ade3790891953f4302d5f8729ad67621f9b69dccec317bb8e5b47175d58ac", + "reference/plugin-api/icons.d.ts": "8465ce60f8e4f426dd04777cce38ce03dbcaa113f4c115ce76a715020ce9960d" + } +} diff --git a/plugins/appos-dev/compiled/plugin-factory-context.md b/plugins/appos-dev/compiled/plugin-factory-context.md index c96e7d5..3894cce 100644 --- a/plugins/appos-dev/compiled/plugin-factory-context.md +++ b/plugins/appos-dev/compiled/plugin-factory-context.md @@ -3,8 +3,9 @@ > This file is auto-generated by scripts/compile-factory-context.sh. > Do NOT edit manually. Regenerate from appos-dev-plugin sources. -You are a plugin factory for the AppOS macOS dual-pane workspace manager. -You generate TypeScript plugins that run in the AppOS plugin runtime. +You are a plugin factory for AppOS, the macOS desktop platform built on a +dual-pane file manager. You generate TypeScript plugins that run in the +AppOS plugin runtime. Use the tools provided to write plugin files, build them with esbuild, and activate them in the running app. Follow the patterns and API @@ -15,22 +16,50 @@ specifications below exactly. ## Plugin Development Guide --- +name: appos-plugin-dev description: > - Build plugins for AppOS, the dual-pane macOS workspace manager. Use this - skill whenever someone asks about creating, building, testing, or deploying AppOS - plugins, or when working in a repo that imports from @appos.space/*. Triggers on: - "AppOS plugin", "@appos.space/plugin-types", "registerWebPanel", - "pipeShellToWebPanel", "WorkspaceTemplate", "SmartFolder filter", "lifecycle - dependencies", "plugin.json minHostVersion", or any of the 22 plugin API namespaces. - Also use PROACTIVELY when the user is working with TypeScript files that import from - @appos.space/plugin-types or call globalThis.activate with PluginContext. + Build plugins for AppOS, the dual-pane macOS workspace manager. Use + whenever someone asks about creating, building, testing, or deploying + AppOS plugins, or when working in a repo importing from @appos.space/*. + Triggers on: "AppOS plugin", "@appos.space/plugin-types", "PluginContext", + "actions.register", "ActionExecutionContext", "registerWebPanel", + "pipeShellToWebPanel", "WorkspaceTemplate", "extensions[]", "plugin.json + minHostVersion", or any ctx. plugin API call. Also use + PROACTIVELY on TypeScript that imports @appos.space/plugin-types or + assigns activate/deactivate onto globalThis. --- # AppOS Plugin Development -Build plugins for AppOS, a dual-pane workspace manager for macOS. Plugins are TypeScript modules compiled to IIFE bundles and executed inside JavaScriptCore (JSC). They receive a typed `PluginContext` object that exposes 22 API namespaces for interacting with the host. - -The canonical reference implementation is **`appos-plugin-ytdlp`** — the flagship yt-dlp GUI plugin. When designing anything non-trivial, mirror its patterns. +Build plugins for AppOS, a dual-pane workspace manager for macOS. Plugins +are TypeScript modules compiled to IIFE bundles executed inside +JavaScriptCore (JSC). They receive a typed `PluginContext` exposing, as of +SDK 3.0.0, 43 namespaces — the original host surface (panels, files, +shell, workspaces) plus the core-plugin platform wave (actions, scheduler, +notifications, storage, vault, entities, LLM, recipes, ...). The canonical +reference implementation is **`appos-plugin-ytdlp`** — the flagship yt-dlp +GUI plugin; when designing anything non-trivial, mirror its patterns. + +## Reference files (read these early) + +Everything enumerable lives in `reference/` — this file teaches the shape, +the references carry the detail: + +- `reference/extension-api.md` — namespace-by-namespace API map, + `extensions[]` manifests, the permission-scope model, catalog bundle + layout, WebView bridge + CSS tokens. +- `reference/patterns.md` — canonical patterns (activation ordering, + actions, notifications, scheduler, message typing, resume loops). +- `reference/plugin-api/` — byte-verbatim mirror of the published + `@appos.space/plugin-types` d.ts modules. Namespace methods: + `grep -rn "interface ActionsAPI" reference/plugin-api/`; all namespaces: + `grep -n "readonly" reference/plugin-api/core.d.ts`; permission union: + `grep -n "CanonicalPermissionScope" reference/plugin-api/permissions.d.ts`. +- `reference/migration-2.x-to-3.0.md` — migrating a 2.4.x-era plugin to + SDK 3.0.0 (renames, exec-context handlers, token returns, pins). + +For a new plugin: read `patterns.md` first, spot-check every API you plan +to call against the `plugin-api/` mirror. ## Architecture @@ -39,28 +68,41 @@ TypeScript source → esbuild (IIFE, es2020, bundle) → dist/main.js → JSCore runtime - → PluginContext (22 namespaces) + → PluginContext (typed namespaces) → native SwiftUI OR WKWebView with plugin-panel:// scheme ``` - Each plugin runs in its own JSC isolate on a serial dispatch queue. -- No shared state between plugins except through `dataContracts` and `interPluginEvents`. -- UI has **two rendering modes**, chosen per-panel: - - **ViewDescriptor** — declarative JSON tree → native SwiftUI. Use for lightweight sidebars, file-row annotations, toolbars, menu bar popovers. - - **WebView panel** — ships HTML/CSS/JS in the plugin bundle, loaded via `plugin-panel://` into a WKWebView. Use for rich UI, streaming progress, media playback, complex forms. -- **JSC has no DOM, no Node, no browser APIs.** Timers may or may not exist — guard with `typeof setTimeout === 'function'`. + Cross-plugin interaction flows through platform surfaces (public actions, + the typed event bus, `dataContracts`, shared-store grants) — never + shared memory. +- UI has **two rendering modes**, chosen per-panel: **ViewDescriptor** + (declarative JSON tree → native SwiftUI; lightweight sidebars, + annotations, toolbars, popovers) or **WebView panel** (HTML/CSS/JS in + the bundle via `plugin-panel://`; rich UI, streaming, media, forms). +- **JSC has no DOM, no Node, no browser APIs.** Timers may or may not + exist — guard with `typeof setTimeout === 'function'`. Same for `URL`: + hosts 1.1.0+ inject a Foundation-bridged `URL` global (immutable v1 + subset, no `searchParams`), but older hosts, the + `appos.jsc.urlGlobal.disabled` kill switch, and menu-bar contexts lack + it — guard with `typeof URL === 'function'` + (`reference/patterns.md` §24). ## The SDK packages (`@appos.space/*`) -Always depend on the official SDK packages instead of hand-writing types or utilities: +Always depend on the official SDK packages (3.0.0 line) instead of +hand-writing types or utilities: | Package | Purpose | Install | |---|---|---| -| `@appos.space/plugin-types` | TypeScript types for `PluginContext`, manifest, all 22 namespaces, permission scopes, design tokens. Declaration-only, zero runtime. | `devDependency` | +| `@appos.space/plugin-types` | Types for `PluginContext`, every namespace, manifest, permission scopes, design tokens. Declaration-only, zero runtime. | `devDependency` | | `@appos.space/plugin-utils` | Pure helpers: `urlToPath`, `pathToUrl`, `fileExtension`, `formatSize`, `formatDate`, `generateId`, `debounce`, `throttle`, `createActionRouter`. | `dependency` | -| `@appos.space/view-builders` | Typed helpers (`vstack`, `section`, `listItem`, `button`) that return plain `ViewDescriptor` objects. Zero runtime. | `dependency` | +| `@appos.space/view-builders` | Typed helpers (`vstack`, `section`, `listItem`, `button`, `encodeMenuActions`) returning plain `ViewDescriptor` objects. | `dependency` | -The plugin-types version `2.4.x` tracks **plugin API** `2.4.x` — it is NOT the host app version. See the *minHostVersion landmine* section below. +Pin the 3.x line: `"@appos.space/plugin-types": "^3.0.0"` (and siblings). +The SDK version is NOT the host app version (see the *minHostVersion +landmine* below), and the README inside the published plugin-types tarball +is stale — trust the d.ts, not the package README. ### Mandatory tsconfig flag @@ -70,36 +112,45 @@ Because `@appos.space/plugin-types` is declaration-only, you MUST enable: { "compilerOptions": { "verbatimModuleSyntax": true } } ``` -Without it, `import { PluginContext }` compiles to a runtime import against a no-JS package and the bundler will fail. +Without it, `import { PluginContext }` compiles to a runtime import against +a no-JS package and the bundler will fail. -### Importing from the SDK +### Importing from the SDK — no ambient globals ```ts -import type { PluginContext, DependencyStatus, WorkspaceTemplate } from '@appos.space/plugin-types'; -import { urlToPath, formatSize, createActionRouter } from '@appos.space/plugin-utils'; +import type { PluginContext, ActionExecutionContext, WorkspaceTemplate } from '@appos.space/plugin-types'; +import { urlToPath, formatSize } from '@appos.space/plugin-utils'; import { vstack, section, listItem, button } from '@appos.space/view-builders'; ``` -Use `import type` for everything from `plugin-types`. The other two packages have real runtime exports. +The SDK's main entry ships no ambient globals — `import type` every +`plugin-types` name you use (TS2304 on an SDK name means you forgot). The +other two packages have real runtime exports. One opt-in exception: the +SDK 3.0.1+ `@appos.space/plugin-types/globals` subpath types the +host-injected `URL` global — it augments nothing unless a tsconfig +references it; the scaffolded `src/jsc-globals.ts` declares the same +surface locally (any 3.x pin; keep exactly ONE). ## Plugin entry pattern -The JSC runtime looks up `activate` / `deactivate` on `globalThis` — not as named ESM exports (IIFE format doesn't expose them). Assign at the bottom of `src/main.ts`: +The JSC runtime looks up `activate` / `deactivate` on `globalThis` — not as +named ESM exports (IIFE format doesn't expose them). Assign at the bottom +of `src/main.ts`: ```ts import type { PluginContext } from '@appos.space/plugin-types'; +declare function registerDownloadPanel(ctx: PluginContext): Promise<() => void>; + const disposables: Array<() => void | Promise> = []; async function activate(ctx: PluginContext): Promise { - // Register everything. Push every disposer onto disposables[]. + // Register everything; push every disposer onto disposables[]. disposables.push(await registerDownloadPanel(ctx)); - // ... } async function deactivate(): Promise { - // Drain in reverse order with per-item try/catch so one bad dispose - // never blocks the rest. + // Drain in reverse with per-item try/catch — one bad dispose never blocks the rest. while (disposables.length) { const d = disposables.pop(); try { await d?.(); } catch (err) { console.error('[plugin] Dispose error:', err); } @@ -110,284 +161,258 @@ async function deactivate(): Promise { ;(globalThis as any).deactivate = deactivate; ``` -The leading `;` on the globalThis assignments prevents ASI hazards when the preceding statement lacks a semicolon. **Use `ctx` as the parameter name**, not `pluginContext` or `context`. - -## Decision tree: UI needs → API choice - -| Need | API | Rendering | Notes | -|---|---|---|---| -| Rich forms, streaming progress, media playback | `ctx.ui.registerWebPanel()` | WKWebView | Bundle HTML/CSS/JS under `webview//`. Use for yt-dlp-style UIs. | -| Lightweight sidebar (file annotations, git status, file stats) | `ctx.ui.registerPanel()` | SwiftUI via ViewDescriptor | Cheap, reactive, composes well. | -| Activity bar icon + sidebar | `ctx.ui.registerActivityView()` | SwiftUI via ViewDescriptor | Use for primary feature entry points. | -| Menu bar status item (with badge) | `ctx.menubar.register()` / `setBadge()` | NSStatusItem | Subscribe to `menubar.clicked` event to handle clicks. | -| Multi-pane layout (opening the plugin puts tabs in both panes) | `ctx.workspaces.register()` + `apply()` | Native window layout | Apply unconditionally on every activation. See workspace section below. | -| File-aware filters (smart folders) | `ctx.smartFolders.registerFilterType()` | Host invokes `evaluate` closure in plugin's JSC | Closures capture plugin state — rebuild lookups on state change via `subscribe()`. | -| Toast / HUD / alert | `ctx.feedback.toast()` / `.hud()` / `.alert()` | Native AppKit | `notify()` auto-routes: focused→toast, unfocused→HUD, background→system notification. | -| Keyboard shortcuts | `ctx.shortcuts.register({ commandId, keys })` | — | Must bind to an already-registered `ctx.commands.register()`. | -| Persistent state (queue, library, history) | `ctx.cache.set(key, value, { persist: true })` | SQLite write-through | Default is memory-only — pass `persist: true` for durability. `cache.get()` returns deserialized values — do NOT `JSON.parse`. | -| Run a CLI with streaming output | `ctx.ui.pipeShellToWebPanel(panelId, shellOpts)` | Chunks flow into the webview | **Method lives on `ctx.ui`, NOT `ctx.shell`.** Hard 120s timeout — use resume-loops for long jobs. | -| React to dependency changes | `ctx.lifecycle.onDependencyStatusChanged(fn)` | — | Host pushes status at activation. `getDependencyStatus()` reads on demand; `recheckDependencies()` re-probes (both host-wired). | - -### Two WebView panels maximum per plugin - -The host caps WebView panels at **2 per plugin / 6 globally**. If your UI needs more surfaces, use view switching inside a panel or fall back to ViewDescriptor-based panels. - -### ViewDescriptor quick reference +The leading `;` on the globalThis assignments prevents ASI hazards. **Use +`ctx` as the parameter name**, not `pluginContext` or `context`. -The SDK defines **17 ViewDescriptor types**: `vstack`, `hstack`, `scroll`, `list`, `grid`, `text`, `label`, `image`, `remoteImage`, `badge`, `button`, `listItem`, `textField`, `progress`, `section`, `divider`, `spacer`. Use `@appos.space/view-builders` for typed helpers. +## Decision tree: need → API -### WebView Panel Guidance - -**When to choose WebView over ViewDescriptor:** -- Rich interactive UI: forms, tables, streaming terminal output, media playback, charts -- Complex layouts that exceed what `vstack`/`hstack`/`grid` can express -- Reuse of existing HTML/CSS/JS libraries +| Need | API | Notes | +|---|---|---| +| Expose a capability to the palette, other plugins, or AI agents | `ctx.actions.register()` | The fn-89 action fabric — schema-validated, receipted. See below. | +| Rich forms, streaming progress, media playback | `ctx.ui.registerWebPanel()` | WKWebView; bundle HTML/CSS/JS under `webview//`. | +| Lightweight sidebar (annotations, status, stats) | `ctx.ui.registerPanel()` | SwiftUI via ViewDescriptor — cheap, reactive. | +| Activity bar icon + sidebar | `ctx.ui.registerActivityView()` | Primary feature entry points. | +| Menu bar status item (with badge) | `ctx.menubar.register()` / `setBadge()` / `setContent()` | `setContent` is REQUIRED or the popover says "No content". | +| Multi-pane layout on open | `ctx.workspaces.register()` + `apply()` | Apply unconditionally on every activation (see below). | +| Run work on a schedule | `ctx.scheduler.scheduleJob()` | Cron/interval/fsEvents/calendar triggers dispatching a registered action. | +| Notify the user (routable) | `ctx.notifications.emit()` | User rules pick the channel — never the emitter. | +| Toast / HUD / alert (always local) | `ctx.feedback.toast()` / `.hud()` / `.alert()` | `notify()` auto-routes by focus state. | +| Durable, queryable storage | `ctx.store` | Document + KV namespaces; prefer over `cache`/`storage` for real data. | +| Secrets | `ctx.vault` | You supply raw material once at `store()`; after that only opaque refs — no read-back into JS. | +| Persistent small state (queue, prefs) | `ctx.cache.set(key, value, { persist: true })` | `cache.get()` returns deserialized values — do NOT `JSON.parse`. | +| Run a CLI with streaming output | `ctx.ui.pipeShellToWebPanel(panelId, shellOpts)` | **On `ctx.ui`, NOT `ctx.shell`.** 120s hard cap — resume-loop long jobs. | +| File-aware filters (smart folders) | `ctx.smartFolders.registerFilterType()` | Synchronous `evaluate` closure; rebuild lookups on state change. | +| React to dependency changes | `ctx.lifecycle.onDependencyStatusChanged(fn)` | `getDependencyStatus()` reads on demand; `recheckDependencies()` re-probes. | + +The full namespace surface (resources/tokens/bundles, entities, ledger, +views, sidecars, input, webhook, LLM, recipes/sequences, ...) is mapped in +`reference/extension-api.md`. + +## Public actions (fn-89) — the platform's front door + +Actions are how a plugin exposes typed, schema-validated capabilities. +Every invocation runs validate → permission → approve → execute → receipt. +The handler receives ONE argument — the execution context — and reads its +payload via `exec.input`: -**When to use ViewDescriptor instead:** -- Simple sidebars, file annotations, status displays, settings panels -- No need for custom styling or complex interaction -- Lower overhead (no WKWebView process) +```ts +import type { ActionExecutionContext } from '@appos.space/plugin-types'; -**File structure for WebView panels:** +type GreetInput = { name: string }; +const token = await ctx.actions.register( + { + id: 'greet', + title: 'Greet Someone', + inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + visibility: ['user', 'api', 'agent'], + approval: 'auto', + }, + async (exec: ActionExecutionContext) => { + // exec: { invocationId, source, input, sourceId? } + const input = exec.input as GreetInput; // assert to a `type`, never an `interface` + return { greeting: `Hello, ${input.name}!` }; + }, +); +void token; // keep for ctx.actions.unregister(token) on dispose ``` -my-plugin/ - src/main.ts # registerWebPanel('main-panel', { htmlPath: 'webview/main/index.html' }) - webview/ - main/ - index.html # Entry point (NO 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 +1391,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" -``` - -**Streaming** — provide `onData` for real-time progress: +console.log(version.stdout.trim()); -```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 +1425,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): + +- `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`. --- ## 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 +1541,236 @@ 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. - -## 2. WebView panel registration +**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. -**File**: `src/panels/download-panel.ts` +## 2. Public action with execution context (fn-89) -```ts -import type { PluginContext } from '@appos.space/plugin-types'; -import { parseInbound } from '../types/webview-messages.js'; +**File**: `src/actions/register-actions.ts` -export function registerDownloadPanel(ctx: PluginContext): () => void { - let disposed = false; +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). - ctx.ui.registerWebPanel('download', { - title: 'Downloads', +```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'; + +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; + + const panelToken = ctx.ui.registerWebPanel('download', { + title: 'Downloads', icon: 'arrow.down.circle', htmlPath: 'webview/download/index.html', allowNavigation: false, }); + void panelToken; // typed as string, but undefined on the 1.0.0 host — see key points - 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 +1780,44 @@ 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; // host removes the panel + handler on plugin unload + }; } ``` **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 +- Per the SDK 3.0.0 types, `registerWebPanel` returns a registration-token + string (2.x hid it) — but the shipped AppOS 1.0.0 host returns + `undefined` from it at runtime (host↔d.ts reconciliation is a known SDK + follow-up). Do NOT build teardown on the runtime value: at deactivation + it would call `ctx.ui.unregister(undefined)`, which cannot unregister + the panel and may throw. The host removes the panel automatically on + plugin unload; the `disposed` flag is the mid-life teardown mechanism. +- `onWebPanelMessage` / `onWebPanelRequest` tokens are `undefined` on the + 1.0.0 host too, and the host exposes NO handler-unregister API either + way — `ctx.ui.unregister` takes only slot-based contribution ids + (panels, toolbar items, status bar items), never handler tokens. 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,30 +1825,53 @@ 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 { if (typeof data !== 'object' || data === null) return null; const m = data as Record; - if (m.v !== 1 || typeof m.type !== 'string') return null; - // Per-type shape validation can go here... + if (m.v !== 1) return null; + // Validate EVERY variant's required fields before the cast. Checking + // only `v` + `typeof type === 'string'` is NOT enough: it would accept + // { v: 1, type: 'queue-download' } and hand downstream code a message + // with requestId/url/format missing — violating the untrusted-input + // contract these messages arrive under. + switch (m.type) { + case 'probe-url': + if (typeof m.probeId !== 'string' || typeof m.url !== 'string') return null; + break; + case 'queue-download': + if (typeof m.requestId !== 'string' || typeof m.url !== 'string' || + typeof m.format !== 'string') return null; + break; + case 'request-state': + break; // no fields beyond the discriminator + default: + return null; // unknown type — reject, no fallthrough + } return data as PanelInboundMessage; } ``` -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 -let throttleTimer: ReturnType | undefined; +declare const state: { getQueue(): unknown[] }; + +// NonNullable because JSC may not inject timers — the guard below narrows +let throttleTimer: ReturnType> | undefined; let lastBroadcast = 0; function broadcastQueue(): void { - if (typeof setTimeout !== 'function') { + if (typeof setTimeout !== 'function' || typeof clearTimeout !== 'function') { // JSC may not inject timers — fall back to synchronous ctx.ui.postToWebPanel('download', { v: 1, type: 'queue-update', entries: [...state.getQueue()], @@ -1171,15 +1895,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 +1926,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 +1939,15 @@ 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 broadcast to ALL live instances of the panel, and this cannot be + filtered: the chunk is `{ stream, data, bytesTotal }` — it carries no + instance identifier (`envelope.instanceId` exists only on + WebView→plugin messages, not on outbound chunks). If you need + per-instance isolation, don't use `pipeShellToWebPanel` — run the + command with `ctx.shell.execute({ onData })` and forward chunks + yourself via `ctx.ui.postToWebPanel(panelId, msg, { instanceId })` -## 6. Workspace template registration +## 10. Workspace template registration **File**: `src/workspace/template.ts` @@ -1243,8 +1981,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 +1991,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 +2011,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 +2033,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 +2044,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' }); @@ -1303,48 +2060,75 @@ export async function registerMenubar(ctx: PluginContext): Promise<() => void> { button('Open Dashboard', { action: 'open-dashboard' }), ]); } - await ctx.menubar.setContent(buildPopoverContent()); let unsubscribed = false; - // ctx.events.subscribe returns a string token, not a disposer - 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 */ } - }); + let clickToken: string | undefined; + let unsubscribeQueue: (() => void) | undefined; + + // Transactional init: register() already succeeded, so any failure in + // the steps below must remove the status item before rethrowing — + // otherwise this function rejects and leaves a dangling menu bar item + // nobody holds a disposer for. + try { + await ctx.menubar.setContent(buildPopoverContent()); + + // ctx.events.subscribe returns a string token, not a disposer + 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 { /* 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); - ctx.menubar.setContent(buildPopoverContent()).catch(() => {}); - }); + // Update badge AND popover content as queue changes + unsubscribeQueue = state.subscribe(() => { + const count = state.getQueue().length; + void ctx.menubar.setBadge(count > 0 ? count : 0); + ctx.menubar.setContent(buildPopoverContent()).catch(() => {}); + }); + } catch (err) { + unsubscribed = true; + if (clickToken !== undefined) ctx.events.unsubscribe(clickToken); + unsubscribeQueue?.(); + await ctx.menubar.remove().catch(() => { /* best effort */ }); + throw err; + } return () => { unsubscribed = true; - ctx.events.unsubscribe(clickToken); - unsubscribeQueue(); + if (clickToken !== undefined) ctx.events.unsubscribe(clickToken); + unsubscribeQueue?.(); ctx.menubar.remove().catch(() => { /* ignore */ }); }; } ``` -**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 (as the example above does) so the menu bar +doesn't leak a dangling item — a rejected init means the caller never +receives the disposer, so nothing else will ever clean it up. -## 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 +2154,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 +2177,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 +2207,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 +2259,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 +2310,10 @@ 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 +beats `npx esbuild ...`: cleaner watch mode, works across platforms. -## 14. tsconfig (mandatory flags) +## 18. tsconfig (mandatory flags) ```json { @@ -1504,16 +2328,89 @@ if (isWatch) { "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, - "lib": ["ES2020", "DOM"] + "types": [], + "lib": ["ES2022"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } ``` -**`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** — without it TypeScript +emits runtime `require`/`import` calls for the type-only +`@appos.space/plugin-types` and the plugin silently fails to activate. + +**`lib` has no `DOM`** — JSC has no `document`/`window`, no browser +`fetch` (use `ctx.network.fetch`), no guaranteed timers, and `URL` only on +hosts that inject it (AppOS 1.1.0+). +**`types` is pinned `[]`** so a later `@types/node` install cannot inject +`process`/Node timer globals that typecheck yet throw in JSC. Ship a +`src/jsc-globals.ts` `declare global` module (matched by +`"include": ["src/**/*.ts"]`) declaring the runtime's real globals: + +```ts +// src/jsc-globals.ts — JSC plugin-runtime ambient globals. A `declare +// global` .ts MODULE, not a .d.ts: skipLibCheck skips every .d.ts (even +// project-owned), so corruption silently degrades to error-`any`; a +// .ts module is always checked. JSC ships a native console; the host +// injects NO timers — `| undefined` typing makes an unguarded +// setTimeout(...) a TS2722 error while a +// `typeof setTimeout === 'function'`-narrowed call compiles. +export {}; + +declare global { + var console: { + log(...args: unknown[]): void; + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; + debug(...args: unknown[]): void; + trace(...args: unknown[]): void; + }; + var setTimeout: ((handler: (...args: unknown[]) => void, timeout?: number, ...args: unknown[]) => number) | undefined; + var clearTimeout: ((id: number | undefined) => void) | undefined; + var setInterval: ((handler: (...args: unknown[]) => void, timeout?: number, ...args: unknown[]) => number) | undefined; + var clearInterval: ((id: number | undefined) => void) | undefined; + // URL — hosts 1.1.0+ inject a Foundation-bridged URL (immutable v1 subset; + // searchParams THROWS — parse url.search manually). Typed `| undefined` + // (older hosts / kill switch / menu-bar contexts lack it): unguarded + // `new URL(...)` is a TS18048 error; a `typeof URL === 'function'` guard + // compiles (usage: §24). Same surface as the SDK 3.0.1+ opt-in + // `@appos.space/plugin-types/globals` subpath — on a >=3.0.1 pin you may + // set tsconfig `types` to that subpath and DELETE this URL block + // (keeping both double-declares URL). + interface URL { + readonly href: string; + readonly protocol: string; + readonly hostname: string; + readonly host: string; + readonly port: string; + readonly pathname: string; + readonly search: string; + readonly hash: string; + readonly origin: string; + readonly username: string; + readonly password: string; + toString(): string; + toJSON(): string; + } + interface URLConstructor { + new (url: string | URL, base?: string | URL): URL; + canParse(url: string | URL, base?: string | URL): boolean; + readonly prototype: URL; + } + var URL: URLConstructor | undefined; +} +``` -## 15. Deploy (rsync with --delete-excluded) +Browser globals in `src/` now fail typecheck (`document` → TS2584, +`window`/`fetch` → TS2304), unguarded `setTimeout(...)` / `new URL(...)` +fail (TS2722 / TS18048), and `typeof`-guarded calls compile (guarded URL +usage: §24). DOM belongs only in the WebView-side `tsconfig.webview.json` +(`webview-panels` skill; `skipLibCheck: false` keeps the +project-owned `webview/twopanez.d.ts` checked). + +## 19. Deploy (rsync with --delete-excluded) ```bash rsync -av --delete --delete-excluded \ @@ -1536,9 +2433,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 +2447,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 +2461,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 +2489,43 @@ 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) => { + void panelToken; // typed as string, but undefined on the 1.0.0 host — see pattern 6 + // Host removes panel + handlers on unload; `disposed` handles mid-life + // teardown. Do NOT push ctx.ui.unregister(panelToken) — that is + // unregister(undefined) on the 1.0.0 host and may throw (pattern 6). + disposables.push(() => { disposed = true; }); + + // SECURITY: messages are SEMANTIC intents, never shell-shaped — the + // plugin hardcodes command + argv per intent. NEVER forward a command + // or argv array from webview input into ctx.shell.execute. + 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 +2538,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 +2553,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', { @@ -1681,55 +2602,48 @@ async function deactivate(): Promise { ### webview/main/styles.css ```css +/* Host-injected design tokens — update live on theme change */ body { margin: 0; padding: 16px; background-color: var(--twopanez-bg); color: var(--twopanez-text); - font-family: -apple-system, BlinkMacSystemFont, sans-serif; } #output { background: var(--twopanez-bg-surface); - border-radius: 8px; - padding: 12px; font-family: 'SF Mono', monospace; - font-size: 12px; white-space: pre-wrap; - min-height: 200px; - overflow-y: auto; } button { background: var(--twopanez-accent); color: var(--twopanez-bg); - border: none; - border-radius: 6px; - padding: 8px 16px; - margin-top: 12px; - cursor: pointer; } ``` ### webview/main/app.js -```js +```js webview +// Strict checkJs-clean; requires webview/twopanez.d.ts (extension-api.md). +/** @typedef {{ v: number, type: string, [key: string]: unknown }} ProtocolMessage */ + const output = document.getElementById('output'); const runBtn = document.getElementById('run'); +if (!output || !runBtn) throw new Error('missing #output/#run'); // Receive protocol messages from plugin via postToWebPanel -window.twopanez.onMessage((msg) => { - if (typeof msg !== 'object' || msg === null || msg.v !== 1) return; +window.twopanez.onMessage((data) => { + const msg = /** @type {Partial | null} */ (data); + if (typeof msg !== 'object' || msg === null || msg.v !== 1 || typeof msg.type !== 'string') return; if (msg.type === 'started') output.textContent = ''; - if (msg.type === 'output') output.textContent += msg.data; - if (msg.type === 'finished') { - output.textContent += `\n[exit ${msg.exitCode}]`; - } + if (msg.type === 'output' && typeof msg.data === 'string') output.textContent += msg.data; + if (msg.type === 'finished') output.textContent += `\n[exit ${msg.exitCode}]`; }); 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 +2655,33 @@ 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 | |---|---|---| @@ -371,38 +834,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 +868,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..58389e4 --- /dev/null +++ b/plugins/appos-dev/skills/appos-plugin-dev/reference/migration-2.x-to-3.0.md @@ -0,0 +1,316 @@ +# 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` are **typed** as returning string tokens — capture them for type-compat, but do NOT build teardown on them: the shipped AppOS 1.0.0 host returns `undefined` at runtime (host↔d.ts reconciliation is a known SDK follow-up). Disposal is a `disposed`-flag guard plus host cleanup on plugin unload. +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 the types) + +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 all three as returning a `string` token — but the shipped +AppOS 1.0.0 host returns `undefined` from them at runtime (host↔d.ts +reconciliation is a known SDK follow-up). This is **silently non-breaking +at runtime** — your old code keeps working — but it means you must not +build teardown on the returned values: + +- `registerWebPanel`: capture the token for type-compat, but do NOT + thread `ctx.ui.unregister(panelToken)` into your disposable tracking — + on the 1.0.0 host that is `ctx.ui.unregister(undefined)`, which cannot + unregister the panel and may throw. The host removes the panel + automatically on plugin unload; a `disposed` flag is the mid-life + teardown mechanism. +- `onWebPanelMessage` / `onWebPanelRequest` return **handler tokens** with + NO unregister path either way — `ctx.ui.unregister` accepts only + slot-based contribution ids (panels, toolbar/status items), not handler + tokens. Capture them for identification/debugging; actual disposal is + the same `disposed` flag guard inside the handler closure plus host + cleanup on plugin deactivation. (One handler per panelId — + re-registering replaces the previous one.) + +```ts +const disposables: Array<() => void | Promise> = []; + +let disposed = false; + +const panelToken = ctx.ui.registerWebPanel('download', { + title: 'Downloads', + htmlPath: 'webview/download/index.html', +}); +void panelToken; // typed as string, but undefined on the 1.0.0 host — do not unregister with it + +const messageToken = ctx.ui.onWebPanelMessage('download', (envelope) => { + if (disposed) return; // the real disposal mechanism + // envelope: { data, instanceId, windowId, paneId } +}); +void messageToken; // diagnostics only — no unregister API + +// The ONE teardown disposable: flip the flag; the host removes the panel +// and handlers on plugin unload. +disposables.push(() => { disposed = true; }); +``` + +## 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. + +--- + +## Manifest permissions: replace dead legacy scope names + +Not a compile break — the SDK's `LegacyPermissionScope` union deliberately +keeps five pre-3.0 names, so a 2.x `plugin.json` (and typed helpers around +it) still type-checks with them — but only ONE of the five is actually +accepted by the host: + +- `network.fetch` is the single real alias: the host's alias map + normalizes it to `network.outbound` at manifest parse time. Tolerated, + but rename it while you are in the manifest anyway. +- `network`, `smartFolders`, and `webview` are SDK-type-only names with no + host-side entry — declaring them grants nothing. REPLACE them with + `network.outbound`, `filesystem.read` (smart folders), and + `ui.webPanel` respectively. +- `shell.uncontained` was never declarable: the T2 uncontained shell tier + is inferred from `filesystem.readAll`, never requested. Delete it. + +A migrated plugin that keeps the four dead names compiles clean and then +runs without the capabilities it thinks it declared — grep your +`plugin.json` for all five (`network.fetch`, `network`, `smartFolders`, +`webview`, `shell.uncontained`) and keep only canonical scopes. + +--- + +## 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..7065dd3 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,27 +64,236 @@ 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', allowNavigation: false, }); + void panelToken; // typed as string, but undefined on the 1.0.0 host — see key points - ctx.ui.onWebPanelMessage('download', (envelope) => { + const messageToken = ctx.ui.onWebPanelMessage('download', (envelope) => { if (disposed) return; const msg = parseInbound(envelope.data); if (!msg) return; @@ -78,21 +303,44 @@ 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; // host removes the panel + handler on plugin unload + }; } ``` **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 +- Per the SDK 3.0.0 types, `registerWebPanel` returns a registration-token + string (2.x hid it) — but the shipped AppOS 1.0.0 host returns + `undefined` from it at runtime (host↔d.ts reconciliation is a known SDK + follow-up). Do NOT build teardown on the runtime value: at deactivation + it would call `ctx.ui.unregister(undefined)`, which cannot unregister + the panel and may throw. The host removes the panel automatically on + plugin unload; the `disposed` flag is the mid-life teardown mechanism. +- `onWebPanelMessage` / `onWebPanelRequest` tokens are `undefined` on the + 1.0.0 host too, and the host exposes NO handler-unregister API either + way — `ctx.ui.unregister` takes only slot-based contribution ids + (panels, toolbar items, status bar items), never handler tokens. 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 } @@ -100,30 +348,53 @@ 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 { if (typeof data !== 'object' || data === null) return null; const m = data as Record; - if (m.v !== 1 || typeof m.type !== 'string') return null; - // Per-type shape validation can go here... + if (m.v !== 1) return null; + // Validate EVERY variant's required fields before the cast. Checking + // only `v` + `typeof type === 'string'` is NOT enough: it would accept + // { v: 1, type: 'queue-download' } and hand downstream code a message + // with requestId/url/format missing — violating the untrusted-input + // contract these messages arrive under. + switch (m.type) { + case 'probe-url': + if (typeof m.probeId !== 'string' || typeof m.url !== 'string') return null; + break; + case 'queue-download': + if (typeof m.requestId !== 'string' || typeof m.url !== 'string' || + typeof m.format !== 'string') return null; + break; + case 'request-state': + break; // no fields beyond the discriminator + default: + return null; // unknown type — reject, no fallthrough + } return data as PanelInboundMessage; } ``` -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 -let throttleTimer: ReturnType | undefined; +declare const state: { getQueue(): unknown[] }; + +// NonNullable because JSC may not inject timers — the guard below narrows +let throttleTimer: ReturnType> | undefined; let lastBroadcast = 0; function broadcastQueue(): void { - if (typeof setTimeout !== 'function') { + if (typeof setTimeout !== 'function' || typeof clearTimeout !== 'function') { // JSC may not inject timers — fall back to synchronous ctx.ui.postToWebPanel('download', { v: 1, type: 'queue-update', entries: [...state.getQueue()], @@ -147,15 +418,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 +449,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 +462,15 @@ 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 broadcast to ALL live instances of the panel, and this cannot be + filtered: the chunk is `{ stream, data, bytesTotal }` — it carries no + instance identifier (`envelope.instanceId` exists only on + WebView→plugin messages, not on outbound chunks). If you need + per-instance isolation, don't use `pipeShellToWebPanel` — run the + command with `ctx.shell.execute({ onData })` and forward chunks + yourself via `ctx.ui.postToWebPanel(panelId, msg, { instanceId })` -## 6. Workspace template registration +## 10. Workspace template registration **File**: `src/workspace/template.ts` @@ -219,8 +504,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 +514,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 +534,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 +556,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 +567,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' }); @@ -287,48 +583,75 @@ export async function registerMenubar(ctx: PluginContext): Promise<() => void> { button('Open Dashboard', { action: 'open-dashboard' }), ]); } - await ctx.menubar.setContent(buildPopoverContent()); let unsubscribed = false; - // ctx.events.subscribe returns a string token, not a disposer - 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 */ } - }); + let clickToken: string | undefined; + let unsubscribeQueue: (() => void) | undefined; + + // Transactional init: register() already succeeded, so any failure in + // the steps below must remove the status item before rethrowing — + // otherwise this function rejects and leaves a dangling menu bar item + // nobody holds a disposer for. + try { + await ctx.menubar.setContent(buildPopoverContent()); + + // ctx.events.subscribe returns a string token, not a disposer + 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 { /* 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); - ctx.menubar.setContent(buildPopoverContent()).catch(() => {}); - }); + // Update badge AND popover content as queue changes + unsubscribeQueue = state.subscribe(() => { + const count = state.getQueue().length; + void ctx.menubar.setBadge(count > 0 ? count : 0); + ctx.menubar.setContent(buildPopoverContent()).catch(() => {}); + }); + } catch (err) { + unsubscribed = true; + if (clickToken !== undefined) ctx.events.unsubscribe(clickToken); + unsubscribeQueue?.(); + await ctx.menubar.remove().catch(() => { /* best effort */ }); + throw err; + } return () => { unsubscribed = true; - ctx.events.unsubscribe(clickToken); - unsubscribeQueue(); + if (clickToken !== undefined) ctx.events.unsubscribe(clickToken); + unsubscribeQueue?.(); ctx.menubar.remove().catch(() => { /* ignore */ }); }; } ``` -**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 (as the example above does) so the menu bar +doesn't leak a dangling item — a rejected init means the caller never +receives the disposer, so nothing else will ever clean it up. -## 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 +677,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 +700,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 +730,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 +782,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 +833,10 @@ 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 +beats `npx esbuild ...`: cleaner watch mode, works across platforms. -## 14. tsconfig (mandatory flags) +## 18. tsconfig (mandatory flags) ```json { @@ -488,16 +851,89 @@ if (isWatch) { "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, - "lib": ["ES2020", "DOM"] + "types": [], + "lib": ["ES2022"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } ``` -**`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** — without it TypeScript +emits runtime `require`/`import` calls for the type-only +`@appos.space/plugin-types` and the plugin silently fails to activate. -## 15. Deploy (rsync with --delete-excluded) +**`lib` has no `DOM`** — JSC has no `document`/`window`, no browser +`fetch` (use `ctx.network.fetch`), no guaranteed timers, and `URL` only on +hosts that inject it (AppOS 1.1.0+). +**`types` is pinned `[]`** so a later `@types/node` install cannot inject +`process`/Node timer globals that typecheck yet throw in JSC. Ship a +`src/jsc-globals.ts` `declare global` module (matched by +`"include": ["src/**/*.ts"]`) declaring the runtime's real globals: + +```ts +// src/jsc-globals.ts — JSC plugin-runtime ambient globals. A `declare +// global` .ts MODULE, not a .d.ts: skipLibCheck skips every .d.ts (even +// project-owned), so corruption silently degrades to error-`any`; a +// .ts module is always checked. JSC ships a native console; the host +// injects NO timers — `| undefined` typing makes an unguarded +// setTimeout(...) a TS2722 error while a +// `typeof setTimeout === 'function'`-narrowed call compiles. +export {}; + +declare global { + var console: { + log(...args: unknown[]): void; + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; + debug(...args: unknown[]): void; + trace(...args: unknown[]): void; + }; + var setTimeout: ((handler: (...args: unknown[]) => void, timeout?: number, ...args: unknown[]) => number) | undefined; + var clearTimeout: ((id: number | undefined) => void) | undefined; + var setInterval: ((handler: (...args: unknown[]) => void, timeout?: number, ...args: unknown[]) => number) | undefined; + var clearInterval: ((id: number | undefined) => void) | undefined; + // URL — hosts 1.1.0+ inject a Foundation-bridged URL (immutable v1 subset; + // searchParams THROWS — parse url.search manually). Typed `| undefined` + // (older hosts / kill switch / menu-bar contexts lack it): unguarded + // `new URL(...)` is a TS18048 error; a `typeof URL === 'function'` guard + // compiles (usage: §24). Same surface as the SDK 3.0.1+ opt-in + // `@appos.space/plugin-types/globals` subpath — on a >=3.0.1 pin you may + // set tsconfig `types` to that subpath and DELETE this URL block + // (keeping both double-declares URL). + interface URL { + readonly href: string; + readonly protocol: string; + readonly hostname: string; + readonly host: string; + readonly port: string; + readonly pathname: string; + readonly search: string; + readonly hash: string; + readonly origin: string; + readonly username: string; + readonly password: string; + toString(): string; + toJSON(): string; + } + interface URLConstructor { + new (url: string | URL, base?: string | URL): URL; + canParse(url: string | URL, base?: string | URL): boolean; + readonly prototype: URL; + } + var URL: URLConstructor | undefined; +} +``` + +Browser globals in `src/` now fail typecheck (`document` → TS2584, +`window`/`fetch` → TS2304), unguarded `setTimeout(...)` / `new URL(...)` +fail (TS2722 / TS18048), and `typeof`-guarded calls compile (guarded URL +usage: §24). DOM belongs only in the WebView-side `tsconfig.webview.json` +(`webview-panels` skill; `skipLibCheck: false` keeps the +project-owned `webview/twopanez.d.ts` checked). + +## 19. Deploy (rsync with --delete-excluded) ```bash rsync -av --delete --delete-excluded \ @@ -520,9 +956,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 +970,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 +984,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 @@ -567,32 +1012,43 @@ 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) => { + void panelToken; // typed as string, but undefined on the 1.0.0 host — see pattern 6 + // Host removes panel + handlers on unload; `disposed` handles mid-life + // teardown. Do NOT push ctx.ui.unregister(panelToken) — that is + // unregister(undefined) on the 1.0.0 host and may throw (pattern 6). + disposables.push(() => { disposed = true; }); + + // SECURITY: messages are SEMANTIC intents, never shell-shaped — the + // plugin hardcodes command + argv per intent. NEVER forward a command + // or argv array from webview input into ctx.shell.execute. + 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' }; } @@ -605,9 +1061,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; @@ -616,8 +1076,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', { @@ -665,55 +1125,48 @@ async function deactivate(): Promise { ### webview/main/styles.css ```css +/* Host-injected design tokens — update live on theme change */ body { margin: 0; padding: 16px; background-color: var(--twopanez-bg); color: var(--twopanez-text); - font-family: -apple-system, BlinkMacSystemFont, sans-serif; } #output { background: var(--twopanez-bg-surface); - border-radius: 8px; - padding: 12px; font-family: 'SF Mono', monospace; - font-size: 12px; white-space: pre-wrap; - min-height: 200px; - overflow-y: auto; } button { background: var(--twopanez-accent); color: var(--twopanez-bg); - border: none; - border-radius: 6px; - padding: 8px 16px; - margin-top: 12px; - cursor: pointer; } ``` ### webview/main/app.js -```js +```js webview +// Strict checkJs-clean; requires webview/twopanez.d.ts (extension-api.md). +/** @typedef {{ v: number, type: string, [key: string]: unknown }} ProtocolMessage */ + const output = document.getElementById('output'); const runBtn = document.getElementById('run'); +if (!output || !runBtn) throw new Error('missing #output/#run'); // Receive protocol messages from plugin via postToWebPanel -window.twopanez.onMessage((msg) => { - if (typeof msg !== 'object' || msg === null || msg.v !== 1) return; +window.twopanez.onMessage((data) => { + const msg = /** @type {Partial | null} */ (data); + if (typeof msg !== 'object' || msg === null || msg.v !== 1 || typeof msg.type !== 'string') return; if (msg.type === 'started') output.textContent = ''; - if (msg.type === 'output') output.textContent += msg.data; - if (msg.type === 'finished') { - output.textContent += `\n[exit ${msg.exitCode}]`; - } + if (msg.type === 'output' && typeof msg.data === 'string') output.textContent += msg.data; + if (msg.type === 'finished') output.textContent += `\n[exit ${msg.exitCode}]`; }); 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 @@ -725,19 +1178,33 @@ 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 `