feat(coding-agent): add ctx.reload and reload-runtime example closes #1371

This commit is contained in:
Mario Zechner 2026-02-08 15:08:16 +01:00
parent 0bd205ab87
commit e1b56c1d28
8 changed files with 114 additions and 0 deletions

View file

@ -771,6 +771,62 @@ Options:
- `replaceInstructions`: If true, `customInstructions` replaces the default prompt instead of being appended
- `label`: Label to attach to the branch summary entry (or target entry if not summarizing)
### ctx.reload()
Run the same reload flow as `/reload`.
```typescript
pi.registerCommand("reload-runtime", {
description: "Reload extensions, skills, prompts, and themes",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});
```
Important behavior:
- `await ctx.reload()` emits `session_shutdown` for the current extension runtime
- It then reloads resources and emits `session_start` (and `resources_discover` with reason `"reload"`) for the new runtime
- The currently running command handler still continues in the old call frame
- Code after `await ctx.reload()` still runs from the pre-reload version
- Code after `await ctx.reload()` must not assume old in-memory extension state is still valid
- After the handler returns, future commands/events/tool calls use the new extension version
For predictable behavior, treat reload as terminal for that handler (`await ctx.reload(); return;`).
Tools run with `ExtensionContext`, so they cannot call `ctx.reload()` directly. Use a command as the reload entrypoint, then expose a tool that queues that command as a follow-up user message.
Example tool the LLM can call to trigger reload:
```typescript
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
export default function (pi: ExtensionAPI) {
pi.registerCommand("reload-runtime", {
description: "Reload extensions, skills, prompts, and themes",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});
pi.registerTool({
name: "reload_runtime",
label: "Reload Runtime",
description: "Reload extensions, skills, prompts, and themes",
parameters: Type.Object({}),
async execute() {
pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" });
return {
content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }],
};
},
});
}
```
## ExtensionAPI Methods
### pi.on(event, handler)
@ -1778,6 +1834,7 @@ All examples in [examples/extensions/](../examples/extensions/).
| `handoff.ts` | Cross-provider model handoff | `registerCommand`, `ui.editor`, `ui.custom` |
| `qna.ts` | Q&A with custom UI | `registerCommand`, `ui.custom`, `setEditorText` |
| `send-user-message.ts` | Inject user messages | `registerCommand`, `sendUserMessage` |
| `reload-runtime.ts` | Reload command and LLM tool handoff | `registerCommand`, `ctx.reload()`, `sendUserMessage` |
| `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` |
| **Events & Gates** |||
| `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` |

View file

@ -66,6 +66,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests: anchors, margins, stacking, overflow, animation |
| `doom-overlay/` | DOOM game running as an overlay at 35 FPS (demonstrates real-time game rendering) |
| `shutdown-command.ts` | Adds `/quit` command demonstrating `ctx.shutdown()` |
| `reload-runtime.ts` | Adds `/reload-runtime` and `reload_runtime` tool showing safe reload flow |
| `interactive-shell.ts` | Run interactive commands (vim, htop) with full terminal via `user_bash` hook |
| `inline-bash.ts` | Expands `!{command}` patterns in prompts via `input` event transformation |

View file

@ -0,0 +1,37 @@
/**
* Reload Runtime Extension
*
* Demonstrates ctx.reload() from ExtensionCommandContext and an LLM-callable
* tool that queues a follow-up command to trigger reload.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
export default function (pi: ExtensionAPI) {
// Command entrypoint for reload.
// Treat reload as terminal for this handler.
pi.registerCommand("reload-runtime", {
description: "Reload extensions, skills, prompts, and themes",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});
// LLM-callable tool. Tools get ExtensionContext, so they cannot call ctx.reload() directly.
// Instead, queue a follow-up user command that executes the command above.
pi.registerTool({
name: "reload_runtime",
label: "Reload Runtime",
description: "Reload extensions, skills, prompts, and themes",
parameters: Type.Object({}),
async execute() {
pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" });
return {
content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }],
details: {},
};
},
});
}

View file

@ -147,6 +147,8 @@ export type NavigateTreeHandler = (
export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled: boolean }>;
export type ReloadHandler = () => Promise<void>;
export type ShutdownHandler = () => void;
/**
@ -210,6 +212,7 @@ export class ExtensionRunner {
private forkHandler: ForkHandler = async () => ({ cancelled: false });
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
private switchSessionHandler: SwitchSessionHandler = async () => ({ cancelled: false });
private reloadHandler: ReloadHandler = async () => {};
private shutdownHandler: ShutdownHandler = () => {};
private shortcutDiagnostics: ResourceDiagnostic[] = [];
private commandDiagnostics: ResourceDiagnostic[] = [];
@ -269,6 +272,7 @@ export class ExtensionRunner {
this.forkHandler = actions.fork;
this.navigateTreeHandler = actions.navigateTree;
this.switchSessionHandler = actions.switchSession;
this.reloadHandler = actions.reload;
return;
}
@ -277,6 +281,7 @@ export class ExtensionRunner {
this.forkHandler = async () => ({ cancelled: false });
this.navigateTreeHandler = async () => ({ cancelled: false });
this.switchSessionHandler = async () => ({ cancelled: false });
this.reloadHandler = async () => {};
}
setUIContext(uiContext?: ExtensionUIContext): void {
@ -501,6 +506,7 @@ export class ExtensionRunner {
fork: (entryId) => this.forkHandler(entryId),
navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
switchSession: (sessionPath) => this.switchSessionHandler(sessionPath),
reload: () => this.reloadHandler(),
};
}

View file

@ -306,6 +306,9 @@ export interface ExtensionCommandContext extends ExtensionContext {
/** Switch to a different session file. */
switchSession(sessionPath: string): Promise<{ cancelled: boolean }>;
/** Reload extensions, skills, prompts, and themes. */
reload(): Promise<void>;
}
// ============================================================================
@ -1234,6 +1237,7 @@ export interface ExtensionCommandContextActions {
options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string },
) => Promise<{ cancelled: boolean }>;
switchSession: (sessionPath: string) => Promise<{ cancelled: boolean }>;
reload: () => Promise<void>;
}
/**

View file

@ -1068,6 +1068,9 @@ export class InteractiveMode {
await this.handleResumeSession(sessionPath);
return { cancelled: false };
},
reload: async () => {
await this.handleReloadCommand();
},
},
shutdownHandler: () => {
this.shutdownRequested = true;

View file

@ -63,6 +63,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await session.reload();
},
},
onError: (err) => {
console.error(`Extension error (${err.extensionPath}): ${err.error}`);

View file

@ -295,6 +295,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await session.reload();
},
},
shutdownHandler: () => {
shutdownRequested = true;