/** * Extension system types. * * Extensions are TypeScript modules that can: * - Subscribe to agent lifecycle events * - Register LLM-callable tools * - Register commands, keyboard shortcuts, and CLI flags * - Interact with the user via UI primitives */ import { type ExtensionUiComponent, type ExtensionUiComponentFactory, type ExtensionWidgetContent, type MessageRenderer, type AssistantThinkingRenderer, type AssistantTextRenderer, type TranscriptPresenter, } from "@oh-my-pi/pi-tui/chat/extension-types"; export { type ExtensionUiComponent, type ExtensionUiComponentFactory, type ExtensionWidgetContent, type MessageRenderOptions, type MessageRenderer, type AssistantThinkingRenderContext, type AssistantThinkingRenderer, type AssistantTextRenderContext, type AssistantTextRenderer, type TranscriptBlock, type TranscriptPresentation, type TranscriptPresenter, } from "@oh-my-pi/pi-tui/chat/extension-types"; import type { type as ArkType } from "@oh-my-pi/omptype"; import type * as TypeBox from "@oh-my-pi/omptype/typebox"; import type * as zod from "@oh-my-pi/omptype/zod"; import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel, ToolApproval, ToolLoadMode, } from "@oh-my-pi/pi-agent-core"; import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction"; import type { ContextUsage } from "@oh-my-pi/pi-tui/status-line/types"; import type { LoopConditionConfig } from "@oh-my-pi/pi-tui/status-line/loop"; import type { LivePhase } from "@oh-my-pi/pi-tui/apps/live-visualizer"; import type { ConfiguredThinkingLevel } from "@oh-my-pi/pi-tui/thinking"; import type { Api, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, ImageContent, Model, ModelSpec, ProviderResponseMetadata, ServiceTier, ServiceTierByFamily, ServiceTierFamily, SimpleStreamOptions, Static, TextContent, TSchema, UsageProvider, } from "@oh-my-pi/pi-ai"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; import type { AutocompleteItem, AutocompleteProvider, Component, EditorTheme, Keybinding, KeyId, OverlayHandle, OverlayOptions, TUI, } from "@oh-my-pi/pi-tui"; import type { logger as PiLogger } from "@oh-my-pi/pi-utils"; import type { KeybindingsManager } from "@oh-my-pi/pi-tui/app-keybindings"; import type { ComposerShapeDefinition } from "@oh-my-pi/pi-tui/overlays/composer-shape-registry"; import type { CodeReviewAnnotation, TextReviewAnnotation } from "@oh-my-pi/pi-tui/overlays/annotation-types"; export type { ComposerShapeDefinition } from "@oh-my-pi/pi-tui/overlays/composer-shape-registry"; import type { ModelRegistry } from "../../config/model-registry"; import type { EditToolDetails } from "@oh-my-pi/pi-tui/tools/edit"; import type { PythonResult } from "../../eval/py/executor"; import type { BashResult } from "../../exec/bash-executor"; import type { LiveTranscript } from "../../live/controller"; import type { ExecOptions, ExecResult } from "../../exec/exec"; import type * as PiCodingAgent from "../../index"; import type { LocalProtocolOptions } from "../../internal-urls/local-protocol"; import type { LoopLimitConfig } from "../../modes/loop-limit"; import type { MemoryRuntimeContext } from "../../memory-backend"; import type { CustomEditor } from "@oh-my-pi/pi-tui/prompt/custom-editor"; import type { Theme } from "@oh-my-pi/pi-tui/theme"; import type { NativeToolView } from "@oh-my-pi/pi-tui/tools/renderer"; import type { AsyncJobSnapshot, SendUserMessageOptions } from "../../session/agent-session"; import type { EphemeralTurnOptions, EphemeralTurnResult } from "../../session/agent-session-types"; import type { CompactMode } from "../../session/compact-modes"; import type { CustomMessagePayload } from "../../session/messages"; import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager"; import type { BashToolInput, GlobToolInput, GrepToolInput, ReadToolInput, WriteToolInput } from "../../tools"; import type { GlobToolDetails } from "@oh-my-pi/pi-tui/tools/glob"; import type { GrepToolDetails } from "@oh-my-pi/pi-tui/tools/grep"; import type { ReadToolDetails } from "@oh-my-pi/pi-tui/tools/read"; import type { ApprovalMode } from "../../tools/approval"; import type { BashToolDetails } from "@oh-my-pi/pi-tui/tools/bash"; import type { FileDeleteFallbackHandler, FileWriteFallbackHandler } from "../../tools/file-write-fallback"; import type { EventBus } from "../../utils/event-bus"; import type { AgentEndEvent, AgentStartEvent, AutoCompactionEndEvent, AutoCompactionStartEvent, AutoRetryEndEvent, AutoRetryStartEvent, CacheWarmingDecisionEvent, CacheWarmingDecisionEventResult, ContextEvent, GoalUpdatedEvent, RetryFallbackAppliedEvent, RetryFallbackSucceededEvent, SessionBeforeBranchEvent, SessionBeforeBranchResult, SessionBeforeCompactEvent, SessionBeforeCompactResult, SessionBeforeSwitchEvent, SessionBeforeSwitchResult, SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionBranchEvent, SessionCompactEvent, SessionCompactingEvent, SessionCompactingResult, SessionEvent, SessionShutdownEvent, SessionStartEvent, SessionStopEvent, SessionStopEventResult, SessionSwitchEvent, SessionTreeEvent, TodoReminderEvent, ToolCallEventResult, ToolResultEventResult, TtsrTriggeredEvent, TurnEndEvent, TurnStartEvent, } from "../shared-events"; import type { SlashCommandInfo } from "../slash-commands"; import type { TerminalLaunchRequest, TerminalLaunchResult } from "../../subprocess/terminal-launch"; export type { Keybinding, OverlayHandle, OverlayOptions } from "@oh-my-pi/pi-tui"; export type { AppKeybinding, KeybindingsManager } from "@oh-my-pi/pi-tui/app-keybindings"; export type { ExecOptions, ExecResult } from "../../exec/exec"; export type { AgentToolResult, AgentToolUpdateCallback }; // ============================================================================ // UI Context // ============================================================================ export interface ExtensionUISelectOption { label: string; description?: string; } export type ExtensionUISelectItem = string | ExtensionUISelectOption; import type { ExtensionAskDialogQuestion, ExtensionAskDialogResult, ExtensionAskDialogSubmitResult, } from "@oh-my-pi/pi-tui/overlays/ask-dialog"; export type { ExtensionAskDialogOption, ExtensionAskDialogQuestion, ExtensionAskDialogResultItem, ExtensionAskDialogSubmitResult, ExtensionAskDialogChatResult, ExtensionAskDialogResult, } from "@oh-my-pi/pi-tui/overlays/ask-dialog"; import type { HtmlFormData } from "@oh-my-pi/pi-tui/html-form/data"; export type { HtmlFormData, HtmlFormValue } from "@oh-my-pi/pi-tui/html-form/data"; /** A submitted {@link ExtensionUIContext.formDialog}: the form data, flagged when the timeout submitted the defaults. */ export interface ExtensionFormDialogResult { data: HtmlFormData; timedOut?: boolean; } export function getExtensionUISelectOptionLabel(option: ExtensionUISelectItem): string { return typeof option === "string" ? option : option.label; } /** Answers an ask dialog whose timeout elapsed: each question gets its recommended option, else the first. */ export function timedOutAskDialogResult(questions: ExtensionAskDialogQuestion[]): ExtensionAskDialogSubmitResult { return { kind: "submit", results: questions.map(question => { const labels = question.options.map(option => option.label); const fallbackIndex = Math.min(Math.max(question.recommended ?? 0, 0), Math.max(labels.length - 1, 0)); const fallback = labels[fallbackIndex]; return { id: question.id, question: question.question, options: labels, multi: question.multi ?? false, selectedOptions: fallback === undefined ? [] : [fallback], customInput: undefined, timedOut: true, }; }), }; } /** * UI dialog options for extensions. */ export interface ExtensionUIDialogOptions { signal?: AbortSignal; timeout?: number; /** Invoked when the UI times out while waiting for a selection/input */ onTimeout?: () => void; /** Invoked when the UI-managed timeout countdown starts */ onTimeoutStart?: () => void; /** Invoked when user input resets a UI-managed timeout countdown */ onTimeoutReset?: () => void; /** * Host hint to mask entered characters in `input` dialogs. * Enforced by the interactive TUI (masked as bullets), forwarded on RPC `input` * requests for hosts that honor it, and rejected (`undefined`) in ACP mode where * form elicitation does not support secret masking. */ secret?: boolean; /** Initial cursor position for select dialogs (0-indexed) */ initialIndex?: number; /** Render an outlined list for select dialogs */ outline?: boolean; /** Invoked when user presses left arrow in select dialogs */ onLeft?: () => void; /** Invoked when user presses right arrow in select dialogs */ onRight?: () => void; /** Invoked when user presses the external editor shortcut in select dialogs */ onExternalEditor?: () => void; /** Optional footer hint text rendered by interactive selector */ helpText?: string; /** Render a leading radio/checkbox marker before each markable option in * select dialogs (matches the ask transcript). "radio" fills the cursor row * for single-choice; "checkbox" reflects `checkedIndices` per row for * multi-select. Options beyond `markableCount` keep the plain cursor. */ selectionMarker?: "radio" | "checkbox"; /** For `selectionMarker: "checkbox"`: option indices currently checked. */ checkedIndices?: readonly number[]; /** Number of leading options that receive a selection marker; the remaining * trailing options (e.g. "Other"/"Done" actions) keep the plain cursor. * Defaults to all options when `selectionMarker` is set. */ markableCount?: number; /** Allow image pastes in rich ask-dialog custom-answer and note prompts. */ acceptImages?: boolean; } /** Raw terminal input listener for extensions. */ export type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined; export type WidgetPlacement = "aboveEditor" | "belowEditor"; export interface ExtensionWidgetOptions { placement?: WidgetPlacement; } /** Options for `ExtensionUIContext.custom()` (overlay rendering of a custom component). */ export interface ExtensionCustomOptions { /** Render the component as an overlay over the transcript instead of replacing the editor area. */ overlay?: boolean; /** Static or lazily resolved overlay positioning/sizing options forwarded to `showOverlay`. */ overlayOptions?: OverlayOptions | (() => OverlayOptions); /** Invoked with the overlay handle once the overlay is created (overlay mode only). */ onHandle?: (handle: OverlayHandle) => void; /** Abort the custom UI and reject its promise. */ signal?: AbortSignal; } /** Wrap the current autocomplete provider with additional behavior (pi-compatible). */ export type AutocompleteProviderFactory = (current: AutocompleteProvider) => AutocompleteProvider; /** The renderings of a tool-approval prompt passed to `ExtensionUIContext.approve()`. */ export interface ExtensionApprovalPrompt { full: string; compact: string; /** Label of the "approve for this session" choice; absent when the call must be approved each time. */ session?: string; /** * Grants an `always-ask-cached` prompt can remember, in display order; absent otherwise. A host * that does not render them still answers `once`/`session`/`deny`. */ grants?: readonly ExtensionApprovalGrant[]; } /** One remembered-approval choice of an `always-ask-cached` prompt. */ export interface ExtensionApprovalGrant { /** `folder`: the directory around the call, its own entries only; `target`: the exact file or URL. */ scope: "folder" | "target"; /** Label of the choice remembered for this session, e.g. `Approve Directory: ~/src`. */ label: string; /** Label of the same choice remembered across sessions, e.g. `Always Approve Directory: ~/src`. */ alwaysLabel: string; } /** * An answer to `ExtensionUIContext.approve()`: this call only, later like calls this session too, * one of the prompt's {@link ExtensionApprovalPrompt.grants} (`always`: across sessions), or no. */ export type ExtensionApprovalAnswer = | "once" | "session" | "deny" | { grant: ExtensionApprovalGrant["scope"]; always: boolean }; /** * UI context for extensions to request interactive UI. * Each mode (interactive, RPC, print) provides its own implementation. */ // fallow-ignore-next-line code-duplication // Parallel to HookUIContext: extensions expose a strictly larger UI surface // (custom editor component, header/footer, widgets, theming, terminal input) // and may be invoked from event handlers that have already taken the agent // loop's lock — hooks intentionally cannot. export interface ExtensionUIContext { /** True when selector timeouts start only after the dialog is presented. */ timeoutStartsOnPresentation?: boolean; /** True when pasteToEditor updates a local or remote composer; absent means unsupported. */ supportsEditor?: boolean; /** Show a selector and return the selected label, even when an option also includes a description. */ select( title: string, options: ExtensionUISelectItem[], dialogOptions?: ExtensionUIDialogOptions, ): Promise; /** Show a confirmation dialog. */ confirm(title: string, message: string, dialogOptions?: ExtensionUIDialogOptions): Promise; /** Create a pane or multiplexer group and run an argv command when the TUI host supports it. */ openTerminal?(request: TerminalLaunchRequest): Promise; /** Show a text input dialog. */ input(title: string, placeholder?: string, dialogOptions?: ExtensionUIDialogOptions): Promise; /** Show the rich ask dialog when the interactive TUI surface is available. */ askDialog?( questions: ExtensionAskDialogQuestion[], dialogOptions?: ExtensionUIDialogOptions, ): Promise; /** * Show an HTML `
` (see `@oh-my-pi/pi-tui/html-form/parse`) as an interactive form and * resolve its data on submit, `undefined` on cancel. With `timeout`, the form's current values * submit when it elapses (`timedOut: true`); input resets the countdown. Absent on hosts that * cannot draw a form; callers then fall back to field-by-field `select`/`input`/`editor`. */ formDialog?(html: string, dialogOptions?: ExtensionUIDialogOptions): Promise; /** * Show a tool-approval prompt; anything but an explicit approval is `deny`, `session` only when * the prompt offers it, and a grant only one the prompt lists. Each prompt is a title line then * details: `compact` summarizes the tool's own details (command, path, content) to one row for a * host with little room, `full` keeps them whole. Absent: callers fall back to a `select`. */ approve?( prompt: ExtensionApprovalPrompt, dialogOptions?: ExtensionUIDialogOptions, ): Promise; /** Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; /** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */ onTerminalInput(handler: TerminalInputHandler): () => void; /** * Draw the assistant reply sent at `timestamp` (`message.timestamp`) again, re-running the * assistant text renderers, e.g. after a click changed what they return. Interactive mode only, * and only while `extensionRenderers.assistantText` is on; a no-op elsewhere. */ rerenderMessage(timestamp: number): void; /** * Ask the transcript presenters again for the blocks with these ids (`TranscriptBlock.id`) * and redraw those whose display changed. omp re-asks on its own only when a block's data * changes; call this when the presenter's own state changes (an `agent_end` handler marking a * run's calls done, say). A changed block with rows already in terminal scrollback replays the * transcript once. Interactive mode only; a no-op elsewhere. */ refreshTranscript(ids: readonly string[]): void; /** * True while a link whose scheme an extension registered can be picked to raise `link_activate`: * interactive mode in a plain terminal with `extensionRenderers.assistantText` on, and either the * link-hint key (`app.links.hint`, Alt+O by default) bound or `tui.mouse` on. Changing the setting, * or `tui.mouse` while the hint key is unbound, redraws the transcript and re-runs the assistant * text renderers, so a renderer can read this on each call, e.g. to show content inline instead of * behind a link nobody can pick. False everywhere else. */ getLinkClicksAvailable(): boolean; /** Set status text in the footer/status bar. Pass undefined to clear. */ setStatus(key: string, text: string | undefined): void; /** Set the working/loading message shown during streaming. Call with no argument to restore default. */ setWorkingMessage(message?: string): void; /** Set a widget to display above or below the editor. Accepts string array or component factory. */ setWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void; /** Set a custom footer component, or undefined to restore the built-in footer. */ setFooter(factory: ExtensionUiComponentFactory | undefined): void; /** Set a custom header component, or undefined to restore the built-in header. */ setHeader(factory: ExtensionUiComponentFactory | undefined): void; /** Set the terminal window/tab title. */ setTitle(title: string): void; /** Show a custom component with keyboard focus. */ custom( factory: ( tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: T) => void, ) => ExtensionUiComponent | Promise, options?: ExtensionCustomOptions, ): Promise; /** Set the text in the core input editor. */ setEditorText(text: string): void; /** * Paste text into the core input editor. * * Interactive mode should route through the editor's paste handling (e.g. large paste markers). * Non-interactive modes may fall back to replacing the editor text. */ pasteToEditor(text: string): void; /** Get the current text from the core input editor. */ getEditorText(): string; /** Show a multi-line editor for text editing. */ editor( title: string, prefill?: string, dialogOptions?: ExtensionUIDialogOptions, editorOptions?: { promptStyle?: boolean }, ): Promise; /** * Stack additional autocomplete behavior on top of the built-in provider * (pi-compatible). Interactive mode rebuilds the editor's provider through * every registered factory, in registration order; headless modes (print, * RPC, ACP, subagents) accept and ignore the factory. */ addAutocompleteProvider(factory: AutocompleteProviderFactory): void; /** * Set a custom editor component via factory function, or `undefined` to restore the default editor. * * The factory must return a {@link CustomEditor} subclass. Plain `EditorComponent`/`Editor` * instances do not implement the action-keys, escape callbacks, and custom-key-handler surface * required by interactive mode. */ setEditorComponent( factory: ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => CustomEditor) | undefined, ): void; /** Get the current custom editor factory, if one has been installed. */ getEditorComponent(): ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => CustomEditor) | undefined; /** Get the current theme for styling. */ readonly theme: Theme; /** Get all available themes with names and paths. */ getAllThemes(): Promise<{ name: string; path: string | undefined }[]>; /** Load a theme by name without switching to it. */ getTheme(name: string): Promise; /** Set the current theme by name or Theme object. */ setTheme(theme: string | Theme): Promise<{ success: boolean; error?: string }>; /** Get current tool output expansion state. */ getToolsExpanded(): boolean; /** Set tool output expansion state. */ setToolsExpanded(expanded: boolean): void; } // ============================================================================ // Extension Context // ============================================================================ export type { ContextUsage }; export interface CompactOptions { onComplete?: (result: CompactionResult) => void; onError?: (error: Error) => void; /** * Force a one-off compaction mode for this invocation, replacing the * configured `compaction.methodOrder` (`/compact soft`, `remote`, or * `snapcompact`). Omitted = configured preference order. */ mode?: CompactMode; /** * Internal summarizer guidance — piped only to native summarization, never * exposed as `customInstructions` on the `session_before_compact` extension * hook. Used by plan-mode "Approve and compact context" so extensions that * treat `customInstructions` as user focus don't mistake plan-mode * boilerplate for the operator's intent (issue #4359). * * When both `customInstructions` and `internalGuidance` are set, the * summarizer uses `internalGuidance`; the hook still sees only the public * `customInstructions`. */ internalGuidance?: string; /** * A manual compaction aborts any turn in flight and, once the summary is * committed (or at once when there was nothing to compact), resumes it with * the auto-continue nudge. Set this when the caller dispatches its own * follow-up turn after compaction — plan-mode "Approve and compact context" — * so the two don't double-prompt. Compactions that interrupt nothing never * continue. Steer/follow-up messages queued during the compaction are * unaffected: they always drain once compaction ends (issue #5800), before * and independent of this option. */ suppressContinuation?: boolean; /** * The model that will read the compacted context when the caller switches to * it right after (a switch onto a smaller window compacts first). A * provider-native method whose replay payload that model cannot read is * skipped, so the switch does not land on a placeholder summary. */ readerModel?: Model; } /** * Context passed to extension event handlers. */ // fallow-ignore-next-line code-duplication // Parallel to HookContext: extensions expose a strictly larger runtime // surface (model registry, system prompt, shutdown, full session manager // access). Field overlap is incidental; merging into a base would require // hooks to widen their public contract. /** * Read-only model query facade exposed at `ctx.models`. Lets an extension select a * model the same way core does — list authenticated models, read the session model, * resolve a model string or role alias, and compare model families — without reaching * into the mutable registry or re-implementing matching/family heuristics. */ export interface ExtensionModelQuery { /** Authenticated models available this session (the same set `--model` selection sees). */ list(): Model[]; /** The current session model, if one is set. */ current(): Model | undefined; /** * Resolve a model string (`provider/id`, bare id) or role alias (`@slow`, a * configured role) to a Model, using the same settings-backed aliases and match * preferences as core selection. Thinking/routing suffixes are accepted and resolved * to the base model (pass effort separately). Returns undefined when nothing matches. */ resolve(spec: string): Model | undefined; /** * Opaque lineage token for "are these the same family?" comparisons — every Claude * point release shares a token, Claude and GPT differ. Backed by catalog canonical * identity. Compare it; do not persist it (the vocabulary tracks new releases). */ family(model: Model): string; } /** Runtime host mode exposed to Pi-compatible extensions. */ export type ExtensionMode = "tui" | "rpc" | "json" | "print"; /** * The agent a session runs. Extension factories are rebound to every subagent session * (task tool, eval `agent()`, `/tan` clones), so this tells a handler which agent it is serving. * An advisor's own tool calls reach the advising session's `tool_call`/`tool_result` handlers * with `{ kind: "sub", id: "advisor", name: "advisor", depth: 0, parentId: }`. */ export interface ExtensionAgentIdentity { /** * `"main"` for a top-level session, `"sub"` for any spawned session. Check this, not `depth`, * to tell subagents apart: `/tan` clones are subagents at depth 0. */ kind: "main" | "sub"; /** Agent registry id, e.g. `"Main"` or `"0-Explore"`. */ id: string; /** * Lowercased agent definition name, e.g. `"main"`, `"task"`, `"explore"`. Subagents spawned * without a definition (such as `/tan` clones) report `"sub"`. */ name: string; /** Task-tool nesting depth: 0 for a top-level session and for subagents not spawned by `task`. */ depth: number; /** Registry id of the spawning agent; absent for a top-level session. */ parentId?: string; } /** Text an annotation run targets; `file` paths resolve against the live session cwd. */ export type AnnotationTextSource = | { kind: "text"; text: string; label?: string } | { kind: "file"; path: string } /** The latest non-empty assistant reply on the active branch. */ | { kind: "last" }; /** A diff an annotation run targets, frozen once before notes are matched or the overlay opens. */ export type AnnotationDiffSource = /** A git-format patch supplied by the caller. */ | { kind: "diff"; diff: string; label?: string } /** Staged + unstaged (or jj working-copy) changes in the live session cwd. */ | { kind: "uncommitted" } /** A GitHub PR or GitLab MR: `https://github.com/owner/repo/pull/N`, `pr://owner/repo/N`, `https://///-/merge_requests/N`, or `mr://///N/diff`. */ | { kind: "pr"; ref: string }; export type AnnotationSource = AnnotationTextSource | AnnotationDiffSource; /** * A note on a text source: whole-source when `line` is omitted, else one 1-based source line. * `quote` pins the expected line text; when it differs from the current line, the note rejects. */ export interface AnnotationTextNote { note: string; line?: number; quote?: string; } /** * A note on a diff source: whole-file when `line` is omitted. `line` is a line number on * `side` (default `"new"`; use `"old"` for removed lines); `occurrence` picks among repeated * sections for the same path (default 1). `rawLine` pins the expected diff row including its * `+`/`-`/space prefix; when it differs from the matched row, the note rejects. */ export interface AnnotationDiffNote { path: string; note: string; line?: number; side?: "old" | "new"; occurrence?: number; rawLine?: string; } /** * Where the built feedback goes. `"auto"` pastes into the composer when the interactive TUI * editor is available and the text is not an LLM review request; otherwise it sends. * `"paste"` and `"send"` force one channel (`"send"` uses `pi.sendUserMessage` semantics and * queues as a follow-up while the agent is streaming); `"none"` only returns the text. */ export type AnnotationDelivery = "auto" | "paste" | "send" | "none"; interface AnnotationRequestBase { deliver?: AnnotationDelivery; } /** Diff-only options: `review` builds the full `/review` request; `focus` adds review focus text. */ interface AnnotationDiffRequestOptions { review?: boolean; focus?: string; } /** Text sources never build an LLM review request, so the diff-only options are rejected. */ interface AnnotationTextRequestOptions { review?: never; focus?: never; } export type AnnotationSubmitRequest = | (AnnotationRequestBase & AnnotationTextRequestOptions & { source: AnnotationTextSource; notes: AnnotationTextNote[] }) | (AnnotationRequestBase & AnnotationDiffRequestOptions & { source: AnnotationDiffSource; notes: AnnotationDiffNote[]; }); export type AnnotationOpenRequest = | (AnnotationRequestBase & AnnotationTextRequestOptions & { source: AnnotationTextSource }) | (AnnotationRequestBase & Pick & { source: AnnotationDiffSource }); interface AnnotationResultBase { /** Feedback exactly as `/annotate` renders it; undefined when there was nothing to report. */ text: string | undefined; /** Channel the text went to; `"none"` when it was only returned. */ delivered: "paste" | "send" | "none"; } /** Result for a text source (`text`, `file`, `last`). */ export interface AnnotationTextResult extends AnnotationResultBase { kind: "text"; review: false; /** Normalized notes: line notes carry the quoted source line. */ annotations: TextReviewAnnotation[]; /** * Set only by `open` when the operator replaced the source through the external editor * inside the overlay; `text` and `annotations` refer to this edited text, not the original. */ editedText?: string; } /** Result for a diff source (`diff`, `uncommitted`, `pr`). */ export interface AnnotationDiffResult extends AnnotationResultBase { kind: "diff"; /** Whether `text` is an LLM review request rather than pasteable notes. */ review: boolean; /** Normalized notes: line notes carry the matched diff row. */ annotations: CodeReviewAnnotation[]; } /** Discriminated by `kind`, which follows the request source. */ export type AnnotationResult = AnnotationTextResult | AnnotationDiffResult; /** * `/annotate` as an API. `submit` turns caller-supplied notes into the same feedback without UI; * `open` mounts the annotation overlay on a caller-chosen source (`mode === "tui"` only) and * resolves with the operator's notes, or undefined when dismissed. * Both reject when a source cannot be read or a note does not match its source. */ export interface ExtensionAnnotationsAPI { submit(request: AnnotationSubmitRequest): Promise; open(request: AnnotationOpenRequest): Promise; } /** * Builds `ctx.annotations` for one context. `getContext` is read per call so the API sees the * receiving context's live cwd and UI; the host injects the implementation. */ export type ExtensionAnnotationsFactory = ( getContext: () => ExtensionContext, sendUserMessage: (text: string, options?: SendUserMessageOptions) => void, ) => ExtensionAnnotationsAPI; /** Options for {@link ExtensionContext.startLive}. */ export interface ExtensionLiveStartOptions { /** * SDP offer from the extension's own WebRTC peer, e.g. a browser `RTCPeerConnection` with one * sendrecv Opus audio track and an `oai-events` data channel. That peer carries all audio; * the host opens no microphone or speaker. */ sdpOffer: string; /** Realtime voice; defaults to the `live.voice` setting. */ voice?: string; /** * Handlebars template replacing the bundled live instructions, rendered with * `{ username, firstName }` like the bundled prompt. */ instructions?: string; /** * Phase changes (`connecting`, `listening`, `working`, `muted`, `error`). `muted` is a request: * the host has no track to mute, so silence the peer's microphone track until the next phase. */ onPhase?(phase: LivePhase): void; /** Streaming and final transcripts of both sides of the conversation. */ onTranscript?(transcript: LiveTranscript): void; /** * Called exactly once when the session ends, with the failure that ended it if any. A start * that fails to connect calls it too, before `startLive` rejects. */ onEnd?(error?: Error): void; } /** A live voice session started by {@link ExtensionContext.startLive}. */ export interface ExtensionLiveSession { /** SDP answer to apply to the extension's peer. */ sdpAnswer: string; /** Voice in use. */ voice: string; /** * Ends the session; resolves once it has stopped. Safe to call repeatedly. The host has no media * peer, so it never sees the extension's peer go away: call this when that peer disconnects * (`connectionstatechange` to `failed` or `closed`, or the relay socket closing). Until then the * call stays open and the TUI keeps its live visualizer up. */ stop(): Promise; } export interface ExtensionContext { /** UI methods for user interaction */ ui: ExtensionUIContext; /** Current run mode. Use `"tui"` to guard terminal-only UI such as custom components. */ mode: ExtensionMode; /** Get current context usage for the active model. */ getContextUsage(): ContextUsage | undefined; /** Get a read-only snapshot of async jobs owned by this session. */ getAsyncJobSnapshot(): AsyncJobSnapshot | null; /** Compact the session context (interactive mode shows UI). */ compact(instructionsOrOptions?: string | CompactOptions): Promise; /** Whether UI is available (false in print/RPC mode) */ hasUI: boolean; /** Current working directory */ cwd: string; /** Session manager (read-only) */ sessionManager: ReadonlySessionManager; /** Model registry for API key resolution */ modelRegistry: ModelRegistry; /** Calling session's `local://` root mapping for external tool bridges. */ localProtocolOptions?: LocalProtocolOptions; /** Current model (may be undefined) */ model: Model | undefined; /** Read-only model query facade: list / current / resolve / family. */ models: ExtensionModelQuery; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; /** Abort the current agent operation */ abort(): void; /** Whether there are queued messages waiting */ hasPendingMessages(): boolean; /** Gracefully shutdown and exit. */ shutdown(): void; /** * Answer the open interactive plan review as if the operator picked `choice` in it. * `refine` needs non-empty `input.feedback`, which is sent as the next planning prompt. * `save` writes the plan under its default file name without the path prompt. * Approvals execute the plan as it is when picked (in-overlay edits, else the plan * file), which may differ from the `planContent` that `plan_review_requested` reported. * `input.role` selects an approval's execution tier, matched against `plan_review_requested`'s * `tiers` by role name; without it the approval runs on the tier selected in the * overlay's model slider. `role` applies only to approvals (`execute`, `compact`, `keep`). * False when that review is not open, the choice is disabled or missing its feedback, * `input.role` names no selectable tier or accompanies a non-approval choice, or the * mode has no plan review. */ resolvePlanReview(reviewId: string, choice: PlanReviewChoice, input?: { feedback?: string; role?: string }): boolean; /** * The open interactive plan review still awaiting an answer, as `plan_review_requested` reported it, * or `undefined`. Lets an extension that loaded or reconnected after the event answer the review. */ getPendingPlanReview(): PlanReviewRequestedEvent | undefined; /** * Open the interactive plan review on the newest plan file, like `/plan-review`, while the * session is idle. True once a review is awaiting an answer (an already-open review is kept, * not replaced), without waiting for a choice. False, without aborting anything or showing * warnings, when plan mode is inactive, the agent is streaming or compacting, or no non-empty * plan file exists. */ openPlanReview(): Promise; /** * Query and control the built-in plan, vibe, goal, and loop modes. Interactive (TUI) sessions * only: `undefined` in print, JSON, RPC, and ACP modes and in subagents. */ sessionModes?: ExtensionSessionModes; /** Identity of the agent this session runs: the top-level session or a subagent. */ agent: ExtensionAgentIdentity; /** * Whether the current project/workspace is trusted. OMP performs no * project-trust gating — project-level settings and extensions load * unconditionally — so this always returns `true`. Exposed for * compatibility with extensions authored against upstream Pi, whose * `SettingsManager` accepts a `projectTrusted` flag. */ isProjectTrusted(): boolean; /** Get the current effective system prompt. */ getSystemPrompt(): string[]; /** Run a /btw-style side turn without appending to history or executing tool calls. * Pass tools: false to omit tool definitions; existing context/provider hooks still run. * Inherits event-handler and registered-tool cancellation, combined with options.signal. * Hooks reached within a running side turn cannot start another one (bounded recursion). * Optional for compatibility with hosts that do not provide side turns. */ runEphemeralTurn?(options: EphemeralTurnOptions): Promise; /** * Start a Codex live voice session bound to this session, like `/live`, but with audio on the * extension's own WebRTC peer (for example a browser on another device). Delegated work runs as * normal turns of this session. Rejects while another live session is active. * Optional: present only in hosts that run live sessions (currently interactive mode). */ startLive?(options: ExtensionLiveStartOptions): Promise; /** Structured memory runtime for status/search/save across the configured backend. */ memory?: MemoryRuntimeContext; /** `/annotate` as an API: build or collect annotation feedback on text and diffs. */ annotations: ExtensionAnnotationsAPI; /** * Schedule a repeating callback whose throws are contained. Unlike raw * `setInterval`, a synchronous throw or rejected promise from `callback` is * logged and surfaced through the extension error channel instead of * escaping as a process-fatal `uncaughtException` — one misbehaving timer * can no longer take down the whole session. The handle is `unref`'d and * cleared automatically on `session_shutdown`. Prefer this over raw * `setInterval` for any extension background work. */ setInterval(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer; /** * Schedule a one-shot callback whose throws are contained, mirroring * {@link setInterval}. Cleared automatically on `session_shutdown` if it has * not yet fired. */ setTimeout(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer; /** Clear a timer scheduled via {@link setInterval} or {@link setTimeout}. */ clearTimer(timer: Timer): void; /** * Attach trusted, extension-authored instructions to the next provider * request with developer/system priority where supported. Present only while * a registered tool is executing. Raw tool output and other untrusted data * must stay in the ordinary tool result. */ addAdditionalContext?(context: string): void; /** * Run the NATIVE built-in implementation of the tool this handler re-registered, with `params`, * and return its result. Lets a tool that re-registers a built-in (e.g. wrapping `write` to add * logging or a policy check) delegate to the original instead of reimplementing it — the native * tool performs its own side effects and internal bookkeeping. * * Delegation is same-tool only: it invokes the built-in of the SAME name as the registering tool, * never an arbitrary target, so it cannot escalate past the approval already granted for this * call. Present only when a native built-in of that name exists (undefined otherwise, e.g. for a * net-new tool that shadows no built-in). Recursion is depth-guarded per call chain. */ invokeTool?( params: Record, options?: { signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback }, ): Promise>; /** * Whether project-local inputs for the current working directory (extensions, settings, * skills, resources) are trusted. Upstream `@earendil-works/pi-coding-agent` (>=0.79) asks the * user once per directory before loading project-local inputs and exposes the saved decision * here; extensions written against that API (e.g. Plannotator) feature-detect this method to * decide whether project-local config is safe to load, and warn when it is absent. * * OMP has no equivalent per-directory trust gate: `.omp/extensions`, `.omp/config.yml`, and * other project-local inputs are already discovered and loaded unconditionally (see * `docs/extension-loading.md`). This method exists for compatibility with that upstream surface * and always returns `true`, truthfully reflecting that OMP already trusts project-local inputs * by default -- it does not narrow or widen OMP's own security model. */ isProjectTrusted(): boolean; } /** * Extended context for command handlers. * Includes session control methods only safe in user-initiated commands. */ // fallow-ignore-next-line code-duplication // Parallel to HookCommandContext: same method names, different invariants — // extension commands additionally permit `switchSession` and `reload`, // which hooks must not call to avoid deadlocking the agent loop. export interface ExtensionCommandContext extends ExtensionContext { /** Get current context usage for the active model. */ getContextUsage(): ContextUsage | undefined; /** Wait for the agent to finish streaming */ waitForIdle(): Promise; /** Start a new session, optionally with initialization. */ newSession(options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise; }): Promise<{ cancelled: boolean }>; /** Branch from a specific entry, creating a new session file. */ branch(entryId: string): Promise<{ cancelled: boolean }>; /** Navigate to a different point in the session tree. */ navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>; /** Switch to a different session file. */ switchSession(sessionPath: string): Promise<{ cancelled: boolean }>; /** Reload the current session/runtime state. */ reload(): Promise; /** Compact the session context (interactive mode shows UI). */ compact(instructionsOrOptions?: string | CompactOptions): Promise; } // ============================================================================ // Tool Types // ============================================================================ /** Rendering options for tool results */ export interface ToolRenderResultOptions { /** Whether the result view is expanded */ expanded: boolean; /** Whether this is a partial/streaming result */ isPartial: boolean; /** Current spinner frame index for animated elements (optional) */ spinnerFrame?: number; } /** Session event for tool onSession lifecycle */ export interface ToolSessionEvent { /** Reason for the session event */ reason: "start" | "switch" | "branch" | "tree" | "shutdown"; /** Previous session file path, or undefined for "start" and "shutdown" */ previousSessionFile: string | undefined; } /** Shell invocation details supplied to a registered tool's environment hook. */ export interface ToolShellEnvironmentContext { command: string; cwd: string; env: Record; } /** Supplies environment values for a user-initiated shell invocation. */ export type ToolShellEnvironmentHook = (context: ToolShellEnvironmentContext) => Record | undefined; /** * Tool definition for registerTool(). */ export interface ToolDefinition { /** Tool name (used in LLM tool calls) */ name: string; /** Human-readable label for UI */ label: string; /** Description for LLM */ description: string; /** Parameter schema (Zod, or TypeBox for legacy/extension compat). */ parameters: TParams; /** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */ hidden?: boolean; /** If true, tool is registered but not auto-included in the initial active set. * The registering extension is responsible for activating/deactivating it via setActiveTools(). */ defaultInactive?: boolean; /** How this tool is presented when enabled. See {@link ToolLoadMode}. Extension tools default to `"discoverable"`; set `"essential"` to stay top-level. */ loadMode?: ToolLoadMode; /** If true, tool may stage deferred changes that require explicit resolve/discard. */ deferrable?: boolean; /** Whether this tool can read `skill://` instruction content. */ readsSkillUris?: boolean; /** Tool approval tier. Defaults to `"exec"` when omitted. * `"read"`: read-only operations. `"write"`: mutations. `"exec"`: code execution. */ approval?: ToolApproval; /** Exact targets an approval mode may remember for this call (`always-ask-cached`), one key per * target, derived from the call's args. Omit it and approvals are never remembered per target. */ approvalCacheTargets?: (args: unknown) => readonly string[] | undefined; /** Structured-output strict grammar opt-in/out. `false` is meaningful: OpenAI-family * serializers preserve an explicit `strict: false` on the wire (#4336/#4340). */ strict?: boolean; /** MCP server name for discovery/search metadata when this tool fronts an MCP server. */ mcpServerName?: string; /** Original MCP tool name for discovery/search metadata. */ mcpToolName?: string; /** MCP read-only annotation, independent of the approval tier. */ mcpReadOnlyHint?: boolean; /** Previous public name when a rename changed minting. Forwarded through * RegisteredToolAdapter so approval falls back to legacy `deny`/`prompt`. */ legacyName?: string; /** Optional environment hook applied when the interactive user shell invokes this tool's shell surface. */ shellEnv?: ToolShellEnvironmentHook; /** Authoritative originating file for a discovered custom-tool module. */ sourcePath?: string; /** Execute the tool. */ execute( toolCallId: string, params: Static, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, ctx: ExtensionContext, ): Promise>; /** Called on session lifecycle events - use to reconstruct state or cleanup resources */ onSession?: (event: ToolSessionEvent, ctx: ExtensionContext) => void | Promise; /** * Custom rendering for tool call display. * * At runtime `options` also answers the {@link Theme} API, so renderers * ported from upstream pi — declared `renderCall(args, theme, context)` — * keep styling correctly. */ renderCall?: (args: Static, options: ToolRenderResultOptions, theme: Theme) => Component; /** Custom rendering for tool result display */ renderResult?: ( result: AgentToolResult, options: ToolRenderResultOptions, theme: Theme, args?: Static, ) => Component; /** Semantic call view for TSP terminals (the native counterpart of {@link renderCall}). */ describeCall?: (args: Static, options: ToolRenderResultOptions) => NativeToolView | undefined; /** Semantic result view for TSP terminals (the native counterpart of {@link renderResult}). */ describeResult?: ( result: AgentToolResult, options: ToolRenderResultOptions, args?: Static, ) => NativeToolView | undefined; } /** Whether a tool's source is scoped to the user, the project, or a transient runtime session. */ export type SourceScope = "user" | "project" | "temporary"; /** Whether a tool's source came from an installed package or a top-level (loose) file. */ export type SourceOrigin = "package" | "top-level"; /** * Provenance metadata describing where a registered tool came from. Mirrors the * `@earendil-works/pi-coding-agent` `SourceInfo` contract so extensions authored * against upstream pi (e.g. gentle-pi) can read `sourceInfo.source` unchanged. */ export interface SourceInfo { /** Synthetic or on-disk identifier for the tool's origin (e.g. ``). */ path: string; /** Origin class: `"builtin"`, `"sdk"`, `"mcp"`, or `"extension"`. */ source: string; scope: SourceScope; origin: SourceOrigin; baseDir?: string; } /** Tool metadata returned by {@link ExtensionAPI.getAllTools}: identity, schema, and source provenance. */ export interface ToolInfo { name: string; description: string; parameters: TSchema; promptGuidelines?: string[]; sourceInfo: SourceInfo; /** MCP server name (as configured) when this tool fronts an MCP server. */ mcpServerName?: string; /** Original MCP tool name, before `mcp___` minting. */ mcpToolName?: string; /** The MCP tool's `annotations.readOnlyHint`, when it declared one. Lets an * extension tell a read-only MCP call from one that changes things (e.g. * to allow only read-only tools in a restricted turn). */ mcpReadOnlyHint?: boolean; } // ============================================================================ // Resource Events // ============================================================================ /** Fired after session_start to allow extensions to provide additional resource paths. */ export interface ResourcesDiscoverEvent { type: "resources_discover"; cwd: string; reason: "startup" | "reload"; } /** Result from resources_discover event handler */ export interface ResourcesDiscoverResult { skillPaths?: string[]; promptPaths?: string[]; themePaths?: string[]; } // ============================================================================ // Session Events (shared with hooks subsystem) // ============================================================================ export type { SessionBeforeBranchEvent, SessionBeforeCompactEvent, SessionBeforeSwitchEvent, SessionBeforeTreeEvent, SessionBranchEvent, SessionBranchReason, SessionCompactEvent, SessionCompactingEvent, SessionEvent, SessionShutdownEvent, SessionStartEvent, SessionSwitchEvent, SessionTreeEvent, TreePreparation, } from "../shared-events"; // ============================================================================ // Agent Events // ============================================================================ export type { ContextEvent } from "../shared-events"; // ============================================================================ // Cache Warming Events // ============================================================================ export type { CacheWarmingDecisionEvent, CacheWarmingDecisionEventResult } from "../shared-events"; export type { CacheWarmingAction, CacheWarmingDecision, CacheWarmingMode, CacheWarmingStatus, } from "../../session/cache-warmer"; /** Fired before a provider request is sent. Can replace the payload. */ export interface BeforeProviderRequestEvent { type: "before_provider_request"; payload: unknown; } /** Fired after a provider response is received, before its stream body is consumed. */ export interface AfterProviderResponseEvent extends ProviderResponseMetadata { type: "after_provider_response"; } /** * Fired before an ordinary prompt or an actually dequeued user-containing batch reaches the provider * (`reason: "prompt"`), and once with an empty `prompt` when a parked subagent is revived * (`reason: "revive"`, result discarded). */ export interface BeforeAgentStartEvent { type: "before_agent_start"; reason: "prompt" | "revive"; /** Already-transformed text; queued batches join user messages with two newlines, excluding agent companions. */ prompt: string; /** Already-normalized user images in delivery order. */ images?: ImageContent[]; systemPrompt: string[]; } /** Fired in the parent session before a subagent (task tool or eval `agent()`) resolves its model. */ export interface BeforeSubagentSpawnEvent { type: "before_subagent_spawn"; /** Agent definition name being spawned. */ agent: string; invocationKind: "task" | "eval"; /** Pre-expansion role alias the patterns came from (`@task` -> "task"); undefined for explicit selectors. */ modelRole?: string; /** Expanded model patterns core would spawn with, in attempt order. */ patterns: string[]; /** Stable per-spawn key for deterministic selection, when the caller supplies one. */ spawnKey?: string; } export type { AgentEndEvent, AgentStartEvent, SessionStopEvent, SessionStopEventResult, TurnEndEvent, TurnStartEvent, } from "../shared-events"; /** Fired when a message starts (user, assistant, or toolResult) */ export interface MessageStartEvent { type: "message_start"; message: AgentMessage; } /** Fired during assistant message streaming with token-by-token updates */ export interface MessageUpdateEvent { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent; } /** * Fired when a message ends. Notification-only: the message is a detached * snapshot, so in-place changes do not rewrite agent or provider context. * Persistence and subscriber delivery do not wait for this handler to finish. * Use `assistant_message` to rewrite a finalized assistant message. */ export interface MessageEndEvent { type: "message_end"; message: AgentMessage; } /** * Fired once per finalized assistant message, after the provider stream settles * and before the message reaches agent context, `message_end` listeners (TUI, * RPC, exporters), session persistence, or tool dispatch. Return * {@link AssistantMessageRewriteResult} to replace its content; the replacement * is the single source of truth for history, persistence, `message_end` * consumers, and the next provider request. Text already streamed through * `message_update` is not retracted, so stream-rendering clients may keep * showing the original. Handlers chain: each sees the previous handler's * replacement. * * `message` is a detached copy — in-place mutation has no effect; return * `content` instead. If cancellation arrives while handlers are pending, * rewrites accepted so far are returned and remaining handlers are skipped. * This event is not fired if the provider stream is cut off before finalizing. */ export interface AssistantMessageRewriteEvent { type: "assistant_message"; message: AssistantMessage; } /** Fired when a tool starts executing */ export interface ToolExecutionStartEvent { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown; intent?: string; } /** Fired during tool execution with partial/streaming output */ export interface ToolExecutionUpdateEvent { type: "tool_execution_update"; toolCallId: string; toolName: string; args: unknown; partialResult: unknown; } /** Fired when a tool finishes executing */ export interface ToolExecutionEndEvent { type: "tool_execution_end"; toolCallId: string; toolName: string; result: unknown; isError: boolean; } export type { AutoCompactionEndEvent, AutoCompactionStartEvent, AutoRetryEndEvent, AutoRetryStartEvent, RetryFallbackAppliedEvent, RetryFallbackSucceededEvent, TodoReminderEvent, TtsrTriggeredEvent, } from "../shared-events"; /** Fired when AuthStorage automatically soft-disables a credential (e.g. OAuth `invalid_grant`). Not fired for user-initiated `remove()` or duplicate-credential dedup. */ export interface CredentialDisabledEvent { type: "credential_disabled"; /** Provider id whose credential was disabled (e.g. "anthropic"). */ provider: string; /** Verbatim error captured for forensics (truncated upstream). */ disabledCause: string; /** Database row id of the disabled credential. */ credentialId?: number; /** Account identity recorded on the disabled OAuth credential, when the provider supplied one. */ email?: string; accountId?: string; /** Organization/workspace the credential was scoped to. */ orgId?: string; orgName?: string; } // ============================================================================ // MCP Events // ============================================================================ /** * Fired for every JSON-RPC notification received from a connected MCP server, * AFTER the runtime's own handling of known list/update methods. Unknown or * server-custom methods are delivered too — extensions can bridge them into * session behavior by inspecting `method`/`params` and injecting a follow-up * via `pi.sendMessage(..., { deliverAs })` or `pi.sendUserMessage(...)`. */ export interface McpNotificationEvent { type: "mcp_notification"; /** * Server name as declared in the MCP config (raw, unsanitized). Note this * differs from the sanitized prefix used in `mcp___` * tool names — filter by this raw name, not by tool-name prefix matching. */ server: string; /** JSON-RPC method (e.g. `notifications/tools/list_changed`, or server-custom). */ method: string; /** JSON-RPC params, opaque to the runtime. */ params: unknown; } // ============================================================================ // Link Activation Events // ============================================================================ /** * The user picked a link whose scheme this extension registered with `pi.registerLinkScheme()`, * by its hint tag or a click. Interactive mode only; delivered to the owning extension only, and * run without the event-handler timeout, like a command. The URL is untrusted: the model can write * such a link with any URL behind any label, so validate it like user input. */ export interface LinkActivateEvent { type: "link_activate"; /** The link's full URL as drawn, e.g. `cc-stretch:open/1792/0/2`. Untrusted. */ url: string; /** The registered scheme the URL uses, lowercase and without `:`. */ scheme: string; } // ============================================================================ // User Bash Events // ============================================================================ /** Fired when user executes a bash command via ! or !! prefix */ export interface UserBashEvent { type: "user_bash"; /** The command to execute */ command: string; /** True if !! prefix was used (excluded from LLM context) */ excludeFromContext: boolean; /** Current working directory */ cwd: string; } // ============================================================================ // User Python Events // ============================================================================ /** Fired when user executes Python code via $ or $$ prefix */ export interface UserPythonEvent { type: "user_python"; /** The Python code to execute */ code: string; /** True if $$ prefix was used (excluded from LLM context) */ excludeFromContext: boolean; /** Current working directory */ cwd: string; } // ============================================================================ // Input Events // ============================================================================ /** Fired when the user submits input (interactive mode only). */ export interface InputEvent { type: "input"; text: string; images?: ImageContent[]; source: "interactive" | "rpc" | "extension"; } // ============================================================================ // Tool Events // ============================================================================ export interface ToolApprovalRequestedEvent { type: "tool_approval_requested"; sessionId: string; toolCallId: string; toolName: string; reason?: string; approvalMode: ApprovalMode; } export interface ToolApprovalResolvedEvent { type: "tool_approval_resolved"; sessionId: string; toolCallId: string; toolName: string; approved: boolean; reason?: string; } /** Stable ids for the interactive plan review's choices; labels may change (keep's label carries a token count). */ export type PlanReviewChoice = "execute" | "compact" | "keep" | "refine" | "save"; /** A choice as displayed in the interactive plan review, delivered in display order by `plan_review_requested`. */ export interface PlanReviewOption { id: PlanReviewChoice; label: string; disabled: boolean; } /** An execution tier selectable in the plan review's model slider; pass `role` back as `input.role` to `ctx.resolvePlanReview`. */ export interface PlanReviewTier { role: string; provider: string; model: string; /** Thinking level the role's configured model pins (a `provider/model:high` suffix); absent when it inherits. */ thinkingLevel?: ConfiguredThinkingLevel; } /** * The interactive plan review opened (interactive mode only). Resolve it with `ctx.resolvePlanReview`. * `plan_review_resolved` waits until every handler of this event returns, so handlers should hand * long work (a remote round-trip, a dialog) off rather than await it. */ export interface PlanReviewRequestedEvent { type: "plan_review_requested"; reviewId: string; sessionId: string; title: string; planFilePath: string; planContent: string; options: PlanReviewOption[]; /** Execution tiers in slider order (not selection order); omitted when fewer than two resolve. */ tiers?: PlanReviewTier[]; } /** * The plan review closed: a choice, or `undefined` when it was dismissed or cancelled (session switch, * replacement review, shutdown). `by` is `"extension"` only for a choice made via `ctx.resolvePlanReview`. */ export interface PlanReviewResolvedEvent { type: "plan_review_resolved"; reviewId: string; sessionId: string; choice: PlanReviewChoice | undefined; by: "local" | "extension"; } /** * An explicit, idempotent mode transition. Requesting a state that already holds succeeds with * `changed: false`. No transition submits a prompt; send one separately (e.g. `pi.sendUserMessage`). * Plan `exit` leaves plan mode fully without approving the plan or deleting its draft. Loop `prompt` * sets the text repeated after each turn ends (otherwise the user's next typed prompt); on an active * loop it replaces that text, and on a paused loop `enter` resumes it. */ export type SessionModeRequest = | { mode: "plan" | "vibe"; action: "enter" | "exit" } | { mode: "goal"; action: "enter"; objective: string; tokenBudget?: number } | { mode: "goal"; action: "pause" | "resume" | "drop" } | { mode: "loop"; action: "enter"; prompt?: string; limit?: LoopLimitConfig; condition?: LoopConditionConfig } | { mode: "loop"; action: "exit" }; /** Detached snapshot of the built-in session modes. `paused` states come from the slash commands or a goal pause. */ export interface SessionModeState { plan: { status: "inactive" | "active" | "paused"; planFilePath?: string }; vibe: { status: "inactive" | "active" }; goal: { status: "inactive" | "active" | "paused"; objective?: string; tokenBudget?: number }; loop: { status: "inactive" | "active" | "paused" }; } /** * Outcome of `ctx.sessionModes.set()`. `busy`: a turn, compaction, or another mode transition is * running, the session is still restoring its saved modes (startup or a session switch), or a * completed goal is still being torn down — retry once idle. `conflict`: another mode blocks this one * (exit it first). `disabled`: a setting gates it (`extensionHandlers.modeControl`, `plan.enabled`, * `goal.enabled`). `invalid_request`: malformed or not applicable now. */ export type SessionModeResult = | { ok: true; changed: boolean; state: SessionModeState } | { ok: false; code: "busy" | "conflict" | "disabled" | "invalid_request"; message: string; state: SessionModeState; }; /** Direct built-in mode control, without slash commands or dialogs. */ export interface ExtensionSessionModes { getState(): SessionModeState; set(request: SessionModeRequest): Promise; } /** A built-in session mode changed, from any source: `ctx.sessionModes`, a slash command, a tool, or session restore. */ export interface ModeChangedEvent { type: "mode_changed"; previous: SessionModeState; state: SessionModeState; } interface ToolCallEventBase { type: "tool_call"; toolCallId: string; } export interface BashToolCallEvent extends ToolCallEventBase { toolName: "bash"; input: BashToolInput; } export interface ReadToolCallEvent extends ToolCallEventBase { toolName: "read"; input: ReadToolInput; } export interface EditToolCallEvent extends ToolCallEventBase { toolName: "edit"; input: Record; } export interface WriteToolCallEvent extends ToolCallEventBase { toolName: "write"; input: WriteToolInput; } export interface GrepToolCallEvent extends ToolCallEventBase { toolName: "grep"; input: GrepToolInput; } export interface GlobToolCallEvent extends ToolCallEventBase { toolName: "glob"; input: GlobToolInput; } export interface CustomToolCallEvent extends ToolCallEventBase { toolName: string; input: Record; } /** Fired before a tool executes. Can block. */ export type ToolCallEvent = | BashToolCallEvent | ReadToolCallEvent | EditToolCallEvent | WriteToolCallEvent | GrepToolCallEvent | GlobToolCallEvent | CustomToolCallEvent; interface ToolResultEventBase { type: "tool_result"; toolCallId: string; input: Record; content: (TextContent | ImageContent)[]; isError: boolean; } export interface BashToolResultEvent extends ToolResultEventBase { toolName: "bash"; details: BashToolDetails | undefined; } export interface ReadToolResultEvent extends ToolResultEventBase { toolName: "read"; details: ReadToolDetails | undefined; } export interface EditToolResultEvent extends ToolResultEventBase { toolName: "edit"; details: EditToolDetails | undefined; } export interface WriteToolResultEvent extends ToolResultEventBase { toolName: "write"; details: undefined; } export interface GrepToolResultEvent extends ToolResultEventBase { toolName: "grep"; details: GrepToolDetails | undefined; } export interface GlobToolResultEvent extends ToolResultEventBase { toolName: "glob"; details: GlobToolDetails | undefined; } export interface CustomToolResultEvent extends ToolResultEventBase { toolName: string; details: unknown; } /** Fired after a tool executes. Can modify result. */ export type ToolResultEvent = | BashToolResultEvent | ReadToolResultEvent | EditToolResultEvent | WriteToolResultEvent | GrepToolResultEvent | GlobToolResultEvent | CustomToolResultEvent; /** * Type guard for narrowing ToolCallEvent by tool name. * * Built-in tools narrow automatically (no type params needed): * ```ts * if (isToolCallEventType("bash", event)) { * event.input.command; // string * } * ``` * * Custom tools require explicit type parameters: * ```ts * if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) { * event.input.action; // typed * } * ``` * * Note: Direct narrowing via `event.toolName === "bash"` doesn't work because * CustomToolCallEvent.toolName is `string` which overlaps with all literals. */ export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent; export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent; export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent; export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent; export function isToolCallEventType(toolName: "grep", event: ToolCallEvent): event is GrepToolCallEvent; export function isToolCallEventType(toolName: "glob", event: ToolCallEvent): event is GlobToolCallEvent; export function isToolCallEventType>( toolName: TName, event: ToolCallEvent, ): event is ToolCallEvent & { toolName: TName; input: TInput }; export function isToolCallEventType(toolName: string, event: ToolCallEvent): boolean { return event.toolName === toolName; } /** Union of all event types */ export type ExtensionEvent = | ResourcesDiscoverEvent | SessionEvent | ContextEvent | CacheWarmingDecisionEvent | BeforeProviderRequestEvent | AfterProviderResponseEvent | BeforeAgentStartEvent | BeforeSubagentSpawnEvent | AgentStartEvent | AgentEndEvent | SessionStopEvent | TurnStartEvent | TurnEndEvent | MessageStartEvent | MessageUpdateEvent | MessageEndEvent | AssistantMessageRewriteEvent | ToolExecutionStartEvent | ToolExecutionUpdateEvent | ToolExecutionEndEvent | AutoCompactionStartEvent | AutoCompactionEndEvent | AutoRetryStartEvent | AutoRetryEndEvent | RetryFallbackAppliedEvent | RetryFallbackSucceededEvent | TtsrTriggeredEvent | TodoReminderEvent | GoalUpdatedEvent | CredentialDisabledEvent | McpNotificationEvent | LinkActivateEvent | UserBashEvent | UserPythonEvent | InputEvent | ToolCallEvent | ToolResultEvent | ToolApprovalRequestedEvent | ToolApprovalResolvedEvent | PlanReviewRequestedEvent | PlanReviewResolvedEvent | ModeChangedEvent; // ============================================================================ // Event Results // ============================================================================ export interface ContextEventResult { messages?: AgentMessage[]; } /** * Result from an `assistant_message` handler. Return `undefined` to leave the * message unchanged. * * Text blocks must remain in their original positions: only their `text` may * change. Non-text blocks and all other block metadata must remain unchanged. * A text block with unchanged text keeps its original `textSignature` even if * a handler replaces it; editing text removes its signature because that * provider replay state cannot be reused for different text. Invalid * replacements are reported as extension errors and skipped. */ export interface AssistantMessageRewriteResult { content?: AssistantMessage["content"]; } export type BeforeProviderRequestEventResult = unknown; export type { ToolCallEventResult } from "../shared-events"; /** Result from input event handler */ export interface InputEventResult { /** If true, the input was handled and should not continue through normal flow */ handled?: boolean; /** Replace the input text */ text?: string; /** Replace any pending images */ images?: ImageContent[]; } /** Result from user_bash event handler */ export interface UserBashEventResult { /** Full replacement: extension handled execution, use this result */ result?: BashResult; } /** Result from user_python event handler */ export interface UserPythonEventResult { /** Full replacement: extension handled execution, use this result */ result?: PythonResult; } export type { ToolResultEventResult } from "../shared-events"; export interface BeforeAgentStartEventResult { message?: CustomMessagePayload; /** Replace policy for the next request and its continuations, until the next preparation. Extensions chain in order. */ systemPrompt?: string[]; } export interface BeforeSubagentSpawnEventResult { /** Replacement model patterns in attempt order (selectors or role aliases). Role identity is preserved. */ model?: string | string[]; /** Refuse the spawn. */ block?: boolean; /** Refusal reason surfaced to the caller. */ reason?: string; /** Human-readable routing explanation surfaced with the resolved model. */ note?: string; } export type { SessionBeforeBranchResult, SessionBeforeCompactResult, SessionBeforeSwitchResult, SessionBeforeTreeResult, SessionCompactingResult, } from "../shared-events"; // ============================================================================ // Message Rendering // ============================================================================ // ============================================================================ // Command Registration // ============================================================================ // fallow-ignore-next-line code-duplication // Parallel to HookAPI's RegisteredCommand: extensions add // `getArgumentCompletions` and bind handlers to ExtensionCommandContext. export interface RegisteredCommand { name: string; description?: string; getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null; handler: (args: string, ctx: ExtensionCommandContext) => Promise; } // ============================================================================ // Extension API // ============================================================================ /** Handler function type for events */ export type ExtensionHandler = (event: E, ctx: ExtensionContext) => Promise | R | void; /** Service tiers accepted by each provider family. */ export type ExtensionServiceTier = Family extends "anthropic" ? "priority" : Family extends "google" ? "flex" | "priority" : ServiceTier; /** * What holds a keyboard chord, as reported by {@link ExtensionAPI.getShortcutConflict}. * * - `reserved`: omp keeps the chord; a shortcut on it is never bound. * - `keybinding`: these keymap actions are bound to it in the main editor: `app.*` actions, which * usually take the key first where they apply, and `tui.editor.*` / `tui.input.*` editing keys, * which a shortcut on the chord would take over. * - `extension`: another extension's shortcut is on it; only one of them runs. */ export type ShortcutConflict = | { kind: "reserved" } | { kind: "keybinding"; keybindings: Keybinding[] } | { kind: "extension"; extensionPath: string }; /** * ExtensionAPI passed to extension factory functions. * * Methods retain their extension binding when destructured or passed as callbacks. */ export interface ExtensionAPI { // ========================================================================= // Module Access // ========================================================================= /** File logger for error/warning/debug messages */ logger: typeof PiLogger; /** Injected TypeBox shim for legacy `Type.Object(...)` parameter authoring. */ typebox: typeof TypeBox; /** Injected omptype schema builder for extension tools. */ arktype: typeof ArkType; /** Injected Zod-compatible omptype builder for extension tools. */ zod: typeof zod; /** Injected pi-coding-agent exports for accessing SDK utilities */ pi: typeof PiCodingAgent; // ========================================================================= // Event Subscription // ========================================================================= on(event: "resources_discover", handler: ExtensionHandler): void; on(event: "session_start", handler: ExtensionHandler): void; on( event: "session_before_switch", handler: ExtensionHandler, ): void; on(event: "session_switch", handler: ExtensionHandler): void; on( event: "session_before_branch", handler: ExtensionHandler, ): void; on(event: "session_branch", handler: ExtensionHandler): void; on( event: "session_before_compact", handler: ExtensionHandler, ): void; on(event: "session.compacting", handler: ExtensionHandler): void; on( event: "cache_warming_decision", handler: ExtensionHandler, ): void; on(event: "session_compact", handler: ExtensionHandler): void; on(event: "session_shutdown", handler: ExtensionHandler): void; on(event: "session_before_tree", handler: ExtensionHandler): void; on(event: "session_tree", handler: ExtensionHandler): void; on(event: "context", handler: ExtensionHandler): void; on( event: "before_provider_request", handler: ExtensionHandler, ): void; on(event: "after_provider_response", handler: ExtensionHandler): void; on(event: "before_agent_start", handler: ExtensionHandler): void; on( event: "before_subagent_spawn", handler: ExtensionHandler, ): void; on(event: "agent_start", handler: ExtensionHandler): void; on(event: "agent_end", handler: ExtensionHandler): void; on(event: "session_stop", handler: ExtensionHandler): void; on(event: "turn_start", handler: ExtensionHandler): void; on(event: "turn_end", handler: ExtensionHandler): void; on(event: "message_start", handler: ExtensionHandler): void; on(event: "message_update", handler: ExtensionHandler): void; on(event: "message_end", handler: ExtensionHandler): void; on( event: "assistant_message", handler: ExtensionHandler, ): void; on(event: "tool_execution_start", handler: ExtensionHandler): void; on(event: "tool_execution_update", handler: ExtensionHandler): void; on(event: "tool_execution_end", handler: ExtensionHandler): void; on(event: "auto_compaction_start", handler: ExtensionHandler): void; on(event: "auto_compaction_end", handler: ExtensionHandler): void; on(event: "auto_retry_start", handler: ExtensionHandler): void; on(event: "auto_retry_end", handler: ExtensionHandler): void; on(event: "retry_fallback_applied", handler: ExtensionHandler): void; on(event: "retry_fallback_succeeded", handler: ExtensionHandler): void; on(event: "ttsr_triggered", handler: ExtensionHandler): void; on(event: "todo_reminder", handler: ExtensionHandler): void; on(event: "goal_updated", handler: ExtensionHandler): void; on(event: "credential_disabled", handler: ExtensionHandler): void; on(event: "input", handler: ExtensionHandler): void; on(event: "tool_approval_requested", handler: ExtensionHandler): void; on(event: "tool_approval_resolved", handler: ExtensionHandler): void; on(event: "plan_review_requested", handler: ExtensionHandler): void; on(event: "plan_review_resolved", handler: ExtensionHandler): void; on(event: "mode_changed", handler: ExtensionHandler): void; on(event: "tool_call", handler: ExtensionHandler): void; on(event: "tool_result", handler: ExtensionHandler): void; on(event: "user_bash", handler: ExtensionHandler): void; on(event: "user_python", handler: ExtensionHandler): void; on(event: "mcp_notification", handler: ExtensionHandler): void; on(event: "link_activate", handler: ExtensionHandler): void; // ========================================================================= // Tool Registration // ========================================================================= /** Register a tool that the LLM can call. */ registerTool(tool: ToolDefinition): void; /** * Register a fallback writer consulted when a native `write`/`edit` byte-write is * denied with a permission error (`EPERM`/`EACCES`/`EROFS`). Every other write * error is unaffected. Handlers run in registration order; the first one to * resolve `true` counts as the bytes being durably on disk, and the native tool * continues 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. Intended for a host embedding the agent inside a sandbox * that denies direct filesystem writes but exposes a privileged write channel. * * A denial that `Bun.write` masks as `ENOENT` — a write into a directory the host * may not create — also diverts here, with `req.dst`'s parent absent and the * handler responsible for creating it. * * `req.dst` is symlink-RESOLVED: the path the failed write itself acted on, not * the one the tool was given. A link anywhere in a lexical path redirects the * bytes while still passing a prefix allowlist, so treat `req.dst` as * authoritative. A destination that cannot be resolved is never brokered. * * Call this during extension load, like the other `register*` methods: handlers * are installed when the runner initializes, so an extension that has registered * none by then is skipped and a first registration made later never takes effect. * * The underlying registry is process-wide, so a handler may be consulted for a * denied write from any session in the process, not only its own. * `req.sessionId` names the session that issued the write and * `ctx.sessionManager.getSessionId()` names the handler's own; compare them * before prompting, because `ctx.ui` belongs to the latter. See * `docs/extensions.md`. */ registerFileWriteFallback(handler: FileWriteFallbackHandler): void; /** * Register a fallback deleter consulted when a native `edit`/`apply_patch` unlink is * denied with a permission error (`EPERM`/`EACCES`/`EROFS`). Covers `edit`'s `REM`, * the source side of a hashline `MV`, and `apply_patch`'s delete op. Return `true` * once `dst` is gone from disk. * * A handler MUST remove `dst` with a plain unlink and MUST NOT fall back to a * recursive removal. `unlink` on a directory reports `EPERM` on Darwin, so the seam * checks the target before diverting — but when the target's own metadata is behind * the same boundary that denied the unlink, which is the common sandbox case, that * check cannot be resolved and `dst` may be a directory. `req.confirmedFile` says * which situation the handler is in. * * `req.dst` resolves every component ABOVE the last, for the same reason the * write seam resolves all of them; the last is left alone because `unlink` * removes a link rather than its target, so `req.dst` may name a link. * * Separate from {@link registerFileWriteFallback} on purpose. A write handler * brokers `req.content` to `req.dst`, so a delete request reaching it with no * content invites brokering an empty write and truncating the file instead of * removing it. Registering for deletes is therefore an explicit opt-in, and the * same load-time and process-wide notes above apply. */ registerFileDeleteFallback(handler: FileDeleteFallbackHandler): void; // ========================================================================= // Command, Shortcut, Flag Registration // ========================================================================= /** Register a custom command. */ registerCommand( name: string, options: { description?: string; getArgumentCompletions?: RegisteredCommand["getArgumentCompletions"]; handler: RegisteredCommand["handler"]; }, ): void; /** Register a keyboard shortcut. */ registerShortcut( shortcut: KeyId, options: { description?: string; handler: (ctx: ExtensionContext) => Promise | void; }, ): void; /** * What already holds `shortcut`, or `undefined` when it is free. Chords compare as the TUI matches * them (`alt+ctrl+x` is `ctrl+alt+x`; `?` also covers `shift+?`); this extension's own * registrations don't count. The first of `reserved`, `keybinding`, `extension` is reported. * Keybindings come from this session's TUI keymap (user remaps included), or omp's defaults * without a TUI. * * Throws while extensions are loading, so call it from a handler, not the factory. Shortcuts are * bound before `session_start`, so a chord validated there applies once the factory registers it * on the next start. */ getShortcutConflict(shortcut: KeyId): ShortcutConflict | undefined; /** Register a CLI flag. */ registerFlag( name: string, options: { description?: string; type: "boolean" | "string"; default?: boolean | string; }, ): void; /** Set the display label for this extension, or set a label on a specific entry. */ setLabel(entryIdOrLabel: string, label?: string | undefined): void; /** Get the value of a registered CLI flag. */ getFlag(name: string): boolean | string | undefined; // ========================================================================= // Message Rendering // ========================================================================= /** Register a custom renderer for CustomMessageEntry. */ registerMessageRenderer(customType: string, renderer: MessageRenderer): void; /** Register a renderer for assistant thinking blocks. Rendered after the original thinking text. */ registerAssistantThinkingRenderer(renderer: AssistantThinkingRenderer): void; /** * Register a display-only transform for assistant text blocks: it returns the markdown to draw. * Called with the full text so far each time a streaming update is drawn (replies skip the * smooth-streaming reveal while renderers are active) and once more when the text is final. * The session, exports and model context keep the original text. Interactive mode only, and * only while the `extensionRenderers.assistantText` setting is on. */ registerAssistantTextRenderer(renderer: AssistantTextRenderer): void; /** * Choose how the main session's transcript blocks are drawn (see {@link TranscriptBlock}). * Presenters are asked in extension load order, the first answer other than `undefined` wins; * a suspended extension's presenter is skipped. One that throws or answers anything but a * {@link TranscriptPresentation} (a Promise included) is reported once and draws the block in * full. Never mutate the block's `args` or `result`: they are omp's live objects. omp may ask * again at any time (a transcript rebuild, a data change) and keeps no earlier answer: answer * from the block and the extension's own state only, keyed by block id rather than by run. A * block the user expanded is always drawn in full, and `display.hideToolActivity` still hides * tool activity. A focused subagent, a collab guest and the transcript overlays draw stock * cards. Display only: the session, exports and model context are unchanged. Returns a * disposer that removes the presenter. Presenters run synchronously while the transcript draws, * so keep them cheap. Interactive mode only, and only while the * `extensionRenderers.transcriptPresenters` setting is on; elsewhere the presenter is registered * but never asked. */ registerTranscriptPresenter(presenter: TranscriptPresenter): () => void; /** * Claim a link scheme (e.g. `"cc-stretch"` for `cc-stretch:…` links). In a plain terminal, * Markdown links using it draw without the ` (url)` suffix and are never handed to the terminal * or browser to open; picking one (its `app.links.hint` tag, or a click with `tui.mouse` on) * raises `link_activate` for this extension. Tern's native view draws them as its own links and * does not route their clicks back. Interactive mode only, and only while the * `extensionRenderers.assistantText` setting is on. Throws for an invalid scheme (fewer than two * characters, or not URL scheme syntax) or a reserved one (`http`, `https`, `file`, `mailto`, …). */ registerLinkScheme(scheme: string): void; /** * Register a composer shape for the interactive editor. * * Registration happens during extension load. Built-in ids cannot be * replaced; when extensions reuse an id, the later extension wins. */ registerComposerShape(definition: ComposerShapeDefinition): void; // ========================================================================= // Actions // ========================================================================= /** * Send a custom message to the session. * * With the default delivery (no `deliverAs`), an idle `display: true` message renders in the * transcript immediately, even with `triggerTurn: false`, without starting a turn. This does * not apply to `deliverAs: "nextTurn"` or `deliverAs: "aside"`, which keep the semantics * described below (`nextTurn` stays hidden until consumed; `aside` starts a turn when idle). * * `deliverAs: "nextTurn"` keeps the message hidden from the editable pending-message UI. * If `triggerTurn` is also true while the current turn is still unwinding, the session schedules * an internal continuation that consumes the message on the next turn. * * `deliverAs: "aside"` injects the message at the next agent step boundary without interrupting * the in-flight tool batch; when the session is idle it starts a turn regardless of `triggerTurn` * (plan mode folds it into context instead). */ sendMessage( message: CustomMessagePayload, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" | "aside" }, ): void; /** Send a user prompt: idle starts a turn; streaming queues as steer unless deliverAs is set. * `deliverAs: "aside"` injects at the next step boundary without interrupting the in-flight tool * batch while streaming, except that it ends a running interruptible `wait`; idle still starts a turn. * Rejects with `PromptDroppedError` if the message was not admitted (e.g. abort or cost cap). */ sendUserMessage(content: string | (TextContent | ImageContent)[], options?: SendUserMessageOptions): Promise; /** Append a custom entry to the session for state persistence (not sent to LLM). */ appendEntry(customType: string, data?: T): void; /** Execute a shell command. */ exec(command: string, args: string[], options?: ExecOptions): Promise; /** Get the list of currently active tool names. */ getActiveTools(): string[]; /** Get all configured tools (built-in + extension tools) with schema and source metadata. */ getAllTools(): ToolInfo[]; /** Set the active tools by name. */ setActiveTools(toolNames: string[]): Promise; /** Get available slash commands in the current session. */ getCommands(): SlashCommandInfo[]; /** Set the current model. Returns false if no API key available. */ setModel(model: Model): Promise; /** Get current thinking level. */ getThinkingLevel(): ThinkingLevel | undefined; /** Set thinking level for the current session. */ setThinkingLevel(level: ThinkingLevel): void; /** Get a snapshot of the current session's per-family service tiers. */ getServiceTiers(): Readonly; /** * Set one provider family's service tier for subsequent requests, or clear * its session override with `undefined`. */ setServiceTier( family: Family, tier: ExtensionServiceTier | undefined, ): void; /** Get the current session name. */ getSessionName(): string | undefined; /** Set the session name. Persists to the session file. */ setSessionName(name: string): Promise; // ========================================================================= // Provider Registration // ========================================================================= /** * Register or override a model provider. * * If `models` is provided: replaces all existing models for this provider. * If only `baseUrl` is provided: overrides the URL for existing models. * If `streamSimple` is provided: registers a custom API stream handler. * * @example * // Register a new provider with custom models and streaming * pi.registerProvider("google-vertex-claude", { * baseUrl: "https://us-east5-aiplatform.googleapis.com", * apiKey: "GOOGLE_CLOUD_PROJECT", * api: "vertex-claude-api", * streamSimple: myStreamFunction, * models: [ * { * id: "claude-sonnet-4@20250514", * name: "Claude Sonnet 4 (Vertex)", * reasoning: true, * thinking: { mode: "anthropic-adaptive", efforts: ["minimal", "low", "medium", "high"] }, * input: ["text", "image"], * cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, * contextWindow: 200000, * maxTokens: 64000, * ] * }); * * @example * // Override baseUrl for an existing provider * pi.registerProvider("anthropic", { * baseUrl: "https://proxy.example.com" * }); */ registerProvider(name: string, config: ProviderConfig): void; /** * Unregister a provider previously registered by an extension. * * Removes extension-provided models and restores overridden built-in models. * Has no effect when the provider is not registered. */ unregisterProvider(name: string): void; /** Shared event bus for extension communication. */ events: EventBus; } // ============================================================================ // Provider Registration Types // ============================================================================ /** Configuration for registering a provider via pi.registerProvider(). */ export interface ProviderConfig { /** Base URL for the API endpoint. Required when defining models. */ baseUrl?: string; /** * API key or environment variable name. Required when defining models unless oauth is provided. * * Without `oauth`, this overrides stored OAuth and `/login` credentials for the provider. With * `oauth`, it is a fallback: a key saved by `/login` wins, and this value is used only when no * stored login credential exists. */ apiKey?: string; /** API type identifier. Required when registering streamSimple or when models don't specify one. */ api?: Api; /** Custom streaming function for non-built-in APIs. */ streamSimple?: (model: Model, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream; /** Custom headers to include in requests. */ headers?: Record; /** If true, adds Authorization: Bearer header with the resolved API key. */ authHeader?: boolean; /** Models to register. If provided, replaces all existing models for this provider. */ models?: ProviderModelConfig[]; /** Optional normalized usage fetcher used by AuthStorage for this provider. */ usage?: UsageProvider; /** OAuth provider for /login support. */ oauth?: { /** Display name in login UI. */ name: string; /** Run the provider login flow and return credentials (or a plain API key) to persist. */ login(callbacks: OAuthLoginCallbacks): Promise; /** Refresh expired credentials. */ refreshToken?(credentials: OAuthCredentials): Promise; /** Convert credentials to an API key string for requests. */ getApiKey?(credentials: OAuthCredentials): string; /** Optional model rewrite hook for credential-aware routing (e.g., enterprise URLs). */ modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; }; /** * Async factory that fetches the live model list from the provider endpoint. * Runs through the same SQLite model-cache as built-in providers (keyed by * provider name, default 24 h TTL). Receives the resolved API key (undefined * when unauthenticated). When combined with `models`, the static models remain as fallbacks * alongside the live catalog. */ fetchDynamicModels?: (apiKey: string | undefined) => Promise; } /** Configuration for a model within a provider. */ export interface ProviderModelConfig { /** Model ID (e.g., "claude-sonnet-4@20250514"). */ id: string; /** Display name (e.g., "Claude Sonnet 4 (Vertex)"). */ name: string; /** API type override for this model. */ api?: Api; /** * Catalog kind; omitted means the api's kind (`image` for `openai-images`, …) or `chat`. * Must be a kind the api serves, as in `models.yml`. */ kind?: Model["kind"]; /** Whether the model supports extended thinking at all. */ reasoning: boolean; /** Optional canonical thinking capability metadata for per-model effort support. */ thinking?: Model["thinking"]; /** Supported input types. */ input: ("text" | "image")[]; /** Cost per million tokens. */ cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; /** Premium Copilot requests charged per user-initiated request. */ premiumMultiplier?: number; /** Maximum context window size in tokens. */ contextWindow: number; /** Maximum output tokens. */ maxTokens: number; /** WebSocket preference within catalog policy; explicit false disables transport selection. */ preferWebsockets?: boolean; /** Custom headers for this model. */ headers?: Record; /** OpenAI compatibility settings. */ compat?: ModelSpec["compat"]; } /** Extension factory function type. Supports both sync and async initialization. */ export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise; // ============================================================================ // Loaded Extension Types // ============================================================================ export interface RegisteredTool { definition: ToolDefinition; extensionPath: string; /** * Upstream-shaped provenance mirroring {@link SourceInfo}. Extensions authored * against `@earendil-works/pi-coding-agent` — whose registered tools expose * `sourceInfo` — read `sourceInfo.path` off `getAllRegisteredTools()` entries, * so it carries the same value `SessionTools.getAllToolInfos()` synthesizes. */ sourceInfo: SourceInfo; } /** Internal observer invoked when an already-loaded extension registers or replaces a tool. */ export type ToolRegistrationListener = (toolName: string) => void; export interface ExtensionFlag { name: string; description?: string; type: "boolean" | "string"; default?: boolean | string; extensionPath: string; } export interface ExtensionShortcut { shortcut: KeyId; description?: string; handler: (ctx: ExtensionContext) => Promise | void; extensionPath: string; } type HandlerFn = (...args: unknown[]) => Promise; export type SendMessageHandler = ( message: CustomMessagePayload, /** * `deliverAs: "nextTurn"` queues hidden custom context for the next turn. * When paired with `triggerTurn: true` during prompt teardown, the session schedules * an internal continuation without surfacing the message in the editable pending queue. * `deliverAs: "aside"` injects at the next step boundary without interrupting the in-flight * tool batch, except that it ends a running interruptible `wait`; idle starts a turn regardless of `triggerTurn` (plan mode folds into context). */ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" | "aside" }, ) => void; /** `deliverAs: "aside"` injects at the next step boundary without interrupting the in-flight tool * batch while streaming, except that it ends a running interruptible `wait`; idle still starts a turn. */ export type SendUserMessageHandler = ( content: string | (TextContent | ImageContent)[], options?: SendUserMessageOptions, ) => Promise; export type AppendEntryHandler = (customType: string, data?: T) => void; export type GetActiveToolsHandler = () => string[]; export type GetAllToolsHandler = () => ToolInfo[]; export type GetCommandsHandler = () => SlashCommandInfo[]; export type SetActiveToolsHandler = (toolNames: string[]) => Promise; export type SetModelHandler = (model: Model) => Promise; export type GetThinkingLevelHandler = () => ThinkingLevel | undefined; export type SetThinkingLevelHandler = (level: ThinkingLevel, persist?: boolean) => void; export type GetServiceTiersHandler = () => ServiceTierByFamily; export type SetServiceTierHandler = (family: ServiceTierFamily, tier: ServiceTier | undefined) => void; /** Shared state created by loader, used during registration and runtime. */ export interface ExtensionRuntimeState { flagValues: Map; /** Provider registrations queued during extension loading, processed during session initialization */ pendingProviderRegistrations: Array<{ name: string; config: ProviderConfig; sourceId: string }>; /** Queue a provider registration until initialization, then apply it immediately. */ registerProvider(name: string, config: ProviderConfig, sourceId: string): void; /** Remove a queued or initialized provider registration. */ unregisterProvider(name: string, sourceId: string): void; /** What holds `shortcut` for the extension at `extensionPath`; answered by the runner once it exists. */ getShortcutConflict(shortcut: KeyId, extensionPath: string): ShortcutConflict | undefined; } /** Action implementations for ExtensionAPI methods. */ export interface ExtensionActions { sendMessage: SendMessageHandler; sendUserMessage: SendUserMessageHandler; appendEntry: AppendEntryHandler; setLabel: (targetId: string, label: string | undefined) => void; getActiveTools: GetActiveToolsHandler; getAllTools: GetAllToolsHandler; setActiveTools: SetActiveToolsHandler; getCommands: GetCommandsHandler; setModel: SetModelHandler; getThinkingLevel: GetThinkingLevelHandler; setThinkingLevel: SetThinkingLevelHandler; getServiceTiers?: GetServiceTiersHandler; setServiceTier?: SetServiceTierHandler; getSessionName: () => string | undefined; setSessionName: (name: string) => Promise; } /** Actions for ExtensionContext (ctx.* in event handlers). */ export interface ExtensionContextActions { getModel: () => Model | undefined; isIdle: () => boolean; abort: () => void; hasPendingMessages: () => boolean; shutdown: () => void; resolvePlanReview?: ( reviewId: string, choice: PlanReviewChoice, input?: { feedback?: string; role?: string }, ) => boolean; getPendingPlanReview?: () => PlanReviewRequestedEvent | undefined; openPlanReview?: () => Promise; sessionModes?: ExtensionSessionModes; /** The host's effective keymap, user remaps included; omitted outside the TUI, where omp's default keymap applies. */ keybindings?: KeybindingsManager; getContextUsage: () => ContextUsage | undefined; compact: (instructionsOrOptions?: string | CompactOptions) => Promise; getSystemPrompt: () => string[]; runEphemeralTurn?: (options: EphemeralTurnOptions) => Promise; startLive?: (options: ExtensionLiveStartOptions) => Promise; } /** Actions for ExtensionCommandContext (ctx.* in command handlers). */ export interface ExtensionCommandContextActions { getContextUsage: () => ContextUsage | undefined; waitForIdle: () => Promise; newSession: (options?: { parentSession?: string; setup?: (sessionManager: SessionManager) => Promise; }) => Promise<{ cancelled: boolean }>; branch: (entryId: string) => Promise<{ cancelled: boolean }>; navigateTree: (targetId: string, options?: { summarize?: boolean }) => Promise<{ cancelled: boolean }>; compact: (instructionsOrOptions?: string | CompactOptions) => Promise; switchSession: (sessionPath: string) => Promise<{ cancelled: boolean }>; reload: () => Promise; } /** Full runtime = state + actions, including host-compatible service-tier fallbacks. */ export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionActions { getServiceTiers: GetServiceTiersHandler; setServiceTier: SetServiceTierHandler; } /** Loaded extension with all registered items. */ export interface Extension { path: string; resolvedPath: string; label?: string; handlers: Map; tools: Map>; toolRegistrationListeners?: Set; assistantThinkingRenderers: AssistantThinkingRenderer[]; assistantTextRenderers: AssistantTextRenderer[]; /** Presenters registered with `registerTranscriptPresenter`, in registration order. */ transcriptPresenters: TranscriptPresenter[]; /** Link schemes claimed with `registerLinkScheme`, lowercase. */ linkSchemes: string[]; fileWriteFallbackHandlers: FileWriteFallbackHandler[]; fileDeleteFallbackHandlers: FileDeleteFallbackHandler[]; messageRenderers: Map; composerShapes: Map; commands: Map; flags: Map; shortcuts: Map; /** Set once the extension calls `registerProvider`; providers feed startup model resolution. */ registersProviders?: boolean; } /** * Imported extension factory detached from any session runtime. The same * prepared module may be rebound to multiple session-scoped ExtensionAPI * instances without evaluating its module graph again. */ export interface PreparedExtension { path: string; resolvedPath: string; factory: ExtensionFactory | null; error: string | null; } /** Result of loading extensions. */ export interface LoadExtensionsResult { extensions: Extension[]; errors: Array<{ path: string; error: string }>; runtime: ExtensionRuntime; /** Session-independent imported factories safe to rebind in child sessions. */ preparedExtensions?: PreparedExtension[]; } // ============================================================================ // Extension Error // ============================================================================ export interface ExtensionError { extensionPath: string; event: string; error: string; stack?: string; }