A host that runs omp inside an OS sandbox can grant a path mid-session but cannot apply that grant to an in-process write: `write` and `edit` do their I/O in the agent process, so an out-of-workspace write fails and stays failed until the process restarts under a wider profile. Nothing available today closes that. A `tool_call` handler can block and a `tool_result` handler can rewrite content, but neither can re-run a tool. `ctx.invokeTool` delegates execution, but the delegated native tool runs in the same process under the same restrictions. And the failure lands AT the write syscall - after the tool computed the final content, before it returned - so the bytes are gone with the throw, and reconstructing them means reimplementing `edit`'s hashline protocol and the snapshot bookkeeping. The byte-write that `write`, `edit` and `apply_patch` perform on an ordinary file path already funnels through one two-line primitive (`file ? file.write(content) : Bun.write(dst, content)`) at four call sites. Routing that primitive through `writeFileWithFallback` gives an embedder a single seam to intercept a permission-denied write: the native tool still records its own snapshot under the real destination path once a handler reports success, so a follow-up hashline `edit` on that path keeps working. Only a permission boundary diverts - `EPERM`/`EACCES`/`EROFS`. Two cases needed more than that: - `Bun.write` creates missing parents itself, and when that `mkdir` is the denied operation it reports the subsequent `open()`'s `ENOENT` instead of the denial - making a sandboxed write into a new out-of-tree directory indistinguishable from an ordinary bad path. Redoing the `mkdir` explicitly recovers the real errno, and because it runs through the same enforcement path as the write it also sees kernel-level denials (Seatbelt, LSM) that a `stat`/`access` probe reports as writable. If no handler takes the write, the original `ENOENT` is still what propagates, with the recovered denial attached as its `cause`. - `apply_patch` creates the parent as a separate step before writing, so a denial there threw before the seam was ever reached. That `mkdir` now tolerates a permission denial when a fallback is registered, letting the write report it. A denial reached through a SYMLINK is never brokered. The in-process write follows the link, so the kernel denied the link's TARGET, but a handler receives `dst` and a privileged helper opening it with ordinary follow semantics would land the bytes wherever the link points. That also defeats the obvious helper-side defence, since a prefix allowlist passes when the link sits inside the allowed root while its target does not. omp cannot vouch for the destination, so it refuses rather than hand the ambiguity to a privileged writer - the same answer `confineToWorkspace` already gives an unresolvable link. Removing a file is a different primitive, so it gets its own seam (`deleteFileWithFallback`, `registerFileDeleteFallback`) covering `edit`'s `REM`, a hashline `MV`'s source unlink, and `apply_patch`'s delete op. Two differences from the write path: `ENOENT` is never diverted, since nothing is created on the way to an unlink and `REM` needs it to become a not-found error; and the seam refuses a target it can confirm is a directory, because `unlink` on a directory reports `EPERM` on Darwin and is otherwise indistinguishable from a sandbox denial. That check cannot always run - a sandbox denying the unlink usually denies the target's metadata too - so the request carries `confirmedFile`, and a handler is required to use a plain unlink rather than resolving or recursing. The two registries are deliberately separate. A write handler brokers `content` to `dst`, so a delete request reaching it with no content invites brokering an empty write and truncating the file it was asked to remove. With nothing registered both seams are inert: the primitives run exactly as before, a failure rethrows from the same place, and no extra syscalls are performed. Scope is deliberately narrow. Archive-member and SQLite writes are unchanged - neither is a byte-write to a path, so brokering them needs a different request shape - along with the ACP bridge's `writeTextFile`, the `lsp` tool's own workspace-edit and formatter writes, and directory removal.
27 KiB
Extensions
Primary guide for authoring runtime extensions in packages/coding-agent.
This document covers the current extension runtime in:
src/extensibility/extensions/types.tssrc/extensibility/extensions/runner.tssrc/extensibility/extensions/wrapper.tssrc/extensibility/extensions/index.tssrc/modes/controllers/extension-ui-controller.ts
For discovery paths and filesystem loading rules, see extension-loading.md.
For packaged user-facing extension CLIs/features, see user-facing-packages.md.
What an extension is
An extension is a TS/JS module exporting a default factory. Factories may initialize synchronously or return a promise:
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function myExtension(pi: ExtensionAPI) {
// register handlers/tools/commands/renderers
}
Extensions can combine all of the following in one module:
- event handlers (
pi.on(...)) - LLM-callable tools (
pi.registerTool(...)) - slash commands (
pi.registerCommand(...)) - keyboard shortcuts and flags
- custom message rendering
- session/message injection APIs (
sendMessage,sendUserMessage,appendEntry)
Runtime model
- Extensions are imported and their factory functions run.
- During that load phase, registration methods are valid; runtime action methods are not yet initialized.
ExtensionRunner.initialize(...)wires live actions/contexts for the active mode.- Session/agent/tool lifecycle events are emitted to handlers.
- Every tool execution is wrapped with extension interception (
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, ...)
Important constraint from loader.ts:
- calling action methods like
pi.sendMessage()during extension load throwsExtensionRuntimeNotInitializedError - register first; perform runtime behavior from events/commands/tools
Quick start
import type { ExtensionAPI } from "@oh-my-pi/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:
on(event, handler)registerTool,registerCommand,registerShortcut,registerFlagregisterMessageRenderer,registerAssistantThinkingRenderersetLabel,getFlagsendMessage,sendUserMessage,appendEntry,execgetActiveTools,getAllTools,setActiveToolsgetCommandsgetSessionName,setSessionNamesetModel,getThinkingLevel,setThinkingLevelgetServiceTiers,setServiceTierregisterProviderregisterFileWriteFallback,registerFileDeleteFallbackevents(shared event bus)
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:
pi.loggerpi.arktype(the omptypetype(...)schema builder)pi.zod(Zod-compatible builder backed by omptype)pi.typebox(legacy TypeBox-compatible shim)pi.pi(package exports)
Message delivery semantics
pi.sendMessage(message, options) supports:
deliverAs: "steer"(default) — interrupts current rundeliverAs: "followUp"— queued to run after current rundeliverAs: "nextTurn"— stored and injected on the next user prompttriggerTurn: true— starts a turn when idle (also honored withdeliverAs: "nextTurn": idle prompts immediately; while streaming the queued message schedules an internal continuation)
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:
uihasUIcwdsessionManager(read-only)modelRegistry,modelmodels(read-only model query — see below)localProtocolOptions(optional calling-sessionlocal://root mapping for external tool bridges)getContextUsage()getAsyncJobSnapshot()returns the current session's read-only async-job snapshot, ornullwhen no session owns the contextcompact(...)isIdle(),hasPendingMessages(),abort()shutdown()getSystemPrompt()memory(optional structured memory runtime — status/search/save across the configured backend)setInterval(fn, ms, ...args)/setTimeout(fn, ms, ...args)/clearTimer(timer)— managed timers (see below)
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:
- run the callback with the same isolation as handler dispatch — a synchronous throw or a rejected promise is logged and reported through the extension error channel, and the session keeps running;
- return a handle you can pass to
ctx.clearTimer(handle); - are
unref'd (never keep the process alive on their own) and are cleared automatically onsession_shutdown.
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:
list()— authenticated models available this session.current()— the live session model (read lazily, so it reflects/modelswitches).resolve(spec)— a model string (provider/id, bare id) or role alias (@slow, a configured role) →Model, honoring the same settings-backed aliases and match preferences as--model. Returnsundefinedwhen nothing matches.family(model)— an opaque lineage token for "same family?" checks (Claude point releases share a token; Claude and GPT differ). Compare it; don't persist it (the vocabulary tracks new releases).
// 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:
waitForIdle()newSession(...)switchSession(...)branch(entryId)navigateTree(targetId, { summarize })reload()
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
session_startsession_before_switch/session_switchsession_before_branch/session_branchsession_before_compact/session.compacting/session_compactsession_before_tree/session_treesession_shutdown
Cancelable pre-events:
session_before_switch→{ cancel?: boolean }session_before_branch→{ cancel?: boolean; skipConversationRestore?: boolean }session_before_compact→{ cancel?: boolean; compaction?: CompactionResult }session_before_tree→{ cancel?: boolean; summary?: { summary: string; details?: unknown } }
Prompt and turn lifecycle
inputbefore_agent_startbefore_provider_request(may replace provider request payload)after_provider_responsecontextagent_start/agent_end— agent loop lifecycle notification;agent_endremains notification-onlysession_stop— main-session stop hook, awaited before settle; may continue with{ continue: true, additionalContext }or{ decision: "block", reason }; capped at 8 consecutive continuations and never fires for task/subagent sessionsturn_start/turn_endmessage_start/message_update/message_end— lifecycle notifications;message_endreceives a detached message snapshot, so usetool_resultorcontextwhen an extension needs to change provider context
Tool lifecycle
tool_call(pre-exec, may block, or revise the tool's executioninput; for model-issued calls it fires at arg-prep time in the agent loop, so a revision is revalidated and seen by concurrency scheduling, execution events, the persisted assistant message, and the approval gate alike)tool_result(post-exec, may patch content/details/isError)tool_execution_start/tool_execution_update/tool_execution_end(observability)tool_approval_requested/tool_approval_resolved(observability; emitted bywrapper.tsonly when a tool requires approval and an approval handler is registered)
tool_result is middleware-style: handlers run in extension order and each sees prior modifications.
Reliability/runtime signals
auto_compaction_start/auto_compaction_endauto_retry_start/auto_retry_endttsr_triggeredtodo_remindergoal_updatedcredential_disabled
MCP notifications
mcp_notification— fired for every JSON-RPC notification received from a connected MCP server, AFTER the manager's own handling of known list/update methods (notifications/tools/list_changed,notifications/resources/list_changed,notifications/resources/updated,notifications/prompts/list_changed). Unknown or server-custom methods are also delivered. Payload:{ server: string; method: string; params: unknown }. Multiple extensions may subscribe; a handler that throws does not prevent other handlers from firing. Notifications received before any listener attaches are buffered (bounded FIFO, cap 100, drop-oldest) and drained into the first subscriber — so startup-time frames aren't lost even if the extension binds after MCP discovery.
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
user_bash(override with{ result })user_python(override with{ result })
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, loadMode ("discoverable" by default, or "essential"), deferrable, approval ("exec" by default), strict, mcpServerName, mcpToolName, renderCall, and renderResult fields.
File write fallback (registerFileWriteFallback)
write, edit and apply_patch perform the real byte-write to an ordinary file
path through one shared primitive
(file ? file.write(content) : Bun.write(dst, content)). When that primitive fails
with a permission error (EPERM/EACCES/EROFS — every other error, such as
EISDIR, is unaffected), the coding agent consults handlers registered
via pi.registerFileWriteFallback before giving up:
import type { FileWriteFallbackHandler } from "@oh-my-pi/pi-coding-agent";
const writeThroughBroker: FileWriteFallbackHandler = async (req, ctx) => {
// req: { dst: string; content: string; cause: unknown }
const ok = await myPrivilegedWriter.write(req.dst, req.content);
return ok;
};
pi.registerFileWriteFallback(writeThroughBroker);
Handlers run in registration order; the first one to resolve true counts as the
bytes being durably on disk, and the native tool continues exactly as if its own
write had succeeded — including recording its file snapshot under the real
destination path, so a later hashline edit on that path keeps working. A
throwing handler is logged and skipped in favor of the next one; if every handler
returns false (or none are registered), the original error is rethrown
unchanged. Intended for a host that embeds the agent inside a sandbox denying
direct filesystem writes but exposing a privileged write channel.
Two details matter when the destination is outside what the host allows:
- A missing parent directory.
Bun.writecreates missing parents itself, and when thatmkdiris the operation being denied it reports the subsequentopen()'sENOENTrather than the denial. The agent redoes themkdirexplicitly to recover the real errno, so this still reaches a handler — withreq.causeset to themkdirdenial. In that casereq.dst's parent does not exist yet and the handler is responsible for creating it. AnENOENTwith a genuinely creatable or invalid parent is not diverted. (apply_patchcreates the parent as a separate step before writing; thatmkdirtolerates a denial when a fallback is registered, so the write still reaches the handler.) - A hashline
MV.edit's move writes its destination directly rather than through the LSP writethrough. It is routed to the same handlers, and the source unlink goes to the delete seam below, so a move out of a directory you cannot write completes too.
This is deliberately not an interception of every write the agent can make. A permission error from these surfaces as it does today, with no handler consulted:
writeto an archive member (foo.zip:entry) or to a SQLite row. Neither is a byte-write todst: an archive rewrite reads the whole archive, replaces one entry, writes a temp file and renames over the original, so what lands is a whole binary container rather than the string the tool was handed; a SQLite write is a row operation inside the database engine with no byte payload at all. Brokering either needs a different request shape than "these bytes belong at this path".- The ACP bridge's
writeTextFile, which hands the write to a remote client. - The
lsptool's own writes: applying a workspace edit or code action, and the Biome formatter, which writes the buffer and then shells out tobiome format --write— a subprocess write no in-process seam can reach.
File delete fallback (registerFileDeleteFallback)
Removing a file is a different primitive from writing one, and it has its own seam:
pi.registerFileDeleteFallback(async (req, ctx) => {
// req: { dst: string; cause: unknown; confirmedFile: boolean } — no `content`.
return await myPrivilegedWriter.unlink(req.dst);
});
It covers edit's REM, the source side of a hashline MV, and apply_patch's
delete op, and follows the same rules as the write seam: same permission codes, first
true wins, a throwing handler is skipped, the original error is rethrown if none
succeed, and nothing happens at all when no handler is registered. Two differences:
ENOENTis never diverted. Nothing is created on the way to an unlink, so a missing file genuinely is missing —REMturns it into a not-found error.- A handler must unlink, never remove recursively.
unlinkon a directory reportsEPERMon macOS, which is indistinguishable from a sandbox denial by error code alone, so the seamlstats the target and refuses to divert a directory. But when the target's own metadata sits behind the same boundary that denied the unlink — the common sandbox case — that check cannot be resolved, andreq.dstmay then be a directory.req.confirmedFileistrueonly when the seam positively established the target is not one. A privileged helper that recursively removesreq.dstwould delete a whole tree on behalf of a tool that only ever removes one file.
Registering for deletes is deliberately separate from registering for writes. A
write handler brokers req.content to req.dst; if a delete request reached it, the
missing content invites brokering an empty write and truncating the file that was
meant to be removed. A write-only handler therefore never sees a delete.
Two lifecycle constraints, which apply to both seams:
- Register during extension load (from the default factory), like other
register*calls. Handlers are installed whenExtensionRunner.initializeruns; an extension that registered nothing by then is skipped entirely, so a first registration made later never takes effect. - The registries are process-wide. A process can host several sessions (a subagent
gets its own runner), and
reqcarries no session identity, so a handler may be consulted for a denied write or delete from any session in the process — not only the one whose extension registered it. Handlers are removed onsession_shutdown.
With nothing registered none of this engages: the primitive runs exactly as it did before and performs no extra syscalls.
UI integration points
ctx.ui implements the ExtensionUIContext interface. Support differs by mode.
Interactive mode (extension-ui-controller.ts)
Supported:
- dialogs:
select,confirm,input,editor - input editing:
setEditorText,getEditorText,pasteToEditor,editor - autocomplete stacking:
addAutocompleteProvider(factory)wraps the built-in editor provider (factories apply in registration order and re-apply on every slash-command refresh) - terminal title and working message (
setTitle,setWorkingMessage) - notifications/status/editor text/terminal input/custom overlays
- theme listing/loading by name (
setThemesupports string names) - tools expanded toggle
Current no-op methods in this controller:
setFootersetHeader
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:
- dialog methods (
select,confirm,input,editor) round-trip to client responses - fire-and-forget methods emit requests (
notify,setStatus,setWidgetfor string arrays,setEditorText;setTitleemits only whenPI_RPC_EMIT_TITLE=1)
Unsupported/no-op in RPC implementation:
onTerminalInputcustomsetFooter,setHeader,setEditorComponent,addAutocompleteProvidersetWorkingMessage- theme switching/loading (
setThemereturns failure) - tool expansion controls are inert
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:
- Persist with
pi.appendEntry("com.example.my-extension.state", data). ThecustomTypenamespace is global: use a package- or reverse-domain-qualified value and avoid the core-reserved values in thecustomsession-entry reference. - Rebuild state from
ctx.sessionManager.getBranch()onsession_start,session_branch,session_tree. - Keep tool result
detailsstructured 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 === "com.example.my-extension.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 "@oh-my-pi/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
- Runtime actions are unavailable during extension load.
tool_callerrors block execution (fail-closed).- Command name conflicts with built-ins are skipped with diagnostics.
- Reserved shortcuts are ignored (
ctrl+c,ctrl+d,ctrl+z,ctrl+k,ctrl+p,ctrl+l,ctrl+o,ctrl+t,ctrl+g,ctrl+q,alt+m,shift+tab,shift+ctrl+p,alt+enter,escape,enter). - Treat
ctx.reload()as terminal for the current command handler frame.
Extensions vs hooks vs custom-tools
Use the right surface:
- Extensions (
src/extensibility/extensions/*): unified system (events + tools + commands + renderers + provider registration). - Hooks (
src/extensibility/hooks/*): separate legacy event API. - Custom-tools (
src/extensibility/custom-tools/*): tool-focused modules; when loaded alongside extensions they are adapted and still pass through extension interception wrappers.
If you need one package that owns policy, tools, command UX, and rendering together, use extensions.