AIRI desktop could load Extensions that were already present on disk, but users could not safely add an Extension folder. This PR completes Extension Manifest v2 and adds a review-first folder import flow. An imported Extension remains disabled and unloaded until the user explicitly enables and loads it. The design was informed by the incomplete `claude-code-plugin` experiment and the Agent Activity Extension work tracked in #2507. ## User flow 1. Select an Extension folder in Plugin Host Devtools. 2. Review its identity, version, runtime, entrypoints, permissions, Kit declarations, file count, and total size. 3. Confirm the import. 4. Enable and load the Extension when it is ready to run. 5. Disable and unload it to stop its runtime session. ## What changed ### Extension Manifest v2 - Add a strict schema for identity, version, AIRI compatibility, runtimes, entrypoints, permissions, and Kit declarations. - Reject unknown fields, duplicate runtime or Kit declarations, unsafe Extension ids, missing entrypoints, and invalid semantic versions. - Check `engines.airi` and the current runtime before importing an Extension entrypoint. - Keep Plugin Host transfer types in the runtime-neutral `@proj-airi/stage-shared/plugin-host` boundary. ### Folder import - Add separate prepare and commit steps so package code cannot run during review. - Bind the reviewed manifest to a package fingerprint and revalidate the source before and after copying it. - Reject symbolic links, absolute entrypoints, entrypoints outside the package, duplicate Extension ids, and source changes after review. - Limit packages to 10,000 entries and 512 MiB and manifests to 1 MiB. Hash and copy file data with streams. - Copy into `.imports` staging and publish the package with an atomic rename. - Record the validated committed entry directly in the registry after a successful import. - Retain the macOS security-scoped bookmark from folder selection through commit. - Wait for an active import during Host shutdown so no managed-directory write outlives the Host. ### Desktop controls - Add Devtools actions for importing an Extension folder. - Add combined **Enable and load** and **Disable and unload** actions that model real user behavior. - Add English and Simplified Chinese strings for the new controls and import review. ## Scope This PR defines Kit declarations as manifest data. Extension-hosted Kit routing, Provider and Consumer lifecycle, revocable clients, and dependency activation order are isolated in draft PR #2549. ## Verification - GitHub CI passed: unit tests, type checks, lint, provenance checks, and all configured builds. - Focused Plugin SDK core, directory importer, and Electron Plugin Host tests: 86 passed. - Stage UI Plugin Host store tests: 8 passed. - `pnpm typecheck`: 52 workspace tasks passed. - `pnpm lint`: passed with 646 existing repository warnings and no errors. - `sem diff --staged --no-cosmetics -v --file-exts .ts .tsx`: the final shutdown batch analyzed 113 entities; its JSON report contained no diagnostics. - The importer shutdown regression failed before the fix and passed after it. ## Visual change | Before | After | |---|---| | Extension folder import was unavailable. |  |
@proj-airi/plugin-sdk
Runtime-agnostic SDK for AIRI extensions.
Extension Manifest
Each installable Extension package has an extension.airi.json file at its root. The current Host accepts only Manifest v2:
{
"manifestVersion": 2,
"kind": "manifest.extension.airi.moeru.ai",
"id": "example-extension",
"version": "1.0.0",
"engines": {
"airi": "*",
"runtimes": ["electron"]
},
"entrypoints": {
"electron": "./extension.mjs"
},
"permissions": {},
"kits": {
"uses": [
{
"id": "dev.airi.example",
"version": "1.0.0",
"optional": true
}
]
}
}
Manifest parsing is strict. Unknown fields, unsafe ids, empty entrypoints, and missing runtime entrypoints fail validation. Kit declarations use exact semantic versions. The manifest owns the Extension version used by the Host session. defineExtension(...) owns runtime setup and must use the same Extension id.
Kit API Naming
Kits should hide transport details from extension authors. A normal extension should use a kit as a normal API object directly from setup:
const gamelets = await ctx.kits.use(gameletKit)
await gamelets.mount(input)
Explicit module scopes are an advanced lifecycle and attribution API. Use module.kits.use(...) only when the host needs a contribution to be associated with a sub-scope that may later be inspected, disposed, or restarted independently.
When a kit needs to work across process or network boundaries, expose shared Eventa invoke contracts from the kit package and build the client from those contracts. Do not introduce kit-specific transport method names such as invokeGamelet, gameletRpc, or gameletRuntime.
Use these names consistently:
| Name | Meaning |
|---|---|
gameletKitApis |
Shared Eventa API contract exported by the kit package. This is usually a map of defineInvokeEventa(...) entries. |
gameletKitService |
Host-side implementation of the kit behavior. It owns real side effects such as mounting, updating, and cleaning up UI. |
gameletKit |
The kit definition consumed by ctx.kits.use(...) or an optional module scope. It owns identity, version, availability policy, and client creation. |
gamelets |
The client instance returned to extension authors. Prefer a plural namespace when the client exposes multiple operations. |
createGameletKit(...) |
Factory that wires dependencies into gameletKit, including local client creation and remote Eventa-backed client creation. |
Example shape:
export const gameletKitApis = {
mount: defineInvokeEventa<GameletMountResult, GameletMountInput>(
'airi:kit:gamelet:mount',
),
}
export interface GameletKitService {
mount: (input: GameletMountInput, scope: KitCallScope) => Promise<GameletMountResult>
}
export function createGameletKit(options: { service: GameletKitService }) {
return defineKit<GameletClient>({
id: 'kit.gamelet',
version: '1.0.0',
createClient(runtime) {
return {
mount: input => options.service.mount(input, runtime),
}
},
})
}
Remote clients should reuse Eventa invoke instead of defining a parallel RPC protocol. Use a lazy context callback when the underlying transport can reconnect or be created after the client object:
const mount = defineInvoke(getContext, gameletKitApis.mount)
const gamelets = {
mount(input: GameletMountInput) {
return mount(input, scope)
},
}
The shared artifact is the Eventa API contract, not the implementation function. Local clients may call gameletKitService directly; remote clients call the same API through Eventa. Both should expose the same authoring shape.