Investigated extension command registration and UI notification.
UnderstandBound UI contexts on startup for reload support.
BuildUpdated changelog with the fix.
EditRan tests and pushed fix to main branch.
Verifyi had you write a trivial extension in another session to .pi/extensions/test.ts (no longer there). it registered a command /test that would use ui.notify().
when i started the session, test.ts didn'T exist yet, you created it. then i did /reload. the /test command got registered, but running it did not show the ui notification. can you figure out why that is?
The user is describing an issue with a pi extension. They created a test extension that registered a /test command using ui.notify(), but when they ran the command after /reload, the notification didn't show.
Let me investigate how extensions work in pi, particularly around UI notifications and command registration. I should look at:
ui.notify() worksLet me start by reading the extension docs and related code.
Let me investigate how extensions and UI notifications work in pi.
Let me look at the examples directory and understand how commands and notifications work.
>ls -la /Users/badlogic/workspaces/pi-mono/packages/coding-agent/examples/extensions/total 632
drwxr-xr-x@ 57 badlogic staff 1824 Jan 26 16:47 .
drwxr-xr-x@ 5 badlogic staff 160 Jan 25 03:33 ..
-rw-r--r--@ 1 badlogic staff 12549 Jan 25 03:33 antigravity-image-gen.ts
-rw-r--r--@ 1 badlogic staff 1569 Jan 8 19:37 auto-commit-on-exit.ts
-rw-r--r--@ 1 badlogic staff 1553 Jan 26 11:43 bookmark.ts
-rw-r--r--@ 1 badlogic staff 2486 Jan 9 23:31 claude-rules.ts
-rw-r--r--@ 1 badlogic staff 1678 Jan 12 23:33 confirm-destructive.ts
-rw-r--r--@ 1 badlogic staff 4069 Jan 8 19:37 custom-compaction.ts
-rw-r--r--@ 1 badlogic staff 2143 Jan 12 23:33 custom-footer.ts
-rw-r--r--@ 1 badlogic staff 2395 Jan 17 11:39 custom-header.ts
drwxr-xr-x@ 7 badlogic staff 224 Jan 26 22:01 custom-provider-anthropic
drwxr-xr-x@ 6 badlogic staff 192 Jan 26 22:01 custom-provider-gitlab-duo
-rw-r--r--@ 1 badlogic staff 1477 Jan 12 23:33 dirty-repo-guard.ts
drwxr-xr-x@ 11 badlogic staff 352 Jan 13 22:08 doom-overlay
-rw-r--r--@ 1 badlogic staff 1346 Jan 26 11:43 event-bus.ts
-rw-r--r--@ 1 badlogic staff 1031 Jan 8 19:37 file-trigger.ts
-rw-r--r--@ 1 badlogic staff 1466 Jan 12 23:33 git-checkpoint.ts
-rw-r--r--@ 1 badlogic staff 4628 Jan 12 23:33 handoff.ts
-rw-r--r--@ 1 badlogic staff 627 Jan 8 19:37 hello.ts
-rw-r--r--@ 1 badlogic staff 2956 Jan 25 03:33 inline-bash.ts
-rw-r--r--@ 1 badlogic staff 1442 Jan 16 02:42 input-transform.ts
-rw-r--r--@ 1 badlogic staff 4778 Jan 9 23:31 interactive-shell.ts
-rw-r--r--@ 1 badlogic staff 1195 Jan 9 23:31 mac-system-theme.ts
-rw-r--r--@ 1 badlogic staff 1906 Jan 26 11:43 message-renderer.ts
-rw-r--r--@ 1 badlogic staff 2413 Jan 16 12:47 modal-editor.ts
-rw-r--r--@ 1 badlogic staff 955 Jan 12 23:33 model-status.ts
-rw-r--r--@ 1 badlogic staff 776 Jan 12 23:33 notify.ts
-rw-r--r--@ 1 badlogic staff 28484 Jan 13 22:47 overlay-qa-tests.ts
-rw-r--r--@ 1 badlogic staff 5367 Jan 16 12:47 overlay-test.ts
-rw-r--r--@ 1 badlogic staff 1017 Jan 8 19:37 permission-gate.ts
-rw-r--r--@ 1 badlogic staff 1459 Jan 9 23:31 pirate.ts
drwxr-xr-x@ 5 badlogic staff 160 Jan 16 00:45 plan-mode
-rw-r--r--@ 1 badlogic staff 13339 Jan 12 23:33 preset.ts
-rw-r--r--@ 1 badlogic staff 806 Jan 8 19:37 protected-paths.ts
-rw-r--r--@ 1 badlogic staff 3554 Jan 8 23:49 qna.ts
-rw-r--r--@ 1 badlogic staff 7764 Jan 19 15:00 question.ts
-rw-r--r--@ 1 badlogic staff 12807 Jan 16 12:47 questionnaire.ts
-rw-r--r--@ 1 badlogic staff 2384 Jan 16 12:47 rainbow-editor.ts
-rw-r--r--@ 1 badlogic staff 8033 Jan 26 11:43 README.md
drwxr-xr-x@ 6 badlogic staff 192 Jan 13 22:01 sandbox
-rw-r--r--@ 1 badlogic staff 2830 Jan 8 19:37 send-user-message.ts
-rw-r--r--@ 1 badlogic staff 783 Jan 26 11:43 session-name.ts
-rw-r--r--@ 1 badlogic staff 2053 Jan 8 23:49 shutdown-command.ts
-rw-r--r--@ 1 badlogic staff 9409 Jan 12 01:32 snake.ts
-rw-r--r--@ 1 badlogic staff 15162 Jan 25 03:33 space-invaders.ts
-rw-r--r--@ 1 badlogic staff 7289 Jan 9 23:31 ssh.ts
-rw-r--r--@ 1 badlogic staff 1137 Jan 8 19:37 status-line.ts
drwxr-xr-x@ 7 badlogic staff 224 Jan 16 00:45 subagent
-rw-r--r--@ 1 badlogic staff 5006 Jan 13 22:01 summarize.ts
-rw-r--r--@ 1 badlogic staff 2236 Jan 8 19:37 timed-confirm.ts
-rw-r--r--@ 1 badlogic staff 8966 Jan 12 23:33 todo.ts
-rw-r--r--@ 1 badlogic staff 4747 Jan 9 23:31 tool-override.ts
-rw-r--r--@ 1 badlogic staff 3872 Jan 12 23:33 tools.ts
-rw-r--r--@ 1 badlogic staff 1025 Jan 17 11:43 trigger-compact.ts
-rw-r--r--@ 1 badlogic staff 6358 Jan 8 19:37 truncated-tool.ts
-rw-r--r--@ 1 badlogic staff 526 Jan 19 15:55 widget-placement.ts
drwxr-xr-x@ 7 badlogic staff 224 Jan 26 22:01 with-depsLet me look at the notify.ts example to see how notifications work. Also let me look at how command registration works.
These examples show tools and OSC notifications. Let me look at how commands are registered and how ctx.ui.notify works. Let me check the command registration code and the UI types.
Let me look at how commands work and how ctx.ui.notify is implemented:
>rg -l "registerCommand" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src --type ts/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/extensions/types.ts
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/extensions/loader.ts>rg -l "ui\.notify" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src --type ts(no output)
Command exited with code 1ui.notify is not found directly. Let me look at how the UI is provided in the extension context and where notify is implemented.
>rg "notify" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src --type ts -l/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/rpc/rpc-mode.ts
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/rpc/rpc-types.ts
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/extensions/runner.ts
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/extensions/types.tsLet me look at the extension runner to see how the context is passed to commands and how ui.notify is implemented.
I see the noOpUIContext which is used when there's no UI. Now let me understand the flow better. The ExtensionRunner has a setUIContext method that sets the UI context. Let me look at how extensions are loaded and when the UI context is set vs when commands are registered.
Let me look at the extension loader to understand the order of operations.
Now I understand the loading process. Let me look at how the interactive mode sets up the UI context and when commands are run. The key issue is:
setUIContext is called later by the mode (interactive mode) to provide the actual UI implementationctx.ui - but at registration time, this might be the noOpUIContextLet me look at how commands are executed to see if there's a closure issue.
>rg "handler.*ctx" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/extensions/types.ts -A 5 -B 5export interface RegisteredCommand {
name: string;
description?: string;
getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null;
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
}
// ============================================================================
// Extension API
// ============================================================================
--
/** Register a keyboard shortcut. */
registerShortcut(
shortcut: KeyId,
options: {
description?: string;
handler: (ctx: ExtensionContext) => Promise<void> | void;
},
): void;
/** Register a CLI flag. */
registerFlag(
--
}
export interface ExtensionShortcut {
shortcut: KeyId;
description?: string;
handler: (ctx: ExtensionContext) => Promise<void> | void;
extensionPath: string;
}
type HandlerFn = (...args: unknown[]) => Promise<unknown>;I see that commands receive ctx as a parameter in their handler. Let me look at how the command is executed, specifically when the ctx is created and passed to the handler. Let me look at how interactive mode handles commands.
>rg "getCommand|executeCommand" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src --type ts -l/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/model-registry.ts
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/extensions/runner.ts
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/components/bash-execution.tsLet me check how commands are executed in agent-session.ts, since that seems to be where command execution happens.
>rg "getCommand" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts -A 20 -B 5// Parse command name and args
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
const command = this._extensionRunner.getCommand(commandName);
if (!command) return false;
// Get command context from extension runner (includes session control methods)
const ctx = this._extensionRunner.createCommandContext();
try {
await command.handler(args, ctx);
return true;
} catch (err) {
// Emit error via extension runner
this._extensionRunner.emitError({
extensionPath: `command:${commandName}`,
event: "command",
error: err instanceof Error ? err.message : String(err),
});
return true;
}
}
/**
--
private _throwIfExtensionCommand(text: string): void {
if (!this._extensionRunner) return;
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const command = this._extensionRunner.getCommand(commandName);
if (command) {
throw new Error(
`Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`,
);
}
}
/**
* Send a custom message to the session. Creates a CustomMessageEntry.
*
* Handles three cases:
* - Streaming: queues message, processed when loop pulls from queue
* - Not streaming + triggerTurn: appends to state/session, starts new turn
* - Not streaming + no trigger: appends to state/session, no turn
*
* @param message Custom message with customType, content, display, details
* @param options.triggerTurn If true and not streaming, triggers a new LLM turn
* @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn"
*/I see! When a command is executed, ctx is created via this._extensionRunner.createCommandContext(). Let me look at createCommandContext() in runner.ts more closely.
>rg "createCommandContext|createContext" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/extensions/runner.ts -A 20 -B 2* Context values are resolved at call time, so changes via bindCore/bindUI are reflected.
*/
createContext(): ExtensionContext {
const getModel = this.getModel;
return {
ui: this.uiContext,
hasUI: this.hasUI(),
cwd: this.cwd,
sessionManager: this.sessionManager,
modelRegistry: this.modelRegistry,
get model() {
return getModel();
},
isIdle: () => this.isIdleFn(),
abort: () => this.abortFn(),
hasPendingMessages: () => this.hasPendingMessagesFn(),
shutdown: () => this.shutdownHandler(),
getContextUsage: () => this.getContextUsageFn(),
compact: (options) => this.compactFn(options),
};
}
createCommandContext(): ExtensionCommandContext {
return {
...this.createContext(),
waitForIdle: () => this.waitForIdleFn(),
newSession: (options) => this.newSessionHandler(options),
fork: (entryId) => this.forkHandler(entryId),
navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
};
}
private isSessionBeforeEvent(
type: string,
): type is "session_before_switch" | "session_before_fork" | "session_before_compact" | "session_before_tree" {
return (
type === "session_before_switch" ||
type === "session_before_fork" ||
type === "session_before_compact" ||
type === "session_before_tree"
);
}
async emit(
event: ExtensionEvent,
): Promise<SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined> {
const ctx = this.createContext();
let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined;
for (const ext of this.extensions) {
const handlers = ext.handlers.get(event.type);
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const handlerResult = await handler(event, ctx);
if (this.isSessionBeforeEvent(event.type) && handlerResult) {
result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult;
if (result.cancel) {
return result;
}
}
if (event.type === "tool_result" && handlerResult) {
result = handlerResult as ToolResultEventResult;
}
--
async emitToolCall(event: ToolCallEvent): Promise<ToolCallEventResult | undefined> {
const ctx = this.createContext();
let result: ToolCallEventResult | undefined;
for (const ext of this.extensions) {
const handlers = ext.handlers.get("tool_call");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
const handlerResult = await handler(event, ctx);
if (handlerResult) {
result = handlerResult as ToolCallEventResult;
if (result.block) {
return result;
}
}
}
}
return result;
}
async emitUserBash(event: UserBashEvent): Promise<UserBashEventResult | undefined> {
const ctx = this.createContext();
for (const ext of this.extensions) {
const handlers = ext.handlers.get("user_bash");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const handlerResult = await handler(event, ctx);
if (handlerResult) {
return handlerResult as UserBashEventResult;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
event: "user_bash",
error: message,
stack,
});
--
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
const ctx = this.createContext();
let currentMessages = structuredClone(messages);
for (const ext of this.extensions) {
const handlers = ext.handlers.get("context");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: ContextEvent = { type: "context", messages: currentMessages };
const handlerResult = await handler(event, ctx);
if (handlerResult && (handlerResult as ContextEventResult).messages) {
currentMessages = (handlerResult as ContextEventResult).messages!;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
event: "context",
--
systemPrompt: string,
): Promise<BeforeAgentStartCombinedResult | undefined> {
const ctx = this.createContext();
const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
let currentSystemPrompt = systemPrompt;
let systemPromptModified = false;
for (const ext of this.extensions) {
const handlers = ext.handlers.get("before_agent_start");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: BeforeAgentStartEvent = {
type: "before_agent_start",
prompt,
images,
systemPrompt: currentSystemPrompt,
};
const handlerResult = await handler(event, ctx);
if (handlerResult) {
const result = handlerResult as BeforeAgentStartEventResult;
--
/** Emit input event. Transforms chain, "handled" short-circuits. */
async emitInput(text: string, images: ImageContent[] | undefined, source: InputSource): Promise<InputEventResult> {
const ctx = this.createContext();
let currentText = text;
let currentImages = images;
for (const ext of this.extensions) {
for (const handler of ext.handlers.get("input") ?? []) {
try {
const event: InputEvent = { type: "input", text: currentText, images: currentImages, source };
const result = (await handler(event, ctx)) as InputEventResult | undefined;
if (result?.action === "handled") return result;
if (result?.action === "transform") {
currentText = result.text;
currentImages = result.images ?? currentImages;
}
} catch (err) {
this.emitError({
extensionPath: ext.path,
event: "input",
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});I see that createContext() is called at command execution time and it uses this.uiContext. The issue is:
createContext(): ExtensionContext { const getModel = this.getModel; return { ui: this.uiContext, // <-- This captures the value at call time ... };}The context captures this.uiContext at the time createContext() is called. So when the command runs, it gets the current UI context.
Let me check when setUIContext is called in the interactive mode. The issue could be:
/reload loads extensions but doesn't re-set the UI contextLet me look at the /reload implementation:
>rg "/reload" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src --type ts -l/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.tsLet me look at how /reload is handled in interactive-mode.ts.
>rg "reload" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -A 30 -B 5{ name: "login", description: "Login with OAuth provider" },
{ name: "logout", description: "Logout from OAuth provider" },
{ name: "new", description: "Start a new session" },
{ name: "compact", description: "Manually compact the session context" },
{ name: "resume", description: "Resume a different session" },
{ name: "reload", description: "Reload extensions, skills, prompts, and themes" },
];
// Convert prompt templates to SlashCommand format for autocomplete
const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
// Convert extension commands to SlashCommand format
const extensionCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map(
(cmd) => ({
name: cmd.name,
description: cmd.description ?? "(extension command)",
getArgumentCompletions: cmd.getArgumentCompletions,
}),
);
// Build skill commands from session.skills (if enabled)
this.skillCommands.clear();
const skillCommandList: SlashCommand[] = [];
if (this.settingsManager.getEnableSkillCommands()) {
for (const skill of this.session.resourceLoader.getSkills().skills) {
const commandName = `skill:${skill.name}`;
this.skillCommands.set(commandName, skill.filePath);
skillCommandList.push({ name: commandName, description: skill.description });
}
}
// Setup autocomplete
this.autocompleteProvider = new CombinedAutocompleteProvider(
--
const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
this.editor.setText("");
await this.handleCompactCommand(customInstructions);
return;
}
if (text === "/reload") {
this.editor.setText("");
await this.handleReloadCommand();
return;
}
if (text === "/debug") {
this.handleDebugCommand();
this.editor.setText("");
return;
}
if (text === "/arminsayshi") {
this.handleArminSaysHi();
this.editor.setText("");
return;
}
if (text === "/resume") {
this.showSessionSelector();
this.editor.setText("");
return;
}
if (text === "/quit" || text === "/exit") {
this.editor.setText("");
await this.shutdown();
return;
}
// Handle bash command (! for normal, !! for excluded from context)
if (text.startsWith("!")) {
const isExcluded = text.startsWith("!!");
const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
if (command) {
--
// Command handlers
// =========================================================================
private async handleReloadCommand(): Promise<void> {
if (this.session.isStreaming) {
this.showWarning("Wait for the current response to finish before reloading.");
return;
}
if (this.session.isCompacting) {
this.showWarning("Wait for compaction to finish before reloading.");
return;
}
this.resetExtensionUI();
const loader = new BorderedLoader(this.ui, theme, "Reloading extensions, skills, prompts, themes...", {
cancellable: false,
});
const previousEditor = this.editor;
this.editorContainer.clear();
this.editorContainer.addChild(loader);
this.ui.setFocus(loader);
this.ui.requestRender();
const dismissLoader = (editor: Component) => {
loader.dispose();
this.editorContainer.clear();
this.editorContainer.addChild(editor);
this.ui.setFocus(editor);
this.ui.requestRender();
};
try {
await this.session.reload();
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
this.rebuildAutocomplete();
const runner = this.session.extensionRunner;
if (runner) {
this.setupExtensionShortcuts(runner);
}
this.rebuildChatFromMessages();
dismissLoader(this.editor as Component);
this.showLoadedResources({ extensionPaths: runner?.getExtensionPaths() ?? [], force: true });
const modelsJsonError = this.session.modelRegistry.getError();
if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`);
}
this.showStatus("Reloaded extensions, skills, prompts, themes");
} catch (error) {
dismissLoader(previousEditor as Component);
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async handleExportCommand(text: string): Promise<void> {
const parts = text.split(/\s+/);
const outputPath = parts.length > 1 ? parts[1] : undefined;
try {
const filePath = await this.session.exportToHtml(outputPath);
this.showStatus(`Session exported to: ${filePath}`);
} catch (error: unknown) {
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
}Let me look at the session.reload() method to see what happens during reload:
>rg "async reload" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts -A 50async reload(): Promise<void> {
const previousFlagValues = this._extensionRunner?.getFlagValues();
await this._extensionRunner?.emit({ type: "session_shutdown" });
resetApiProviders();
await this._resourceLoader.reload();
this._buildRuntime({
activeToolNames: this.getActiveToolNames(),
flagValues: previousFlagValues,
includeAllExtensionTools: true,
});
const hasBindings =
this._extensionUIContext ||
this._extensionCommandContextActions ||
this._extensionShutdownHandler ||
this._extensionErrorListener;
if (this._extensionRunner && hasBindings) {
await this._extensionRunner.emit({ type: "session_start" });
}
}
// =========================================================================
// Auto-Retry
// =========================================================================
/**
* Check if an error is retryable (overloaded, rate limit, server errors).
* Context overflow errors are NOT retryable (handled by compaction instead).
*/
private _isRetryableError(message: AssistantMessage): boolean {
if (message.stopReason !== "error" || !message.errorMessage) return false;
// Context overflow is handled by compaction, not retry
const contextWindow = this.model?.contextWindow ?? 0;
if (isContextOverflow(message, contextWindow)) return false;
const err = message.errorMessage;
// Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection errors, fetch failed, terminated
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|terminated/i.test(
err,
);
}
/**
* Handle retryable errors with exponential backoff.
* @returns true if retry was initiated, false if max retries exceeded or disabled
*/
private async _handleRetryableError(message: AssistantMessage): Promise<boolean> {
const settings = this.settingsManager.getRetrySettings();
if (!settings.enabled) return false;Let me check the _buildRuntime method to see if it sets up the UI context properly after reload:
>rg "_buildRuntime" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts -A 80this._buildRuntime({
activeToolNames: this._initialActiveToolNames,
includeAllExtensionTools: true,
});
}
/** Model registry for API key resolution and model discovery */
get modelRegistry(): ModelRegistry {
return this._modelRegistry;
}
// =========================================================================
// Event Subscription
// =========================================================================
/** Emit an event to all listeners */
private _emit(event: AgentSessionEvent): void {
for (const l of this._eventListeners) {
l(event);
}
}
// Track last assistant message for auto-compaction check
private _lastAssistantMessage: AssistantMessage | undefined = undefined;
/** Internal handler for agent events - shared by subscribe and reconnect */
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
// When a user message starts, check if it's from either queue and remove it BEFORE emitting
// This ensures the UI sees the updated queue state
if (event.type === "message_start" && event.message.role === "user") {
const messageText = this._getUserMessageText(event.message);
if (messageText) {
// Check steering queue first
const steeringIndex = this._steeringMessages.indexOf(messageText);
if (steeringIndex !== -1) {
this._steeringMessages.splice(steeringIndex, 1);
} else {
// Check follow-up queue
const followUpIndex = this._followUpMessages.indexOf(messageText);
if (followUpIndex !== -1) {
this._followUpMessages.splice(followUpIndex, 1);
}
}
}
}
// Emit to extensions first
await this._emitExtensionEvent(event);
// Notify all listeners
this._emit(event);
// Handle session persistence
if (event.type === "message_end") {
// Check if this is a custom message from extensions
if (event.message.role === "custom") {
// Persist as CustomMessageEntry
this.sessionManager.appendCustomMessageEntry(
event.message.customType,
event.message.content,
event.message.display,
event.message.details,
);
} else if (
event.message.role === "user" ||
event.message.role === "assistant" ||
event.message.role === "toolResult"
) {
// Regular LLM message - persist as SessionMessageEntry
this.sessionManager.appendMessage(event.message);
}
// Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
// Track assistant message for auto-compaction (checked on agent_end)
if (event.message.role === "assistant") {
this._lastAssistantMessage = event.message;
}
}
// Check auto-retry and auto-compaction after agent completes
if (event.type === "agent_end" && this._lastAssistantMessage) {
--
private _buildRuntime(options: {
activeToolNames?: string[];
flagValues?: Map<string, boolean | string>;
includeAllExtensionTools?: boolean;
}): void {
const autoResizeImages = this.settingsManager.getImageAutoResize();
const shellCommandPrefix = this.settingsManager.getShellCommandPrefix();
const baseTools = this._baseToolsOverride
? this._baseToolsOverride
: createAllTools(this._cwd, {
read: { autoResizeImages },
bash: { commandPrefix: shellCommandPrefix },
});
this._baseToolRegistry = new Map(Object.entries(baseTools).map(([name, tool]) => [name, tool as AgentTool]));
const extensionsResult = this._resourceLoader.getExtensions();
if (options.flagValues) {
for (const [name, value] of options.flagValues) {
extensionsResult.runtime.flagValues.set(name, value);
}
}
const hasExtensions = extensionsResult.extensions.length > 0;
const hasCustomTools = this._customTools.length > 0;
this._extensionRunner =
hasExtensions || hasCustomTools
? new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
this._cwd,
this.sessionManager,
this._modelRegistry,
)
: undefined;
if (this._extensionRunnerRef) {
this._extensionRunnerRef.current = this._extensionRunner;
}
if (this._extensionRunner) {
this._bindExtensionCore(this._extensionRunner);
this._applyExtensionBindings(this._extensionRunner);
}
const registeredTools = this._extensionRunner?.getAllRegisteredTools() ?? [];
const allCustomTools = [
...registeredTools,
...this._customTools.map((def) => ({ definition: def, extensionPath: "<sdk>" })),
];
const wrappedExtensionTools = this._extensionRunner
? wrapRegisteredTools(allCustomTools, this._extensionRunner)
: [];
const toolRegistry = new Map(this._baseToolRegistry);
for (const tool of wrappedExtensionTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
const defaultActiveToolNames = this._baseToolsOverride
? Object.keys(this._baseToolsOverride)
: ["read", "bash", "edit", "write"];
const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;
const activeToolNameSet = new Set<string>(baseActiveToolNames);
if (options.includeAllExtensionTools) {
for (const tool of wrappedExtensionTools as AgentTool[]) {
activeToolNameSet.add(tool.name);
}
}
const extensionToolNames = new Set(wrappedExtensionTools.map((tool) => tool.name));
const activeBaseTools = Array.from(activeToolNameSet)
.filter((name) => this._baseToolRegistry.has(name) && !extensionToolNames.has(name))
.map((name) => this._baseToolRegistry.get(name) as AgentTool);
const activeExtensionTools = wrappedExtensionTools.filter((tool) => activeToolNameSet.has(tool.name));
const activeToolsArray: AgentTool[] = [...activeBaseTools, ...activeExtensionTools];
if (this._extensionRunner) {
const wrappedActiveTools = wrapToolsWithExtensions(activeToolsArray, this._extensionRunner);
this.agent.setTools(wrappedActiveTools as AgentTool[]);
const wrappedAllTools = wrapToolsWithExtensions(Array.from(toolRegistry.values()), this._extensionRunner);
this._toolRegistry = new Map(wrappedAllTools.map((tool) => [tool.name, tool]));
--
this._buildRuntime({
activeToolNames: this.getActiveToolNames(),
flagValues: previousFlagValues,
includeAllExtensionTools: true,
});
const hasBindings =
this._extensionUIContext ||
this._extensionCommandContextActions ||
this._extensionShutdownHandler ||
this._extensionErrorListener;
if (this._extensionRunner && hasBindings) {
await this._extensionRunner.emit({ type: "session_start" });
}
}
// =========================================================================
// Auto-Retry
// =========================================================================
/**
* Check if an error is retryable (overloaded, rate limit, server errors).
* Context overflow errors are NOT retryable (handled by compaction instead).
*/
private _isRetryableError(message: AssistantMessage): boolean {
if (message.stopReason !== "error" || !message.errorMessage) return false;
// Context overflow is handled by compaction, not retry
const contextWindow = this.model?.contextWindow ?? 0;
if (isContextOverflow(message, contextWindow)) return false;
const err = message.errorMessage;
// Match: overloaded_error, rate limit, 429, 500, 502, 503, 504, service unavailable, connection errors, fetch failed, terminated
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|terminated/i.test(
err,
);
}
/**
* Handle retryable errors with exponential backoff.
* @returns true if retry was initiated, false if max retries exceeded or disabled
*/
private async _handleRetryableError(message: AssistantMessage): Promise<boolean> {
const settings = this.settingsManager.getRetrySettings();
if (!settings.enabled) return false;
this._retryAttempt++;
// Create retry promise on first attempt so waitForRetry() can await it
if (this._retryAttempt === 1 && !this._retryPromise) {
this._retryPromise = new Promise((resolve) => {
this._retryResolve = resolve;
});
}
if (this._retryAttempt > settings.maxRetries) {
// Max retries exceeded, emit final failure and reset
this._emit({
type: "auto_retry_end",
success: false,
attempt: this._retryAttempt - 1,
finalError: message.errorMessage,
});
this._retryAttempt = 0;
this._resolveRetry(); // Resolve so waitForRetry() completes
return false;
}
const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
this._emit({
type: "auto_retry_start",
attempt: this._retryAttempt,
maxAttempts: settings.maxRetries,
delayMs,
errorMessage: message.errorMessage || "Unknown error",
});
// Remove error message from agent state (keep in session for history)
const messages = this.agent.state.messages;
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {Now let me look at _applyExtensionBindings to understand what happens:
>rg "_applyExtensionBindings" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts -A 20 -B 5if (bindings.onError !== undefined) {
this._extensionErrorListener = bindings.onError;
}
if (this._extensionRunner) {
this._applyExtensionBindings(this._extensionRunner);
await this._extensionRunner.emit({ type: "session_start" });
}
}
private _applyExtensionBindings(runner: ExtensionRunner): void {
runner.setUIContext(this._extensionUIContext);
runner.bindCommandContext(this._extensionCommandContextActions);
this._extensionErrorUnsubscriber?.();
this._extensionErrorUnsubscriber = this._extensionErrorListener
? runner.onError(this._extensionErrorListener)
: undefined;
}
private _bindExtensionCore(runner: ExtensionRunner): void {
runner.bindCore(
{
sendMessage: (message, options) => {
this.sendCustomMessage(message, options).catch((err) => {
runner.emitError({
extensionPath: "<runtime>",
event: "send_message",
error: err instanceof Error ? err.message : String(err),
});
});
--
if (this._extensionRunnerRef) {
this._extensionRunnerRef.current = this._extensionRunner;
}
if (this._extensionRunner) {
this._bindExtensionCore(this._extensionRunner);
this._applyExtensionBindings(this._extensionRunner);
}
const registeredTools = this._extensionRunner?.getAllRegisteredTools() ?? [];
const allCustomTools = [
...registeredTools,
...this._customTools.map((def) => ({ definition: def, extensionPath: "<sdk>" })),
];
const wrappedExtensionTools = this._extensionRunner
? wrapRegisteredTools(allCustomTools, this._extensionRunner)
: [];
const toolRegistry = new Map(this._baseToolRegistry);
for (const tool of wrappedExtensionTools as AgentTool[]) {
toolRegistry.set(tool.name, tool);
}
const defaultActiveToolNames = this._baseToolsOverride
? Object.keys(this._baseToolsOverride)
: ["read", "bash", "edit", "write"];
const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;Now I see the issue! The _applyExtensionBindings function calls runner.setUIContext(this._extensionUIContext). This sets the UI context on the extension runner.
But let me check what this._extensionUIContext is set to. It should be set by bindExtensions():
>rg "_extensionUIContext" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts -A 5 -B 5private _baseToolRegistry: Map<string, AgentTool> = new Map();
private _cwd: string;
private _extensionRunnerRef?: { current?: ExtensionRunner };
private _initialActiveToolNames?: string[];
private _baseToolsOverride?: Record<string, AgentTool>;
private _extensionUIContext?: ExtensionUIContext;
private _extensionCommandContextActions?: ExtensionCommandContextActions;
private _extensionShutdownHandler?: ShutdownHandler;
private _extensionErrorListener?: ExtensionErrorListener;
private _extensionErrorUnsubscriber?: () => void;
--
return this.settingsManager.getCompactionEnabled();
}
async bindExtensions(bindings: ExtensionBindings): Promise<void> {
if (bindings.uiContext !== undefined) {
this._extensionUIContext = bindings.uiContext;
}
if (bindings.commandContextActions !== undefined) {
this._extensionCommandContextActions = bindings.commandContextActions;
}
if (bindings.shutdownHandler !== undefined) {
--
await this._extensionRunner.emit({ type: "session_start" });
}
}
private _applyExtensionBindings(runner: ExtensionRunner): void {
runner.setUIContext(this._extensionUIContext);
runner.bindCommandContext(this._extensionCommandContextActions);
this._extensionErrorUnsubscriber?.();
this._extensionErrorUnsubscriber = this._extensionErrorListener
? runner.onError(this._extensionErrorListener)
--
flagValues: previousFlagValues,
includeAllExtensionTools: true,
});
const hasBindings =
this._extensionUIContext ||
this._extensionCommandContextActions ||
this._extensionShutdownHandler ||
this._extensionErrorListener;
if (this._extensionRunner && hasBindings) {
await this._extensionRunner.emit({ type: "session_start" });I see the flow now. Let me trace it:
session.bindExtensions({ uiContext: ... }) which stores the UI context in _extensionUIContext and applies it to the runner/reload, session.reload() is called which:
_buildRuntime() which creates a NEW ExtensionRunner_applyExtensionBindings(runner) which sets runner.setUIContext(this._extensionUIContext)So the flow seems correct. Let me look more closely at the interactive mode's handleReloadCommand to see if maybe the issue is with how the UI context is created.
Actually, wait. Let me look at what _extensionUIContext contains. This is set by bindExtensions({ uiContext: ... }). Let me see what the interactive mode passes:
>rg "bindExtensions" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -A 100 -B 5return;
}
// Create extension UI context
const uiContext = this.createExtensionUIContext();
await this.session.bindExtensions({
uiContext,
commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Delegate to AgentSession (handles setup + agent state sync)
const success = await this.session.newSession(options);
if (!success) {
return { cancelled: true };
}
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
// Render any messages added via setup, or show empty session
this.renderInitialMessages();
this.ui.requestRender();
return { cancelled: false };
},
fork: async (entryId) => {
const result = await this.session.fork(entryId);
if (result.cancelled) {
return { cancelled: true };
}
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Forked to new session");
return { cancelled: false };
},
navigateTree: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
});
if (result.cancelled) {
return { cancelled: true };
}
this.chatContainer.clear();
this.renderInitialMessages();
if (result.editorText) {
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
return { cancelled: false };
},
},
shutdownHandler: () => {
this.shutdownRequested = true;
},
onError: (error) => {
this.showExtensionError(error.extensionPath, error.error, error.stack);
},
});
this.setupExtensionShortcuts(extensionRunner);
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false });
}
/**
* Get a registered tool definition by name (for custom rendering).
*/
private getRegisteredToolDefinition(toolName: string) {
const tools = this.session.extensionRunner?.getAllRegisteredTools() ?? [];
const registeredTool = tools.find((t) => t.definition.name === toolName);
return registeredTool?.definition;
}
/**
* Set up keyboard shortcuts registered by extensions.
*/
private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void {
const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());
if (shortcuts.size === 0) return;
// Create a context for shortcut handlers
const createContext = (): ExtensionContext => ({
ui: this.createExtensionUIContext(),
hasUI: true,
cwd: process.cwd(),
sessionManager: this.sessionManager,
modelRegistry: this.session.modelRegistry,
model: this.session.model,I see! Let me look at createExtensionUIContext():
>rg "createExtensionUIContext" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -A 80 -B 2// Create extension UI context
const uiContext = this.createExtensionUIContext();
await this.session.bindExtensions({
uiContext,
commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Delegate to AgentSession (handles setup + agent state sync)
const success = await this.session.newSession(options);
if (!success) {
return { cancelled: true };
}
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
// Render any messages added via setup, or show empty session
this.renderInitialMessages();
this.ui.requestRender();
return { cancelled: false };
},
fork: async (entryId) => {
const result = await this.session.fork(entryId);
if (result.cancelled) {
return { cancelled: true };
}
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Forked to new session");
return { cancelled: false };
},
navigateTree: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
});
if (result.cancelled) {
return { cancelled: true };
}
this.chatContainer.clear();
this.renderInitialMessages();
if (result.editorText) {
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
return { cancelled: false };
},
},
shutdownHandler: () => {
this.shutdownRequested = true;
},
onError: (error) => {
this.showExtensionError(error.extensionPath, error.error, error.stack);
},
});
this.setupExtensionShortcuts(extensionRunner);
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false });
}
/**
* Get a registered tool definition by name (for custom rendering).
*/
--
// Create a context for shortcut handlers
const createContext = (): ExtensionContext => ({
ui: this.createExtensionUIContext(),
hasUI: true,
cwd: process.cwd(),
sessionManager: this.sessionManager,
modelRegistry: this.session.modelRegistry,
model: this.session.model,
isIdle: () => !this.session.isStreaming,
abort: () => this.session.abort(),
hasPendingMessages: () => this.session.pendingMessageCount > 0,
shutdown: () => {
this.shutdownRequested = true;
},
getContextUsage: () => this.session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await this.executeCompaction(options?.customInstructions, false);
if (result) {
options?.onComplete?.(result);
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
});
// Set up the extension shortcut handler on the default editor
this.defaultEditor.onExtensionShortcut = (data: string) => {
for (const [shortcutStr, shortcut] of shortcuts) {
// Cast to KeyId - extension shortcuts use the same format
if (matchesKey(data, shortcutStr as KeyId)) {
// Run handler async, don't block input
Promise.resolve(shortcut.handler(createContext())).catch((err) => {
this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`);
});
return true;
}
}
return false;
};
}
/**
* Set extension status text in the footer.
*/
private setExtensionStatus(key: string, text: string | undefined): void {
this.footerDataProvider.setExtensionStatus(key, text);
this.ui.requestRender();
}
/**
* Set an extension widget (string array or custom component).
*/
private setExtensionWidget(
key: string,
content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined,
options?: ExtensionWidgetOptions,
): void {
const placement = options?.placement ?? "aboveEditor";
const removeExisting = (map: Map<string, Component & { dispose?(): void }>) => {
const existing = map.get(key);
if (existing?.dispose) existing.dispose();
map.delete(key);
};
removeExisting(this.extensionWidgetsAbove);
removeExisting(this.extensionWidgetsBelow);
if (content === undefined) {
this.renderWidgets();
return;
}
let component: Component & { dispose?(): void };
if (Array.isArray(content)) {
// Wrap string array in a Container with Text components
const container = new Container();
for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) {
--
* Create the ExtensionUIContext for extensions.
*/
private createExtensionUIContext(): ExtensionUIContext {
return {
select: (title, options, opts) => this.showExtensionSelector(title, options, opts),
confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts),
input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts),
notify: (message, type) => this.showExtensionNotify(message, type),
setStatus: (key, text) => this.setExtensionStatus(key, text),
setWorkingMessage: (message) => {
if (this.loadingAnimation) {
if (message) {
this.loadingAnimation.setMessage(message);
} else {
this.loadingAnimation.setMessage(
`${this.defaultWorkingMessage} (${appKey(this.keybindings, "interrupt")} to interrupt)`,
);
}
} else {
// Queue message for when loadingAnimation is created (handles agent_start race)
this.pendingWorkingMessage = message;
}
},
setWidget: (key, content, options) => this.setExtensionWidget(key, content, options),
setFooter: (factory) => this.setExtensionFooter(factory),
setHeader: (factory) => this.setExtensionHeader(factory),
setTitle: (title) => this.ui.terminal.setTitle(title),
custom: (factory, options) => this.showExtensionCustom(factory, options),
setEditorText: (text) => this.editor.setText(text),
getEditorText: () => this.editor.getText(),
editor: (title, prefill) => this.showExtensionEditor(title, prefill),
setEditorComponent: (factory) => this.setCustomEditorComponent(factory),
get theme() {
return theme;
},
getAllThemes: () => getAvailableThemesWithPaths(),
getTheme: (name) => getThemeByName(name),
setTheme: (themeOrName) => {
if (themeOrName instanceof Theme) {
setThemeInstance(themeOrName);
this.ui.requestRender();
return { success: true };
}
const result = setTheme(themeOrName, true);
if (result.success) {
this.ui.requestRender();
}
return result;
},
};
}
/**
* Show a selector for extensions.
*/
private showExtensionSelector(
title: string,
options: string[],
opts?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
return new Promise((resolve) => {
if (opts?.signal?.aborted) {
resolve(undefined);
return;
}
const onAbort = () => {
this.hideExtensionSelector();
resolve(undefined);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
this.extensionSelector = new ExtensionSelectorComponent(
title,
options,
(option) => {
opts?.signal?.removeEventListener("abort", onAbort);
this.hideExtensionSelector();
resolve(option);
},
() => {
opts?.signal?.removeEventListener("abort", onAbort);
this.hideExtensionSelector();Now I see the issue more clearly. Let me look at showExtensionNotify:
>rg "showExtensionNotify" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -A 20 -B 2confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts),
input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts),
notify: (message, type) => this.showExtensionNotify(message, type),
setStatus: (key, text) => this.setExtensionStatus(key, text),
setWorkingMessage: (message) => {
if (this.loadingAnimation) {
if (message) {
this.loadingAnimation.setMessage(message);
} else {
this.loadingAnimation.setMessage(
`${this.defaultWorkingMessage} (${appKey(this.keybindings, "interrupt")} to interrupt)`,
);
}
} else {
// Queue message for when loadingAnimation is created (handles agent_start race)
this.pendingWorkingMessage = message;
}
},
setWidget: (key, content, options) => this.setExtensionWidget(key, content, options),
setFooter: (factory) => this.setExtensionFooter(factory),
setHeader: (factory) => this.setExtensionHeader(factory),
setTitle: (title) => this.ui.terminal.setTitle(title),
custom: (factory, options) => this.showExtensionCustom(factory, options),
--
* Show a notification for extensions.
*/
private showExtensionNotify(message: string, type?: "info" | "warning" | "error"): void {
if (type === "error") {
this.showError(message);
} else if (type === "warning") {
this.showWarning(message);
} else {
this.showStatus(message);
}
}
/** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */
private async showExtensionCustom<T>(
factory: (
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
options?: {
overlay?: boolean;
overlayOptions?: OverlayOptions | (() => OverlayOptions);I see! The showExtensionNotify just calls this.showStatus(message) for info notifications. Let me look at showStatus:
>rg "showStatus" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -A 15 -B 2this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Forked to new session");
return { cancelled: false };
},
navigateTree: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
});
if (result.cancelled) {
return { cancelled: true };
}
this.chatContainer.clear();
--
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
return { cancelled: false };
},
},
shutdownHandler: () => {
this.shutdownRequested = true;
},
onError: (error) => {
this.showExtensionError(error.extensionPath, error.error, error.stack);
},
});
this.setupExtensionShortcuts(extensionRunner);
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false });
}
--
this.showWarning(message);
} else {
this.showStatus(message);
}
}
/** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */
private async showExtensionCustom<T>(
factory: (
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
options?: {
overlay?: boolean;
overlayOptions?: OverlayOptions | (() => OverlayOptions);
onHandle?: (handle: OverlayHandle) => void;
--
// Handle result
if (event.aborted) {
this.showStatus("Auto-compaction cancelled");
} else if (event.result) {
// Rebuild chat to show compacted state
this.chatContainer.clear();
this.rebuildChatFromMessages();
// Add compaction component at bottom so user sees it without scrolling
this.addMessageToChat({
role: "compactionSummary",
tokensBefore: event.result.tokensBefore,
summary: event.result.summary,
timestamp: Date.now(),
});
this.footer.invalidate();
} else if (event.errorMessage) {
// Compaction failed (e.g., quota exceeded, API error)
this.chatContainer.addChild(new Spacer(1));
--
* we update the previous status line instead of appending new ones to avoid log spam.
*/
private showStatus(message: string): void {
const children = this.chatContainer.children;
const last = children.length > 0 ? children[children.length - 1] : undefined;
const secondLast = children.length > 1 ? children[children.length - 2] : undefined;
if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) {
this.lastStatusText.setText(theme.fg("dim", message));
this.ui.requestRender();
return;
}
const spacer = new Spacer(1);
const text = new Text(theme.fg("dim", message), 1, 0);
this.chatContainer.addChild(spacer);
this.chatContainer.addChild(text);
this.lastStatusSpacer = spacer;
--
if (compactionCount > 0) {
const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
this.showStatus(`Session compacted ${times}`);
}
}
async getUserInput(): Promise<string> {
return new Promise((resolve) => {
this.onInputCallback = (text: string) => {
this.onInputCallback = undefined;
resolve(text);
};
});
}
private rebuildChatFromMessages(): void {
this.chatContainer.clear();
const context = this.sessionManager.buildSessionContext();
--
const restored = this.restoreQueuedMessagesToEditor();
if (restored === 0) {
this.showStatus("No queued messages to restore");
} else {
this.showStatus(`Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`);
}
}
private updateEditorBorderColor(): void {
if (this.isBashMode) {
this.editor.borderColor = theme.getBashModeBorderColor();
} else {
const level = this.session.thinkingLevel || "off";
this.editor.borderColor = theme.getThinkingBorderColor(level);
}
this.ui.requestRender();
}
private cycleThinkingLevel(): void {
const newLevel = this.session.cycleThinkingLevel();
if (newLevel === undefined) {
this.showStatus("Current model does not support thinking");
} else {
this.footer.invalidate();
this.updateEditorBorderColor();
this.showStatus(`Thinking level: ${newLevel}`);
}
}
private async cycleModel(direction: "forward" | "backward"): Promise<void> {
try {
const result = await this.session.cycleModel(direction);
if (result === undefined) {
const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
this.showStatus(msg);
} else {
this.footer.invalidate();
this.updateEditorBorderColor();
const thinkingStr =
result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
this.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`);
}
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
}
private toggleToolOutputExpansion(): void {
this.toolOutputExpanded = !this.toolOutputExpanded;
for (const child of this.chatContainer.children) {
if (isExpandable(child)) {
child.setExpanded(this.toolOutputExpanded);
}
}
this.ui.requestRender();
}
--
}
this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
}
private openExternalEditor(): void {
// Determine editor (respect $VISUAL, then $EDITOR)
const editorCmd = process.env.VISUAL || process.env.EDITOR;
if (!editorCmd) {
this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
return;
}
const currentText = this.editor.getExpandedText?.() ?? this.editor.getText();
const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`);
try {
// Write current content to temp file
--
this.editor.setText("");
this.updatePendingMessagesDisplay();
this.showStatus("Queued message for after compaction");
}
private isExtensionCommand(text: string): boolean {
if (!text.startsWith("/")) return false;
const extensionRunner = this.session.extensionRunner;
if (!extensionRunner) return false;
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
return !!extensionRunner.getCommand(commandName);
}
private async flushCompactionQueue(options?: { willRetry?: boolean }): Promise<void> {
if (this.compactionQueuedMessages.length === 0) {
--
this.footer.invalidate();
this.updateEditorBorderColor();
this.showStatus(`Model: ${model.id}`);
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
return;
}
this.showModelSelector(searchTerm);
}
private async findExactModelMatch(searchTerm: string): Promise<Model<any> | undefined> {
const term = searchTerm.trim();
if (!term) return undefined;
let targetProvider: string | undefined;
let targetModelId = "";
--
this.updateEditorBorderColor();
done();
this.showStatus(`Model: ${model.id}`);
} catch (error) {
done();
this.showError(error instanceof Error ? error.message : String(error));
}
},
() => {
done();
this.ui.requestRender();
},
initialSearchInput,
);
return { component: selector, focus: selector };
});
}
--
if (allModels.length === 0) {
this.showStatus("No models available");
return;
}
// Check if session has scoped models (from previous session-only changes or CLI --models)
const sessionScopedModels = this.session.scopedModels;
const hasSessionScope = sessionScopedModels.length > 0;
// Build enabled model IDs from session state or settings
const enabledModelIds = new Set<string>();
let hasFilter = false;
if (hasSessionScope) {
// Use current session's scoped models
for (const sm of sessionScopedModels) {
enabledModelIds.add(`${sm.model.provider}/${sm.model.id}`);
--
: enabledIds;
this.settingsManager.setEnabledModels(newPatterns);
this.showStatus("Model selection saved to settings");
},
onCancel: () => {
done();
this.ui.requestRender();
},
},
);
return { component: selector, focus: selector };
});
}
private showUserMessageSelector(): void {
const userMessages = this.session.getUserMessagesForForking();
if (userMessages.length === 0) {
this.showStatus("No messages to fork from");
return;
}
this.showSelector((done) => {
const selector = new UserMessageSelectorComponent(
userMessages.map((m) => ({ id: m.entryId, text: m.text })),
async (entryId) => {
const result = await this.session.fork(entryId);
if (result.cancelled) {
// Extension cancelled the fork
done();
this.ui.requestRender();
return;
}
--
this.editor.setText(result.selectedText);
done();
this.showStatus("Branched to new session");
},
() => {
done();
this.ui.requestRender();
},
);
return { component: selector, focus: selector.getMessageList() };
});
}
private showTreeSelector(initialSelectedId?: string): void {
const tree = this.sessionManager.getTree();
const realLeafId = this.sessionManager.getLeafId();
// Find the visible leaf for display (skip metadata entries like labels)
--
if (tree.length === 0) {
this.showStatus("No entries in session");
return;
}
this.showSelector((done) => {
const selector = new TreeSelectorComponent(
tree,
visibleLeafId,
this.ui.terminal.rows,
async (entryId) => {
// Selecting the visible leaf is a no-op (already there)
if (entryId === visibleLeafId) {
done();
this.showStatus("Already at this point");
return;
}
// Ask about summarization
done(); // Close selector first
// Loop until user makes a complete choice or cancels to tree
let wantsSummary = false;
let customInstructions: string | undefined;
while (true) {
const summaryChoice = await this.showExtensionSelector("Summarize branch?", [
"No summary",
"Summarize",
"Summarize with custom prompt",
--
if (result.aborted) {
// Summarization aborted - re-show tree selector with same selection
this.showStatus("Branch summarization cancelled");
this.showTreeSelector(entryId);
return;
}
if (result.cancelled) {
this.showStatus("Navigation cancelled");
return;
}
// Update UI
this.chatContainer.clear();
this.renderInitialMessages();
if (result.editorText) {
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
} finally {
if (summaryLoader) {
summaryLoader.stop();
this.statusContainer.clear();
}
this.defaultEditor.onEscape = originalOnEscape;
}
},
() => {
done();
this.ui.requestRender();
},
(entryId, label) => {
--
this.chatContainer.clear();
this.renderInitialMessages();
this.showStatus("Resumed session");
}
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
if (mode === "logout") {
const providers = this.session.modelRegistry.authStorage.list();
const loggedInProviders = providers.filter(
(p) => this.session.modelRegistry.authStorage.get(p)?.type === "oauth",
);
if (loggedInProviders.length === 0) {
this.showStatus("No OAuth providers logged in. Use /login first.");
return;
}
}
this.showSelector((done) => {
const selector = new OAuthSelectorComponent(
mode,
this.session.modelRegistry.authStorage,
async (providerId: string) => {
done();
if (mode === "login") {
await this.showLoginDialog(providerId);
} else {
// Logout flow
--
this.session.modelRegistry.refresh();
await this.updateAvailableProviderCount();
this.showStatus(`Logged out of ${providerName}`);
} catch (error: unknown) {
this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
},
() => {
done();
this.ui.requestRender();
},
);
return { component: selector, focus: selector };
});
}
private async showLoginDialog(providerId: string): Promise<void> {
--
this.session.modelRegistry.refresh();
await this.updateAvailableProviderCount();
this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`);
} catch (error: unknown) {
restoreEditor();
const errorMsg = error instanceof Error ? error.message : String(error);
if (errorMsg !== "Login cancelled") {
this.showError(`Failed to login to ${providerName}: ${errorMsg}`);
}
}
}
// =========================================================================
// Command handlers
// =========================================================================
private async handleReloadCommand(): Promise<void> {
if (this.session.isStreaming) {
--
this.showError(`models.json error: ${modelsJsonError}`);
}
this.showStatus("Reloaded extensions, skills, prompts, themes");
} catch (error) {
dismissLoader(previousEditor as Component);
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async handleExportCommand(text: string): Promise<void> {
const parts = text.split(/\s+/);
const outputPath = parts.length > 1 ? parts[1] : undefined;
try {
const filePath = await this.session.exportToHtml(outputPath);
this.showStatus(`Session exported to: ${filePath}`);
} catch (error: unknown) {
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
private async handleShareCommand(): Promise<void> {
// Check if gh is available and logged in
try {
const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" });
if (authResult.status !== 0) {
this.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
return;
}
} catch {
this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
--
proc?.kill();
restoreEditor();
this.showStatus("Share cancelled");
};
try {
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]);
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => resolve({ stdout, stderr, code }));
});
--
// Create the preview URL
const previewUrl = getShareViewerUrl(gistId);
this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
} catch (error: unknown) {
if (!loader.signal.aborted) {
restoreEditor();
this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
}
private handleCopyCommand(): void {
const text = this.session.getLastAssistantText();
if (!text) {
this.showError("No agent messages to copy yet.");
return;
}
try {
copyToClipboard(text);
this.showStatus("Copied last agent message to clipboard");
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
}
private handleNameCommand(text: string): void {
const name = text.replace(/^\/name\s*/, "").trim();
if (!name) {
const currentName = this.sessionManager.getSessionName();
if (currentName) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0));
} else {
this.showWarning("Usage: /name <name>");
}I see. showStatus adds a Text component to the chatContainer. This seems fine. Let me trace through the code once more.
When a command is executed:
_extensionRunner.getCommand(commandName) gets the command_extensionRunner.createCommandContext() creates the context with this.uiContext (current UI context)command.handler(args, ctx) is calledSo when /reload is executed:
handleReloadCommand() is called which calls this.session.reload()reload() creates a NEW ExtensionRunner via _buildRuntime()_buildRuntime() then calls _applyExtensionBindings(runner) which calls runner.setUIContext(this._extensionUIContext)So the UI context IS set on the new runner. But wait, let me check the noOpUIContext usage again:
const noOpUIContext: ExtensionUIContext = { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {}, // <-- This is a no-op! ...};And in ExtensionRunner:
constructor(...) { this.uiContext = noOpUIContext; // Default to no-op ...}Then setUIContext is called to set the real UI context. This seems fine.
Let me check if maybe the issue is with how runner.setUIContext(this._extensionUIContext) works when _extensionUIContext is undefined:
>rg "setUIContext" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/extensions/runner.ts -A 5 -B 2}
setUIContext(uiContext?: ExtensionUIContext): void {
this.uiContext = uiContext ?? noOpUIContext;
}
getUIContext(): ExtensionUIContext {
return this.uiContext;I see it now!
setUIContext(uiContext?: ExtensionUIContext): void { this.uiContext = uiContext ?? noOpUIContext;}If uiContext is undefined, it falls back to noOpUIContext. And in _applyExtensionBindings:
private _applyExtensionBindings(runner: ExtensionRunner): void { runner.setUIContext(this._extensionUIContext); // Could be undefined! ...}The _extensionUIContext is only set when bindExtensions() is called. Let me check what happens on startup vs reload. The issue might be that _extensionUIContext is not set properly.
Actually wait, let me re-read bindExtensions:
async bindExtensions(bindings: ExtensionBindings): Promise<void> { if (bindings.uiContext !== undefined) { this._extensionUIContext = bindings.uiContext; } ...}So _extensionUIContext is set once by interactive mode during initialization. And when /reload happens, _applyExtensionBindings passes the stored _extensionUIContext to the new runner. This should work.
Let me check if maybe there's an issue with the interactive mode's createExtensionUIContext:
>rg "createExtensionUIContext" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -B 5 -A 50 | head -100this.showLoadedResources({ extensionPaths: [], force: false });
return;
}
// Create extension UI context
const uiContext = this.createExtensionUIContext();
await this.session.bindExtensions({
uiContext,
commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Delegate to AgentSession (handles setup + agent state sync)
const success = await this.session.newSession(options);
if (!success) {
return { cancelled: true };
}
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
// Render any messages added via setup, or show empty session
this.renderInitialMessages();
this.ui.requestRender();
return { cancelled: false };
},
fork: async (entryId) => {
const result = await this.session.fork(entryId);
if (result.cancelled) {
return { cancelled: true };
}
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Forked to new session");
return { cancelled: false };
},
navigateTree: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
--
const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());
if (shortcuts.size === 0) return;
// Create a context for shortcut handlers
const createContext = (): ExtensionContext => ({
ui: this.createExtensionUIContext(),
hasUI: true,
cwd: process.cwd(),
sessionManager: this.sessionManager,
modelRegistry: this.session.modelRegistry,
model: this.session.model,
isIdle: () => !this.session.isStreaming,
abort: () => this.session.abort(),
hasPendingMessages: () => this.session.pendingMessageCount > 0,
shutdown: () => {
this.shutdownRequested = true;
},
getContextUsage: () => this.session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await this.executeCompaction(options?.customInstructions, false);
if (result) {
options?.onComplete?.(result);
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
});
// Set up the extension shortcut handler on the default editor
this.defaultEditor.onExtensionShortcut = (data: string) => {
for (const [shortcutStr, shortcut] of shortcuts) {
// Cast to KeyId - extension shortcuts use the same format
if (matchesKey(data, shortcutStr as KeyId)) {
// Run handler async, don't block input
Promise.resolve(shortcut.handler(createContext())).catch((err) => {
this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`);
});
return true;I notice something interesting. The createExtensionUIContext() creates a NEW object each time it's called. In the setupExtensionShortcuts function, for shortcuts, it creates a fresh context with this.createExtensionUIContext(). But for the bindExtensions call, it only happens once during initialization.
Let me trace through what happens when:1. pi starts2. user creates test.ts extension
3. user does /reload
4. the extension is loaded
5. user runs /testAt step 4, the extension is loaded via _buildRuntime(). The _applyExtensionBindings() is called which sets the UI context on the runner.At step 5, when the /test command runs:
_extensionRunner.getCommand("test") returns the command_extensionRunner.createCommandContext() creates the context with this.uiContext- The handler is called with the contextThe createCommandContext() returns { ...this.createContext(), ... } which has ui: this.uiContext. And this.uiContext is set to the value from setUIContext().
Hmm, this should work. Let me check if maybe the issue is that the extension was created before /reload was called, and the extension stores a reference to the UI context somewhere.
Actually, wait! Looking back at the user's description: "test.ts didn't exist yet, you created it. then i did /reload." So:1. Pi started (no test.ts extension)
2. I (the agent) created test.ts
3. User did /reload
4. Extension was loaded
5. User ran /test
Let me verify this by looking at what happens during extension loading. The createExtensionAPI function creates the API that the extension uses. When the extension calls pi.registerCommand(), it stores the command. But the command's handler is a function that the extension provides, and it takes ctx as a parameter.Let me look again:
Let me look at how the command handler receives its context. The key issue is understanding if the ctx.ui that the command receives is properly bound to the real UI context after /reload.Let me create a test to see what's happening:
Let me check the exact flow by looking at how the command gets executed after reload:
>rg "tryExecuteExtensionCommand" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src --type ts -A 30/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts: const handled = await this._tryExecuteExtensionCommand(text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- if (handled) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- // Extension command executed, no prompt to send
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- return;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- // Emit input event for extension interception (before skill/template expansion)
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- let currentText = text;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- let currentImages = options?.images;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- if (this._extensionRunner?.hasHandlers("input")) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- const inputResult = await this._extensionRunner.emitInput(
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- currentText,
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- currentImages,
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- options?.source ?? "interactive",
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- );
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- if (inputResult.action === "handled") {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- return;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- if (inputResult.action === "transform") {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- currentText = inputResult.text;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- currentImages = inputResult.images ?? currentImages;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- // Expand skill commands (/skill:name args) and prompt templates (/template args)
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- let expandedText = currentText;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- if (expandPromptTemplates) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- expandedText = this._expandSkillCommand(expandedText);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- }
--
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts: private async _tryExecuteExtensionCommand(text: string): Promise<boolean> {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- if (!this._extensionRunner) return false;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- // Parse command name and args
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- const spaceIndex = text.indexOf(" ");
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- const command = this._extensionRunner.getCommand(commandName);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- if (!command) return false;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- // Get command context from extension runner (includes session control methods)
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- const ctx = this._extensionRunner.createCommandContext();
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- try {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- await command.handler(args, ctx);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- return true;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- } catch (err) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- // Emit error via extension runner
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- this._extensionRunner.emitError({
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- extensionPath: `command:${commandName}`,
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- event: "command",
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- error: err instanceof Error ? err.message : String(err),
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- });
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- return true;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- /**
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- * Expand skill commands (/skill:name args) to their full content.
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts- * Returns the expanded text, or the original text if not a skill command or skill not found.I see the flow:1. _tryExecuteExtensionCommand is called2. It gets the command from this._extensionRunner.getCommand(commandName)3. It creates context via this._extensionRunner.createCommandContext()4. The context includes ui: this.uiContext from the runnerSo when /reload happens:1. A new ExtensionRunner is created in _buildRuntime2. _applyExtensionBindings is called which sets runner.setUIContext(this._extensionUIContext)3. Commands are now available on the new runner4. When /test runs, the context is created with the runner's uiContext
This should work. Let me check if _extensionUIContext itself might be the problem. Maybe it's getting garbage collected or something? Or maybe there's a race condition.
Wait, I need to check something. Let me look at how the command execution flows from the interactive mode:
>rg "tryExecuteExtensionCommand\|/test\|isExtensionCommand" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -A 10 -B 3(no output)
Command exited with code 1Let me search for how commands are handled in the interactive mode:
>rg "handleInput\|handleSubmit\|session\.prompt" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -A 10 -B 3 | head -100(no output)Let me look for where input is submitted in the interactive mode:
>rg "onSubmit\|sendPrompt\|prompt\(" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -A 10 -B 3 | head -150(no output)Let me search in a different way:
>rg "isExtensionCommand" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src --type ts -A 10 -B 3/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- // Queue input during compaction (extension commands execute immediately)
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- if (this.session.isCompacting) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts: if (this.isExtensionCommand(text)) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- this.editor.addToHistory?.(text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- this.editor.setText("");
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- await this.session.prompt(text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- } else {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- this.queueCompactionMessage(text, "steer");
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- return;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- // If streaming, use prompt() with steer behavior
--
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- // Queue input during compaction (extension commands execute immediately)
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- if (this.session.isCompacting) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts: if (this.isExtensionCommand(text)) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- this.editor.addToHistory?.(text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- this.editor.setText("");
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- await this.session.prompt(text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- } else {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- this.queueCompactionMessage(text, "followUp");
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- return;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- // Alt+Enter queues a follow-up message (waits until agent finishes)
--
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- this.showStatus("Queued message for after compaction");
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts: private isExtensionCommand(text: string): boolean {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- if (!text.startsWith("/")) return false;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- const extensionRunner = this.session.extensionRunner;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- if (!extensionRunner) return false;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- const spaceIndex = text.indexOf(" ");
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- return !!extensionRunner.getCommand(commandName);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
--
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- if (options?.willRetry) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- // When retry is pending, queue messages for the retry turn
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- for (const message of queuedMessages) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts: if (this.isExtensionCommand(message.text)) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- await this.session.prompt(message.text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- } else if (message.mode === "followUp") {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- await this.session.followUp(message.text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- } else {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- await this.session.steer(message.text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- this.updatePendingMessagesDisplay();
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- return;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- // Find first non-extension-command message to use as prompt
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts: const firstPromptIndex = queuedMessages.findIndex((message) => !this.isExtensionCommand(message.text));
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- if (firstPromptIndex === -1) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- // All extension commands - execute them all
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- for (const message of queuedMessages) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- await this.session.prompt(message.text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- return;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- // Execute any extension commands before the first prompt
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- const preCommands = queuedMessages.slice(0, firstPromptIndex);
--
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts-
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- // Queue remaining messages
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- for (const message of rest) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts: if (this.isExtensionCommand(message.text)) {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- await this.session.prompt(message.text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- } else if (message.mode === "followUp") {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- await this.session.followUp(message.text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- } else {
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- await this.session.steer(message.text);
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- }
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- this.updatePendingMessagesDisplay();
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- void promptPromise;
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts- } catch (error) {I see that extension commands are executed via this.session.prompt(text). Let me look at how prompt handles commands:
>rg "async prompt\(" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/core/agent-session.ts -A 50 | head -80async prompt(text: string, options?: PromptOptions): Promise<void> {
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
// Handle extension commands first (execute immediately, even during streaming)
// Extension commands manage their own LLM interaction via pi.sendMessage()
if (expandPromptTemplates && text.startsWith("/")) {
const handled = await this._tryExecuteExtensionCommand(text);
if (handled) {
// Extension command executed, no prompt to send
return;
}
}
// Emit input event for extension interception (before skill/template expansion)
let currentText = text;
let currentImages = options?.images;
if (this._extensionRunner?.hasHandlers("input")) {
const inputResult = await this._extensionRunner.emitInput(
currentText,
currentImages,
options?.source ?? "interactive",
);
if (inputResult.action === "handled") {
return;
}
if (inputResult.action === "transform") {
currentText = inputResult.text;
currentImages = inputResult.images ?? currentImages;
}
}
// Expand skill commands (/skill:name args) and prompt templates (/template args)
let expandedText = currentText;
if (expandPromptTemplates) {
expandedText = this._expandSkillCommand(expandedText);
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
}
// If streaming, queue via steer() or followUp() based on option
if (this.isStreaming) {
if (!options?.streamingBehavior) {
throw new Error(
"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
);
}
if (options.streamingBehavior === "followUp") {
await this._queueFollowUp(expandedText);
} else {
await this._queueSteer(expandedText);
}
return;I see the flow now:1. prompt() is called with /test
2. _tryExecuteExtensionCommand() is called which:
createCommandContext()command.handler(args, ctx)Let me add some debug logging to understand what's happening. Actually, wait, I think I found the issue!
Let me look at createContext() again:
createContext(): ExtensionContext { const getModel = this.getModel; return { ui: this.uiContext, // <-- Captures the value at call time hasUI: this.hasUI(), // <-- Also called at call time ... };}If the issue is that hasUI() returns false, then... wait, that shouldn't affect ctx.ui.notify() working. Let me check what showExtensionNotify does again:
>rg "showExtensionNotify\|showStatus" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -A 15 | head -50(no output)Let me search more broadly:
>grep -n "showExtensionNotify\|showStatus" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts | head -301027: this.showStatus("Forked to new session");
1047: this.showStatus("Navigated to selected point");
1328: notify: (message, type) => this.showExtensionNotify(message, type),
1598: private showExtensionNotify(message: string, type?: "info" | "warning" | "error"): void {
1604: this.showStatus(message);
2189: this.showStatus("Auto-compaction cancelled");
2270: private showStatus(message: string): void {
2460: this.showStatus(`Session compacted ${times}`);
2580: this.showStatus("No queued messages to restore");
2582: this.showStatus(`Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`);
2599: this.showStatus("Current model does not support thinking");
2603: this.showStatus(`Thinking level: ${newLevel}`);
2612: this.showStatus(msg);
2618: this.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`);
2650: this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`);
2826: this.showStatus("Queued message for after compaction");
3071: this.showStatus(`Model: ${model.id}`);
3142: this.showStatus(`Model: ${model.id}`);
3164: this.showStatus("No models available");
3264: this.showStatus("Model selection saved to settings");
3280: this.showStatus("No messages to fork from");
3300: this.showStatus("Branched to new session");
3325: this.showStatus("No entries in session");
3338: this.showStatus("Already at this point");
3403: this.showStatus("Branch summarization cancelled");
3408: this.showStatus("Navigation cancelled");
3418: this.showStatus("Navigated to selected point");
3498: this.showStatus("Resumed session");
3508: this.showStatus("No OAuth providers logged in. Use /login first.");
3531: this.showStatus(`Logged out of ${providerName}`);Let me look at line 1328 more closely (the notify handler) and line 2270 (showStatus):
I see that showStatus adds a Text component to the chat container and calls this.ui.requestRender(). This should work.Let me think about this differently. The user says "the /test command got registered, but running it did not show the ui notification." This means the command is found (registered correctly), but ctx.ui.notify() didn't show anything.If I look at the flow:1. /test is entered2. Interactive mode sends it to session.prompt("/test")
3. _tryExecuteExtensionCommand is called4. The command is found (yes, because the user said it got registered)5. createCommandContext() is called which includes ui: this.uiContext
6. The command handler is called with this context7. The handler calls ctx.ui.notify(...)If this.uiContext is the no-op context, then notify would be a no-op. Let me check the noOpUIContext.notify:
const noOpUIContext: ExtensionUIContext = { ... notify: () => {}, // <-- This is a no-op! ...};Yes! If the no-op context is being used, notify() does nothing.
So the question is: why is this.uiContext still the noOpUIContext after /reload?
Let me trace through /reload:1. handleReloadCommand() is called
2. It calls this.session.reload()3. reload() calls _buildRuntime() which creates a NEW ExtensionRunner4. _buildRuntime calls _applyExtensionBindings(runner)5. _applyExtensionBindings calls runner.setUIContext(this._extensionUIContext)
The key is: is this._extensionUIContext set?It should have been set during initialization in bindExtensions().
I need to check whether bindExtensions actually runs during startup and properly initializes this._extensionUIContext with the real UI context instead of leaving it as the no-op version.
>rg "bindExtensions" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -B 5 -A 5return;
}
// Create extension UI context
const uiContext = this.createExtensionUIContext();
await this.session.bindExtensions({
uiContext,
commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => {
if (this.loadingAnimation) {I see that bindExtensions is called once. But I need to understand where this is called and whether it happens before or after /reload.
Let me search for where this binding code is located:
>rg -n "Create extension UI context" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts985: // Create extension UI contextLet me look at the context around line 985 to understand when this is called:
I see! initExtensions() is a method that initializes the extension system. Let me find where this is called:
>rg "initExtensions\(\)" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts -B 5 -A 5// Set terminal title
this.updateTerminalTitle();
// Initialize extensions with TUI-based UI context
await this.initExtensions();
// Subscribe to agent events
this.subscribeToAgent();
// Set up theme file watcher
--
}
/**
* Initialize the extension system with TUI-based UI context.
*/
private async initExtensions(): Promise<void> {
const extensionRunner = this.session.extensionRunner;
if (!extensionRunner) {
this.showLoadedResources({ extensionPaths: [], force: false });
return;
}initExtensions() is called during the run() method (initialization). But the key point is that bindExtensions is only called if extensionRunner exists.
Now let me understand the issue: when pi starts initially and no test.ts exists, initExtensions() is called. At that point, this.session.extensionRunner might be undefined (no extensions loaded yet). So bindExtensions is NOT called and _extensionUIContext remains undefined.Then when /reload happens:1. test.ts now exists
2. session.reload() creates a NEW ExtensionRunner (with the test.ts extension)
3. _applyExtensionBindings(runner) is called4. It calls runner.setUIContext(this._extensionUIContext)5. But _extensionUIContext is still undefined!6. So setUIContext(undefined) sets this.uiContext = noOpUIContextThat's the bug! When /reload happens, the UI context is not re-created if it wasn't created initially.Let me verify my understanding by looking at the reload handler to see if it re-initializes the UI context:
Let me look at the handleReloadCommand specifically:
>grep -n "handleReloadCommand" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts1883: await this.handleReloadCommand();
3639: private async handleReloadCommand(): Promise<void> {Let me see the full handleReloadCommand:
I found the bug. In handleReloadCommand():1.
actually, codex, please take over
Acknowledging takeover and summarizing analysis
Ok, I'll take over. The UI context issue likely happens because if no extensions exist at startup, the initialization skips binding the UI context after reload. Should I reproduce the issue, inspect files further, or proceed with a fix?
Explaining UI context reload issue and proposing fix options
After reload, the extension loads but _extensionUIContext is undefined, so the runner uses a no-op UI context, causing notifications not to appear during tests. To fix this, reload could call bindExtensions or create the UI context anew—possibly in the reload handler or session code. Should I implement this fix? If so, where do you want it applied?
Taking over. The likely cause is that when pi started there were no extensions, so InteractiveMode.initExtensions() returned early and never called session.bindExtensions(). That means _extensionUIContext stayed undefined. On /reload, AgentSession.reload() creates a new ExtensionRunner and calls _applyExtensionBindings, which passes the undefined UI context. ExtensionRunner.setUIContext(undefined) falls back to the no-op UI context, so ctx.ui.notify() does nothing. That matches what you saw.
If you want, I can prepare a fix. One option is to re-run initExtensions() after session.reload() or call session.bindExtensions() inside handleReloadCommand() whenever a new runner exists.
ohhhh that explains it! i was running it in ../shittycodingagent.ai/ and i think i have no user extensions loaded? yeah, i don't. howe can we fix that?
Explaining extension UI context reload fix
The fix involves ensuring the UI context binds in interactive mode even if no extensions start initially. This means calling session.bindExtensions at startup without exiting early when no extensionRunner exists, so _extensionUIContext is set for later reloads. I can implement this if requested.
Recommending reload UI context fix
The best approach is to call session.bindExtensions after reload in handleReloadCommand to ensure the UI context updates when a new runner appears. Since initExtensions exits early without a runner, bindExtensions offers a safer way to rebind without redundant setup calls. I can implement this if wanted and explain options for handling no extensions at startup.
It happens when you start with zero extensions. initExtensions() returns early, so session.bindExtensions() never runs and _extensionUIContext stays undefined. On /reload, the new runner gets noOpUIContext, so ctx.ui.notify() does nothing.
Fix options:
InteractiveMode.initExtensions(), call session.bindExtensions() even when there is no runner, so the UI context is stored for future reloads.handleReloadCommand(), after session.reload(), call session.bindExtensions() with createExtensionUIContext() if a runner exists.initExtensions() after reload instead of only rebuilding shortcuts.Tell me which approach you want, or I can pick one and implement it.
i suppose we need to do the same in prc and print(json mode?
Planning analysis of related code paths
I'll inspect the rpc and print/json mode code to check usage of bindExtensions before suggesting a fix, focusing on analysis rather than implementation since it wasn't requested yet.
>rg -n "bindExtensions|createExtensionUIContext" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/rpc/rpc-mode.ts:119: const createExtensionUIContext = (): ExtensionUIContext => ({
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/rpc/rpc-mode.ts:259: await session.bindExtensions({
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/rpc/rpc-mode.ts:260: uiContext: createExtensionUIContext(),
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/print-mode.ts:41: await session.bindExtensions({
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts:986: const uiContext = this.createExtensionUIContext();
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts:987: await this.session.bindExtensions({
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts:1082: ui: this.createExtensionUIContext(),
/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts:1323: private createExtensionUIContext(): ExtensionUIContext {also, i would think agent-session.ts reload() would handle the rewiring of extensions and contexts. it's very weird for modes to having to do that on reload. what concise solution would make it so agent-session.ts handles all of that?
Proposing persistent binding storage in AgentSession
The key fix is to have AgentSession store extension bindings independently of runner existence by always calling bindExtensions; then AgentSession.reload can use these stored bindings to rewire even if mode didn’t bind initially.
also because if we registered the ui context after session.reload(), extensions might already have been run, specifically session_start events could have been emitted, wherein extensions can do ui things, no?
Analyzing extension binding persistence
The issue is that extension bindings aren’t consistently stored or reapplied across agent-session reloads because bindExtensions isn’t always called when no extensionRunner exists initially. To fix this, either the mode should always call bindExtensions to store bindings regardless of runner presence, or agent-session should manage storing and reapplying bindings independently before emitting session_start on reload.
Confirming mode behavior on extension bindings
The core problem is that modes like interactive skip calling bindExtensions if no extensionRunner, leaving UI context unset. To fix, modes (including rpc and print) should always call bindExtensions to set UI context regardless of runner presence, ensuring session reloads can reapply bindings. Next, I'll review rpc-mode and print-mode to verify if they also skip binding when no extensions.
Noticing conditional RPC binding
RPC mode only binds if extensionRunner exists, mirroring the earlier issue; considering whether print-mode faces the same condition.
Proposing session-level extension binding
Considering moving extension binding from modes to AgentSession to ensure context is set regardless of runner presence, either by calling bindExtensions unconditionally at startup or managing default bindings inside AgentSession for consistent context during reloads.
Clarifying extension binding fix
To ensure UI context is set before reload, all modes should call session.bindExtensions unconditionally at startup—even if no extension runner exists—so AgentSession stores bindings early. On reload, AgentSession already reapplies stored bindings before emitting session_start, preserving correct context without modes handling rewiring. This applies to interactive, RPC, and print modes.
Yes, same issue in RPC and print or json. They only call session.bindExtensions() when session.extensionRunner exists, so if you start with zero extensions the UI context never gets stored. On /reload, the new runner gets noOpUIContext, so ctx.ui.notify() does nothing.
AgentSession.reload() already rebinds and emits session_start if bindings are present. The missing piece is that the bindings never get stored when there were no extensions on startup. The concise fix is to call session.bindExtensions() unconditionally in each mode during initialization, even when there is no extension runner. That stores the UI context and command actions up front. Then reload reuses them and session_start runs with the real UI context.
This also answers your last point. If we only bind after reload(), session_start can fire before the UI is set. So the binding must be stored before reload runs.
If you want me to implement it, I will update:
packages/coding-agent/src/modes/interactive/interactive-mode.tspackages/coding-agent/src/modes/rpc/rpc-mode.tspackages/coding-agent/src/modes/print-mode.tsTell me to proceed.
please implement concisely
Clarifying file reading approach
The instructions require reading full files before editing without using offsets or limits, but the read tool truncates large files; this conflicts with the mandate. The next step is to resolve how to handle large files given this constraint.
Resolving reading large files strategy
I clarified that reading a file "in full" means initially calling read without offset/limit even if truncated, then following up with offset reads to complete it; this respects instructions while ensuring full content. The plan is to start with smaller files for edits, then handle the large interactive-mode.ts similarly.
Refactoring extension binding to always call
Planning to call session.bindExtensions unconditionally in both RPC and interactive modes, removing extensionRunner presence checks; this ensures UI context is always created and bound, while handling runner-dependent features like shortcuts and resource display conditionally.
Finalizing extension binding uniformity and reading large file
Decided to always create UI context and call bindExtensions unconditionally across modes, adjusting handling based on runner presence; preparing to read the full interactive-mode.ts despite its size to ensure safe comprehensive edits.
Resolving full file read despite truncation
To fully read large files, I'll use offset and limit in multiple reads after the initial no-offset read, balancing system instructions with practical file size limits.
Continuing full file read in chunks
Proceeding to read the remaining lines using offsets in multiple chunks to ensure the entire file is fully retrieved before editing.
Pausing analysis
Reading core print-mode file
Refactoring extension initialization
Planning to always call bindExtensions by moving UI context creation and bindExtensions call before checking for the extension runner, then conditionally run setupShortcuts and show resources afterward for clearer flow and to ensure UI context setup even without a runner.
Preparing precise code replacements
I'm crafting exact old and new code blocks for initExtensions and updating rpc-mode and print-mode to always call bindExtensions without checking extensionRunner first, ensuring no extra blanks for exact text match using the edit tool.
Updating RPC mode with edit
Locating block for precise editing
I'm planning to read a snippet around the target comment to capture the exact text of the block that needs editing before copying it from the file.
>rg -n "Set up extensions with RPC-based UI context" /Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/modes/rpc/rpc-mode.ts256: // Set up extensions with RPC-based UI contextConsidering partial file reading with offset
I'm exploring using a read-with-offset approach around line 245 to handle snippets, though the requirement states reading the full file before editing; this could optimize showing code blocks.
Extending block capture to include closing braces
I'm checking for additional lines to ensure the entire block, including its closing braces, is captured correctly.
Applying exact old text to snippet
Editing print-mode.ts to replace block
Confirming extension binding ensures UI context on reload
The bindExtensions call now always runs, setting UI context upfront even if no extension runner existed before reload, ensuring session_start emits with proper context. Next, I'll verify interactive mode calls bindExtensions unconditionally to avoid race conditions.
Validating unconditional binding won't break logic
Calling bindExtensions always is safe since it stores handlers and only emits session_start if extensionRunner exists, preserving prior behavior even in print mode without UI context. Next, I'll run the required full npm run check to catch any errors or warnings after these code changes.
>cd /Users/badlogic/workspaces/pi-mono && npm run check> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && cd packages/web-ui && npm run check
Checked 440 files in 292ms. No fixes applied.
> @mariozechner/pi-web-ui@0.50.0 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 47ms. No fixes applied.
Checked 3 files in 8ms. No fixes applied.Summarizing extension binding changes
Extensions now bind in all modes irrespective of the extension runner, ensuring UI context persists across reloads with session info saved. The initialization was adjusted to bind extensions before checking the runner, and RPC and print modes also bind unconditionally. Tests ran successfully with npm run check. Key files updated include those managing interactive, RPC, and print modes.
Summary:
packages/coding-agent/src/modes/interactive/interactive-mode.ts to bind extension UI context before checking for a runner, so reload has the context even when starting with zero extensions.packages/coding-agent/src/modes/rpc/rpc-mode.ts and packages/coding-agent/src/modes/print-mode.ts to bind extensions unconditionally for the same reason.Tests:
npm run checklgtm, commit and push, add a changelog entry
Preparing to update changelog
I'm reviewing the full changelog file to identify the appropriate section under Unreleased for adding a new Fixed entry and will read the full file to ensure proper insertion before making edits and committing.
Adding unreleased fix entry
I'm adding a third bullet under Unreleased Fixed: "Fixed extension UI contexts not being bound when no extensions were loaded at startup, so /reload could enable UI in new extensions," formatted as a simple sentence without an em dash or issue link.
createContext(): ExtensionContext { const getModel = this.getModel; return { ui: this.uiContext, // <-- This captures the value at call time ... };}const noOpUIContext: ExtensionUIContext = { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {}, // <-- This is a no-op! ...};constructor(...) { this.uiContext = noOpUIContext; // Default to no-op ...}setUIContext(uiContext?: ExtensionUIContext): void { this.uiContext = uiContext ?? noOpUIContext;}private _applyExtensionBindings(runner: ExtensionRunner): void { runner.setUIContext(this._extensionUIContext); // Could be undefined! ...}async bindExtensions(bindings: ExtensionBindings): Promise<void> { if (bindings.uiContext !== undefined) { this._extensionUIContext = bindings.uiContext; } ...}createContext(): ExtensionContext { const getModel = this.getModel; return { ui: this.uiContext, // <-- Captures the value at call time hasUI: this.hasUI(), // <-- Also called at call time ... };}const noOpUIContext: ExtensionUIContext = { ... notify: () => {}, // <-- This is a no-op! ...};