feat(coding-agent): ResourceLoader, package management, and /reload command (#645)

- Add ResourceLoader interface and DefaultResourceLoader implementation
- Add PackageManager for npm/git extension sources with install/remove/update
- Add session.reload() and session.bindExtensions() APIs
- Add /reload command in interactive mode
- Add CLI flags: --skill, --theme, --prompt-template, --no-themes, --no-prompt-templates
- Add pi install/remove/update commands for extension management
- Refactor settings.json to use arrays for skills, prompts, themes
- Remove legacy SkillsSettings source flags and filters
- Update SDK examples and documentation for ResourceLoader pattern
- Add theme registration and loadThemeFromPath for dynamic themes
- Add getShellEnv to include bin dir in PATH for bash commands
This commit is contained in:
Mario Zechner 2026-01-20 23:34:53 +01:00
parent 866d21c252
commit b846a4bfcf
51 changed files with 2724 additions and 1852 deletions

View file

@ -1,42 +1,66 @@
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
/** Loader wrapped with borders for extension UI */
export class BorderedLoader extends Container {
private loader: CancellableLoader;
private loader: CancellableLoader | Loader;
private cancellable: boolean;
private signalController?: AbortController;
constructor(tui: TUI, theme: Theme, message: string) {
constructor(tui: TUI, theme: Theme, message: string, options?: { cancellable?: boolean }) {
super();
this.cancellable = options?.cancellable ?? true;
const borderColor = (s: string) => theme.fg("border", s);
this.addChild(new DynamicBorder(borderColor));
this.loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
if (this.cancellable) {
this.loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
} else {
this.signalController = new AbortController();
this.loader = new Loader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
}
this.addChild(this.loader);
this.addChild(new Spacer(1));
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
if (this.cancellable) {
this.addChild(new Spacer(1));
this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0));
}
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder(borderColor));
}
get signal(): AbortSignal {
return this.loader.signal;
if (this.cancellable) {
return (this.loader as CancellableLoader).signal;
}
return this.signalController?.signal ?? new AbortController().signal;
}
set onAbort(fn: (() => void) | undefined) {
this.loader.onAbort = fn;
if (this.cancellable) {
(this.loader as CancellableLoader).onAbort = fn;
}
}
handleInput(data: string): void {
this.loader.handleInput(data);
if (this.cancellable) {
(this.loader as CancellableLoader).handleInput(data);
}
}
dispose(): void {
this.loader.dispose();
if ("dispose" in this.loader && typeof this.loader.dispose === "function") {
this.loader.dispose();
}
}
}

View file

