MusePi

扩展

English 中文

本文件是 packages/coding-agent 中运行时扩展的主要编写指南。

本文覆盖当前扩展运行时涉及的实现位置:

关于发现路径与文件系统加载规则,见 extension-loading.md

关于面向用户的扩展 CLI/特性封装,见 user-facing-packages.md

什么是扩展

扩展是一个 TS/JS 模块,默认导出 factory:

import type { ExtensionAPI } from "@musepi/pi-coding-agent";

export default function myExtension(pi: ExtensionAPI) {
  // 注册 handlers/tools/commands/renderers
}

一个扩展模块可以组合以下能力:

运行时模型

  1. 扩展被导入,其 factory 执行。
  2. 在加载阶段,注册方法是可用的;运行时动作方法尚未初始化。
  3. ExtensionRunner.initialize(...) 为当前 mode/session/tool registry 接通实时动作与上下文。
  4. 会话/agent/tool 生命周期事件被分发给 handlers。
  5. 每次工具执行都会被扩展拦截层包裹:tool_call / tool_result
Extension lifecycle (simplified)

load paths
   │
   ▼
import module + run factory (registration only)
   │
   ▼
ExtensionRunner.initialize(mode/session/tool registry)
   │
   ├─ emit session/agent events to handlers
   ├─ wrap tool execution (tool_call/tool_result)
   └─ expose runtime actions (sendMessage, setActiveTools, ...)

loader.ts 的重要约束:

Runtime self-bootstrapping (daemon 会话工具,2026-08-20)

GUI daemon 的每个会话额外注入 5 个会话级 CustomToolextension-lifecycle-tools.ts,经 #extensionManagerTools 注入 createSession/activatecustomTools):

语义要点:

Quick start

import type { ExtensionAPI } from "@musepi/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  const z = pi.zod;

  pi.setLabel("Safety + Utilities");

  pi.on("session_start", async (_event, ctx) => {
    ctx.ui.notify(`Extension loaded in ${ctx.cwd}`, "info");
  });

  pi.on("tool_call", async (event) => {
    if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
      return { block: true, reason: "Blocked by extension policy" };
    }
  });

  pi.registerTool({
    name: "hello_extension",
    label: "Hello Extension",
    description: "Return a greeting",
    parameters: z.object({ name: z.string() }),
    async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
      return {
        content: [{ type: "text", text: `Hello, ${params.name}` }],
        details: { greeted: params.name },
      };
    },
  });

  pi.registerCommand("hello-ext", {
    description: "Show queue state",
    handler: async (_args, ctx) => {
      ctx.ui.notify(`pending=${ctx.hasPendingMessages()}`, "info");
    },
  });
}

Extension API surfaces

1) Registration and actions (ExtensionAPI)

Core methods:

getServiceTiers() returns a detached snapshot of the session’s live per-family tier map. setServiceTier(family, tier) changes one family for subsequent requests; pass undefined to clear that session override. OpenAI accepts auto, default, flex, scale, or priority; Anthropic accepts priority; Google accepts flex or priority. Changes made while a response is streaming do not alter that in-flight request.

In interactive mode, input handlers run before the built-in first-message auto-title check. Extensions that call await pi.setSessionName(...) from input can set the persisted session name and prevent the default auto-generated title from running for that session.

Also exposed:

Message delivery semantics

pi.sendMessage(message, options) supports:

pi.sendUserMessage(content, { deliverAs }) always goes through prompt flow. Omit deliverAs to start a normal prompt when idle; while streaming, omitted deliverAs queues the message as a steer. Set deliverAs: "followUp" to wait until the current run finishes.

2) Handler context (ExtensionContext)

Handlers and tool execute receive ctx with:

Background work (ctx.setInterval / ctx.setTimeout)

Extensions run in-process with no isolation. A raw setInterval/setTimeout/detached-promise callback that throws runs outside the handler-dispatch try/catch, surfaces as a process-level uncaughtException, and the global postmortem handler treats it as fatal — the whole session is torn down, not just the offending extension.

Use ctx.setInterval / ctx.setTimeout for any periodic or deferred background work. They mirror the platform signatures but:

pi.on("session_start", async (_event, ctx) => {
  const timer = ctx.setInterval(() => {
    // A throw here is contained — it will not crash the session.
    ctx.ui.notify("tick", "info");
  }, 60_000);
  // Optional: clear it yourself; otherwise it is cleared on shutdown.
  pi.on("session_shutdown", () => ctx.clearTimer(timer));
});

If you use raw setInterval/setTimeout or detached promises instead, you own the isolation: wrap the callback body in your own try/catch (an unhandled throw will take down the session) and clear the timer on session_shutdown.

Model selection (ctx.models)

ctx.models is a read-only facade for picking and comparing models the same way core does:

// Pick a model from a different family than the current one (e.g. a cross-family reviewer).
const current = ctx.models.current();
const contrasting = ctx.models
  .list()
  .find(m => current && ctx.models.family(m) !== ctx.models.family(current));

3) Command context (ExtensionCommandContext)

Command handlers additionally get:

Use command context for session-control flows; these methods are intentionally separated from general event handlers.

Event surface (current names and behavior)

Canonical event unions and payload types are in types.ts.

Session lifecycle

Cancelable pre-events:

Prompt and turn lifecycle

Tool lifecycle

tool_result is middleware-style: handlers run in extension order and each sees prior modifications.

Reliability/runtime signals

MCP notifications

Bridging a push-capable MCP into a session steer:

