pi-mono/apps/p2/docs/plugin-hooks.md
Armin Ronacher dd8e0df4a9 feat: prompt cache warming in p2
p2 never kept prompt caches warm: every request after more than five quiet
minutes rewrote the whole prompt (about a quarter of p2's spend in the
sessions analyzed). The new cache-warming plugin does what pi-coding-agent's
cache warmer does, per conversation.

- pi-ai: the warming policy (TTL, refresh delay, replayability, refresh
  options, cost decision and constants) moves from pi-coding-agent's
  cache warmer to shared helpers in pi-ai; pi-coding-agent uses them.
- pi-durable: afterResponse gets the request the attempt sent
  (GenerationResponse { message, request }), so a hook can repeat it;
  recordUsage is exported.
- p2: plugins/cache-warming registers a hook-only Durable extension,
  keeps the worker alive while warming (liveness kind cacheWarming), and
  adds the cacheWarming setting (idle by default, streaming, off).
2026-11-05 23:45:49 +01:00

37 KiB

Plugin hooks

Outdated: describes p2 on Chord v1 (before the move to Chord v2; Chord v1 is deleted, so its APIs such as self.register and objects of lifetime "host", and the packages/chord/src/old paths cited below, no longer exist). The registries and hooks are largely the same on v2; how p2 uses Chord v2: p2-notes.md Chord v2.

Reference of everything a p2 plugin could hook into, per facet. Paths are relative to apps/p2/ unless they start with packages/. "Owner/guest" says which principals reach a hook's effects; facet code itself is trusted code that the server or the visitor's process runs.

How plugins are loaded (and the planned single pipeline) is in plugin-loading.md. How to write one, step by step: Chord's tutorial. TUI design: p2-tui-port.md §6-7.

1. Cross-cutting

1.1 Facets and how they load

Facet File Loaded by Runs as
Server plugins/<name>/server.ts or server.tsx Chord nodeLoader via PluginRuntime.loader (src/hosts/plugins.ts), alphabetical order server host, host.name === "server"
Worker plugins/<name>/worker.ts or worker.tsx same, in every session worker, from the list in its init (src/hosts/worker.ts) session:<id> host
Web plugins/<name>/web.ts or web.tsx buildWebBundle bundles selected facets; startWeb installs them (src/hosts/bundles.ts, src/hosts/web/main.tsx) browser client host
TUI plugins/<name>/tui.ts or tui.tsx buildTuiBundle bundles selected facets; setupTui installs them with the core facet (src/hosts/bundles.ts, src/hosts/tui-worker-runtime.ts) locked-down worker on the visitor's machine

A facet is defineFacet((host) => …). There is no manifest and no derived dependency list: a plugin needs what it holds, resolved by contract name at runtime (docs/plugin-loading.md 4.2).

1.2 contribute() and registries

contribute(host, name, register) (src/runtime/contribute.ts:10) serves a local object of lifetime "host" whose factory only calls self.register(Registry, { key: value }). Registrations live exactly as long as that object: a reload replaces them, removing the plugin removes them. Only objects of lifetime "host" may register (packages/chord/src/old/object.ts).

export default defineFacet((host) =>
	contribute(host, "p2.web-envs", (self) => self.register(Slots, { "sidebar-foot:envs": EnvsButton })),
);

A plugin that needs state, resources or other objects serves its own object of lifetime "host" and registers from its factory (e.g. plugins/codemode/worker.ts:39-60, plugins/terminals/server.ts:10-30).

