refactor(coding-agent): add runtime host for session switching closes #2024
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user