refactor(coding-agent): replace AgentSessionRuntimeHost with closure-based AgentSessionRuntime

- Replace AgentSessionRuntimeHost and bootstrap abstractions with AgentSessionRuntime
- Runtime creation is now closure-based via CreateAgentSessionRuntimeFactory
- Factory closes over process-global fixed inputs, recreates cwd-bound services per effective cwd
- Session config (model, thinking, tools, scoped models) re-resolved per target cwd
- CLI resource paths resolved once at startup as absolute paths
- Swap lifecycle: teardown old, create next, apply next (hard fail on creation error)
- Unified diagnostics model (info/warning/error) for args, services, session resolution, resources
- No logging or process exits inside creation/parsing logic
- Removed session_directory support
- Removed session_switch and session_fork extension events (use session_start with reason)
- Moved package/config CLI to package-manager-cli.ts
- Fixed theme init for --resume session picker
- Fixed flaky reftable footer test (content-based polling)
- Fixed silent drop of unknown single-dash CLI flags
- Added error diagnostics for missing explicit CLI resource paths
- Updated SDK docs, examples, plans, exports, tests, changelog

fixes #2753
This commit is contained in:
Mario Zechner
2026-04-03 20:14:12 +02:00
parent 042066b982
commit 9f9277ccdd
38 changed files with 2180 additions and 1366 deletions

View File

