Skip to content

RFC: unified extension contribution kernel (contributions × slots × kinds) #82

Description

@ashwin-pc

Status: design RFC with a working prototype. The panel slot (FAB action + shared right panel + POST /api/web-panel/invoke) and a global-notepad extension exercising it are implemented and validated locally (unit + e2e + live browser). An audit of every known extension (repo examples + installed) confirmed all of them use either pi-core APIs, the settings platform, or the five set* APIs — all preserved under this proposal.

RFC: pi-web extension architecture — contributions, slots, and kinds

Status: draft for review · Scope: pi-web browser-facing extensibility

1. Motivation

pi-web currently exposes five browser contribution APIs (setFooter, setHeaderAction,
setArtifactAction, setGitTab, setPanel). Each one re-implements the same six layers:
an API method, a per-session registry, a serializer + wire event, a snapshot field +
frontend reducer, an invoke endpoint + sanitizer, and (for interactive surfaces) its own
data-* callback dialect. Adding surface #6 (setPanel) touched seven files whose content
was structurally identical to the previous five. Cost per new surface is O(n) bespoke code
for O(1) novelty. Interactive and asset-serving extensions (ECharts, live dashboards,
webview editors) would each add more bespoke paths under the current model.

2. Tenets

  1. One kernel, surfaces as data. Registry, wire protocol, invoke/asset endpoints and
    sanitization are written once. New capability = new slot or new kind, never a new
    bespoke API.
  2. Capability kinds, lowest-kind-wins. Extensions start declarative and upgrade only
    the pieces that need richness. Documentation presents a default path with upgrade
    triggers, not a matrix.
  3. Extension code never runs in the main app DOM. Server-side logic talks through
    envelopes; rich client code (webviews) runs in a sandboxed iframe behind a tiny,
    frozen bridge. This protects core refactorability as much as security.
  4. Graceful degradation. Slots are strings; descriptors are versioned; unknown
    slot/kind/field = ignored, never an error surfaced to the user.

3. Core concepts

  • Contribution — the unit an extension registers: a descriptor plus optional content.
  • Slot — a named surface in pi-web that hosts contributions (footer,
    header-action, artifact-action, git-tab, panel, …). Slot hosts are the only
    legitimately bespoke frontend code.
  • Kind — how a contribution delivers content: static, rendered, or webview.
  • Facilities — orthogonal services any contribution can use: assets (files served
    at stable URLs) and invalidation (server-initiated refresh).

4. Extension-facing API

ctx.ui.web.contribute(key: string, spec: Contribution | undefined): void;
ctx.ui.web.update(key: string): void;   // invalidation: mark stale, hosts re-fetch

type Contribution = {
  slot?: string;                       // omit for pure asset bundles
  // kind is inferred: view → static, render → rendered, entry → webview
  title?: string; label?: string; icon?: string;
  match?: { kinds?: string[]; extensions?: string[] };   // slot-interpreted filters

  view?: View;                                            // kind: static
  render?: (event?: Event) => View | Promise<View>;       // kind: rendered
  entry?: string;                                         // kind: webview (asset key of iframe entry)
  onMessage?: (message: unknown, reply: (m: unknown) => void) => void; // webview bridge

  assets?: Record<string, string>;     // fileName → absolute path, served at
                                       // /api/web-assets/<key>/<fileName>
};

Legacy APIs (setFooter, setHeaderAction, setArtifactAction, setGitTab,
setPanel) become one-line wrappers over contribute() and remain indefinitely as the
typed, documented convenience layer. registerSettings stays a separate platform
(persistence + validation is a different problem than rendering).

5. Envelopes

One interaction vocabulary shared by every slot:

type Event = {
  action?: string;      // from data-web-action on the clicked element / submitted form
  payload?: unknown;    // extension-authored JSON from data-web-payload
  fields?: Record<string, string | string[]>;  // serialized form controls
  context?: Record<string, unknown>;           // slot-supplied (repo, artifact, …)
};

type View = {
  title?: string;
  html?: string;              // full surface re-render
  markdown?: string;          // rendered popover/card
  message?: string;           // plain-text acknowledgement
  composerContext?: { id?: string; label: string; title?: string; content: string };
  download?: { filename?: string };
};

render(undefined) is the initial render. Which View fields a slot accepts is defined
by the slot policy table (§7), enforced by one shared sanitizer with per-slot budgets.
One action grammar everywhere: data-web-action, data-web-payload, plus HTML form
serialization. Existing dialects (data-web-git-tab-action, data-web-panel-action)
are accepted as aliases during migration.

6. Kinds

Kind Delivery Client code Use when
static descriptor + view pushed in session snapshots none footers, labels, badges
rendered POST /api/web-contributions/invoke, Event → View none (server-rendered) forms, lists, planners, dashboards
webview sandboxed iframe loading extension assets; postMessage bridge extension-authored JS canvas, drag-drop, terminals, editors

