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

@@ -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