Registries are plain descriptors (defineRegistry, packages/chord/src/old/registry.ts:11):

  • Conflict mode. "error" (default) or "last". Every p2 registry uses "error"; TuiSlots and SessionArchived get it by default. Two objects registering one key make the table build throw registry_conflict (packages/chord/src/old/host.ts:608-623), which refuses the load or reload. "last" is unused.
  • Order. A table iterates in host-wide first-registration order of its keys, stable across reloads (packages/chord/src/old/host.ts:625-638). "Ordered" registries sort keys instead: ordered() with numeric-aware compare and <order>:<plugin> keys (src/runtime/tui-renderers.ts:48), ComposerExpanders by key (src/runtime/composer.ts:10), TUI prefix slots by key (src/runtime/tui.ts:266-271). Web SlotList uses registration order (src/runtime/web.tsx:77-88).
  • Where defined. Runtime registries live in src/runtime/*; plugins define their own in contract.ts (SendRouters, TranscriptEntries in plugins/chat/contract.ts:256,270; SessionArchived in plugins/sessions/contract.ts:58). Using one imports that plugin, i.e. depends on it.
  • Reading. host.registry(R).table(ctx) / .get(key, ctx) inside calls, .subscribe(fn) for live tables; web: useRegistry(R) (src/runtime/web.tsx:52).

1.3 Contracts and references between facets

  • local(name, …) types are host-internal (only facets of the same host get them); remote(name, …) types are reachable over links, with one variant per principal chosen by the factory (e.g. Chat viewer/participant, plugins/chat/contract.ts:195-243). A factory that throws forbidden() denies.
  • Placement: every remote contract says where it lives, host: serverHost or host: sessionHost (src/runtime/chord.ts; sessionHost places an object on session: + its input's session). Callers never name a host: a get goes where the contract places it, and the server routes session: to the worker, spawning it. Web and TUI hold server and worker objects; workers hold server objects with a worker principal.
  • References: web useObject (re-gets after gone, src/runtime/web.tsx:139); TUI TuiScope.watch (backoff, stops on permanent refusals, src/runtime/tui.ts:77-146); backend self.get (as a resource dependency) or ctx.get inside calls; code outside Chord calls (Durable tools) uses host.root.

1.4 Principals

src/runtime/principals.ts: P2Principal is owner (access: "local" | "relay" | "device", device with "device"), guest (one session, mode: "view" | "full", grant revision), worker (session + generation, stamped by the server route), server (the server's own requests on a worker) and host (a host's own code). Plugins import defineFacet, Ctx, Host and friends from src/runtime/chord.ts, Chord's API typed by P2Principal, so factories narrow on principal.kind. Helpers: isOwner, isGuest, isWorker, canView, canSend, authorIdOf, authorOf. mayReach (src/runtime/server.ts:158) adds SessionAccess.

Who gets which facet code: web and TUI facets are the same for owners and guests (both receive the selected facets' bundles; src/hosts/web.ts, src/hosts/server.ts). UI gating (useOwner(), TUI when: "owner") is cosmetic; contracts and factories are the enforcement.

1.5 Hot reload per facet

Facet Trigger What happens
Server explicit only: Reload button, npm run reload, reload_plugins (dev mode; docs/p2-notes.md "Dev mode") Chord cluster transaction with every running worker: compatible changes keep state and resources; registrations swap atomically; contract/state changes need versions or migrations; a registry_conflict or failure keeps the old code. src/runtime, src/hosts need a restart. No reload without P2_DEV=1.
Worker same transaction as server; new workers spawn with the committed list. A Durable phase keeps the extension snapshot it started with (src/runtime/durable.ts:35-41); ExtensionSwitches changes apply to the next phase.
Web backend reload builds a new content-named bundle; a visible page polls /__p2_web every 5 seconds Facets are installed once per page (startWeb, src/hosts/web/main.tsx); any manifest-version change reloads the page. Foregrounding checks immediately; at most two previous generations remain available for up to 60 seconds.
TUI backend reload builds a new bundle; the launcher polls the hash every second; /reload Whole worker swap carrying bounded UI state (docs/p2-notes.md "TUI"). Within one worker: a TuiCommands change closes overlays (src/runtime/tui.ts:461-465); a changed TuiSlots value disposes and recreates that surface (:471-485).

2. Server facets

2.1 Objects the server host serves

Contract Kind What Pointer
Server local, lifetime "host" info (dataDir, fake, allowedHosts, port), revokeOwners, revokeGuests, test resourceOptions src/runtime/server.ts:44
Hosts local, lifetime "host" workers (per-session running/generation/pid/error), stop :185
HostLifecycle local manager (opaque SessionHosts: pause, relocate, retry, stop) :194, :228-493
Access remote caller is owner, or guest with its grant :535, src/runtime/access.ts:14
TuiPreferences remote server defaults for TUI keys/theme/settings (tui.json) src/runtime/tui-preferences.ts:52

2.2 Registries

Registry Key → value Consulted Used by Notes
Routes "METHOD /path" or "METHOD /prefix/*" → (req, res, {principal, origin}, ctx) after host paths, longest prefix wins (src/hosts/server.ts:459, src/runtime/server.ts:124) files, artifacts, workspace, sharing, devices Handler authorizes itself; principal may be undefined (stranger), owner, guest.
Upgrades exact path → (req, socket, head, principal, ctx) WebSocket upgrades other than /chord (src/hosts/server.ts) terminals /terminal (plugins/terminals/server.ts:22) Only authenticated principals reach it.
Authenticators name → (req, origin, ctx) => principal every request; first owner wins, else first principal (src/hosts/server.ts:343-355) devices (owner cookie), sharing (guest cookie)
RequestResolvers name → (req, ctx) => {principal, origin} before Host checks (src/hosts/server.ts:357) radius, devices Trusted transports only.
SessionAccess name → (principal, session, ctx) => boolean mayReach: worker routes, files (src/runtime/server.ts:158-165) sharing (plugins/sharing/server.ts:332) Any yes admits a non-owner.
WorkerInitValues key → (session, ctx) => value before each worker spawn; a throw refuses it (src/runtime/server.ts:297-303) sessions (plugins/sessions/server.ts:103, key session) Worker reads WorkerProcess.init.values[key].
SessionArchived name → (session, ctx) => Promise awaited before archive returns (plugins/sessions/server.ts:69) ports (plugins/ports/server.ts:42) Defined by the sessions plugin. No restore/delete/create counterparts.
// plugins/sharing/server.ts:326
self.register(Authenticators, {
	guest: async (request, origin, ctx) => {
		const cookie = cookieValue(request, cookieName(origin.port));
		return cookie === undefined ? undefined : ((await shares.authenticate({ cookie }, ctx)) ?? undefined);
	},
});

Reserved host endpoints are /health, /__p2_dev, /__p2_tui[/<hash>.mjs] and the /chord WebSocket upgrade (src/hosts/server.ts). Plugin Routes run before the web host, so they can shadow app pages, /__p2_web[/…] and public/ files.

2.3 Dev, logs, notices

  • Logs: processLogs (src/runtime/logs.ts:196) is a process global: append(source, level, text), notice(source, message) (error-level owner notice, one per source, :157-164), redactor.addSecret/addDirectory (models, login, radius use it), persistLogSource/capturePublishedLogs for detached processes (terminals). Worker stderr lines starting p2 worker: become notices (src/runtime/server.ts:335-343).
  • Notices: surfaced by the logs plugin's owner-only LogNotices (plugins/logs/contract.ts:24), rendered by the core web lane (src/web/OwnerNotices.tsx:30-31) and the logs TUI facet. There is no tone, action or dismiss API for plugins: only processLogs.notice (errors).
  • Dev: no registry. The dev channel is host code (src/hosts/dev-channel.ts); the dev-tools worker plugin imports it directly (plugins/dev-tools/worker.ts:5). There is no "after reload" hook: resources with a version and Chord's per-cell migrations carry state across reloads.

3. Worker facets

3.1 Durable extensions (Extensions)

Extensions (src/runtime/worker.ts:57): key = extension name, value = a Durable Extension (packages/durable/src/harness/types.ts:365-374). Every conversation selects every installed extension unless its pi.agent.extensions says otherwise (packages/durable/src/harness/types.ts:530-532); the Harness reads the registry through durableRegistry (src/runtime/durable.ts:41).

Part Field What p2 users
Tools tools (defineTool, types.ts:293) parameters, execute(args, api, ctx) returning output/structuredOutput/details; replay: "safe" | "unsafe" (rerun after a crash, :298); callers: ["model" | "tools"] (:317); structuredOutputSchema (:311); deferred (:322); executionMode, prepareArguments, outputLimits; api.executeTool for nested calls (:281) coding-tools, artifacts, send-file, ports, conversations, sessions, subagents, dev-tools, codemode, powershell; agent is callers: ["tools"], replay-safe with a schema (plugins/agent/tool.ts:53-57)
Prompt sections (section(key, render, {tag}), types.ts:349) rendered before each request, in extension order prompt (plugins/prompt/worker.ts:185-191), coding-tools, ports, conversations, dev-tools
Hooks hooks: [hook(Task, handlers)] GenerationTask: beforeRequest, afterResponse, onYield, afterTools (types.ts:757); ToolTask: beforeTool, afterTool (:778); CompactionTask: beforeCompact (:795); handlers get HookApi (doc reads, memos, models, :745) cache-warming (afterResponse with the sent request, afterRun)
Wraps wraps: [wrapTool(tool, fn) | wrapSection(section, fn)] (types.ts:360) replace another extension's tool or section where selected none yet
Tasks tasks: [defineTask(…)] durable background work, resolved by name subagents (Anchor, Reporter, plugins/subagents/tools.ts:72,118,473)
// plugins/ports/worker.ts: the tool calls the server's Ports object from outside Chord through host.root
const extension = createPortsExtension(
	(query, origin, context) =>
		host.root((ctx) => ctx.get(Ports, {}).query({ conversation: 1, origin, query }, ctx), {
			signal: context.abortSignal,
		}),
	host.name.slice("session:".length),
);
contribute(host, "p2.port-tools", (self) => self.register(Extensions, { [extension.name]: extension }));

Who: tools run for whoever's input started the turn. Durable knows no principal; a tool declares what it needs (P2Tool: origin: "owner", scope: "main") and the worker's registry checks it before each call (guardTool, originOf over the run's inputs; see p2-notes Tool requirements). Reload: a running phase keeps its snapshot; tool code that must survive reload reads live resources through getters (plugins/codemode/worker.ts:37-43).

3.2 Worker registries and locals

Hook Kind What Consulted Used by
ExtensionSwitches registry: extension name → Readable<boolean> false leaves that extension out of new snapshots src/runtime/durable.ts:31,43-93 codemode (codemode setting), powershell (Windows), dev-tools (dev checkout)
InputGates registry: name → (ctx) => Promise awaited before any input is submitted plugins/chat/worker.ts:320,380,404, plugins/conversations/worker.ts:132 prompt (first AGENTS.md load)
SendRouters registry (chat): name → (RoutedSend, ctx) => {submissionId} | undefined Chat.send asks each; untaken sends become p2.input plugins/chat/worker.ts:571 chat-mode (plugins/chat-mode/worker.ts:134)
ReadOnlyConversations registry: name → (conversation, ctx) => reason? refuses Chat.send/dequeue/configure plugins/chat/worker.ts:514 subagents (one-shot agents)
ConversationConfigured registry: name → (conversation, ctx) after a browser/TUI configure plugins/chat/worker.ts:482 subagents
HarnessConfig registry, key "default" only models + Durable settings src/runtime/worker.ts:332-350,410 models
HarnessEnvironments registry, key "remote" only opens a remote machine (at(cwd) environments, one connection) per environment name src/runtime/worker.ts envs
ConversationLocations local, one provider conversations following the session, own locations (place, own) src/runtime/worker.ts subagents (plugins/subagents/worker.ts)
Harness local, lifetime "host" harness (native Durable Harness), environments (session, locate(id), at(location)) src/runtime/worker.ts most worker plugins
WorkerProcess local, lifetime "host" init (dataDir, generation, values from WorkerInitValues), liveness (add("viewers" | "activeCalls"), stopping) src/runtime/worker.ts workspace (viewers), participants' calls (activeCalls, the relocation fence), codemode (stopping)
Conversation local (chat) submit (input with own entry kind), write (display-only entry), stop, history, compact, reset, runShell… plugins/chat/contract.ts:140-189 chat-mode, conversations
// plugins/chat-mode/worker.ts:134 (shortened)
self.register(SendRouters, {
	"chat-mode": async ({ conversation, author, text, requestId }, ctx) => {
		if (!(await enabled(conversation, ctx))) return undefined; // leave it to the next router / p2.input
		const chat = ctx.get(Conversation, { conversation });
		return chat.write({ kind: CHAT_ENTRY, data: { author, text, attachments: [] }, requestId, author }, ctx);
	},
});

3.3 Entries, documents, environments

  • Entry kinds: Conversation.submit({ …, entry: { kind, data } }) places an input under a plugin kind (plugins/chat/contract.ts:127-128); write appends display-only entries (:150-158). Data that is a person's message (MessageDataSchema, src/runtime/input.ts:144) is shown by both UIs; other model-less kinds are hidden on the web (src/web/transcript.ts:506-512) and need a TuiEntryRenderers entry in the TUI.
  • Documents: defineDoc from @earendil-works/pi-durable; write in harness.commit(tx => tx.doc(Doc, id)), read with harness.snapshot(Doc, id) or in tools/hooks via their API. Conversation docs reach clients in Chat.view.docs. Examples: p2.subagents, p2.agent-run, p2.artifacts, chat-mode's ChatModeDoc (plugins/chat-mode/worker.ts:25). Change signals: CommitSignal (src/runtime/commits.ts:4), watchSource (src/runtime/durable.ts:113).
  • Environments: tools use api.env; plugins use Harness.environment / environments.at(cwd). Only one remote provider (HarnessEnvironments["remote"]).

4. Web facets

4.1 Slots

Slots (src/runtime/web.tsx:49): key → React component. <Slot name> renders one (or its children/fallback), <SlotList prefix> renders every prefix:* key. Every rendering site today:

Slot Rendered at Kind Filled by
app src/hosts/web/main.tsx (startWeb) single web-shell
sidebar src/web/AppLayout.tsx:21 single sessions
main src/web/AppLayout.tsx:60 single chat
workspace-panel src/web/AppLayout.tsx:62 single workspace
artifact-actions, terminal-actions, workspace-actions, session-options, session-actions chat top bar, plugins/chat/web.tsx:327-331 single each artifacts, terminals, workspace, sessions, sharing
composer:* plugins/chat/web.tsx:925 (SlotList) list chat-mode
chat-bottom plugins/chat/web.tsx:934 single terminals
chat-content plugins/chat/web.tsx:1133 wrapper (gets children) artifacts (viewer contexts)
chat-attribution plugins/chat/web.tsx:1134 wrapper conversations
sidebar-foot:providers|envs|devices|ports|logs plugins/sessions/web.tsx:29-37 five fixed Slots, not a list login, envs, devices, ports, logs
envs-actions:* plugins/envs/EnvsPanel.tsx:524 (SlotList) list ports
workspace-terminal src/web/Workspace.tsx:730 single terminals
chat-parent nowhere — subagents registers it (plugins/subagents/web.tsx:22)

4.2 Sheets, navigation, hooks

API Pointer Use
openLayer({kind: "sheet" | "viewer", name, locator?}, {fromDrawer?}), closeLayers() src/runtime/web.tsx:296,302 push a named modal; Back and reload restore it
useSheet(name, kind?) :316 the top layer if it is yours, with its locator
<Sheet title onClose> :91 shared modal (portal, Escape, scroll memory)
useNavigation(), useRoute(), navigate() :275-293, src/web/navigation.ts push/replace/close; never browser history
useObject, useReadable, useStatus :139,183,195 hold objects, read state
useOwner() :325 owner check (UI only)
useConnected(), useShareEnded() :225,247 connection, ended guest share
useHost(), useRegistry() :42,52 raw host and registry tables

Pattern: the plugin's slot component renders its button and, when useSheet(name) matches, its sheet (plugins/envs/web.tsx:6-21).

4.3 Composer, transcript, notices, theme

  • Composer: ComposerExpanders (src/runtime/composer.ts:7): (host, session, text) => text?, asked in key order for text starting with / (plugins/chat/web.tsx:621); prompt registers one (plugins/prompt/composer.ts:10). Chips: composer:* slots. Shared state: useComposerState() (src/web/composer-state.tsx:123: draft, files, addFiles, replyTo, canReply/setCanReply, pinChat). @/Tab completion is core (src/web/file-completion.tsx). No web commands, slash commands or key bindings.
  • Transcript: TranscriptEntries (plugins/chat/contract.ts:270): entry kind → pure (data) => {replyTo?, note?}, adds to a person's message (chat-mode, shared with the TUI). Tool cards are a fixed switch (tool.name) in src/web/Chat.tsx:303-511 (bash, codemode, read, write, subagent, artifact, ports, send_file, edit, generic). React contexts the chat plugin provides to cards (src/web/Chat.tsx:65-77: OpenConversation, OneShotAgents, OpenPeerConversation, WorkingDirectory, OpenWorkspaceFile, SessionId, ReplyTo) are not a plugin API.
  • Owner notices: one core lane (src/web/OwnerNotices.tsx) fed by LogNotices and the dev status; plugins can only publish server errors (2.3).
  • Theme: src/web/theme.ts (system/light/dark, setTheme/subscribeTheme) and CSS in src/web/style.css; plugins import their own CSS (plugins/chat-mode/style.css). No theme contribution.

5. TUI facets

5.1 Slots and surfaces

TuiSlots (src/runtime/tui.ts:50): key → (runtime) => TuiSurface (:32-45: render, dispose, focus, contexts(), layout(), saveState()). tuiFacet(name, factory, commands) (:52) registers one slot plus commands. Every factory is instantiated when registered (:480), rendered or not.

Key Rendered Filled by
a name without : (except header, footer, status) as a screen; also a mutually exclusive when term (:397-401); ctx.navigate(name) sessions, chat
header replaces the built-in header (:254) —
footer replaces the built-in footer (:497) status
footer:* first one only, inside status's footer (plugins/status/tui.ts:153) workspace footer:branch
composer:* chips under the editor, key order (plugins/chat/tui.ts:78) chat-mode
anything else with : (service:*, widget-above:*) never rendered: used as per-runtime background holders logs, models, prompt, sharing

5.2 Commands, keys, slash

TuiCommands (src/runtime/tui-commands.ts:29-49): key = command id (pi ids such as app.model.select where the meaning matches, else <plugin>.<verb>; tui.* and launcher ids are refused, :260). Value: title, category, keys, when, slash: {name, argumentHint, complete, tabOnly}, palette, run(ctx, args).

  • when grammar (:65-113): &&, ||, !, parentheses over terms: screen names, owner/guest, connected, overlay, overlay:<id>, and the active surface's contexts() (src/runtime/tui.ts:403-411). More terms win.
  • Key conflicts, launcher-owned keys, slash collisions are reported in /hotkeys, never thrown (:248-330).
  • Visitors rebind ids in pi's keybindings.json or prefs.json; server defaults in tui.json.
// plugins/artifacts/tui.ts:192
"p2.artifacts": { title: "Artifacts", category: "Session", when: "chat && !overlay",
	slash: { name: "artifacts" }, run: (ctx) => openArtifacts(ctx) },

TuiSlashSources (:57-62): slash names that are not commands (entries(ctx), expand(ctx, text)); commands win. Prompt registers templates and skills (plugins/prompt/tui.ts:87).

5.3 Renderers

Registry Key → value Order Users
TuiToolRenderers tool name → pi ToolRenderers (renderCall, renderResult, renderShell; src/tui/tools/types.ts:15-62) overrides built-ins artifacts, ports, send-file, subagents
TuiToolRendererResolvers <order>:<plugin> → (toolName, next) => renderers key order, then the registry, then built-ins (src/tui/tools/index.ts:20-28) quiet-tools (10:: one line per call, full content when expanded or failed)
TuiSettingFields <plugin>.<name> → /settings row (label, values, fallback), stored at settings.plugins.<plugin>.<name> of the visitor's prefs; read with pluginSetting; resolvers get the settings, a change redraws every card (src/runtime/tui-preferences.ts) after the built-in rows, key order quiet-tools
TuiEntryRenderers entry kind → (entry, ctx) => Component | undefined (replaces the display; undefined hides) — core p2.shell (src/tui/core-commands.ts:290)
TuiMarkdownTransformers <order>:<plugin> → pi MarkdownTransformer key order core 00:mermaid
TranscriptEntries shared with web (4.3) — chat-mode

All in src/runtime/tui-renderers.ts:16-45; consumed by the chat surface (plugins/chat/tui.ts:226-248).

  • TuiSessionSources (src/runtime/tui-sessions.ts:22): children(runtime, onChange) and search(runtime, query, archived) for the session picker (subagents, search). TuiSessionPlaces (:34): where(ctx), moveTo(ctx, env) (sessions; used by envs).
  • Overlays: ctx.overlay(id, component, options) (src/runtime/tui-context.ts:112); overlay-scoped commands run first; closed on navigation, reload and command-table changes.
  • Status lines: ctx.notify(message, kind) (:107), last three, cleared on navigation (src/runtime/tui.ts:258-264).
  • Footer: replace it (footer) or add one footer:* line part; no ordered footer items.
  • Other TuiContext members (src/runtime/tui-context.ts:91-126): host, theme, keys, hints, settings, role, identity, navigate, view (expanded tools, thinking), commands (run, runSlash, palette, list, report), surface(name), exit, reload, prefs (theme names, preview, save via grant). Plugins also reach into the chat surface: ctx.surface("chat") instanceof ChatSurface (plugins/send-file/tui.ts:93, plugins/chat-mode/tui.ts:70).
  • Theme: per runtime, the visitor's (ctx.theme.current); plugins cannot add themes.

5.5 Launcher grants

ctx.grant(action, payload) asks the visitor's launcher (src/hosts/tui-actions.ts:376-700). Only C and gesture actions can be requested; K actions fire on the visitor's own key and may ask the worker for data through runtime.launcherAnswers (src/runtime/tui.ts:197, plugins/chat/tui-commands.ts:139). Anything else is denied (src/hosts/tui-client.ts:226-259).

Action Kind Payload Owner only
attach C {path?} → uploaded file no
copy C {text, label?} no
openUrl C {kind: "external", url} (https) or {kind: "origin", path} no
download C {kind: "file" | "artifact", session, …} no
savePrefs C {patch} for prefs.json no
devCommand C {action: "reload"} yes
signOut C {all} yes
copy.selection gesture selection text after the launcher saw the mouse release no
attach.key, paste.key, copy.key, editor.external, suspend K — (visitor's key) no

6. Inconsistencies

  1. Tool cards. TUI: TuiToolRenderers + ordered resolvers, pi's contract. Web: a hard-coded switch in core (src/web/Chat.tsx:303-511) that knows plugin tools (artifact, ports, send_file, subagent) and imports a plugin contract (src/web/Chat.tsx:12). A new plugin's tool gets the generic JSON card on the web.
  2. Entry display. TUI TuiEntryRenderers replaces an entry's display; web has only TranscriptEntries (adds a reply quote and a note to a person's message) and silently drops other model-less kinds (src/web/transcript.ts:506-512). Core reads plugin documents (view.docs["p2.artifacts"], src/web/transcript.ts:371).
  3. Commands. TUI has one registry for keys, palette, slash and help with a when grammar. Web has none: no slash commands (only ComposerExpanders for /template), no palette, no shortcuts. The same action (compact, model, artifacts, share) is a TuiCommand in the TUI and a hand-placed button in the web.
  4. Same job, two registries. Prompt expansion is ComposerExpanders (web, (host, session, text)) and TuiSlashSources (TUI, (ctx, text) plus entries); the prompt plugin registers both. Composer chips are Slots composer:* (registration order) and TuiSlots composer:* (key order).
  5. Naming. TUI registries are Tui*; web ones are unprefixed (Slots, ComposerExpanders); TranscriptEntries lives in the chat plugin but is used by both UIs; worker SendRouters lives in chat, ReadOnlyConversations in the runtime, SessionArchived in sessions. Ordered registries are marked only by convention in key strings.
  6. Slot lists that are not lists. sidebar-foot:* is five fixed names (plugins/sessions/web.tsx:29-37): a new plugin's sidebar-foot:foo never renders. footer:* in the TUI renders only the first entry. chat-parent is registered and rendered nowhere. Wrapper slots (chat-content, chat-attribution) admit one plugin each.
  7. Slots as lifecycle. TUI plugins register invisible service:* / widget-above:* slots to get a per-runtime object, then find it with ctx.surface(name) (plugins/prompt/tui.ts:74-80, plugins/models/tui.ts:116-118). The designed widget-above/widget-below rendering (docs/p2-tui-port.md §7.2) does not exist.
  8. The chat surface as API. TUI plugins import ChatSurface and call its methods (toolViews, setCanReply); web plugins use useComposerState() from src/web. Neither is a declared contract.
  9. Core knows plugins. Route has workspace/terminal fields (src/web/navigation.ts:20-23); OwnerNotices imports the logs contract (src/web/OwnerNotices.tsx:3); the server reports hasLogs: plugins.includes("logs") (src/hosts/server.ts:424); settings.json has a closed schema with plugin keys such as codemode (src/runtime/settings.ts:63-68); src/web/Workspace.tsx is the workspace plugin's UI in core.
  10. Built-ins use internals. dev-tools imports src/hosts/dev-channel.ts; devices imports src/hosts/principals.ts; sessions and status drive HostLifecycle.manager (opaque SessionHosts); models, login and radius call the process-global processLogs.redactor. Single-key registries (HarnessConfig["default"], HarnessEnvironments["remote"]) and the single-provider ConversationLocations mean only one plugin can ever provide models, remote environments or conversation locations.
  11. Notices. Owner notices are server errors only (processLogs.notice), shown by core web code and by the logs TUI facet; TUI ctx.notify is local; the web has no local notification API for plugins.
  12. Reload. Server/worker reload in place; TUI swaps the worker; web facets take effect through a page reload. Registries behave the same, their consumers do not.

7. What is lacking (compared with pi 1.0 extensions)

Reference: packages/coding-agent/docs/extensions.md.

pi 1.0 p2 today Gap
pi.on() lifecycle: session_start/shutdown, before_agent_start, context, message_end, tool_call/tool_result, turn_end, agent_before_settle, session_before_compact, user_bash, input, provider_stream_event, cache_warming_decision Chord resources for start/shutdown; Durable hooks cover context (beforeRequest), message_end (notify-only afterResponse), tool_call/tool_result, turn_end (afterTools), settle (onYield), compaction; SendRouters covers Chat.send input only No input hook for peer/subagent/agent inputs; no user_bash (shell is hard-coded in chat); no stream events; no whole-prompt override (sections and wrapSection instead). Only cache-warming uses hooks (afterResponse, afterRun); pi's cache_warming_decision has no p2 counterpart.
registerTool with exposure, namespace, annotations, prepareLoadout, reachesTools callers, deferred, replay policy No annotations (no approval policy), no namespaces; tool_search not installed.
registerCommand, registerShortcut TUI only No web commands or shortcuts.
registerFlag none Plugins get configuration only via env vars or their own files; settings.json is closed.
registerProvider, registerVirtualModel models.json and HarnessConfig["default"] owned by the models plugin No provider or virtual-model contribution.
registerMcpServer none (open item in docs/handoff.md) No MCP.
sendUserMessage, sendMessage, appendEntry Conversation.submit / write (worker), Chat.send (clients) Equivalent.
setActiveTools, model and thinking control configure on Durable pi.agent; ExtensionSwitches Equivalent, per conversation.
registerToolRenderer, message renderers TUI only Web has neither (6.1, 6.2).
ctx.ui dialogs (confirm, select, input), widgets, status, title TUI overlays and status lines inside the TUI; nothing a tool can ask A tool cannot ask the person (ports asks the model to obtain confirmation in chat).
pi.events between extensions Chord contracts and registries Stronger in p2.
packages with skills, prompts, themes built-in plugins only Designed in plugin-loading.md.
themes TUI: visitor's own; web: fixed CSS No plugin themes.

8. Proposals

Ordered by value; each keeps one name and one shape across surfaces.

  1. Tool and entry renderers on the web. Add WebToolRenderers (tool name → { card(tool, ctx) }) and WebToolRendererResolvers (<order>:<plugin>, next()), mirroring the TUI pair, and WebEntryRenderers (entry kind → component). Move each case of src/web/Chat.tsx:303-511 into its plugin (coding-tools: bash, read, write, edit; codemode; subagents; artifacts; ports; send-file). Pass today's React contexts as one documented CardContext. Core then reads no plugin documents or contracts.
  2. One command model. Move the command shape (id, title, category, when, slash, keys) to a surface-neutral module with run(ctx) where ctx has the shared part (host, identity, role, navigate, notify), and keep TuiCommands plus a new WebCommands that share ids (app.session.compact on both). The web gets slash commands in the composer, a palette and shortcuts from the same entries.
  3. One slash source. Replace ComposerExpanders and TuiSlashSources with SlashSources (entries(identity), expand(identity, text)) used by both composers.
  4. Consistent naming and order. Prefix per surface (WebSlots, WebComposerExpanders → or shared names when shared); give registries an explicit ordered: true option (or an order field in values) instead of key conventions; make SlotList key-ordered like the TUI. Move shared registries (TranscriptEntries) to src/runtime or a neutral contract module.
  5. Real lists. sidebar-foot:* and TUI footer:* as ordered lists; delete or render chat-parent; make wrapper slots chains (chat-content:*).
  6. A TUI service registry. TuiServices (name → (runtime) => { dispose() }) for per-runtime holders, and render widget-above:*/widget-below:* as designed; replace ctx.surface("chat") instanceof ChatSurface with a ctx.chat interface (editor text, tool views, replies) and expose the same on the web instead of useComposerState() internals.
  7. Owner notices as a contract. A server Notices registry/object (tone, message, actions as command ids, dismiss) with web lane and TUI status lines as two renderers; processLogs.notice becomes one producer.
  8. Plugin settings. settings.json plugins.<name> validated by a schema each plugin registers (SettingsSchemas), hot-reloaded like today; moves codemode out of core. This also covers pi's flags.
  9. Multi-provider registries. HarnessConfig → ModelProviders (several plugins add providers, merged into Models); HarnessEnvironments keyed by environment kind; ConversationLocations as a registry. Unblocks registerProvider-like plugins and MCP.
  10. Hooks that p2 owns. An input hook for every submission path (Chat, peer, subagent, agent), a shell hook for Chat.shell, and a worker-side ToolApprovals question document with web and TUI renderers, so a tool can ask a person (pi's ctx.ui.confirm) and annotations can drive it.
  11. Core free of plugin names. Web facets loaded from the plugin graph like the other surfaces (plugin-loading.md); Route extension data per plugin (route.plugins[name]); no plugins.includes(...) in hosts; dev-tools through a DevControl contract instead of src/hosts/dev-channel.ts.