Loader and runner internals manage extension initialization, environment setup, and action dispatch: the loader builds the extension runtime with stale-context detection and queued registration, while the runner wires those to core implementations, handles keybinding conflicts, and provides type-safe event emission. Prime Agent's loader uses compile-time bundling flags and module aliasing to ensure extensions see unified typebox instances and correctly resolve paths, then protects against stale contexts through assertActive() checks and invalidation markers.
The __PI_BUNDLED__ compile-time constant in loader.ts is replaced with true by the esbuild CLI bundle (scripts/bundle.mjs); in unbundled dist/ and under tsx it remains undefined. This flag controls whether virtual modules or jiti aliases are used for resolving pi packages inside extensions.[1] loader.ts aliases typebox, typebox/compile, typebox/value, @sinclair/typebox, @sinclair/typebox/compile, and @sinclair/typebox/value so that extensions importing either the scoped or unscoped typebox package resolve to the same instance bundled with the agent.[1] expandPath() in loader.ts normalizes Unicode spaces (non-breaking, em-space, thin-space, etc.) before expanding ~/ or ~ prefixes to the OS home directory, ensuring extension paths pasted from rich-text editors resolve correctly.[1]
createExtensionRuntime() in loader.ts returns an ExtensionRuntime whose action methods (sendMessage, appendEntry, etc.) all throw "Extension runtime not initialized. Action methods cannot be called during extension loading." until Runner.bindCore() replaces them with real implementations.[1] createExtensionRuntime() pre-initializes refreshTools as a no-op rather than a throwing stub, because registerTool() is valid during extension loading — refreshTools only needs a real implementation after bindCore() wires it up.[1] Before bindCore() is called, registerProvider in createExtensionRuntime() queues registrations into runtime.pendingProviderRegistrations instead of calling the model registry directly; bindCore() flushes this queue and then replaces registerProvider with a direct call.[1]
runtime.invalidate(message?) in loader.ts marks the runtime as stale, causing all subsequent assertActive() calls to throw. The default stale message explicitly lists the operations that invalidate a context: ctx.newSession(), ctx.fork(), ctx.switchSession(), and ctx.reload().[1] Every action method on the ExtensionAPI created by createExtensionAPI() in loader.ts calls runtime.assertActive() first, so using a stale context after session replacement or reload throws immediately rather than silently doing nothing.[1]
registerFlag() in createExtensionAPI() sets the flag's default value in runtime.flagValues only if no value is already stored, preventing a re-register from overriding a user-supplied value.[1] getFlag() in createExtensionAPI() returns undefined if the calling extension has not registered the named flag — guarded with !extension.flags.has(name) — even if another extension registered a flag with the same name.[1] The exec() method on ExtensionAPI merges the session-supplied environment (runtime.getExecEnv?.()) with any caller-supplied options.env, with the caller's values winning. The session env is read at call time rather than at registration time, so per-session variables are always current.[1]
runner.ts defines RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS, enumerating the global TUI actions that extensions cannot override: app.interrupt, app.clear, app.exit, app.suspend, app.model.select, app.tools.expand, app.messages.expand, app.thinking.toggle, app.subagents.focus, app.editor.external, app.message.followUp, tui.input.submit, tui.select.confirm, tui.select.cancel, tui.input.copy, and tui.editor.deleteToLineEnd.[2] buildBuiltinKeybindings() in runner.ts normalizes key IDs to lowercase when building the built-in keybinding map, and if the same physical key is bound to both a reserved and a non-reserved action, the reserved action always wins regardless of iteration order.[2]
runner.ts defines RunnerEmitEvent as all ExtensionEvent types except ToolCallEvent, ToolResultEvent, UserBashEvent, ContextEvent, BeforeProviderRequestEvent, BeforeAgentStartEvent, MessageEndEvent, ResourcesDiscoverEvent, and InputEvent, which have dedicated typed emitXxx() methods for stronger type safety.[2] emitSessionShutdownEvent() in runner.ts only calls extensionRunner.emit() when at least one handler for "session_shutdown" is registered, returning true if emitted and false if skipped.[2]
ExtensionRunner in runner.ts initializes all its handler fields (newSessionHandler, forkHandler, navigateTreeHandler, switchSessionHandler, reloadHandler, shutdownHandler, etc.) to safe no-op defaults so extensions can be constructed and events emitted before the full application context is wired.[2] ExtensionRunner.bindCore() in runner.ts flushes the pendingProviderRegistrations queue accumulated during extension loading, calling either the injected providerActions.registerProvider or this.modelRegistry.registerProvider for each entry. Errors are forwarded to emitError() rather than thrown.[2]
The NewSessionHandler type exported from runner.ts accepts an optional parentSession string, a setup callback receiving a SessionManager, and a withSession callback receiving a ReplacedSessionContext, and resolves to { cancelled: boolean }.[2] The ForkHandler type exported from runner.ts takes an entryId string and optional position ("before" | "at") plus a withSession callback, resolving to { cancelled: boolean }. The NavigateTreeHandler additionally supports summarize, customInstructions, replaceInstructions, and label options.[2]
Sources