@ -66,7 +66,6 @@ import { type AppAction, KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { resolveModelScope } from "../../core/model-resolver.js";
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
import { loadProjectContextFiles } from "../../core/system-prompt.js";
import type { TruncationResult } from "../../core/tools/truncate.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js";
@ -156,7 +155,6 @@ export class InteractiveMode {
private keybindings: KeybindingsManager;
private version: string;
private isInitialized = false;
private hasRenderedInitialMessages = false;
private onInputCallback?: (text: string) => void;
private loadingAnimation: Loader | undefined = undefined;
private readonly defaultWorkingMessage = "Working...";
@ -321,6 +319,7 @@ export class InteractiveMode {
{ 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
@ -617,133 +616,63 @@ export class InteractiveMode {
// Extension System
// =========================================================================
private showLoadedResources(options?: { extensionPaths?: string[]; force?: boolean }): void {
const shouldShow = options?.force || !this.settingsManager.getQuietStartup();
if (!shouldShow) {
return;
}
const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
if (contextFiles.length > 0) {
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const skills = this.session.skills;
if (skills.length > 0) {
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const skillWarnings = this.session.skillWarnings;
if (skillWarnings.length > 0) {
const warningList = skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const templates = this.session.promptTemplates;
if (templates.length > 0) {
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
const extensionPaths = options?.extensionPaths ?? [];
if (extensionPaths.length > 0) {
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
/**
* Initialize the extension system with TUI-based UI context.
*/
private async initExtensions(): Promise<void> {
// Show discovery info unless silenced
if (!this.settingsManager.getQuietStartup()) {
// Show loaded project context files
const contextFiles = loadProjectContextFiles();
if (contextFiles.length > 0) {
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Show loaded skills (already discovered by SDK)
const skills = this.session.skills;
if (skills.length > 0) {
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Show skill warnings if any
const skillWarnings = this.session.skillWarnings;
if (skillWarnings.length > 0) {
const warningList = skillWarnings
.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`))
.join("\n");
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
// Show loaded prompt templates
const templates = this.session.promptTemplates;
if (templates.length > 0) {
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
const extensionRunner = this.session.extensionRunner;
if (!extensionRunner) {
return; // No extensions loaded
this.showLoadedResources({ extensionPaths: [], force: false });
return;
}
// Create extension UI context
const uiContext = this.createExtensionUIContext();
extensionRunner.initialize(
// ExtensionActions - for pi.* API
{
sendMessage: (message, options) => {
const wasStreaming = this.session.isStreaming;
this.session
.sendCustomMessage(message, options)
.then(() => {
// Don't rebuild if initial render hasn't happened yet
// (renderInitialMessages will handle it)
if (!wasStreaming && message.display && this.hasRenderedInitialMessages) {
this.rebuildChatFromMessages();
}
})
.catch((err) => {
this.showError(
`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
},
sendUserMessage: (content, options) => {
this.session.sendUserMessage(content, options).catch((err) => {
this.showError(
`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
},
appendEntry: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
this.sessionManager.appendSessionInfo(name);
this.updateTerminalTitle();
},
getSessionName: () => {
return this.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
this.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => this.session.getActiveToolNames(),
getAllTools: () => this.session.getAllTools(),
setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await this.session.modelRegistry.getApiKey(model);
if (!key) return false;
await this.session.setModel(model);
return true;
},
getThinkingLevel: () => this.session.thinkingLevel,
setThinkingLevel: (level) => this.session.setThinkingLevel(level),
},
// ExtensionContextActions - for ctx.* in event handlers
{
getModel: () => 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);
}
})();
},
},
// ExtensionCommandContextActions - for ctx.* in command handlers
{
await this.session.bindExtensions({
uiContext,
commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => {
if (this.loadingAnimation) {
@ -808,31 +737,16 @@ export class InteractiveMode {
return { cancelled: false };
},
},
uiContext,
);
// Subscribe to extension errors
extensionRunner.onError((error) => {
this.showExtensionError(error.extensionPath, error.error, error.stack);
shutdownHandler: () => {
this.shutdownRequested = true;
},
onError: (error) => {
this.showExtensionError(error.extensionPath, error.error, error.stack);
},
});
// Set up extension-registered shortcuts
this.setupExtensionShortcuts(extensionRunner);
// Show loaded extensions (unless silenced)
if (!this.settingsManager.getQuietStartup()) {
const extensionPaths = extensionRunner.getExtensionPaths();
if (extensionPaths.length > 0) {
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
this.chatContainer.addChild(new Spacer(1));
}
}
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
});
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false });
}
/**
@ -950,6 +864,38 @@ export class InteractiveMode {
this.renderWidgets();
}
private clearExtensionWidgets(): void {
for (const widget of this.extensionWidgetsAbove.values()) {
widget.dispose?.();
}
for (const widget of this.extensionWidgetsBelow.values()) {
widget.dispose?.();
}
this.extensionWidgetsAbove.clear();
this.extensionWidgetsBelow.clear();
this.renderWidgets();
}
private resetExtensionUI(): void {
if (this.extensionSelector) {
this.hideExtensionSelector();
}
if (this.extensionInput) {
this.hideExtensionInput();
}
if (this.extensionEditor) {
this.hideExtensionEditor();
}
this.ui.hideOverlay();
this.setExtensionFooter(undefined);
this.setExtensionHeader(undefined);
this.clearExtensionWidgets();
this.footerDataProvider.clearExtensionStatuses();
this.footer.invalidate();
this.setCustomEditorComponent(undefined);
this.defaultEditor.onExtensionShortcut = undefined;
}
// Maximum total widget lines to prevent viewport overflow
private static readonly MAX_WIDGET_LINES = 10;
@ -1608,6 +1554,11 @@ export class InteractiveMode {
await this.handleCompactCommand(customInstructions);
return;
}
if (text === "/reload") {
this.editor.setText("");
await this.handleReloadCommand();
return;
}
if (text === "/debug") {
this.handleDebugCommand();
this.editor.setText("");
@ -2143,7 +2094,6 @@ export class InteractiveMode {
}
renderInitialMessages(): void {
this.hasRenderedInitialMessages = true;
// Get aligned messages and entries from session context
const context = this.sessionManager.buildSessionContext();
this.renderSessionContext(context, {
@ -3276,6 +3226,53 @@ export class InteractiveMode {
// 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 resources...", { cancellable: false });
const previousEditor = this.editor;
this.editorContainer.clear();
this.editorContainer.addChild(loader);
this.ui.setFocus(loader);
this.ui.requestRender();
const restoreEditor = () => {
loader.dispose();
this.editorContainer.clear();
this.editorContainer.addChild(previousEditor);
this.ui.setFocus(previousEditor as Component);
this.ui.requestRender();
};
try {
await this.session.reload();
this.rebuildAutocomplete();
const runner = this.session.extensionRunner;
if (runner) {
this.setupExtensionShortcuts(runner);
}
restoreEditor();
this.showLoadedResources({ extensionPaths: runner?.getExtensionPaths() ?? [], force: true });
const modelsJsonError = this.session.modelRegistry.getError();
if (modelsJsonError) {
this.showError(`models.json error: ${modelsJsonError}`);
}
this.showStatus("Reloaded resources");
} catch (error) {
restoreEditor();
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;
@ -3632,12 +3629,13 @@ export class InteractiveMode {
private handleDebugCommand(): void {
const width = this.ui.terminal.columns;
const height = this.ui.terminal.rows;
const allLines = this.ui.render(width);
const debugLogPath = getDebugLogPath();
const debugData = [
`Debug output at ${new Date().toISOString()}`,
`Terminal width: ${width}`,
`Terminal: ${width}x${height}`,
`Total lines: ${allLines.length}`,
"",
"=== All rendered lines with visible widths ===",

View file

@ -334,6 +334,8 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
// ============================================================================
export class Theme {
readonly name?: string;
readonly sourcePath?: string;
private fgColors: Map<ThemeColor, string>;
private bgColors: Map<ThemeBg, string>;
private mode: ColorMode;
@ -342,7 +344,10 @@ export class Theme {
fgColors: Record<ThemeColor, string | number>,
bgColors: Record<ThemeBg, string | number>,
mode: ColorMode,
options: { name?: string; sourcePath?: string } = {},
) {
this.name = options.name;
this.sourcePath = options.sourcePath;
this.mode = mode;
this.fgColors = new Map();
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
@ -457,6 +462,9 @@ export function getAvailableThemes(): string[] {
}
}
}
for (const name of registeredThemes.keys()) {
themes.add(name);
}
return Array.from(themes).sort();
}
@ -487,26 +495,16 @@ export function getAvailableThemesWithPaths(): ThemeInfo[] {
}
}
for (const [name, theme] of registeredThemes.entries()) {
if (!result.some((t) => t.name === name)) {
result.push({ name, path: theme.sourcePath });
}
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
function loadThemeJson(name: string): ThemeJson {
const builtinThemes = getBuiltinThemes();
if (name in builtinThemes) {
return builtinThemes[name];
}
const customThemesDir = getCustomThemesDir();
const themePath = path.join(customThemesDir, `${name}.json`);
if (!fs.existsSync(themePath)) {
throw new Error(`Theme not found: ${name}`);
}
const content = fs.readFileSync(themePath, "utf-8");
let json: unknown;
try {
json = JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse theme ${name}: ${error}`);
}
function parseThemeJson(label: string, json: unknown): ThemeJson {
if (!validateThemeJson.Check(json)) {
const errors = Array.from(validateThemeJson.Errors(json));
const missingColors: string[] = [];
@ -522,12 +520,12 @@ function loadThemeJson(name: string): ThemeJson {
}
}
let errorMessage = `Invalid theme "${name}":\n`;
let errorMessage = `Invalid theme "${label}":\n`;
if (missingColors.length > 0) {
errorMessage += `\nMissing required color tokens:\n`;
errorMessage += "\nMissing required color tokens:\n";
errorMessage += missingColors.map((c) => ` - ${c}`).join("\n");
errorMessage += `\n\nPlease add these colors to your theme's "colors" object.`;
errorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`;
errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.';
errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values.";
}
if (otherErrors.length > 0) {
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
@ -535,10 +533,35 @@ function loadThemeJson(name: string): ThemeJson {
throw new Error(errorMessage);
}
return json as ThemeJson;
}
function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
function parseThemeJsonContent(label: string, content: string): ThemeJson {
let json: unknown;
try {
json = JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse theme ${label}: ${error}`);
}
return parseThemeJson(label, json);
}
function loadThemeJson(name: string): ThemeJson {
const builtinThemes = getBuiltinThemes();
if (name in builtinThemes) {
return builtinThemes[name];
}
const customThemesDir = getCustomThemesDir();
const themePath = path.join(customThemesDir, `${name}.json`);
if (!fs.existsSync(themePath)) {
throw new Error(`Theme not found: ${name}`);
}
const content = fs.readFileSync(themePath, "utf-8");
return parseThemeJsonContent(name, content);
}
function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme {
const colorMode = mode ?? detectColorMode();
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
@ -558,10 +581,23 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
fgColors[key as ThemeColor] = value;
}
}
return new Theme(fgColors, bgColors, colorMode);
return new Theme(fgColors, bgColors, colorMode, {
name: themeJson.name,
sourcePath,
});
}
export function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme {
const content = fs.readFileSync(themePath, "utf-8");
const themeJson = parseThemeJsonContent(themePath, content);
return createTheme(themeJson, mode, themePath);
}
function loadTheme(name: string, mode?: ColorMode): Theme {
const registeredTheme = registeredThemes.get(name);
if (registeredTheme) {
return registeredTheme;
}
const themeJson = loadThemeJson(name);
return createTheme(themeJson, mode);
}
@ -617,6 +653,16 @@ function setGlobalTheme(t: Theme): void {
let currentThemeName: string | undefined;
let themeWatcher: fs.FSWatcher | undefined;
let onThemeChangeCallback: (() => void) | undefined;
const registeredThemes = new Map<string, Theme>();
export function setRegisteredThemes(themes: Theme[]): void {
registeredThemes.clear();
for (const theme of themes) {
if (theme.name) {
registeredThemes.set(theme.name, theme);
}
}
}
export function initTheme(themeName?: string, enableWatcher: boolean = false): void {
const name = themeName ?? getDefaultTheme();

View file

@ -35,68 +35,11 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
console.log(JSON.stringify(header));
}
}
// Set up extensions for print mode (no UI, no command context)
// Set up extensions for print mode (no UI)
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize(
// ExtensionActions
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch((e) => {
console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
console.error(`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}`);
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
session.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return session.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
session.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllTools(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await session.modelRegistry.getApiKey(model);
if (!key) return false;
await session.setModel(model);
return true;
},
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: (level) => session.setThinkingLevel(level),
},
// ExtensionContextActions
{
getModel: () => session.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
shutdown: () => {},
getContextUsage: () => session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await session.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
// ExtensionCommandContextActions - commands invokable via prompt("/command")
{
await session.bindExtensions({
commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => {
const success = await session.newSession({ parentSession: options?.parentSession });
@ -119,14 +62,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
return { cancelled: result.cancelled };
},
},
// No UI context - hasUI will be false
);
extensionRunner.onError((err) => {
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
});
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
onError: (err) => {
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
},
});
}

View file

@ -256,67 +256,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
// Set up extensions with RPC-based UI context
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize(
// ExtensionActions
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch((e) => {
output(error(undefined, "extension_send", e.message));
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch((e) => {
output(error(undefined, "extension_send_user", e.message));
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setSessionName: (name) => {
session.sessionManager.appendSessionInfo(name);
},
getSessionName: () => {
return session.sessionManager.getSessionName();
},
setLabel: (entryId, label) => {
session.sessionManager.appendLabelChange(entryId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllTools(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
setModel: async (model) => {
const key = await session.modelRegistry.getApiKey(model);
if (!key) return false;
await session.setModel(model);
return true;
},
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: (level) => session.setThinkingLevel(level),
},
// ExtensionContextActions
{
getModel: () => session.agent.state.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.pendingMessageCount > 0,
shutdown: () => {
shutdownRequested = true;
},
getContextUsage: () => session.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await session.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
},
// ExtensionCommandContextActions - commands invokable via prompt("/command")
{
await session.bindExtensions({
uiContext: createExtensionUIContext(),
commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => {
const success = await session.newSession({ parentSession: options?.parentSession });
@ -340,14 +282,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return { cancelled: result.cancelled };
},
},
createExtensionUIContext(),
);
extensionRunner.onError((err) => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
});
// Emit session_start event
await extensionRunner.emit({
type: "session_start",
shutdownHandler: () => {
shutdownRequested = true;
},
onError: (err) => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
},
});
}
@ -577,8 +517,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
async function checkShutdownRequested(): Promise<void> {
if (!shutdownRequested) return;
if (extensionRunner?.hasHandlers("session_shutdown")) {
await extensionRunner.emit({ type: "session_shutdown" });
const currentRunner = session.extensionRunner;
if (currentRunner?.hasHandlers("session_shutdown")) {
await currentRunner.emit({ type: "session_shutdown" });
}
// Close readline interface to stop waiting for input