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

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

View File

@@ -2,8 +2,77 @@
## [Unreleased]
### Breaking Changes
- Removed extension post-transition events `session_switch` and `session_fork`. Extensions should now use `session_start` and inspect `event.reason`, which is now one of `"startup" | "reload" | "new" | "resume" | "fork"`. For `"new"`, `"resume"`, and `"fork"`, `session_start` also includes `previousSessionFile`. This is better because session replacement now fully reloads extensions, so one post-start hook with explicit reason matches the real lifecycle better than two extra non-cancellable post-transition events.
- Removed session-replacement methods from `AgentSession`. Use `AgentSessionRuntimeHost` for `newSession()`, `switchSession()`, `fork()`, and `importFromJsonl()`. This is better because cross-cwd session replacement rebuilds cwd-bound runtime state and can replace the live `AgentSession` instance entirely. Keeping those operations on a stable runtime host matches the real lifecycle and avoids pretending one `AgentSession` mutates into another.
#### Migration Notes
For existing extensions:
Before:
```ts
pi.on("session_switch", async (event, ctx) => {
if (event.reason === "new") {
resetState();
}
});
pi.on("session_fork", async (_event, ctx) => {
reconstructState(ctx);
});
```
After:
```ts
pi.on("session_start", async (event, ctx) => {
if (event.reason === "new") {
resetState();
}
if (event.reason === "fork" || event.reason === "resume" || event.reason === "startup") {
reconstructState(ctx);
}
});
```
For existing SDK integrations:
Before:
```ts
await session.newSession();
await session.switchSession("/path/to/session.jsonl");
await session.fork("entry-id");
await session.importFromJsonl(jsonl);
```
After:
```ts
const runtime = await createAgentSessionRuntime(bootstrap, {
cwd: process.cwd(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runtimeHost.newSession();
await runtimeHost.switchSession("/path/to/session.jsonl");
await runtimeHost.fork("entry-id");
await runtimeHost.importFromJsonl(jsonl);
const session = runtimeHost.session;
```
After runtime replacement, use `runtimeHost.session` as the new live session and rebind any session-local subscriptions or extension bindings.
### Added
- Added public SDK runtime-host exports `createAgentSessionRuntime()` and `AgentSessionRuntimeHost` for apps that need runtime-backed session replacement and mode-style session switching
- Added label timestamps to the session tree with a `Shift+T` toggle in `/tree`, smart date formatting, and timestamp preservation through branching ([#2691](https://github.com/badlogic/pi-mono/pull/2691) by [@w-winter](https://github.com/w-winter))
## [0.64.0] - 2026-03-29

View File

@@ -392,15 +392,19 @@ See [docs/packages.md](docs/packages.md).
```typescript
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@mariozechner/pi-coding-agent";
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage: AuthStorage.create(),
modelRegistry: ModelRegistry.create(authStorage),
authStorage,
modelRegistry,
});
await session.prompt("What files are in the current directory?");
```
For advanced multi-session runtime replacement, use `createAgentSessionRuntime()` and `AgentSessionRuntimeHost`.
See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/).
### RPC Mode

View File

@@ -37,6 +37,7 @@ See [examples/extensions/](../examples/extensions/) for working implementations.
- [Extension Styles](#extension-styles)
- [Events](#events)
- [Lifecycle Overview](#lifecycle-overview)
- [Resource Events](#resource-events)
- [Session Events](#session-events)
- [Agent Events](#agent-events)
- [Tool Events](#tool-events)
@@ -228,7 +229,8 @@ Run `npm install` in the extension directory, then imports from `node_modules/`
pi starts (CLI only)
├─► session_directory (CLI startup only, no ctx)
─► session_start
─► session_start { reason: "startup" }
└─► resources_discover { reason: "startup" }
user sends prompt ─────────────────────────────────────────┐
@@ -261,11 +263,15 @@ user sends another prompt ◄─────────────────
/new (new session) or /resume (switch session)
├─► session_before_switch (can cancel)
─► session_switch
─► session_shutdown
├─► session_start { reason: "new" | "resume", previousSessionFile? }
└─► resources_discover { reason: "startup" }
/fork
├─► session_before_fork (can cancel)
─► session_fork
─► session_shutdown
├─► session_start { reason: "fork", previousSessionFile }
└─► resources_discover { reason: "startup" }
/compact or auto-compaction
├─► session_before_compact (can cancel or customize)
@@ -282,6 +288,25 @@ exit (Ctrl+C, Ctrl+D)
└─► session_shutdown
```
### Resource Events
#### resources_discover
Fired after `session_start` so extensions can contribute additional skill, prompt, and theme paths.
The startup path uses `reason: "startup"`. Reload uses `reason: "reload"`.
```typescript
pi.on("resources_discover", async (event, _ctx) => {
// event.cwd - current working directory
// event.reason - "startup" | "reload"
return {
skillPaths: ["/path/to/skills"],
promptPaths: ["/path/to/prompts"],
themePaths: ["/path/to/themes"],
};
});
```
### Session Events
See [session.md](session.md) for session storage internals and the SessionManager API.
@@ -309,17 +334,19 @@ pi.on("session_directory", async (event) => {
#### session_start
Fired on initial session load.
Fired when a session is started, loaded, or reloaded.
```typescript
pi.on("session_start", async (_event, ctx) => {
pi.on("session_start", async (event, ctx) => {
// event.reason - "startup" | "reload" | "new" | "resume" | "fork"
// event.previousSessionFile - present for "new", "resume", and "fork"
ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
});
```
#### session_before_switch / session_switch
#### session_before_switch
Fired when starting a new session (`/new`) or switching sessions (`/resume`).
Fired before starting a new session (`/new`) or switching sessions (`/resume`).
```typescript
pi.on("session_before_switch", async (event, ctx) => {
@@ -331,14 +358,12 @@ pi.on("session_before_switch", async (event, ctx) => {
if (!ok) return { cancel: true };
}
});
pi.on("session_switch", async (event, ctx) => {
// event.reason - "new" or "resume"
// event.previousSessionFile - session we came from
});
```
#### session_before_fork / session_fork
After a successful switch or new-session action, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "new" | "resume"` and `previousSessionFile`.
Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`.
#### session_before_fork
Fired when forking via `/fork`.
@@ -349,12 +374,11 @@ pi.on("session_before_fork", async (event, ctx) => {
// OR
return { skipConversationRestore: true }; // Fork but don't rewind messages
});
pi.on("session_fork", async (event, ctx) => {
// event.previousSessionFile - previous session file
});
```
After a successful fork, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`.
Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`.
#### session_before_compact / session_compact
Fired on compaction. See [compaction.md](compaction.md) for details.
@@ -936,7 +960,7 @@ pi.registerCommand("reload-runtime", {
Important behavior:
- `await ctx.reload()` emits `session_shutdown` for the current extension runtime
- It then reloads resources and emits `session_start` (and `resources_discover` with reason `"reload"`) for the new runtime
- It then reloads resources and emits `session_start` with `reason: "reload"` and `resources_discover` with reason `"reload"`
- The currently running command handler still continues in the old call frame
- Code after `await ctx.reload()` still runs from the pre-reload version
- Code after `await ctx.reload()` must not assume old in-memory extension state is still valid
@@ -2187,7 +2211,7 @@ All examples in [examples/extensions/](../examples/extensions/).
| **Compaction & Sessions** |||
| `custom-compaction.ts` | Custom compaction summary | `on("session_before_compact")` |
| `trigger-compact.ts` | Trigger compaction manually | `compact()` |
| `git-checkpoint.ts` | Git stash on turns | `on("turn_end")`, `on("session_fork")`, `exec` |
| `git-checkpoint.ts` | Git stash on turns | `on("turn_start")`, `on("session_before_fork")`, `exec` |
| `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` |
| **UI Components** |||
| `status-line.ts` | Footer status indicator | `setStatus`, session events |

View File

@@ -49,7 +49,7 @@ The SDK is included in the main package. No separate installation needed.
### createAgentSession()
The main factory function. Creates an `AgentSession` with configurable options.
The main factory function for a single `AgentSession`.
`createAgentSession()` uses a `ResourceLoader` to supply extensions, skills, prompt templates, themes, and context files. If you do not provide one, it uses `DefaultResourceLoader` with standard discovery.
@@ -69,61 +69,102 @@ const { session } = await createAgentSession({
### AgentSession
The session manages the agent lifecycle, message history, and event streaming.
The session manages agent lifecycle, message history, model state, compaction, and event streaming.
```typescript
interface AgentSession {
// Send a prompt and wait for completion
// If streaming, requires streamingBehavior option to queue the message
prompt(text: string, options?: PromptOptions): Promise<void>;
// Queue messages during streaming
steer(text: string): Promise<void>; // Queue for delivery after the current assistant turn finishes its tool calls
followUp(text: string): Promise<void>; // Wait: delivered only when agent finishes
steer(text: string): Promise<void>;
followUp(text: string): Promise<void>;
// Subscribe to events (returns unsubscribe function)
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
// Session info
sessionFile: string | undefined; // undefined for in-memory
sessionFile: string | undefined;
sessionId: string;
// Model control
setModel(model: Model): Promise<void>;
setThinkingLevel(level: ThinkingLevel): void;
cycleModel(): Promise<ModelCycleResult | undefined>;
cycleThinkingLevel(): ThinkingLevel | undefined;
// State access
agent: Agent;
model: Model | undefined;
thinkingLevel: ThinkingLevel;
messages: AgentMessage[];
isStreaming: boolean;
// Session management
newSession(options?: { parentSession?: string }): Promise<boolean>; // Returns false if cancelled by hook
switchSession(sessionPath: string): Promise<boolean>;
// Forking
fork(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file
navigateTree(targetId: string, options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
// Hook message injection
sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise<void>;
// In-place tree navigation within the current session file
navigateTree(targetId: string, options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }): Promise<{ editorText?: string; cancelled: boolean }>;
// Compaction
compact(customInstructions?: string): Promise<CompactionResult>;
abortCompaction(): void;
// Abort current operation
abort(): Promise<void>;
// Cleanup
dispose(): void;
}
```
Session replacement APIs such as new-session, resume, fork, and import live on `AgentSessionRuntimeHost`, not on `AgentSession`.
### createAgentSessionRuntime() and AgentSessionRuntimeHost
Use the runtime API when you need to replace the active session and rebuild cwd-bound runtime state.
This is the same layer used by the built-in interactive, print, and RPC modes.
```typescript
import {
AgentSessionRuntimeHost,
createAgentSessionRuntime,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const bootstrap = {
// Optional: authStorage, model, thinkingLevel, tools, customTools, resourceLoader
};
const runtime = await createAgentSessionRuntime(bootstrap, {
cwd: process.cwd(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
```
`createAgentSessionRuntime()` returns an internal runtime bundle. `AgentSessionRuntimeHost` owns replacement of that bundle across:
- `newSession()`
- `switchSession()`
- `fork()`
- `importFromJsonl()`
Important behavior:
- `runtimeHost.session` changes after those operations
- event subscriptions are attached to a specific `AgentSession`, so re-subscribe after replacement
- if you use extensions, call `runtimeHost.session.bindExtensions(...)` again for the new session
```typescript
let session = runtimeHost.session;
let unsubscribe = session.subscribe(() => {});
await runtimeHost.newSession();
unsubscribe();
session = runtimeHost.session;
unsubscribe = session.subscribe(() => {});
```
### Prompting and Message Queueing
The `prompt()` method handles prompt templates, extension commands, and message sending:
@@ -602,7 +643,12 @@ const { session } = await createAgentSession({ resourceLoader: loader });
Sessions use a tree structure with `id`/`parentId` linking, enabling in-place branching.
```typescript
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
import {
AgentSessionRuntimeHost,
createAgentSession,
createAgentSessionRuntime,
SessionManager,
} from "@mariozechner/pi-coding-agent";
// In-memory (no persistence)
const { session } = await createAgentSession({
@@ -610,12 +656,12 @@ const { session } = await createAgentSession({
});
// New persistent session
const { session } = await createAgentSession({
const { session: persisted } = await createAgentSession({
sessionManager: SessionManager.create(process.cwd()),
});
// Continue most recent
const { session, modelFallbackMessage } = await createAgentSession({
const { session: continued, modelFallbackMessage } = await createAgentSession({
sessionManager: SessionManager.continueRecent(process.cwd()),
});
if (modelFallbackMessage) {
@@ -623,26 +669,30 @@ if (modelFallbackMessage) {
}
// Open specific file
const { session } = await createAgentSession({
const { session: opened } = await createAgentSession({
sessionManager: SessionManager.open("/path/to/session.jsonl"),
});
// List available sessions (async with optional progress callback)
const sessions = await SessionManager.list(process.cwd());
for (const info of sessions) {
console.log(`${info.id}: ${info.firstMessage} (${info.messageCount} messages, cwd: ${info.cwd})`);
}
// List sessions
const currentProjectSessions = await SessionManager.list(process.cwd());
const allSessions = await SessionManager.listAll(process.cwd());
// List all sessions across all projects
const allSessions = await SessionManager.listAll((loaded, total) => {
console.log(`Loading ${loaded}/${total}...`);
// Session replacement API for /new, /resume, /fork, and import flows.
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
cwd: process.cwd(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
// Custom session directory (no cwd encoding)
const customDir = "/path/to/my-sessions";
const { session } = await createAgentSession({
sessionManager: SessionManager.create(process.cwd(), customDir),
});
// Replace the active session with a fresh one
await runtimeHost.newSession();
// Replace the active session with another saved session
await runtimeHost.switchSession("/path/to/session.jsonl");
// Replace the active session with a fork from a specific entry
await runtimeHost.fork("entry-id");
```
**SessionManager tree API:**
@@ -650,6 +700,10 @@ const { session } = await createAgentSession({
```typescript
const sm = SessionManager.open("/path/to/session.jsonl");
// Session listing
const currentProjectSessions = await SessionManager.list(process.cwd());
const allSessions = await SessionManager.listAll(process.cwd());
// Tree traversal
const entries = sm.getEntries(); // All entries (excludes header)
const tree = sm.getTree(); // Full tree structure
@@ -859,20 +913,29 @@ The SDK exports run mode utilities for building custom interfaces on top of `cre
Full TUI interactive mode with editor, chat history, and all built-in commands:
```typescript
import { createAgentSession, InteractiveMode } from "@mariozechner/pi-coding-agent";
import {
AgentSessionRuntimeHost,
createAgentSessionRuntime,
InteractiveMode,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const { session } = await createAgentSession({ /* ... */ });
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
cwd: process.cwd(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
const mode = new InteractiveMode(session, {
// All optional
migratedProviders: [], // Show migration warnings
modelFallbackMessage: undefined, // Show model restore warning
initialMessage: "Hello", // Send on startup
initialImages: [], // Images with initial message
initialMessages: [], // Additional startup prompts
const mode = new InteractiveMode(runtimeHost, {
migratedProviders: [],
modelFallbackMessage: undefined,
initialMessage: "Hello",
initialImages: [],
initialMessages: [],
});
await mode.run(); // Blocks until exit
await mode.run();
```
### runPrintMode
@@ -880,15 +943,25 @@ await mode.run(); // Blocks until exit
Single-shot mode: send prompts, output result, exit:
```typescript
import { createAgentSession, runPrintMode } from "@mariozechner/pi-coding-agent";
import {
AgentSessionRuntimeHost,
createAgentSessionRuntime,
runPrintMode,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const { session } = await createAgentSession({ /* ... */ });
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
cwd: process.cwd(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runPrintMode(session, {
mode: "text", // "text" for final response, "json" for all events
initialMessage: "Hello", // First message (can include @file content)
initialImages: [], // Images with initial message
messages: ["Follow up"], // Additional prompts
await runPrintMode(runtimeHost, {
mode: "text",
initialMessage: "Hello",
initialImages: [],
messages: ["Follow up"],
});
```
@@ -897,11 +970,21 @@ await runPrintMode(session, {
JSON-RPC mode for subprocess integration:
```typescript
import { createAgentSession, runRpcMode } from "@mariozechner/pi-coding-agent";
import {
AgentSessionRuntimeHost,
createAgentSessionRuntime,
runRpcMode,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const { session } = await createAgentSession({ /* ... */ });
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
cwd: process.cwd(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runRpcMode(session); // Reads JSON commands from stdin, writes to stdout
await runRpcMode(runtimeHost);
```
See [RPC documentation](rpc.md) for the JSON protocol.
@@ -934,6 +1017,8 @@ The main entry point exports:
```typescript
// Factory
createAgentSession
createAgentSessionRuntime
AgentSessionRuntimeHost
// Auth and Models
AuthStorage

View File

@@ -13,7 +13,7 @@ Sessions are stored as trees where each entry has an `id` and `parentId`. The "l
| View | Flat list of user messages | Full tree structure |
| Action | Extracts path to **new session file** | Changes leaf in **same session** |
| Summary | Never | Optional (user prompted) |
| Events | `session_before_fork` / `session_fork` | `session_before_tree` / `session_tree` |
| Events | `session_before_fork` / `session_start` (`reason: "fork"`) | `session_before_tree` / `session_tree` |
## Tree UI

View File

@@ -33,10 +33,6 @@ export default function (pi: ExtensionAPI) {
applyLabel(ctx);
});
pi.on("session_switch", async (_event, ctx) => {
applyLabel(ctx);
});
pi.registerCommand("thinking-label", {
description: "Set the hidden thinking label. Use without args to reset.",
handler: async (args, ctx) => {

View File

@@ -13,7 +13,7 @@
* - notify() - after each dialog completes
* - setStatus() - on turn_start/turn_end
* - setWidget() - on session_start
* - setTitle() - on session_start and session_switch
* - setTitle() - on session_start
* - setEditorText() - via /rpc-prefill command
*/
@@ -24,18 +24,12 @@ export default function (pi: ExtensionAPI) {
// -- setTitle, setWidget, setStatus on session lifecycle --
pi.on("session_start", async (_event, ctx) => {
ctx.ui.setTitle("pi RPC Demo");
pi.on("session_start", async (event, ctx) => {
ctx.ui.setTitle(event.reason === "new" ? "pi RPC Demo (new session)" : "pi RPC Demo");
ctx.ui.setWidget("rpc-demo", ["--- RPC Extension UI Demo ---", "Loaded and ready."]);
ctx.ui.setStatus("rpc-demo", `Turns: ${turnCount}`);
});
pi.on("session_switch", async (_event, ctx) => {
turnCount = 0;
ctx.ui.setTitle("pi RPC Demo (new session)");
ctx.ui.setStatus("rpc-demo", `Turns: ${turnCount}`);
});
// -- setStatus on turn lifecycle --
pi.on("turn_start", async (_event, ctx) => {

View File

@@ -29,12 +29,4 @@ export default function (pi: ExtensionAPI) {
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
ctx.ui.setStatus("status-demo", check + text);
});
pi.on("session_switch", async (event, ctx) => {
if (event.reason === "new") {
turnCount = 0;
const theme = ctx.ui.theme;
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
}
});
}

View File

@@ -130,8 +130,6 @@ export default function (pi: ExtensionAPI) {
// Reconstruct state on session events
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
// Register the todo tool for the LLM

View File

@@ -138,9 +138,4 @@ export default function toolsExtension(pi: ExtensionAPI) {
pi.on("session_tree", async (_event, ctx) => {
restoreFromBranch(ctx);
});
// Restore state after forking
pi.on("session_fork", async (_event, ctx) => {
restoreFromBranch(ctx);
});
}

View File

@@ -1,17 +1,9 @@
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
const applyWidgets = (ctx: ExtensionContext) => {
if (!ctx.hasUI) return;
ctx.ui.setWidget("widget-above", ["Above editor widget"]);
ctx.ui.setWidget("widget-below", ["Below editor widget"], { placement: "belowEditor" });
};
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function widgetPlacementExtension(pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => {
applyWidgets(ctx);
});
pi.on("session_switch", (_event, ctx) => {
applyWidgets(ctx);
if (!ctx.hasUI) return;
ctx.ui.setWidget("widget-above", ["Above editor widget"]);
ctx.ui.setWidget("widget-below", ["Below editor widget"], { placement: "belowEditor" });
});
}

View File

@@ -0,0 +1,49 @@
/**
* Session Runtime Host
*
* Use the runtime host when you need to replace the active AgentSession,
* for example for new-session, resume, fork, or import flows.
*
* The important pattern is: after the host replaces the runtime, rebind any
* session-local subscriptions and extension bindings to `runtimeHost.session`.
*/
import { AgentSessionRuntimeHost, createAgentSessionRuntime, SessionManager } from "@mariozechner/pi-coding-agent";
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
cwd: process.cwd(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
let unsubscribe: (() => void) | undefined;
async function bindSession() {
unsubscribe?.();
const session = runtimeHost.session;
await session.bindExtensions({});
unsubscribe = session.subscribe((event) => {
if (event.type === "queue_update") {
console.log("Queued:", event.steering.length + event.followUp.length);
}
});
return session;
}
let session = await bindSession();
const originalSessionFile = session.sessionFile;
console.log("Initial session:", originalSessionFile);
await runtimeHost.newSession();
session = await bindSession();
console.log("After newSession():", session.sessionFile);
if (originalSessionFile) {
await runtimeHost.switchSession(originalSessionFile);
session = await bindSession();
console.log("After switchSession():", session.sessionFile);
}
unsubscribe?.();
await runtimeHost.dispose();

View File

@@ -1,6 +1,6 @@
# SDK Examples
Programmatic usage of pi-coding-agent via `createAgentSession()`.
Programmatic usage of pi-coding-agent via `createAgentSession()` and `createAgentSessionRuntime()`.
## Examples
@@ -18,6 +18,7 @@ Programmatic usage of pi-coding-agent via `createAgentSession()`.
| `10-settings.ts` | Override compaction, retry, terminal settings |
| `11-sessions.ts` | In-memory, persistent, continue, list sessions |
| `12-full-control.ts` | Replace everything, no discovery |
| `13-session-runtime.ts` | Manage runtime-backed session replacement |
## Running

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,126 +10,112 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Agent } from "@mariozechner/pi-agent-core";
import { getModel } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.js";
import type { AgentSession } from "../src/core/agent-session.js";
import {
type AgentSessionRuntimeHost,
createAgentSessionRuntime,
AgentSessionRuntimeHost as RuntimeHost,
} from "../src/core/agent-session-runtime.js";
import { AuthStorage } from "../src/core/auth-storage.js";
import { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
import { codingTools } from "../src/core/tools/index.js";
import { API_KEY, createTestResourceLoader } from "./utilities.js";
import { API_KEY } from "./utilities.js";
describe.skipIf(!API_KEY)("AgentSession forking", () => {
let session: AgentSession;
let runtimeHost: AgentSessionRuntimeHost;
let tempDir: string;
let sessionManager: SessionManager;
beforeEach(() => {
// Create temp directory for session files
tempDir = join(tmpdir(), `pi-branching-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(async () => {
if (session) {
session.dispose();
if (runtimeHost) {
await runtimeHost.dispose();
}
process.chdir(tmpdir());
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true });
}
});
function createSession(noSession: boolean = false) {
async function createSession(noSession: boolean = false) {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => API_KEY,
initialState: {
model,
systemPrompt: "You are a helpful assistant. Be extremely concise, reply with just a few words.",
tools: codingTools,
},
});
sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
sessionManager = noSession ? SessionManager.inMemory(tempDir) : SessionManager.create(tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
const modelRegistry = ModelRegistry.create(authStorage, tempDir);
authStorage.setRuntimeApiKey("anthropic", API_KEY!);
session = new AgentSession({
agent,
sessionManager,
settingsManager,
const bootstrap = {
agentDir: tempDir,
authStorage,
model,
tools: codingTools,
resourceLoader: {
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
};
const runtime = await createAgentSessionRuntime(bootstrap, {
cwd: tempDir,
modelRegistry,
resourceLoader: createTestResourceLoader(),
sessionManager,
});
// Must subscribe to enable session persistence
runtimeHost = new RuntimeHost(bootstrap, runtime);
session = runtimeHost.session;
session.subscribe(() => {});
return session;
}
it("should allow forking from single message", async () => {
createSession();
await createSession();
// Send one message
await session.prompt("Say hello");
await session.agent.waitForIdle();
// Should have exactly 1 user message available for forking
const userMessages = session.getUserMessagesForForking();
expect(userMessages.length).toBe(1);
expect(userMessages[0].text).toBe("Say hello");
// Fork from the first message
const result = await session.fork(userMessages[0].entryId);
expect(result.selectedText).toBe("Say hello");
const result = await runtimeHost.fork(userMessages[0].entryId);
expect(result.cancelled).toBe(false);
session = runtimeHost.session;
expect(result.selectedText).toBe("Say hello");
// After forking, conversation should be empty (forked before the first message)
expect(session.messages.length).toBe(0);
// Session file path should be set, but file is created lazily after first assistant message
expect(session.sessionFile).not.toBeNull();
expect(existsSync(session.sessionFile!)).toBe(false);
});
it("should support in-memory forking in --no-session mode", async () => {
createSession(true);
await createSession(true);
// Verify sessions are disabled
expect(session.sessionFile).toBeUndefined();
// Send one message
await session.prompt("Say hi");
await session.agent.waitForIdle();
// Should have 1 user message
const userMessages = session.getUserMessagesForForking();
expect(userMessages.length).toBe(1);
// Verify we have messages before forking
expect(session.messages.length).toBeGreaterThan(0);
// Fork from the first message
const result = await session.fork(userMessages[0].entryId);
expect(result.selectedText).toBe("Say hi");
const result = await runtimeHost.fork(userMessages[0].entryId);
expect(result.cancelled).toBe(false);
session = runtimeHost.session;
expect(result.selectedText).toBe("Say hi");
// After forking, conversation should be empty
expect(session.messages.length).toBe(0);
// Session file should still be undefined (no file created)
expect(session.sessionFile).toBeUndefined();
});
it("should fork from middle of conversation", async () => {
createSession();
await createSession();
// Send multiple messages
await session.prompt("Say one");
await session.agent.waitForIdle();
@@ -139,18 +125,17 @@ describe.skipIf(!API_KEY)("AgentSession forking", () => {
await session.prompt("Say three");
await session.agent.waitForIdle();
// Should have 3 user messages
const userMessages = session.getUserMessagesForForking();
expect(userMessages.length).toBe(3);
// Fork from second message (keeps first message + response)
const secondMessage = userMessages[1];
const result = await session.fork(secondMessage.entryId);
const result = await runtimeHost.fork(secondMessage.entryId);
expect(result.cancelled).toBe(false);
session = runtimeHost.session;
expect(result.selectedText).toBe("Say two");
// After forking, should have first user message + assistant response
expect(session.messages.length).toBe(2);
expect(session.messages[0].role).toBe("user");
expect(session.messages[1].role).toBe("assistant");
});
}, 60000);
});

View File

@@ -0,0 +1,172 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fauxAssistantMessage, registerFauxProvider } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import {
type AgentSessionRuntimeBootstrap,
AgentSessionRuntimeHost,
createAgentSessionRuntime,
} from "../src/core/agent-session-runtime.js";
import { AuthStorage } from "../src/core/auth-storage.js";
import { SessionManager } from "../src/core/session-manager.js";
import type {
ExtensionFactory,
SessionBeforeForkEvent,
SessionBeforeSwitchEvent,
SessionStartEvent,
} from "../src/index.js";
type RecordedSessionEvent = SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionStartEvent;
describe("AgentSessionRuntimeHost session lifecycle events", () => {
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
});
async function createRuntimeHost(extensionFactory: ExtensionFactory) {
const tempDir = join(tmpdir(), `pi-runtime-events-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
const faux = registerFauxProvider();
faux.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two"), fauxAssistantMessage("three")]);
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
const bootstrap: AgentSessionRuntimeBootstrap = {
agentDir: tempDir,
authStorage,
model: faux.getModel(),
resourceLoader: {
extensionFactories: [extensionFactory],
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
};
const runtime = await createAgentSessionRuntime(bootstrap, {
cwd: tempDir,
sessionManager: SessionManager.create(tempDir),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runtimeHost.session.bindExtensions({});
cleanups.push(async () => {
await runtimeHost.dispose();
faux.unregister();
process.chdir(tmpdir());
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
return { runtimeHost, faux };
}
it("emits session_before_switch and session_start for new and resume flows", async () => {
const events: RecordedSessionEvent[] = [];
const { runtimeHost } = await createRuntimeHost((pi) => {
pi.on("session_before_switch", (event) => {
events.push(event);
});
pi.on("session_start", (event) => {
events.push(event);
});
});
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
events.length = 0;
await runtimeHost.session.prompt("hello");
const originalSessionFile = runtimeHost.session.sessionFile;
expect(originalSessionFile).toBeTruthy();
const newSessionResult = await runtimeHost.newSession();
expect(newSessionResult.cancelled).toBe(false);
await runtimeHost.session.bindExtensions({});
expect(events).toEqual([
{ type: "session_before_switch", reason: "new", targetSessionFile: undefined },
{ type: "session_start", reason: "new", previousSessionFile: originalSessionFile },
]);
events.length = 0;
const secondSessionFile = runtimeHost.session.sessionFile;
expect(secondSessionFile).toBeTruthy();
const switchResult = await runtimeHost.switchSession(originalSessionFile!);
expect(switchResult.cancelled).toBe(false);
await runtimeHost.session.bindExtensions({});
expect(events).toEqual([
{ type: "session_before_switch", reason: "resume", targetSessionFile: originalSessionFile },
{ type: "session_start", reason: "resume", previousSessionFile: secondSessionFile },
]);
});
it("honors session_before_switch cancellation", async () => {
const events: RecordedSessionEvent[] = [];
const { runtimeHost } = await createRuntimeHost((pi) => {
pi.on("session_before_switch", (event) => {
events.push(event);
return { cancel: true };
});
pi.on("session_start", (event) => {
events.push(event);
});
});
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
events.length = 0;
await runtimeHost.session.prompt("hello");
const originalSessionFile = runtimeHost.session.sessionFile;
const result = await runtimeHost.newSession();
expect(result.cancelled).toBe(true);
expect(runtimeHost.session.sessionFile).toBe(originalSessionFile);
expect(events).toEqual([{ type: "session_before_switch", reason: "new", targetSessionFile: undefined }]);
});
it("emits session_before_fork and session_start and honors cancellation", async () => {
const events: RecordedSessionEvent[] = [];
let cancelNextFork = false;
const { runtimeHost } = await createRuntimeHost((pi) => {
pi.on("session_before_fork", (event) => {
events.push(event);
if (cancelNextFork) {
cancelNextFork = false;
return { cancel: true };
}
});
pi.on("session_start", (event) => {
events.push(event);
});
});
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
events.length = 0;
await runtimeHost.session.prompt("hello");
const userMessage = runtimeHost.session.getUserMessagesForForking()[0];
const previousSessionFile = runtimeHost.session.sessionFile;
const successResult = await runtimeHost.fork(userMessage.entryId);
expect(successResult.cancelled).toBe(false);
expect(successResult.selectedText).toBe("hello");
await runtimeHost.session.bindExtensions({});
expect(events).toEqual([
{ type: "session_before_fork", entryId: userMessage.entryId },
{ type: "session_start", reason: "fork", previousSessionFile },
]);
events.length = 0;
cancelNextFork = true;
const cancelResult = await runtimeHost.fork(userMessage.entryId);
expect(cancelResult).toEqual({ cancelled: true });
expect(events).toEqual([{ type: "session_before_fork", entryId: userMessage.entryId }]);
});
});

View File

@@ -48,6 +48,7 @@ function createSession(options: {
sessionManager: {
getEntries: () => entries,
getSessionName: () => options.sessionName,
getCwd: () => "/tmp/project",
},
getContextUsage: () => ({ contextWindow: 200_000, percent: 12.3 }),
modelRegistry: {

View File

@@ -17,11 +17,15 @@ type FakeSession = {
bindExtensions: ReturnType<typeof vi.fn>;
subscribe: ReturnType<typeof vi.fn>;
prompt: ReturnType<typeof vi.fn>;
reload: ReturnType<typeof vi.fn>;
};
type FakeRuntimeHost = {
session: FakeSession;
newSession: ReturnType<typeof vi.fn>;
fork: ReturnType<typeof vi.fn>;
navigateTree: ReturnType<typeof vi.fn>;
switchSession: ReturnType<typeof vi.fn>;
reload: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
};
function createAssistantMessage(options?: {
@@ -49,7 +53,7 @@ function createAssistantMessage(options?: {
};
}
function createSession(assistantMessage: AssistantMessage): FakeSession {
function createRuntimeHost(assistantMessage: AssistantMessage): FakeRuntimeHost {
const extensionRunner: FakeExtensionRunner = {
hasHandlers: (eventType: string) => eventType === "session_shutdown",
emit: vi.fn(async () => {}),
@@ -57,7 +61,7 @@ function createSession(assistantMessage: AssistantMessage): FakeSession {
const state = { messages: [assistantMessage] };
return {
const session: FakeSession = {
sessionManager: { getHeader: () => undefined },
agent: { waitForIdle: async () => {} },
state,
@@ -65,12 +69,18 @@ function createSession(assistantMessage: AssistantMessage): FakeSession {
bindExtensions: vi.fn(async () => {}),
subscribe: vi.fn(() => () => {}),
prompt: vi.fn(async () => {}),
newSession: vi.fn(async () => true),
fork: vi.fn(async () => ({ cancelled: false })),
navigateTree: vi.fn(async () => ({ cancelled: false })),
switchSession: vi.fn(async () => true),
reload: vi.fn(async () => {}),
};
return {
session,
newSession: vi.fn(async () => undefined),
fork: vi.fn(async () => ({ selectedText: "" })),
switchSession: vi.fn(async () => undefined),
dispose: vi.fn(async () => {
await session.extensionRunner.emit({ type: "session_shutdown" });
}),
};
}
afterEach(() => {
@@ -79,10 +89,11 @@ afterEach(() => {
describe("runPrintMode", () => {
it("emits session_shutdown in text mode", async () => {
const session = createSession(createAssistantMessage({ text: "done" }));
const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }));
const { session } = runtimeHost;
const images: ImageContent[] = [{ type: "image", mimeType: "image/png", data: "abc" }];
const exitCode = await runPrintMode(session as unknown as Parameters<typeof runPrintMode>[0], {
const exitCode = await runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
mode: "text",
initialMessage: "Say done",
initialImages: images,
@@ -95,9 +106,10 @@ describe("runPrintMode", () => {
});
it("emits session_shutdown in json mode", async () => {
const session = createSession(createAssistantMessage({ text: "done" }));
const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }));
const { session } = runtimeHost;
const exitCode = await runPrintMode(session as unknown as Parameters<typeof runPrintMode>[0], {
const exitCode = await runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
mode: "json",
messages: ["hello"],
});
@@ -109,10 +121,13 @@ describe("runPrintMode", () => {
});
it("emits session_shutdown and returns non-zero on assistant error", async () => {
const session = createSession(createAssistantMessage({ stopReason: "error", errorMessage: "provider failure" }));
const runtimeHost = createRuntimeHost(
createAssistantMessage({ stopReason: "error", errorMessage: "provider failure" }),
);
const { session } = runtimeHost;
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitCode = await runPrintMode(session as unknown as Parameters<typeof runPrintMode>[0], {
const exitCode = await runPrintMode(runtimeHost as unknown as Parameters<typeof runPrintMode>[0], {
mode: "text",
});