Facilities compose with kinds:

  • Assets: any contribution may carry assets; a contribution with only assets and
    no slot is a pure library bundle (e.g. ECharts for HTML artifacts). URLs are stable
    (/api/web-assets/<key>/<file>), cache-friendly, and served while any live session
    registers them. Auth must align with artifact-preview serving (sandboxed opaque-origin
    iframes cannot attach bearer tokens); this is a deliberate security decision.
  • Invalidation: update(key) emits web_contribution_updated; hosts re-invoke
    render() (rendered) or deliver a refresh message (webview). Stateless pull-after-push;
    no streaming protocol.

Webview bridge (deliberately starved)

Client API acquired inside the iframe:

const api = acquirePiWebApi();   // { postMessage, onMessage, getState, setState, version }

No DOM access to the host app, no core UI hooks, no theming API in v1. The bridge is the
forever-contract; small contracts age well. CSP locked to the extension's own asset
namespace. Extension-side handler: onMessage.

7. Slot catalog v1

Slot Kinds allowed Event.context View fields allowed Budget
footer static text lines / html 20 KB
header-action rendered markdown 200 KB
artifact-action rendered artifact {name, path, kind} markdown, message, download 200 KB
git-tab rendered repo {path, root, branch} html, composerContext 500 KB
panel (FAB + right panel) rendered, webview html 500 KB

Slot hosts own presentation (FAB fan geometry, drawer tabs, popovers). Adding a slot =
one frontend host + one policy row.

8. Wire protocol

  • Session snapshots and state_changed carry
    webContributions: Array<{ key, slot, kind, title, label, icon, match }>.
  • Delta event: web_contributions_changed (same payload, session-scoped).
  • Invalidation event: web_contribution_updated { sessionId, key }.
  • Interaction: POST /api/web-contributions/invoke { sessionId, key, event } → sanitized View.
  • Webview messaging: POST /api/web-contributions/message { sessionId, key, message }
    upstream; downstream messages ride the websocket envelope.
  • Assets: GET /api/web-assets/<key>/<path>.

9. Lifecycle

Contributions are session-scoped and auto-released on session_shutdown (explicit
contribute(key, undefined) remains available). Asset bundles remain served while any
live session registers the same key; asset URLs embed no session identity so artifacts
keep working across sessions. Webview client state (getState/setState) persists per
session.

10. Security & trust

  • Extensions are trusted local code (they already run with full user permissions
    server-side). Rendered HTML is trusted in the app DOM, size-budgeted and
    control-character-sanitized — same model as today's footer/git-tab HTML.
  • Webviews are sandboxed iframes with locked CSP; extension JS never runs in the app DOM.
  • Install-time consent (via pi packages) should name the capability tier; a webview
    extension is a visibly bigger grant than a footer.

11. Migration & phasing

Phase Deliverable Proof
1 Kernel consolidation behind existing APIs (behavior-preserving; net core LOC ↓) existing five surfaces keep passing current unit + e2e suites
2 contribute() public + assets facility ECharts artifact extension
3 Invalidation (update) notepad live across sessions; orchestrator status board
4 Webview kind + versioned bridge chart editor / kanban notepad panel
5 Distribution via pi packages, engines-style compat, dev harness third-party extension end-to-end

12. Governance (complexity containment)

  • New slot — routine PR (host + policy row).
  • New View field — design review.
  • New kind — RFC with a named exemplar extension that provably cannot be built one
    kind down. No exemplar, no kind.
  • Expectation (VS Code power law): the vast majority of contributions stay
    static/rendered; webview remains the rare, expensive path.

13. Exemplars

  • Notepad (rendered panel): structured day-planner with provenance; validates
    Event→View, forms, dedup, settings toggle. Implemented and validated.
  • ECharts (pure assets + skill): agent-authored interactive charts in HTML
    artifacts; validates the assets facility and artifact-auth alignment.
  • Orchestrator board (rendered panel + invalidation): live worker status.
  • Chart/kanban editor (webview panel): validates the bridge; sequenced last.

14. Out of scope

  • pi core extension model (tools, commands, events, skills, packages) — already generic,
    owned upstream.
  • Settings persistence platform — separate, already schema-driven.
  • Artifact preview pipeline — already an isolated, script-capable surface; assets plug
    into it rather than replacing it.

15. Open questions

  1. Asset lifetime guarantees after uninstall (artifacts referencing a removed library).
  2. Whether git-tab should eventually be re-expressed as a panel variant.
  3. Multi-panel policy (today: one shared extension panel active per side).
  4. Cross-extension composition (global command indirection à la VS Code) — deferred until
    two real extensions need to invoke each other.
  5. FAB/launcher coupling: today a panel contribution implicitly provides its FAB
    launcher entry (the FAB is the panel slot host's affordance). Should a standalone
    launcher-action slot exist for FAB entries that fire a verb without opening a
    panel? Under the kernel this is a routine new slot; deferred until a real extension
    needs it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions