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

@@ -0,0 +1,358 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import { getAgentDir } from "../config.js";
import { AuthStorage } from "./auth-storage.js";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js";
import { emitSessionShutdownEvent } from "./extensions/runner.js";
import { ModelRegistry } from "./model-registry.js";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js";
import { type CreateAgentSessionResult, createAgentSession } from "./sdk.js";
import { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import type { Tool } from "./tools/index.js";
/**
* Stable bootstrap inputs reused whenever the active runtime is replaced.
*
* Use this for process-level wiring that should survive `/new`, `/resume`,
* `/fork`, and import flows. Session-local state belongs in the session file
* or in settings, not here.
*/
export interface AgentSessionRuntimeBootstrap {
/** Agent directory used for auth, models, settings, sessions, and resource discovery. */
agentDir?: string;
/** Optional shared auth storage. If omitted, file-backed storage under agentDir is used. */
authStorage?: AuthStorage;
/** Initial model for the first created session runtime. */
model?: Model<any>;
/** Initial thinking level for the first created session runtime. */
thinkingLevel?: ThinkingLevel;
/** Optional scoped model list for model cycling and selection. */
scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
/** Built-in tool set override. */
tools?: Tool[];
/** Additional custom tools registered directly through the SDK. */
customTools?: ToolDefinition[];
/**
* Resource loader input used for each created runtime.
*
* Pass either a factory that creates a fully custom ResourceLoader for the
* target cwd, or DefaultResourceLoader options without cwd/agentDir/
* settingsManager, which are supplied by the runtime.
*/
resourceLoader?:
| ((cwd: string, agentDir: string) => Promise<ResourceLoader>)
| Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
}
/** Options for creating one concrete runtime instance. */
export interface CreateAgentSessionRuntimeOptions {
/** Working directory for this runtime instance. */
cwd: string;
/** Optional preselected session manager. If omitted, normal session resolution applies. */
sessionManager?: SessionManager;
/** Optional session_start metadata to emit when the runtime binds extensions. */
sessionStartEvent?: SessionStartEvent;
}
type AgentSessionRuntime = CreateAgentSessionResult & {
cwd: string;
agentDir: string;
authStorage: AuthStorage;
modelRegistry: ModelRegistry;
settingsManager: SettingsManager;
resourceLoader: ResourceLoader;
sessionManager: SessionManager;
};
/**
* Create one runtime instance containing an AgentSession plus the cwd-bound
* services it depends on.
*
* Most SDK callers should keep the returned value wrapped in an
* AgentSessionRuntimeHost instead of holding it directly. The host owns
* replacing the runtime when switching sessions across files or working
* directories.
*/
export async function createAgentSessionRuntime(
bootstrap: AgentSessionRuntimeBootstrap,
options: CreateAgentSessionRuntimeOptions,
): Promise<AgentSessionRuntime> {
const cwd = options.cwd;
const agentDir = bootstrap.agentDir ?? getAgentDir();
const authStorage = bootstrap.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
const settingsManager = SettingsManager.create(cwd, agentDir);
const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json"));
const resourceLoader =
typeof bootstrap.resourceLoader === "function"
? await bootstrap.resourceLoader(cwd, agentDir)
: new DefaultResourceLoader({
...(bootstrap.resourceLoader ?? {}),
cwd,
agentDir,
settingsManager,
});
await resourceLoader.reload();
const extensionsResult = resourceLoader.getExtensions();
for (const { name, config } of extensionsResult.runtime.pendingProviderRegistrations) {
modelRegistry.registerProvider(name, config);
}
extensionsResult.runtime.pendingProviderRegistrations = [];
const created = await createAgentSession({
cwd,
agentDir,
authStorage,
modelRegistry,
settingsManager,
resourceLoader,
sessionManager: options.sessionManager,
model: bootstrap.model,
thinkingLevel: bootstrap.thinkingLevel,
scopedModels: bootstrap.scopedModels,
tools: bootstrap.tools,
customTools: bootstrap.customTools,
sessionStartEvent: options.sessionStartEvent,
});
return {
...created,
cwd,
agentDir,
authStorage,
modelRegistry,
settingsManager,
resourceLoader,
sessionManager: created.session.sessionManager,
};
}
function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") {
return content;
}
return content
.filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("");
}
/**
* Stable wrapper around a replaceable AgentSession runtime.
*
* Use this when your application needs `/new`, `/resume`, `/fork`, or import
* behavior. After replacement, read `session` again and rebind any
* session-local subscriptions or extension bindings.
*/
export class AgentSessionRuntimeHost {
constructor(
private readonly bootstrap: AgentSessionRuntimeBootstrap,
private runtime: AgentSessionRuntime,
) {}
/** The currently active session instance. Re-read this after runtime replacement. */
get session() {
return this.runtime.session;
}
private async emitBeforeSwitch(
reason: "new" | "resume",
targetSessionFile?: string,
): Promise<{ cancelled: boolean }> {
const runner = this.runtime.session.extensionRunner;
if (!runner?.hasHandlers("session_before_switch")) {
return { cancelled: false };
}
const result = await runner.emit({
type: "session_before_switch",
reason,
targetSessionFile,
});
return { cancelled: result?.cancel === true };
}
private async emitBeforeFork(entryId: string): Promise<{ cancelled: boolean }> {
const runner = this.runtime.session.extensionRunner;
if (!runner?.hasHandlers("session_before_fork")) {
return { cancelled: false };
}
const result = await runner.emit({
type: "session_before_fork",
entryId,
});
return { cancelled: result?.cancel === true };
}
private async replace(options: CreateAgentSessionRuntimeOptions): Promise<void> {
const nextRuntime = await createAgentSessionRuntime(this.bootstrap, options);
await emitSessionShutdownEvent(this.runtime.session.extensionRunner);
this.runtime.session.dispose();
if (process.cwd() !== nextRuntime.cwd) {
process.chdir(nextRuntime.cwd);
}
this.runtime = nextRuntime;
}
/**
* Replace the active runtime with one opened from an existing session file.
*
* Emits `session_before_switch` before replacement and returns
* `{ cancelled: true }` if an extension vetoes the switch.
*/
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
if (beforeResult.cancelled) {
return beforeResult;
}
const previousSessionFile = this.runtime.session.sessionFile;
const sessionManager = SessionManager.open(sessionPath);
await this.replace({
cwd: sessionManager.getCwd(),
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
});
return { cancelled: false };
}
/**
* Replace the active runtime with a fresh session in the current cwd.
*
* `setup` runs after replacement against the new session manager, which lets
* callers seed entries before normal use begins.
*/
async newSession(options?: {
/** Optional parent session path recorded in the new session header. */
parentSession?: string;
/** Optional callback for seeding the new session manager after replacement. */
setup?: (sessionManager: SessionManager) => Promise<void>;
}): Promise<{ cancelled: boolean }> {
const beforeResult = await this.emitBeforeSwitch("new");
if (beforeResult.cancelled) {
return beforeResult;
}
const previousSessionFile = this.runtime.session.sessionFile;
const sessionDir = this.runtime.sessionManager.getSessionDir();
const sessionManager = SessionManager.create(this.runtime.cwd, sessionDir);
if (options?.parentSession) {
sessionManager.newSession({ parentSession: options.parentSession });
}
await this.replace({
cwd: this.runtime.cwd,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile },
});
if (options?.setup) {
await options.setup(this.runtime.sessionManager);
this.runtime.session.agent.state.messages = this.runtime.sessionManager.buildSessionContext().messages;
}
return { cancelled: false };
}
/**
* Replace the active runtime with a fork rooted at the given user-message
* entry.
*
* Returns the selected user text so UIs can restore it into the editor after
* the fork completes.
*/
async fork(entryId: string): Promise<{ cancelled: boolean; selectedText?: string }> {
const beforeResult = await this.emitBeforeFork(entryId);
if (beforeResult.cancelled) {
return { cancelled: true };
}
const selectedEntry = this.runtime.sessionManager.getEntry(entryId);
if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
throw new Error("Invalid entry ID for forking");
}
const previousSessionFile = this.runtime.session.sessionFile;
const selectedText = extractUserMessageText(selectedEntry.message.content);
if (this.runtime.sessionManager.isPersisted()) {
const currentSessionFile = this.runtime.session.sessionFile!;
const sessionDir = this.runtime.sessionManager.getSessionDir();
if (!selectedEntry.parentId) {
const sessionManager = SessionManager.create(this.runtime.cwd, sessionDir);
sessionManager.newSession({ parentSession: currentSessionFile });
await this.replace({
cwd: this.runtime.cwd,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
});
return { cancelled: false, selectedText };
}
const sourceManager = SessionManager.open(currentSessionFile, sessionDir);
const forkedSessionPath = sourceManager.createBranchedSession(selectedEntry.parentId)!;
const sessionManager = SessionManager.open(forkedSessionPath, sessionDir);
await this.replace({
cwd: sessionManager.getCwd(),
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
});
return { cancelled: false, selectedText };
}
const sessionManager = this.runtime.sessionManager;
if (!selectedEntry.parentId) {
sessionManager.newSession({ parentSession: this.runtime.session.sessionFile });
} else {
sessionManager.createBranchedSession(selectedEntry.parentId);
}
await this.replace({
cwd: this.runtime.cwd,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
});
return { cancelled: false, selectedText };
}
/**
* Import a JSONL session file into the current session directory and replace
* the active runtime with the imported session.
*/
async importFromJsonl(inputPath: string): Promise<{ cancelled: boolean }> {
const resolvedPath = resolve(inputPath);
if (!existsSync(resolvedPath)) {
throw new Error(`File not found: ${resolvedPath}`);
}
const sessionDir = this.runtime.sessionManager.getSessionDir();
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
const destinationPath = join(sessionDir, basename(resolvedPath));
const beforeResult = await this.emitBeforeSwitch("resume", destinationPath);
if (beforeResult.cancelled) {
return beforeResult;
}
const previousSessionFile = this.runtime.session.sessionFile;
if (resolve(destinationPath) !== resolvedPath) {
copyFileSync(resolvedPath, destinationPath);
}
const sessionManager = SessionManager.open(destinationPath, sessionDir);
await this.replace({
cwd: sessionManager.getCwd(),
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
});
return { cancelled: false };
}
/** Emit session shutdown for the active runtime and dispose it permanently. */
async dispose(): Promise<void> {
await emitSessionShutdownEvent(this.runtime.session.extensionRunner);
this.runtime.session.dispose();
}
}