pi.on("mcp_notification", event => {
  if (event.server !== "peer-bus") return;
  if (event.method !== "notifications/peer_message") return;
  const params = event.params as { from: string; text: string };
  pi.sendUserMessage(`[from ${params.from}] ${params.text}`, { deliverAs: "steer" });
});

The runtime handles the JSON-RPC transport and its own list/update refresh first; the handler runs afterwards and can inject a mid-turn steer via pi.sendMessage / pi.sendUserMessage.

User command interception

resources_discover

resources_discover exists in extension types and ExtensionRunner. Current runtime note: ExtensionRunner.emitResourcesDiscover(...) is implemented, but there are no AgentSession callsites invoking it in the current codebase.

Tool authoring details

registerTool uses ToolDefinition from types.ts. Its parameters field accepts omptype schemas; the injected TypeBox compatibility shim remains available for legacy extensions.

Current execute signature:

execute(
	toolCallId,
	params,
	signal,
	onUpdate,
	ctx,
): Promise<AgentToolResult>

Delegating to a native built-in (ctx.invokeTool)

A tool that re-registers a built-in name (e.g. wrapping write to add logging or a policy check) can run the original instead of reimplementing it. When your registered tool shadows a built-in, the ctx passed to execute carries:

ctx.invokeTool?<TDetails>(
  params: Record<string, unknown>,
  options?: { signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback },
): Promise<AgentToolResult<TDetails>>

It runs the native built-in of the same name as your tool (delegation is same-tool only, so it cannot reach an arbitrary target or escalate past the approval already granted for this call) and returns its result, including the native tool’s own side effects and internal bookkeeping. It is present only when a native built-in of that name exists — ctx.invokeTool is undefined for a net-new tool that shadows no built-in. The native call is not re-gated, since it is the same tool you are already approved as, and delegation depth is guarded against accidental self-recursion.

Template:

const z = pi.zod;

pi.registerTool({
  name: "my_tool",
  label: "My Tool",
  description: "...",
  parameters: z.object({}),
  hidden: false,
  defaultInactive: false,
  deferrable: false,
  async execute(_id, _params, signal, onUpdate, ctx) {
    if (signal?.aborted) {
      return { content: [{ type: "text", text: "Cancelled" }] };
    }
    onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
    return { content: [{ type: "text", text: "Done" }], details: {} };
  },
  onSession(event, ctx) {
    // reason: start|switch|branch|tree|shutdown
  },
  renderCall(args, options, theme) {
    // optional TUI render
  },
  renderResult(result, options, theme, args) {
    // optional TUI render
  },
});

tool_call/tool_result intercept all tools once the registry is wrapped in sdk.ts, including built-ins and extension/custom tools. ToolDefinition also supports optional hidden, defaultInactive, deferrable, approval, mcpServerName, mcpToolName, renderCall, and renderResult fields.

UI integration points

ctx.ui implements the ExtensionUIContext interface. Support differs by mode.

Interactive mode (extension-ui-controller.ts)

Supported:

Current no-op methods in this controller:

setEditorComponent is wired to the live editor (ctx.setEditorComponent(factory)). setWidget renders real widget components above or below the editor via setHookWidget(...) (placement: "aboveEditor" | "belowEditor"; string-array content capped at 10 lines).

RPC mode (rpc-mode.ts)

ctx.ui is backed by RPC extension_ui_request events:

Unsupported/no-op in RPC implementation:

Print/headless/subagent paths

When no UI context is supplied to runner init, ctx.hasUI is false and methods are no-op/default-returning.

ACP mode

ACP installs an elicitation-bridged UI context (createAcpExtensionUiContext in acp-agent.ts). ctx.hasUI is true while select/confirm/input/editor round-trip (as ACP elicitations; defaults are returned when the client lacks the elicitation.form capability). The non-elicitation surface (widgets, theming, terminal input, autocomplete stacking) is stubbed no-op.

Session and state patterns

For durable extension state:

  1. Persist with pi.appendEntry(customType, data).
  2. Rebuild state from ctx.sessionManager.getBranch() on session_start, session_branch, session_tree.
  3. Keep tool result details structured when state should be visible/reconstructible from tool result history.

Example reconstruction pattern:

pi.on("session_start", async (_event, ctx) => {
  let latest;
  for (const entry of ctx.sessionManager.getBranch()) {
    if (entry.type === "custom" && entry.customType === "my-state") {
      latest = entry.data;
    }
  }
  // restore from latest
});

Rendering extension points

Custom message renderer

pi.registerMessageRenderer("my-type", (message, { expanded }, theme) => {
  // return pi-tui Component
});

Used by interactive rendering when custom messages are displayed.

Assistant thinking renderer

import { Container, Text } from "@musepi/pi-tui";

pi.registerAssistantThinkingRenderer((context, theme) => {
  const container = new Container();
  container.addChild(new Text(theme.fg("dim", `thinking chars: ${context.text.length}`), 1, 0));
  return container;
});

Used by interactive rendering to add display-only supplemental UI below each visible assistant thinking block. The renderer receives the already-visible thinking text, content/thinking indexes, theme, and a requestRender() callback for async renderers. All registered renderers that return a component are appended in registration order. Renderers must not mutate messages; the original thinking block remains the provider/session source of truth.

Tool call/result renderer

Provide renderCall / renderResult on registerTool definitions for custom tool visualization in TUI.

Constraints and pitfalls

Extensions vs hooks vs custom-tools

Use the right surface:

If you need one package that owns policy, tools, command UX, and rendering together, use extensions.