@@ -1,139 +1,36 @@
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 type { AgentSession } from "./agent-session.js";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.js";
import type { SessionStartEvent } 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 type { CreateAgentSessionResult } 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.
* Result returned by runtime creation.
*
* 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.
* The caller gets the created session, its cwd-bound services, and all
* diagnostics collected during setup.
*/
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">;
export interface CreateAgentSessionRuntimeResult extends CreateAgentSessionResult {
services: AgentSessionServices;
diagnostics: AgentSessionRuntimeDiagnostic[];
}
/** 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 preloaded resource loader to reuse instead of creating and reloading one. */
resourceLoader?: ResourceLoader;
/** Optional session_start metadata to emit when the runtime binds extensions. */
sessionStartEvent?: SessionStartEvent;
}
type AgentSessionRuntime = CreateAgentSessionResult & {
/**
* Creates a full runtime for a target cwd and session manager.
*
* The factory closes over process-global fixed inputs, recreates cwd-bound
* services for the effective cwd, resolves session options against those
* services, and finally creates the AgentSession.
*/
export type CreateAgentSessionRuntimeFactory = (options: {
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 =
options.resourceLoader ??
(typeof bootstrap.resourceLoader === "function"
? await bootstrap.resourceLoader(cwd, agentDir)
: new DefaultResourceLoader({
...(bootstrap.resourceLoader ?? {}),
cwd,
agentDir,
settingsManager,
}));
if (!options.resourceLoader) {
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,
};
}
sessionStartEvent?: SessionStartEvent;
}) => Promise<CreateAgentSessionRuntimeResult>;
function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") {
@@ -147,28 +44,46 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s
}
/**
* Stable wrapper around a replaceable AgentSession runtime.
* Owns the current AgentSession plus its cwd-bound services.
*
* 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.
* Session replacement methods tear down the current runtime first, then create
* and apply the next runtime. If creation fails, the error is propagated to the
* caller. The caller is responsible for user-facing error handling.
*/
export class AgentSessionRuntimeHost {
export class AgentSessionRuntime {
constructor(
private readonly bootstrap: AgentSessionRuntimeBootstrap,
private runtime: AgentSessionRuntime,
private _session: AgentSession,
private _services: AgentSessionServices,
private readonly createRuntime: CreateAgentSessionRuntimeFactory,
private _diagnostics: AgentSessionRuntimeDiagnostic[] = [],
private _modelFallbackMessage?: string,
) {}
/** The currently active session instance. Re-read this after runtime replacement. */
get session() {
return this.runtime.session;
get services(): AgentSessionServices {
return this._services;
}
get session(): AgentSession {
return this._session;
}
get cwd(): string {
return this._services.cwd;
}
get diagnostics(): readonly AgentSessionRuntimeDiagnostic[] {
return this._diagnostics;
}
get modelFallbackMessage(): string | undefined {
return this._modelFallbackMessage;
}
private async emitBeforeSwitch(
reason: "new" | "resume",
targetSessionFile?: string,
): Promise<{ cancelled: boolean }> {
const runner = this.runtime.session.extensionRunner;
const runner = this.session.extensionRunner;
if (!runner?.hasHandlers("session_before_switch")) {
return { cancelled: false };
}
@@ -182,7 +97,7 @@ export class AgentSessionRuntimeHost {
}
private async emitBeforeFork(entryId: string): Promise<{ cancelled: boolean }> {
const runner = this.runtime.session.extensionRunner;
const runner = this.session.extensionRunner;
if (!runner?.hasHandlers("session_before_fork")) {
return { cancelled: false };
}
@@ -194,48 +109,43 @@ export class AgentSessionRuntimeHost {
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;
private async teardownCurrent(): Promise<void> {
await emitSessionShutdownEvent(this.session.extensionRunner);
this.session.dispose();
}
private apply(result: CreateAgentSessionRuntimeResult): void {
if (process.cwd() !== result.services.cwd) {
process.chdir(result.services.cwd);
}
this._session = result.session;
this._services = result.services;
this._diagnostics = result.diagnostics;
this._modelFallbackMessage = result.modelFallbackMessage;
}
/**
* 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 previousSessionFile = this.session.sessionFile;
const sessionManager = SessionManager.open(sessionPath);
await this.replace({
cwd: sessionManager.getCwd(),
sessionManager,
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
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");
@@ -243,94 +153,106 @@ export class AgentSessionRuntimeHost {
return beforeResult;
}
const previousSessionFile = this.runtime.session.sessionFile;
const sessionDir = this.runtime.sessionManager.getSessionDir();
const sessionManager = SessionManager.create(this.runtime.cwd, sessionDir);
const previousSessionFile = this.session.sessionFile;
const sessionDir = this.session.sessionManager.getSessionDir();
const sessionManager = SessionManager.create(this.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 },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
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;
await options.setup(this.session.sessionManager);
this.session.agent.state.messages = this.session.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);
const selectedEntry = this.session.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 previousSessionFile = this.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 (this.session.sessionManager.isPersisted()) {
const currentSessionFile = this.session.sessionFile;
if (!currentSessionFile) {
throw new Error("Persisted session is missing a session file");
}
const sessionDir = this.session.sessionManager.getSessionDir();
if (!selectedEntry.parentId) {
const sessionManager = SessionManager.create(this.runtime.cwd, sessionDir);
const sessionManager = SessionManager.create(this.cwd, sessionDir);
sessionManager.newSession({ parentSession: currentSessionFile });
await this.replace({
cwd: this.runtime.cwd,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
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 forkedSessionPath = sourceManager.createBranchedSession(selectedEntry.parentId);
if (!forkedSessionPath) {
throw new Error("Failed to create forked session");
}
const sessionManager = SessionManager.open(forkedSessionPath, sessionDir);
await this.replace({
cwd: sessionManager.getCwd(),
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
}),
);
return { cancelled: false, selectedText };
}
const sessionManager = this.runtime.sessionManager;
const sessionManager = this.session.sessionManager;
if (!selectedEntry.parentId) {
sessionManager.newSession({ parentSession: this.runtime.session.sessionFile });
sessionManager.newSession({ parentSession: this.session.sessionFile });
} else {
sessionManager.createBranchedSession(selectedEntry.parentId);
}
await this.replace({
cwd: this.runtime.cwd,
sessionManager,
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: this.cwd,
agentDir: this.services.agentDir,
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();
const sessionDir = this.session.sessionManager.getSessionDir();
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
@@ -341,23 +263,63 @@ export class AgentSessionRuntimeHost {
return beforeResult;
}
const previousSessionFile = this.runtime.session.sessionFile;
const previousSessionFile = this.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 },
});
await this.teardownCurrent();
this.apply(
await this.createRuntime({
cwd: sessionManager.getCwd(),
agentDir: this.services.agentDir,
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();
await emitSessionShutdownEvent(this.session.extensionRunner);
this.session.dispose();
}
}
/**
* Create the initial runtime from a runtime factory and initial session target.
*
* The same factory is stored on the returned AgentSessionRuntime and reused for
* later /new, /resume, /fork, and import flows.
*/
export async function createAgentSessionRuntime(
createRuntime: CreateAgentSessionRuntimeFactory,
options: {
cwd: string;
agentDir: string;
sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent;
},
): Promise<AgentSessionRuntime> {
const result = await createRuntime(options);
if (process.cwd() !== result.services.cwd) {
process.chdir(result.services.cwd);
}
return new AgentSessionRuntime(
result.session,
result.services,
createRuntime,
result.diagnostics,
result.modelFallbackMessage,
);
}
export {
type AgentSessionRuntimeDiagnostic,
type AgentSessionServices,
type CreateAgentSessionFromServicesOptions,
type CreateAgentSessionServicesOptions,
createAgentSessionFromServices,
createAgentSessionServices,
} from "./agent-session-services.js";

View File

@@ -0,0 +1,197 @@
import { join } 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 { ModelRegistry } from "./model-registry.js";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js";
import { type CreateAgentSessionResult, createAgentSession } from "./sdk.js";
import type { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import type { Tool } from "./tools/index.js";
/**
* Non-fatal issues collected while creating services or sessions.
*
* Runtime creation returns diagnostics to the caller instead of printing or
* exiting. The app layer decides whether warnings should be shown and whether
* errors should abort startup.
*/
export interface AgentSessionRuntimeDiagnostic {
type: "info" | "warning" | "error";
message: string;
}
/**
* Inputs for creating cwd-bound runtime services.
*
* These services are recreated whenever the effective session cwd changes.
* CLI-provided resource paths should be resolved to absolute paths before they
* reach this function, so later cwd switches do not reinterpret them.
*/
export interface CreateAgentSessionServicesOptions {
cwd: string;
agentDir?: string;
authStorage?: AuthStorage;
settingsManager?: SettingsManager;
modelRegistry?: ModelRegistry;
extensionFlagValues?: Map<string, boolean | string>;
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
}
/**
* Inputs for creating an AgentSession from already-created services.
*
* Use this after services exist and any cwd-bound model/tool/session options
* have been resolved against those services.
*/
export interface CreateAgentSessionFromServicesOptions {
services: AgentSessionServices;
sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent;
model?: Model<any>;
thinkingLevel?: ThinkingLevel;
scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
tools?: Tool[];
customTools?: ToolDefinition[];
}
/**
* Coherent cwd-bound runtime services for one effective session cwd.
*
* This is infrastructure only. The AgentSession itself is created separately so
* session options can be resolved against these services first.
*/
export interface AgentSessionServices {
cwd: string;
agentDir: string;
authStorage: AuthStorage;
settingsManager: SettingsManager;
modelRegistry: ModelRegistry;
resourceLoader: ResourceLoader;
diagnostics: AgentSessionRuntimeDiagnostic[];
}
function applyExtensionFlagValues(
resourceLoader: ResourceLoader,
extensionFlagValues: Map<string, boolean | string> | undefined,
): AgentSessionRuntimeDiagnostic[] {
if (!extensionFlagValues) {
return [];
}
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
const extensionsResult = resourceLoader.getExtensions();
const registeredFlags = new Map<string, { type: "boolean" | "string" }>();
for (const extension of extensionsResult.extensions) {
for (const [name, flag] of extension.flags) {
registeredFlags.set(name, { type: flag.type });
}
}
const unknownFlags: string[] = [];
for (const [name, value] of extensionFlagValues) {
const flag = registeredFlags.get(name);
if (!flag) {
unknownFlags.push(name);
continue;
}
if (flag.type === "boolean") {
extensionsResult.runtime.flagValues.set(name, true);
continue;
}
if (typeof value === "string") {
extensionsResult.runtime.flagValues.set(name, value);
continue;
}
diagnostics.push({
type: "error",
message: `Extension flag "--${name}" requires a value`,
});
}
if (unknownFlags.length > 0) {
diagnostics.push({
type: "error",
message: `Unknown option${unknownFlags.length === 1 ? "" : "s"}: ${unknownFlags.map((name) => `--${name}`).join(", ")}`,
});
}
return diagnostics;
}
/**
* Create cwd-bound runtime services.
*
* Returns services plus diagnostics. It does not create an AgentSession.
*/
export async function createAgentSessionServices(
options: CreateAgentSessionServicesOptions,
): Promise<AgentSessionServices> {
const cwd = options.cwd;
const agentDir = options.agentDir ?? getAgentDir();
const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json"));
const resourceLoader = new DefaultResourceLoader({
...(options.resourceLoaderOptions ?? {}),
cwd,
agentDir,
settingsManager,
});
await resourceLoader.reload();
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
const extensionsResult = resourceLoader.getExtensions();
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRegistry.registerProvider(name, config);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diagnostics.push({
type: "error",
message: `Extension "${extensionPath}" error: ${message}`,
});
}
}
extensionsResult.runtime.pendingProviderRegistrations = [];
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));
return {
cwd,
agentDir,
authStorage,
settingsManager,
modelRegistry,
resourceLoader,
diagnostics,
};
}
/**
* Create an AgentSession from previously created services.
*
* This keeps session creation separate from service creation so callers can
* resolve model, thinking, tools, and other session inputs against the target
* cwd before constructing the session.
*/
export async function createAgentSessionFromServices(
options: CreateAgentSessionFromServicesOptions,
): Promise<CreateAgentSessionResult> {
return createAgentSession({
cwd: options.services.cwd,
agentDir: options.services.agentDir,
authStorage: options.services.authStorage,
settingsManager: options.services.settingsManager,
modelRegistry: options.services.modelRegistry,
resourceLoader: options.services.resourceLoader,
sessionManager: options.sessionManager,
model: options.model,
thinkingLevel: options.thinkingLevel,
scopedModels: options.scopedModels,
tools: options.tools,
customTools: options.customTools,
sessionStartEvent: options.sessionStartEvent,
});
}

View File

@@ -2350,7 +2350,7 @@ export class AgentSession {
async reload(): Promise<void> {
const previousFlagValues = this._extensionRunner?.getFlagValues();
await this._extensionRunner?.emit({ type: "session_shutdown" });
this.settingsManager.reload();
await this.settingsManager.reload();
resetApiProviders();
await this._resourceLoader.reload();
this._buildRuntime({

View File

@@ -116,9 +116,6 @@ export type {
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionCompactEvent,
SessionDirectoryEvent,
SessionDirectoryHandler,
SessionDirectoryResult,
SessionEvent,
SessionShutdownEvent,
// Events - Session

View File

@@ -441,12 +441,6 @@ export interface ResourcesDiscoverResult {
// Session Events
// ============================================================================
/** Fired before session manager creation to allow custom session directory resolution */
export interface SessionDirectoryEvent {
type: "session_directory";
cwd: string;
}
/** Fired when a session is started, loaded, or reloaded */
export interface SessionStartEvent {
type: "session_start";
@@ -522,7 +516,6 @@ export interface SessionTreeEvent {
}
export type SessionEvent =
| SessionDirectoryEvent
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionBeforeForkEvent
@@ -921,16 +914,6 @@ export interface BeforeAgentStartEventResult {
systemPrompt?: string;
}
export interface SessionDirectoryResult {
/** Custom session directory path. If multiple extensions return this, the last one wins. */
sessionDir?: string;
}
/** Special startup-only handler. Unlike other events, this receives no ExtensionContext. */
export type SessionDirectoryHandler = (
event: SessionDirectoryEvent,
) => Promise<SessionDirectoryResult | undefined> | SessionDirectoryResult | undefined;
export interface SessionBeforeSwitchResult {
cancel?: boolean;
}
@@ -1006,7 +989,6 @@ export interface ExtensionAPI {
// =========================================================================
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
on(event: "session_directory", handler: SessionDirectoryHandler): void;
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
on(
event: "session_before_switch",

View File

@@ -12,11 +12,19 @@ export {
type SessionStats,
} from "./agent-session.js";
export {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeOptions,
AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory,
type CreateAgentSessionRuntimeResult,
createAgentSessionRuntime,
} from "./agent-session-runtime.js";
export {
type AgentSessionRuntimeDiagnostic,
type AgentSessionServices,
type CreateAgentSessionFromServicesOptions,
type CreateAgentSessionServicesOptions,
createAgentSessionFromServices,
createAgentSessionServices,
} from "./agent-session-services.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";

View File

@@ -6,7 +6,7 @@ import {
TUI_KEYBINDINGS,
KeybindingsManager as TuiKeybindingsManager,
} from "@mariozechner/pi-tui";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { getAgentDir } from "../config.js";
@@ -219,7 +219,7 @@ function toKeybindingsConfig(value: unknown): KeybindingsConfig {
return config;
}
function migrateKeybindingNames(rawConfig: Record<string, unknown>): {
export function migrateKeybindingsConfig(rawConfig: Record<string, unknown>): {
config: Record<string, unknown>;
migrated: boolean;
} {
@@ -269,18 +269,6 @@ function loadRawConfig(path: string): Record<string, unknown> | undefined {
}
}
export function migrateKeybindingsConfigFile(agentDir: string = getAgentDir()): boolean {
const configPath = join(agentDir, "keybindings.json");
const rawConfig = loadRawConfig(configPath);
if (!rawConfig) return false;
const { config, migrated } = migrateKeybindingNames(rawConfig);
if (!migrated) return false;
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
return true;
}
export class KeybindingsManager extends TuiKeybindingsManager {
private configPath: string | undefined;
@@ -307,7 +295,7 @@ export class KeybindingsManager extends TuiKeybindingsManager {
private static loadFromFile(path: string): KeybindingsConfig {
const rawConfig = loadRawConfig(path);
if (!rawConfig) return {};
return toKeybindingsConfig(migrateKeybindingNames(rawConfig).config);
return toKeybindingsConfig(migrateKeybindingsConfig(rawConfig).config);
}
}

View File

@@ -57,11 +57,21 @@ export interface PackageUpdate {
scope: Exclude<SourceScope, "temporary">;
}
export interface ConfiguredPackage {
source: string;
scope: "user" | "project";
filtered: boolean;
installedPath?: string;
}
export interface PackageManager {
resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;
install(source: string, options?: { local?: boolean }): Promise<void>;
installAndPersist(source: string, options?: { local?: boolean }): Promise<void>;
remove(source: string, options?: { local?: boolean }): Promise<void>;
removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean>;
update(source?: string): Promise<void>;
listConfiguredPackages(): ConfiguredPackage[];
resolveExtensionSources(
sources: string[],
options?: { local?: boolean; temporary?: boolean },
@@ -837,6 +847,34 @@ export class DefaultPackageManager implements PackageManager {
return this.toResolvedPaths(accumulator);
}
listConfiguredPackages(): ConfiguredPackage[] {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();
const configuredPackages: ConfiguredPackage[] = [];
for (const pkg of globalSettings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: "user",
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, "user"),
});
}
for (const pkg of projectSettings.packages ?? []) {
const source = typeof pkg === "string" ? pkg : pkg.source;
configuredPackages.push({
source,
scope: "project",
filtered: typeof pkg === "object",
installedPath: this.getInstalledPath(source, "project"),
});
}
return configuredPackages;
}
async install(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "user";
@@ -860,6 +898,11 @@ export class DefaultPackageManager implements PackageManager {
});
}
async installAndPersist(source: string, options?: { local?: boolean }): Promise<void> {
await this.install(source, options);
this.addSourceToSettings(source, options);
}
async remove(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "user";
@@ -879,6 +922,11 @@ export class DefaultPackageManager implements PackageManager {
});
}
async removeAndPersist(source: string, options?: { local?: boolean }): Promise<boolean> {
await this.remove(source, options);
return this.removeSourceFromSettings(source, options);
}
async update(source?: string): Promise<void> {
const globalSettings = this.settingsManager.getGlobalSettings();
const projectSettings = this.settingsManager.getProjectSettings();

View File

@@ -315,6 +315,7 @@ export class DefaultResourceLoader implements ResourceLoader {
}
async reload(): Promise<void> {
await this.settingsManager.reload();
const resolvedPaths = await this.packageManager.resolve();
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
temporary: true,
@@ -402,6 +403,11 @@ export class DefaultResourceLoader implements ResourceLoader {
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
}
for (const p of this.additionalExtensionPaths) {
if (!existsSync(p)) {
extensionsResult.errors.push({ path: p, error: `Extension path does not exist: ${p}` });
}
}
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
this.applyExtensionSourceInfo(this.extensionsResult.extensions, metadataByPath);
@@ -411,6 +417,11 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastSkillPaths = skillPaths;
this.updateSkillsFromPaths(skillPaths, metadataByPath);
for (const p of this.additionalSkillPaths) {
if (!existsSync(p) && !this.skillDiagnostics.some((d) => d.path === p)) {
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: p });
}
}
const promptPaths = this.noPromptTemplates
? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths)
@@ -418,6 +429,11 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastPromptPaths = promptPaths;
this.updatePromptsFromPaths(promptPaths, metadataByPath);
for (const p of this.additionalPromptTemplatePaths) {
if (!existsSync(p) && !this.promptDiagnostics.some((d) => d.path === p)) {
this.promptDiagnostics.push({ type: "error", message: "Prompt template path does not exist", path: p });
}
}
const themePaths = this.noThemes
? this.mergePaths(cliEnabledThemes, this.additionalThemePaths)
@@ -425,6 +441,11 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastThemePaths = themePaths;
this.updateThemesFromPaths(themePaths, metadataByPath);
for (const p of this.additionalThemePaths) {
if (!existsSync(p) && !this.themeDiagnostics.some((d) => d.path === p)) {
this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: p });
}
}
const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) };
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;

View File

@@ -86,12 +86,7 @@ export interface CreateAgentSessionResult {
// Re-exports
export {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeOptions,
createAgentSessionRuntime,
} from "./agent-session-runtime.js";
export * from "./agent-session-runtime.js";
export type {
ExtensionAPI,
ExtensionCommandContext,

View File

@@ -359,7 +359,8 @@ export class SettingsManager {
return structuredClone(this.projectSettings);
}
reload(): void {
async reload(): Promise<void> {
await this.writeQueue;
const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global");
if (!globalLoad.error) {
this.globalSettings = globalLoad.settings;