View File

@@ -13,7 +13,7 @@
* Modes use this class and add their own I/O layer on top.
*/
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import type {
Agent,
@@ -29,7 +29,7 @@ import { getDocsPath } from "../config.js";
import { theme } from "../modes/interactive/theme/theme.js";
import { stripFrontmatter } from "../utils/frontmatter.js";
import { sleep } from "../utils/sleep.js";
import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js";
import { type BashResult, executeBashWithOperations } from "./bash-executor.js";
import {
type CompactionResult,
calculateContextTokens,
@@ -54,9 +54,8 @@ import {
type MessageStartEvent,
type MessageUpdateEvent,
type SessionBeforeCompactResult,
type SessionBeforeForkResult,
type SessionBeforeSwitchResult,
type SessionBeforeTreeResult,
type SessionStartEvent,
type ShutdownHandler,
type ToolDefinition,
type ToolExecutionEndEvent,
@@ -78,7 +77,7 @@ import type { SettingsManager } from "./settings-manager.js";
import type { SlashCommandInfo } from "./slash-commands.js";
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
import { buildSystemPrompt } from "./system-prompt.js";
import type { BashOperations } from "./tools/bash.js";
import { type BashOperations, createLocalBashOperations } from "./tools/bash.js";
import { createAllToolDefinitions } from "./tools/index.js";
import { createToolDefinitionFromAgentTool, wrapToolDefinition } from "./tools/tool-definition-wrapper.js";
@@ -160,6 +159,8 @@ export interface AgentSessionConfig {
baseToolsOverride?: Record<string, AgentTool>;
/** Mutable ref used by Agent to access the current ExtensionRunner */
extensionRunnerRef?: { current?: ExtensionRunner };
/** Session start event metadata emitted when extensions bind to this runtime. */
sessionStartEvent?: SessionStartEvent;
}
export interface ExtensionBindings {
@@ -276,6 +277,7 @@ export class AgentSession {
private _extensionRunnerRef?: { current?: ExtensionRunner };
private _initialActiveToolNames?: string[];
private _baseToolsOverride?: Record<string, AgentTool>;
private _sessionStartEvent: SessionStartEvent;
private _extensionUIContext?: ExtensionUIContext;
private _extensionCommandContextActions?: ExtensionCommandContextActions;
private _extensionShutdownHandler?: ShutdownHandler;
@@ -306,6 +308,7 @@ export class AgentSession {
this._extensionRunnerRef = config.extensionRunnerRef;
this._initialActiveToolNames = config.initialActiveToolNames;
this._baseToolsOverride = config.baseToolsOverride;
this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
// Always subscribe to agent events for internal handling
// (session persistence, extensions, auto-compaction, retry logic)
@@ -1346,66 +1349,6 @@ export class AgentSession {
await this.agent.waitForIdle();
}
/**
* Start a new session, optionally with initial messages and parent tracking.
* Clears all messages and starts a new session.
* Listeners are preserved and will continue receiving events.
* @param options.parentSession - Optional parent session path for tracking
* @param options.setup - Optional callback to initialize session (e.g., append messages)
* @returns true if completed, false if cancelled by extension
*/
async newSession(options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}): Promise<boolean> {
const previousSessionFile = this.sessionFile;
// Emit session_before_switch event with reason "new" (can be cancelled)
if (this._extensionRunner?.hasHandlers("session_before_switch")) {
const result = (await this._extensionRunner.emit({
type: "session_before_switch",
reason: "new",
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
return false;
}
}
this._disconnectFromAgent();
await this.abort();
this.agent.reset();
this.sessionManager.newSession({ parentSession: options?.parentSession });
this.agent.sessionId = this.sessionManager.getSessionId();
this._steeringMessages = [];
this._followUpMessages = [];
this._pendingNextTurnMessages = [];
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
// Run setup callback if provided (e.g., to append initial messages)
if (options?.setup) {
await options.setup(this.sessionManager);
// Sync agent state with session manager after setup
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;
}
this._reconnectToAgent();
// Emit session_switch event with reason "new" to extensions
if (this._extensionRunner) {
await this._extensionRunner.emit({
type: "session_switch",
reason: "new",
previousSessionFile,
});
}
// Emit session event to custom tools
return true;
}
// =========================================================================
// Model Management
// =========================================================================
@@ -1460,12 +1403,8 @@ export class AgentSession {
return this._cycleAvailableModel(direction);
}
private _getScopedModelsWithAuth(): Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {
return this._scopedModels.filter((scoped) => this._modelRegistry.hasConfiguredAuth(scoped.model));
}
private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
const scopedModels = this._getScopedModelsWithAuth();
const scopedModels = this._scopedModels.filter((scoped) => this._modelRegistry.hasConfiguredAuth(scoped.model));
if (scopedModels.length <= 1) return undefined;
const currentModel = this.model;
@@ -2081,8 +2020,8 @@ export class AgentSession {
if (this._extensionRunner) {
this._applyExtensionBindings(this._extensionRunner);
await this._extensionRunner.emit({ type: "session_start" });
await this.extendResourcesFromExtensions("startup");
await this._extensionRunner.emit(this._sessionStartEvent);
await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup");
}
}
@@ -2426,7 +2365,7 @@ export class AgentSession {
this._extensionShutdownHandler ||
this._extensionErrorListener;
if (this._extensionRunner && hasBindings) {
await this._extensionRunner.emit({ type: "session_start" });
await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
await this.extendResourcesFromExtensions("reload");
}
}
@@ -2596,15 +2535,15 @@ export class AgentSession {
const resolvedCommand = prefix ? `${prefix}\n${command}` : command;
try {
const result = options?.operations
? await executeBashWithOperations(resolvedCommand, process.cwd(), options.operations, {
onChunk,
signal: this._bashAbortController.signal,
})
: await executeBashCommand(resolvedCommand, {
onChunk,
signal: this._bashAbortController.signal,
});
const result = await executeBashWithOperations(
resolvedCommand,
this.sessionManager.getCwd(),
options?.operations ?? createLocalBashOperations(),
{
onChunk,
signal: this._bashAbortController.signal,
},
);
this.recordBashResult(command, result, options);
return result;
@@ -2682,86 +2621,6 @@ export class AgentSession {
// Session Management
// =========================================================================
/**
* Switch to a different session file.
* Aborts current operation, loads messages, restores model/thinking.
* Listeners are preserved and will continue receiving events.
* @returns true if switch completed, false if cancelled by extension
*/
async switchSession(sessionPath: string): Promise<boolean> {
const previousSessionFile = this.sessionManager.getSessionFile();
// Emit session_before_switch event (can be cancelled)
if (this._extensionRunner?.hasHandlers("session_before_switch")) {
const result = (await this._extensionRunner.emit({
type: "session_before_switch",
reason: "resume",
targetSessionFile: sessionPath,
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
return false;
}
}
this._disconnectFromAgent();
await this.abort();
this._steeringMessages = [];
this._followUpMessages = [];
this._pendingNextTurnMessages = [];
// Set new session
this.sessionManager.setSessionFile(sessionPath);
this.agent.sessionId = this.sessionManager.getSessionId();
// Reload messages
const sessionContext = this.sessionManager.buildSessionContext();
// Emit session_switch event to extensions
if (this._extensionRunner) {
await this._extensionRunner.emit({
type: "session_switch",
reason: "resume",
previousSessionFile,
});
}
// Emit session event to custom tools
this.agent.state.messages = sessionContext.messages;
// Restore model if saved
if (sessionContext.model) {
const previousModel = this.model;
const availableModels = await this._modelRegistry.getAvailable();
const match = availableModels.find(
(m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId,
);
if (match) {
this.agent.state.model = match;
await this._emitModelSelect(match, previousModel, "restore");
}
}
const hasThinkingEntry = this.sessionManager.getBranch().some((entry) => entry.type === "thinking_level_change");
const defaultThinkingLevel = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
if (hasThinkingEntry) {
// Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel);
} else {
const availableLevels = this.getAvailableThinkingLevels();
const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
? defaultThinkingLevel
: this._clampThinkingLevel(defaultThinkingLevel, availableLevels);
this.agent.state.thinkingLevel = effectiveLevel;
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
}
this._reconnectToAgent();
return true;
}
/**
* Set a display name for the current session.
*/
@@ -2769,70 +2628,6 @@ export class AgentSession {
this.sessionManager.appendSessionInfo(name);
}
/**
* Create a fork from a specific entry.
* Emits before_fork/fork session events to extensions.
*
* @param entryId ID of the entry to fork from
* @returns Object with:
* - selectedText: The text of the selected user message (for editor pre-fill)
* - cancelled: True if an extension cancelled the fork
*/
async fork(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
const previousSessionFile = this.sessionFile;
const selectedEntry = this.sessionManager.getEntry(entryId);
if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
throw new Error("Invalid entry ID for forking");
}
const selectedText = this._extractUserMessageText(selectedEntry.message.content);
let skipConversationRestore = false;
// Emit session_before_fork event (can be cancelled)
if (this._extensionRunner?.hasHandlers("session_before_fork")) {
const result = (await this._extensionRunner.emit({
type: "session_before_fork",
entryId,
})) as SessionBeforeForkResult | undefined;
if (result?.cancel) {
return { selectedText, cancelled: true };
}
skipConversationRestore = result?.skipConversationRestore ?? false;
}
// Clear pending messages (bound to old session state)
this._pendingNextTurnMessages = [];
if (!selectedEntry.parentId) {
this.sessionManager.newSession({ parentSession: previousSessionFile });
} else {
this.sessionManager.createBranchedSession(selectedEntry.parentId);
}
this.agent.sessionId = this.sessionManager.getSessionId();
// Reload messages from entries (works for both file and in-memory mode)
const sessionContext = this.sessionManager.buildSessionContext();
// Emit session_fork event to extensions (after fork completes)
if (this._extensionRunner) {
await this._extensionRunner.emit({
type: "session_fork",
previousSessionFile,
});
}
// Emit session event to custom tools (with reason "fork")
if (!skipConversationRestore) {
this.agent.state.messages = sessionContext.messages;
}
return { selectedText, cancelled: false };
}
// =========================================================================
// Tree Navigation
// =========================================================================
@@ -3165,6 +2960,7 @@ export class AgentSession {
const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({
getToolDefinition: (name) => this.getToolDefinition(name),
theme,
cwd: this.sessionManager.getCwd(),
});
return await exportSessionToHtml(this.sessionManager, this.state, {
@@ -3210,32 +3006,6 @@ export class AgentSession {
return filePath;
}
/**
* Import a JSONL session file.
* Copies the file into the session directory and switches to it (like /resume).
* @param inputPath Path to the JSONL file to import.
* @returns true if the session was switched successfully.
*/
async importFromJsonl(inputPath: string): Promise<boolean> {
const resolved = resolve(inputPath);
if (!existsSync(resolved)) {
throw new Error(`File not found: ${resolved}`);
}
// Copy into the session directory so we don't modify the original
const sessionDir = this.sessionManager.getSessionDir();
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
const destPath = join(sessionDir, basename(resolved));
// Avoid overwriting if source and destination are the same file
if (resolve(destPath) !== resolved) {
copyFileSync(resolved, destPath);
}
return this.switchSession(destPath);
}
// =========================================================================
// Utilities
// =========================================================================

View File

@@ -16,6 +16,8 @@ export interface ToolHtmlRendererDeps {
getToolDefinition: (name: string) => ToolDefinition | undefined;
/** Theme for styling */
theme: Theme;
/** Working directory for render context */
cwd: string;
/** Terminal width for rendering (default: 100) */
width?: number;
}
@@ -40,7 +42,7 @@ export interface ToolHtmlRenderer {
* methods, converting the resulting TUI Component output (ANSI) to HTML.
*/
export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRenderer {
const { getToolDefinition, theme, width = 100 } = deps;
const { getToolDefinition, theme, cwd, width = 100 } = deps;
const renderedCallComponents = new Map<string, Component>();
const renderedResultComponents = new Map<string, Component>();
@@ -69,7 +71,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend
invalidate: () => {},
lastComponent,
state: getState(toolCallId),
cwd: process.cwd(),
cwd,
executionStarted: true,
argsComplete: true,
isPartial,

View File

@@ -120,11 +120,9 @@ export type {
SessionDirectoryHandler,
SessionDirectoryResult,
SessionEvent,
SessionForkEvent,
SessionShutdownEvent,
// Events - Session
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
SetActiveToolsHandler,
SetLabelHandler,

View File

@@ -432,9 +432,13 @@ export interface SessionDirectoryEvent {
cwd: string;
}
/** Fired on initial session load */
/** Fired when a session is started, loaded, or reloaded */
export interface SessionStartEvent {
type: "session_start";
/** Why this session start happened. */
reason: "startup" | "reload" | "new" | "resume" | "fork";
/** Previously active session file. Present for "new", "resume", and "fork". */
previousSessionFile?: string;
}
/** Fired before switching to another session (can be cancelled) */
@@ -444,25 +448,12 @@ export interface SessionBeforeSwitchEvent {
targetSessionFile?: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
reason: "new" | "resume";
previousSessionFile: string | undefined;
}
/** Fired before forking a session (can be cancelled) */
export interface SessionBeforeForkEvent {
type: "session_before_fork";
entryId: string;
}
/** Fired after forking a session */
export interface SessionForkEvent {
type: "session_fork";
previousSessionFile: string | undefined;
}
/** Fired before context compaction (can be cancelled or customized) */
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
@@ -519,9 +510,7 @@ export type SessionEvent =
| SessionDirectoryEvent
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeForkEvent
| SessionForkEvent
| SessionBeforeCompactEvent
| SessionCompactEvent
| SessionShutdownEvent
@@ -1008,9 +997,7 @@ export interface ExtensionAPI {
event: "session_before_switch",
handler: ExtensionHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>,
): void;
on(event: "session_switch", handler: ExtensionHandler<SessionSwitchEvent>): void;
on(event: "session_before_fork", handler: ExtensionHandler<SessionBeforeForkEvent, SessionBeforeForkResult>): void;
on(event: "session_fork", handler: ExtensionHandler<SessionForkEvent>): void;
on(
event: "session_before_compact",
handler: ExtensionHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,

View File

@@ -1,5 +1,5 @@
import { type ExecFileException, execFile, spawnSync } from "child_process";
import { existsSync, type FSWatcher, readFileSync, statSync, watch } from "fs";
import { existsSync, type FSWatcher, readFileSync, statSync, unwatchFile, watch, watchFile } from "fs";
import { dirname, join, resolve } from "path";
type GitPaths = {
@@ -12,8 +12,8 @@ type GitPaths = {
* Find git metadata paths by walking up from cwd.
* Handles both regular git repos (.git is a directory) and worktrees (.git is a file).
*/
function findGitPaths(): GitPaths | null {
let dir = process.cwd();
function findGitPaths(cwd: string): GitPaths | null {
let dir = cwd;
while (true) {
const gitPath = join(dir, ".git");
if (existsSync(gitPath)) {
@@ -84,6 +84,7 @@ function resolveBranchWithGitAsync(repoDir: string): Promise<string | null> {
* Token stats, model info available via ctx.sessionManager and ctx.model.
*/
export class FooterDataProvider {
private cwd: string;
private static readonly WATCH_DEBOUNCE_MS = 500;
private extensionStatuses = new Map<string, string>();
@@ -91,6 +92,8 @@ export class FooterDataProvider {
private gitPaths: GitPaths | null | undefined = undefined;
private headWatcher: FSWatcher | null = null;
private reftableWatcher: FSWatcher | null = null;
private reftableTablesListWatcher: FSWatcher | null = null;
private reftableTablesListPath: string | null = null;
private branchChangeCallbacks = new Set<() => void>();
private availableProviderCount = 0;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
@@ -98,8 +101,9 @@ export class FooterDataProvider {
private refreshPending = false;
private disposed = false;
constructor() {
this.gitPaths = findGitPaths();
constructor(cwd: string = process.cwd()) {
this.cwd = cwd;
this.gitPaths = findGitPaths(cwd);
this.setupGitWatcher();
}
@@ -146,6 +150,38 @@ export class FooterDataProvider {
this.availableProviderCount = count;
}
setCwd(cwd: string): void {
if (this.cwd === cwd) {
return;
}
this.cwd = cwd;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
if (this.headWatcher) {
this.headWatcher.close();
this.headWatcher = null;
}
if (this.reftableWatcher) {
this.reftableWatcher.close();
this.reftableWatcher = null;
}
if (this.reftableTablesListWatcher) {
this.reftableTablesListWatcher.close();
this.reftableTablesListWatcher = null;
}
if (this.reftableTablesListPath) {
unwatchFile(this.reftableTablesListPath);
this.reftableTablesListPath = null;
}
this.cachedBranch = undefined;
this.gitPaths = findGitPaths(cwd);
this.setupGitWatcher();
this.notifyBranchChange();
}
/** Internal: cleanup */
dispose(): void {
this.disposed = true;
@@ -161,6 +197,14 @@ export class FooterDataProvider {
this.reftableWatcher.close();
this.reftableWatcher = null;
}
if (this.reftableTablesListWatcher) {
this.reftableTablesListWatcher.close();
this.reftableTablesListWatcher = null;
}
if (this.reftableTablesListPath) {
unwatchFile(this.reftableTablesListPath);
this.reftableTablesListPath = null;
}
this.branchChangeCallbacks.clear();
}
@@ -169,9 +213,10 @@ export class FooterDataProvider {
}
private scheduleRefresh(): void {
if (this.disposed) return;
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
if (this.disposed || this.refreshTimer) return;
if (this.refreshInFlight) {
this.refreshPending = true;
return;
}
this.refreshTimer = setTimeout(() => {
this.refreshTimer = null;
@@ -262,6 +307,27 @@ export class FooterDataProvider {
} catch {
// Silently fail if we can't watch
}
const tablesListPath = join(reftableDir, "tables.list");
if (existsSync(tablesListPath)) {
this.reftableTablesListPath = tablesListPath;
try {
this.reftableTablesListWatcher = watch(tablesListPath, () => {
this.scheduleRefresh();
});
} catch {
// Silently fail if we can't watch
}
watchFile(tablesListPath, { interval: 250 }, (current, previous) => {
if (
current.mtimeMs !== previous.mtimeMs ||
current.ctimeMs !== previous.ctimeMs ||
current.size !== previous.size
) {
this.scheduleRefresh();
}
});
}
}
}
}

View File

@@ -11,6 +11,12 @@ export {
type PromptOptions,
type SessionStats,
} from "./agent-session.js";
export {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeOptions,
createAgentSessionRuntime,
} from "./agent-session-runtime.js";
export { type BashExecutorOptions, type BashResult, executeBash, executeBashWithOperations } from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
@@ -45,10 +51,8 @@ export {
type SessionBeforeSwitchEvent,
type SessionBeforeTreeEvent,
type SessionCompactEvent,
type SessionForkEvent,
type SessionShutdownEvent,
type SessionStartEvent,
type SessionSwitchEvent,
type SessionTreeEvent,
type ToolCallEvent,
type ToolCallEventResult,

View File

@@ -5,7 +5,7 @@ import { getAgentDir, getDocsPath } from "../config.js";
import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
import type { ExtensionRunner, LoadExtensionsResult, ToolDefinition } from "./extensions/index.js";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.js";
import { convertToLlm } from "./messages.js";
import { ModelRegistry } from "./model-registry.js";
import { findInitialModel } from "./model-resolver.js";
@@ -70,6 +70,8 @@ export interface CreateAgentSessionOptions {
/** Settings manager. Default: SettingsManager.create(cwd, agentDir) */
settingsManager?: SettingsManager;
/** Session start event metadata for extension runtime startup. */
sessionStartEvent?: SessionStartEvent;
}
/** Result from createAgentSession */
@@ -84,6 +86,12 @@ export interface CreateAgentSessionResult {
// Re-exports
export {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeOptions,
createAgentSessionRuntime,
} from "./agent-session-runtime.js";
export type {
ExtensionAPI,
ExtensionCommandContext,
@@ -349,6 +357,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
modelRegistry,
initialActiveToolNames,
extensionRunnerRef,
sessionStartEvent: options.sessionStartEvent,
});
const extensionsResult = resourceLoader.getExtensions();

View File

@@ -103,10 +103,8 @@ export type {
SessionBeforeSwitchEvent,
SessionBeforeTreeEvent,
SessionCompactEvent,
SessionForkEvent,
SessionShutdownEvent,
SessionStartEvent,
SessionSwitchEvent,
SessionTreeEvent,
SlashCommandInfo,
SlashCommandSource,
@@ -157,10 +155,14 @@ export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./co
export { DefaultResourceLoader } from "./core/resource-loader.js";
// SDK for programmatic usage
export {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionOptions,
type CreateAgentSessionResult,
type CreateAgentSessionRuntimeOptions,
// Factory
createAgentSession,
createAgentSessionRuntime,
createBashTool,
// Tool factories (for custom cwd)
createCodingTools,

View File

@@ -5,6 +5,7 @@
* createAgentSession() options. The SDK does the heavy lifting.
*/
import { resolve } from "node:path";
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { createInterface } from "readline";
@@ -15,6 +16,11 @@ import { buildInitialMessage } from "./cli/initial-message.js";
import { listModels } from "./cli/list-models.js";
import { selectSession } from "./cli/session-picker.js";
import { APP_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
import {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
createAgentSessionRuntime,
} from "./core/agent-session-runtime.js";
import { AuthStorage } from "./core/auth-storage.js";
import { exportFromFile } from "./core/export-html/index.js";
import type { LoadExtensionsResult } from "./core/extensions/index.js";
@@ -24,7 +30,7 @@ import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/mod
import { restoreStdout, takeOverStdout } from "./core/output-guard.js";
import { DefaultPackageManager } from "./core/package-manager.js";
import { DefaultResourceLoader } from "./core/resource-loader.js";
import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js";
import type { CreateAgentSessionOptions } from "./core/sdk.js";
import { SessionManager } from "./core/session-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
import { printTimings, resetTimings, time } from "./core/timings.js";
@@ -597,6 +603,40 @@ function buildSessionOptions(
return { options, cliThinkingFromModel };
}
function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined {
return paths?.map((value) => resolve(cwd, value));
}
function buildRuntimeBootstrap(
parsed: Args,
cwd: string,
agentDir: string,
authStorage: AuthStorage,
sessionOptions: CreateAgentSessionOptions,
): AgentSessionRuntimeBootstrap {
return {
agentDir,
authStorage,
model: sessionOptions.model,
thinkingLevel: sessionOptions.thinkingLevel,
scopedModels: sessionOptions.scopedModels,
tools: sessionOptions.tools,
customTools: sessionOptions.customTools,
resourceLoader: {
additionalExtensionPaths: resolveCliPaths(cwd, parsed.extensions),
additionalSkillPaths: resolveCliPaths(cwd, parsed.skills),
additionalPromptTemplatePaths: resolveCliPaths(cwd, parsed.promptTemplates),
additionalThemePaths: resolveCliPaths(cwd, parsed.themes),
noExtensions: parsed.noExtensions,
noSkills: parsed.noSkills,
noPromptTemplates: parsed.noPromptTemplates,
noThemes: parsed.noThemes,
systemPrompt: parsed.systemPrompt,
appendSystemPrompt: parsed.appendSystemPrompt,
},
};
}
async function handleConfigCommand(args: string[]): Promise<boolean> {
if (args[0] !== "config") {
return false;
@@ -818,11 +858,7 @@ export async function main(args: string[]) {
modelRegistry,
settingsManager,
);
sessionOptions.authStorage = authStorage;
sessionOptions.modelRegistry = modelRegistry;
sessionOptions.resourceLoader = resourceLoader;
// Handle CLI --api-key as runtime override (not persisted)
if (parsed.apiKey) {
if (!sessionOptions.model) {
console.error(
@@ -833,7 +869,16 @@ export async function main(args: string[]) {
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
}
const { session, modelFallbackMessage } = await createAgentSession(sessionOptions);
const runtimeBootstrap = buildRuntimeBootstrap(parsed, cwd, agentDir, authStorage, sessionOptions);
const runtime = await createAgentSessionRuntime(runtimeBootstrap, {
cwd: sessionManager?.getCwd() ?? cwd,
sessionManager,
});
if (process.cwd() !== runtime.cwd) {
process.chdir(runtime.cwd);
}
const runtimeHost = new AgentSessionRuntimeHost(runtimeBootstrap, runtime);
const { session, modelFallbackMessage } = runtime;
time("createAgentSession");
if (!isInteractive && !session.model) {
@@ -861,7 +906,7 @@ export async function main(args: string[]) {
if (mode === "rpc") {
printTimings();
await runRpcMode(session);
await runRpcMode(runtimeHost);
} else if (isInteractive) {
if (scopedModels.length > 0 && (parsed.verbose || !settingsManager.getQuietStartup())) {
const modelList = scopedModels
@@ -873,7 +918,7 @@ export async function main(args: string[]) {
console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
}
const interactiveMode = new InteractiveMode(session, {
const interactiveMode = new InteractiveMode(runtimeHost, {
migratedProviders,
modelFallbackMessage,
initialMessage,
@@ -900,7 +945,7 @@ export async function main(args: string[]) {
await interactiveMode.run();
} else {
printTimings();
const exitCode = await runPrintMode(session, {
const exitCode = await runPrintMode(runtimeHost, {
mode,
messages: parsed.messages,
initialMessage,

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);