refactor(coding-agent): add runtime host for session switching closes #2024
This commit is contained in:
358
packages/coding-agent/src/core/agent-session-runtime.ts
Normal file
358
packages/coding-agent/src/core/agent-session-runtime.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -120,11 +120,9 @@ export type {
|
||||
SessionDirectoryHandler,
|
||||
SessionDirectoryResult,
|
||||
SessionEvent,
|
||||
SessionForkEvent,
|
||||
SessionShutdownEvent,
|
||||
// Events - Session
|
||||
SessionStartEvent,
|
||||
SessionSwitchEvent,
|
||||
SessionTreeEvent,
|
||||
SetActiveToolsHandler,
|
||||
SetLabelHandler,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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