refactor(coding-agent): add runtime host for session switching closes #2024
This commit is contained in:
@@ -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)}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user