refactor(coding-agent): add runtime host for session switching closes #2024

This commit is contained in:
Mario Zechner
2026-03-31 13:49:57 +02:00
parent a3bf1eb399
commit d86122cbd3
32 changed files with 1328 additions and 692 deletions

View File

@@ -38,6 +38,10 @@ export class FooterComponent implements Component {
private footerData: ReadonlyFooterDataProvider,
) {}
setSession(session: AgentSession): void {
this.session = session;
}
setAutoCompactEnabled(enabled: boolean): void {
this.autoCompactEnabled = enabled;
}
@@ -86,7 +90,7 @@ export class FooterComponent implements Component {
const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
// Replace home directory with ~
let pwd = process.cwd();
let pwd = this.session.sessionManager.getCwd();
const home = process.env.HOME || process.env.USERPROFILE;
if (home && pwd.startsWith(home)) {
pwd = `~${pwd.slice(home.length)}`;

View File

@@ -47,6 +47,7 @@ import {
VERSION,
} from "../../config.js";
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
import type { AgentSessionRuntimeHost } from "../../core/agent-session-runtime.js";
import type {
ExtensionContext,
ExtensionRunner,
@@ -144,7 +145,7 @@ export interface InteractiveModeOptions {
}
export class InteractiveMode {
private session: AgentSession;
private runtimeHost: AgentSessionRuntimeHost;
private ui: TUI;
private chatContainer: Container;
private pendingMessagesContainer: Container;
@@ -242,6 +243,9 @@ export class InteractiveMode {
private customHeader: (Component & { dispose?(): void }) | undefined = undefined;
// Convenience accessors
private get session(): AgentSession {
return this.runtimeHost.session;
}
private get agent() {
return this.session.agent;
}
@@ -253,10 +257,10 @@ export class InteractiveMode {
}
constructor(
session: AgentSession,
runtimeHost: AgentSessionRuntimeHost,
private options: InteractiveModeOptions = {},
) {
this.session = session;
this.runtimeHost = runtimeHost;
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
@@ -277,9 +281,9 @@ export class InteractiveMode {
this.editor = this.defaultEditor;
this.editorContainer = new Container();
this.editorContainer.addChild(this.editor as Component);
this.footerDataProvider = new FooterDataProvider();
this.footer = new FooterComponent(session, this.footerDataProvider);
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
this.footer = new FooterComponent(this.session, this.footerDataProvider);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
// Load hide thinking block setting
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
@@ -412,7 +416,7 @@ export class InteractiveMode {
// Setup autocomplete
this.autocompleteProvider = new CombinedAutocompleteProvider(
[...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],
process.cwd(),
this.sessionManager.getCwd(),
fdPath,
);
this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider);
@@ -524,7 +528,7 @@ export class InteractiveMode {
this.isInitialized = true;
// Initialize extensions first so resources are shown before messages
await this.initExtensions();
await this.bindCurrentSessionExtensions();
// Render initial messages AFTER showing loaded resources
this.renderInitialMessages();
@@ -555,7 +559,7 @@ export class InteractiveMode {
* Update terminal title with session name and cwd.
*/
private updateTerminalTitle(): void {
const cwdBasename = path.basename(process.cwd());
const cwdBasename = path.basename(this.sessionManager.getCwd());
const sessionName = this.sessionManager.getSessionName();
if (sessionName) {
this.ui.terminal.setTitle(`π - ${sessionName} - ${cwdBasename}`);
@@ -673,7 +677,7 @@ export class InteractiveMode {
try {
const packageManager = new DefaultPackageManager({
cwd: process.cwd(),
cwd: this.sessionManager.getCwd(),
agentDir: getAgentDir(),
settingsManager: this.settingsManager,
});
@@ -1161,7 +1165,7 @@ export class InteractiveMode {
/**
* Initialize the extension system with TUI-based UI context.
*/
private async initExtensions(): Promise<void> {
private async bindCurrentSessionExtensions(): Promise<void> {
const uiContext = this.createExtensionUIContext();
await this.session.bindExtensions({
uiContext,
@@ -1173,39 +1177,23 @@ export class InteractiveMode {
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 };
const result = await this.runtimeHost.newSession(options);
if (!result.cancelled) {
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.ui.requestRender();
}
// 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 };
return result;
},
fork: async (entryId) => {
const result = await this.session.fork(entryId);
if (result.cancelled) {
return { cancelled: true };
const result = await this.runtimeHost.fork(entryId);
if (!result.cancelled) {
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
this.showStatus("Forked to new session");
}
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Forked to new session");
return { cancelled: false };
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, {
@@ -1224,7 +1212,6 @@ export class InteractiveMode {
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
return { cancelled: false };
},
switchSession: async (sessionPath) => {
@@ -1259,6 +1246,45 @@ export class InteractiveMode {
this.showLoadedResources({ force: false });
}
private applyRuntimeSettings(): void {
this.footer.setSession(this.session);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
const editorPaddingX = this.settingsManager.getEditorPaddingX();
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
this.defaultEditor.setPaddingX(editorPaddingX);
this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);
if (this.editor !== this.defaultEditor) {
this.editor.setPaddingX?.(editorPaddingX);
this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
}
}
private async handleRuntimeSessionChange(): Promise<void> {
this.resetExtensionUI();
this.unsubscribe?.();
this.unsubscribe = undefined;
this.applyRuntimeSettings();
await this.bindCurrentSessionExtensions();
this.subscribeToAgent();
await this.updateAvailableProviderCount();
this.updateEditorBorderColor();
this.updateTerminalTitle();
}
private renderCurrentSessionState(): void {
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
this.renderInitialMessages();
}
/**
* Get a registered tool definition by name (for custom rendering).
*/
@@ -1277,7 +1303,7 @@ export class InteractiveMode {
const createContext = (): ExtensionContext => ({
ui: this.createExtensionUIContext(),
hasUI: true,
cwd: process.cwd(),
cwd: this.sessionManager.getCwd(),
sessionManager: this.sessionManager,
modelRegistry: this.session.modelRegistry,
model: this.session.model,
@@ -2305,6 +2331,7 @@ export class InteractiveMode {
},
this.getRegisteredToolDefinition(content.name),
this.ui,
this.sessionManager.getCwd(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@@ -2372,6 +2399,7 @@ export class InteractiveMode {
},
this.getRegisteredToolDefinition(event.toolName),
this.ui,
this.sessionManager.getCwd(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@@ -2681,6 +2709,7 @@ export class InteractiveMode {
{ showImages: this.settingsManager.getShowImages() },
this.getRegisteredToolDefinition(content.name),
this.ui,
this.sessionManager.getCwd(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@@ -2779,14 +2808,7 @@ export class InteractiveMode {
private async shutdown(): Promise<void> {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
// Emit shutdown event to extensions
const extensionRunner = this.session.extensionRunner;
if (extensionRunner?.hasHandlers("session_shutdown")) {
await extensionRunner.emit({
type: "session_shutdown",
});
}
await this.runtimeHost.dispose();
// Wait for any pending renders to complete
// requestRender() uses process.nextTick(), so we wait one tick
@@ -3605,17 +3627,15 @@ export class InteractiveMode {
const selector = new UserMessageSelectorComponent(
userMessages.map((m) => ({ id: m.entryId, text: m.text })),
async (entryId) => {
const result = await this.session.fork(entryId);
const result = await this.runtimeHost.fork(entryId);
if (result.cancelled) {
// Extension cancelled the fork
done();
this.ui.requestRender();
return;
}
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
done();
this.showStatus("Branched to new session");
},
@@ -3792,26 +3812,17 @@ export class InteractiveMode {
}
private async handleResumeSession(sessionPath: string): Promise<void> {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Clear UI state
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
// Switch session via AgentSession (emits extension session events)
await this.session.switchSession(sessionPath);
// Clear and re-render the chat
this.chatContainer.clear();
this.renderInitialMessages();
const result = await this.runtimeHost.switchSession(sessionPath);
if (result.cancelled) {
return;
}
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.showStatus("Resumed session");
}
@@ -4063,29 +4074,18 @@ export class InteractiveMode {
}
try {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Clear UI state
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
const success = await this.session.importFromJsonl(inputPath);
if (!success) {
this.showWarning("Import cancelled");
const result = await this.runtimeHost.importFromJsonl(inputPath);
if (result.cancelled) {
this.showStatus("Import cancelled");
return;
}
// Clear and re-render the chat
this.chatContainer.clear();
this.renderInitialMessages();
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`);
} catch (error: unknown) {
this.showError(`Failed to import session: ${error instanceof Error ? error.message : "Unknown error"}`);
@@ -4427,25 +4427,17 @@ export class InteractiveMode {
}
private async handleClearCommand(): Promise<void> {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// New session via session (emits extension session events)
await this.session.newSession();
// Clear UI state
this.headerContainer.clear();
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
const result = await this.runtimeHost.newSession();
if (result.cancelled) {
return;
}
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender();
@@ -4511,7 +4503,7 @@ export class InteractiveMode {
type: "user_bash",
command,
excludeFromContext,
cwd: process.cwd(),
cwd: this.sessionManager.getCwd(),
})
: undefined;

View File

@@ -7,7 +7,7 @@
*/
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
import type { AgentSession } from "../core/agent-session.js";
import type { AgentSessionRuntimeHost } from "../core/agent-session-runtime.js";
import { flushRawStdout, writeRawStdout } from "../core/output-guard.js";
/**
@@ -28,44 +28,46 @@ export interface PrintModeOptions {
* Run in print (single-shot) mode.
* Sends prompts to the agent and outputs the result.
*/
export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise<number> {
export async function runPrintMode(runtimeHost: AgentSessionRuntimeHost, options: PrintModeOptions): Promise<number> {
const { mode, messages = [], initialMessage, initialImages } = options;
let exitCode = 0;
let session = runtimeHost.session;
let unsubscribe: (() => void) | undefined;
try {
if (mode === "json") {
const header = session.sessionManager.getHeader();
if (header) {
writeRawStdout(`${JSON.stringify(header)}\n`);
}
}
// Set up extensions for print mode (no UI)
const rebindSession = async (): Promise<void> => {
session = runtimeHost.session;
await session.bindExtensions({
commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => {
const success = await session.newSession({ parentSession: options?.parentSession });
if (success && options?.setup) {
await options.setup(session.sessionManager);
newSession: async (newSessionOptions) => {
const result = await runtimeHost.newSession(newSessionOptions);
if (!result.cancelled) {
await rebindSession();
}
return { cancelled: !success };
return result;
},
fork: async (entryId) => {
const result = await session.fork(entryId);
const result = await runtimeHost.fork(entryId);
if (!result.cancelled) {
await rebindSession();
}
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
navigateTree: async (targetId, navigateOptions) => {
const result = await session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
summarize: navigateOptions?.summarize,
customInstructions: navigateOptions?.customInstructions,
replaceInstructions: navigateOptions?.replaceInstructions,
label: navigateOptions?.label,
});
return { cancelled: result.cancelled };
},
switchSession: async (sessionPath) => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
const result = await runtimeHost.switchSession(sessionPath);
if (!result.cancelled) {
await rebindSession();
}
return result;
},
reload: async () => {
await session.reload();
@@ -76,38 +78,42 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
},
});
// Always subscribe to enable session persistence via _handleAgentEvent
session.subscribe((event) => {
// In JSON mode, output all events
unsubscribe?.();
unsubscribe = session.subscribe((event) => {
if (mode === "json") {
writeRawStdout(`${JSON.stringify(event)}\n`);
}
});
};
try {
if (mode === "json") {
const header = session.sessionManager.getHeader();
if (header) {
writeRawStdout(`${JSON.stringify(header)}\n`);
}
}
await rebindSession();
// Send initial message with attachments
if (initialMessage) {
await session.prompt(initialMessage, { images: initialImages });
}
// Send remaining messages
for (const message of messages) {
await session.prompt(message);
}
// In text mode, output final response
if (mode === "text") {
const state = session.state;
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage?.role === "assistant") {
const assistantMsg = lastMessage as AssistantMessage;
// Check for error/aborted
if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
exitCode = 1;
} else {
// Output text content
for (const content of assistantMsg.content) {
if (content.type === "text") {
writeRawStdout(`${content.text}\n`);
@@ -119,13 +125,8 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
return exitCode;
} finally {
const extensionRunner = session.extensionRunner;
if (extensionRunner?.hasHandlers("session_shutdown")) {
await extensionRunner.emit({ type: "session_shutdown" });
}
// Ensure stdout is fully flushed before returning
// This prevents race conditions where the process exits before all output is written
unsubscribe?.();
await runtimeHost.dispose();
await flushRawStdout();
}
}

View File

@@ -12,7 +12,7 @@
*/
import * as crypto from "node:crypto";
import type { AgentSession } from "../../core/agent-session.js";
import type { AgentSessionRuntimeHost } from "../../core/agent-session-runtime.js";
import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
@@ -43,8 +43,10 @@ export type {
* Run in RPC mode.
* Listens for JSON commands on stdin, outputs events and responses on stdout.
*/
export async function runRpcMode(session: AgentSession): Promise<never> {
export async function runRpcMode(runtimeHost: AgentSessionRuntimeHost): Promise<never> {
takeOverStdout();
let session = runtimeHost.session;
let unsubscribe: (() => void) | undefined;
const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
writeRawStdout(serializeJsonLine(obj));
@@ -280,49 +282,61 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
},
});
// Set up extensions with RPC-based UI context
await session.bindExtensions({
uiContext: createExtensionUIContext(),
commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => {
// Delegate to AgentSession (handles setup + agent state sync)
const success = await session.newSession(options);
return { cancelled: !success };
const rebindSession = async (): Promise<void> => {
session = runtimeHost.session;
await session.bindExtensions({
uiContext: createExtensionUIContext(),
commandContextActions: {
waitForIdle: () => session.agent.waitForIdle(),
newSession: async (options) => {
const result = await runtimeHost.newSession(options);
if (!result.cancelled) {
await rebindSession();
}
return result;
},
fork: async (entryId) => {
const result = await runtimeHost.fork(entryId);
if (!result.cancelled) {
await rebindSession();
}
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
});
return { cancelled: result.cancelled };
},
switchSession: async (sessionPath) => {
const result = await runtimeHost.switchSession(sessionPath);
if (!result.cancelled) {
await rebindSession();
}
return result;
},
reload: async () => {
await session.reload();
},
},
fork: async (entryId) => {
const result = await session.fork(entryId);
return { cancelled: result.cancelled };
shutdownHandler: () => {
shutdownRequested = true;
},
navigateTree: async (targetId, options) => {
const result = await session.navigateTree(targetId, {
summarize: options?.summarize,
customInstructions: options?.customInstructions,
replaceInstructions: options?.replaceInstructions,
label: options?.label,
});
return { cancelled: result.cancelled };
onError: (err) => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
},
switchSession: async (sessionPath) => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
reload: async () => {
await session.reload();
},
},
shutdownHandler: () => {
shutdownRequested = true;
},
onError: (err) => {
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
},
});
});
// Output all agent events as JSON
session.subscribe((event) => {
output(event);
});
unsubscribe?.();
unsubscribe = session.subscribe((event) => {
output(event);
});
};
await rebindSession();
// Handle a single command
const handleCommand = async (command: RpcCommand): Promise<RpcResponse> => {
@@ -364,8 +378,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
case "new_session": {
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
const cancelled = !(await session.newSession(options));
return success(id, "new_session", { cancelled });
const result = await runtimeHost.newSession(options);
if (!result.cancelled) {
await rebindSession();
}
return success(id, "new_session", result);
}
// =================================================================
@@ -505,12 +522,18 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
}
case "switch_session": {
const cancelled = !(await session.switchSession(command.sessionPath));
return success(id, "switch_session", { cancelled });
const result = await runtimeHost.switchSession(command.sessionPath);
if (!result.cancelled) {
await rebindSession();
}
return success(id, "switch_session", result);
}
case "fork": {
const result = await session.fork(command.entryId);
const result = await runtimeHost.fork(command.entryId);
if (!result.cancelled) {
await rebindSession();
}
return success(id, "fork", { text: result.selectedText, cancelled: result.cancelled });
}
@@ -592,11 +615,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
let detachInput = () => {};
async function shutdown(): Promise<never> {
const currentRunner = session.extensionRunner;
if (currentRunner?.hasHandlers("session_shutdown")) {
await currentRunner.emit({ type: "session_shutdown" });
}
unsubscribe?.();
await runtimeHost.dispose();
detachInput();
process.stdin.pause();
process.exit(0);