refactor(coding-agent): add runtime host for session switching closes #2024
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
}
|
||||
|
||||
49
packages/coding-agent/examples/sdk/13-session-runtime.ts
Normal file
49
packages/coding-agent/examples/sdk/13-session-runtime.ts
Normal 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();
|
||||
@@ -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
|
||||
|
||||
|
||||
358
packages/coding-agent/src/core/agent-session-runtime.ts
Normal file
358
packages/coding-agent/src/core/agent-session-runtime.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { getAgentDir } from "../config.js";
|
||||
import { AuthStorage } from "./auth-storage.js";
|
||||
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js";
|
||||
import { emitSessionShutdownEvent } from "./extensions/runner.js";
|
||||
import { ModelRegistry } from "./model-registry.js";
|
||||
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js";
|
||||
import { type CreateAgentSessionResult, createAgentSession } from "./sdk.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
import { SettingsManager } from "./settings-manager.js";
|
||||
import type { Tool } from "./tools/index.js";
|
||||
|
||||
/**
|
||||
* Stable bootstrap inputs reused whenever the active runtime is replaced.
|
||||
*
|
||||
* Use this for process-level wiring that should survive `/new`, `/resume`,
|
||||
* `/fork`, and import flows. Session-local state belongs in the session file
|
||||
* or in settings, not here.
|
||||
*/
|
||||
export interface AgentSessionRuntimeBootstrap {
|
||||
/** Agent directory used for auth, models, settings, sessions, and resource discovery. */
|
||||
agentDir?: string;
|
||||
/** Optional shared auth storage. If omitted, file-backed storage under agentDir is used. */
|
||||
authStorage?: AuthStorage;
|
||||
/** Initial model for the first created session runtime. */
|
||||
model?: Model<any>;
|
||||
/** Initial thinking level for the first created session runtime. */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Optional scoped model list for model cycling and selection. */
|
||||
scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
|
||||
/** Built-in tool set override. */
|
||||
tools?: Tool[];
|
||||
/** Additional custom tools registered directly through the SDK. */
|
||||
customTools?: ToolDefinition[];
|
||||
/**
|
||||
* Resource loader input used for each created runtime.
|
||||
*
|
||||
* Pass either a factory that creates a fully custom ResourceLoader for the
|
||||
* target cwd, or DefaultResourceLoader options without cwd/agentDir/
|
||||
* settingsManager, which are supplied by the runtime.
|
||||
*/
|
||||
resourceLoader?:
|
||||
| ((cwd: string, agentDir: string) => Promise<ResourceLoader>)
|
||||
| Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
|
||||
}
|
||||
|
||||
/** Options for creating one concrete runtime instance. */
|
||||
export interface CreateAgentSessionRuntimeOptions {
|
||||
/** Working directory for this runtime instance. */
|
||||
cwd: string;
|
||||
/** Optional preselected session manager. If omitted, normal session resolution applies. */
|
||||
sessionManager?: SessionManager;
|
||||
/** Optional session_start metadata to emit when the runtime binds extensions. */
|
||||
sessionStartEvent?: SessionStartEvent;
|
||||
}
|
||||
|
||||
type AgentSessionRuntime = CreateAgentSessionResult & {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
authStorage: AuthStorage;
|
||||
modelRegistry: ModelRegistry;
|
||||
settingsManager: SettingsManager;
|
||||
resourceLoader: ResourceLoader;
|
||||
sessionManager: SessionManager;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create one runtime instance containing an AgentSession plus the cwd-bound
|
||||
* services it depends on.
|
||||
*
|
||||
* Most SDK callers should keep the returned value wrapped in an
|
||||
* AgentSessionRuntimeHost instead of holding it directly. The host owns
|
||||
* replacing the runtime when switching sessions across files or working
|
||||
* directories.
|
||||
*/
|
||||
export async function createAgentSessionRuntime(
|
||||
bootstrap: AgentSessionRuntimeBootstrap,
|
||||
options: CreateAgentSessionRuntimeOptions,
|
||||
): Promise<AgentSessionRuntime> {
|
||||
const cwd = options.cwd;
|
||||
const agentDir = bootstrap.agentDir ?? getAgentDir();
|
||||
const authStorage = bootstrap.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json"));
|
||||
const resourceLoader =
|
||||
typeof bootstrap.resourceLoader === "function"
|
||||
? await bootstrap.resourceLoader(cwd, agentDir)
|
||||
: new DefaultResourceLoader({
|
||||
...(bootstrap.resourceLoader ?? {}),
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const extensionsResult = resourceLoader.getExtensions();
|
||||
for (const { name, config } of extensionsResult.runtime.pendingProviderRegistrations) {
|
||||
modelRegistry.registerProvider(name, config);
|
||||
}
|
||||
extensionsResult.runtime.pendingProviderRegistrations = [];
|
||||
|
||||
const created = await createAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settingsManager,
|
||||
resourceLoader,
|
||||
sessionManager: options.sessionManager,
|
||||
model: bootstrap.model,
|
||||
thinkingLevel: bootstrap.thinkingLevel,
|
||||
scopedModels: bootstrap.scopedModels,
|
||||
tools: bootstrap.tools,
|
||||
customTools: bootstrap.customTools,
|
||||
sessionStartEvent: options.sessionStartEvent,
|
||||
});
|
||||
|
||||
return {
|
||||
...created,
|
||||
cwd,
|
||||
agentDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settingsManager,
|
||||
resourceLoader,
|
||||
sessionManager: created.session.sessionManager,
|
||||
};
|
||||
}
|
||||
|
||||
function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
return content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable wrapper around a replaceable AgentSession runtime.
|
||||
*
|
||||
* Use this when your application needs `/new`, `/resume`, `/fork`, or import
|
||||
* behavior. After replacement, read `session` again and rebind any
|
||||
* session-local subscriptions or extension bindings.
|
||||
*/
|
||||
export class AgentSessionRuntimeHost {
|
||||
constructor(
|
||||
private readonly bootstrap: AgentSessionRuntimeBootstrap,
|
||||
private runtime: AgentSessionRuntime,
|
||||
) {}
|
||||
|
||||
/** The currently active session instance. Re-read this after runtime replacement. */
|
||||
get session() {
|
||||
return this.runtime.session;
|
||||
}
|
||||
|
||||
private async emitBeforeSwitch(
|
||||
reason: "new" | "resume",
|
||||
targetSessionFile?: string,
|
||||
): Promise<{ cancelled: boolean }> {
|
||||
const runner = this.runtime.session.extensionRunner;
|
||||
if (!runner?.hasHandlers("session_before_switch")) {
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
const result = await runner.emit({
|
||||
type: "session_before_switch",
|
||||
reason,
|
||||
targetSessionFile,
|
||||
});
|
||||
return { cancelled: result?.cancel === true };
|
||||
}
|
||||
|
||||
private async emitBeforeFork(entryId: string): Promise<{ cancelled: boolean }> {
|
||||
const runner = this.runtime.session.extensionRunner;
|
||||
if (!runner?.hasHandlers("session_before_fork")) {
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
const result = await runner.emit({
|
||||
type: "session_before_fork",
|
||||
entryId,
|
||||
});
|
||||
return { cancelled: result?.cancel === true };
|
||||
}
|
||||
|
||||
private async replace(options: CreateAgentSessionRuntimeOptions): Promise<void> {
|
||||
const nextRuntime = await createAgentSessionRuntime(this.bootstrap, options);
|
||||
await emitSessionShutdownEvent(this.runtime.session.extensionRunner);
|
||||
this.runtime.session.dispose();
|
||||
if (process.cwd() !== nextRuntime.cwd) {
|
||||
process.chdir(nextRuntime.cwd);
|
||||
}
|
||||
this.runtime = nextRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the active runtime with one opened from an existing session file.
|
||||
*
|
||||
* Emits `session_before_switch` before replacement and returns
|
||||
* `{ cancelled: true }` if an extension vetoes the switch.
|
||||
*/
|
||||
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
|
||||
const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
|
||||
if (beforeResult.cancelled) {
|
||||
return beforeResult;
|
||||
}
|
||||
|
||||
const previousSessionFile = this.runtime.session.sessionFile;
|
||||
const sessionManager = SessionManager.open(sessionPath);
|
||||
await this.replace({
|
||||
cwd: sessionManager.getCwd(),
|
||||
sessionManager,
|
||||
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
|
||||
});
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the active runtime with a fresh session in the current cwd.
|
||||
*
|
||||
* `setup` runs after replacement against the new session manager, which lets
|
||||
* callers seed entries before normal use begins.
|
||||
*/
|
||||
async newSession(options?: {
|
||||
/** Optional parent session path recorded in the new session header. */
|
||||
parentSession?: string;
|
||||
/** Optional callback for seeding the new session manager after replacement. */
|
||||
setup?: (sessionManager: SessionManager) => Promise<void>;
|
||||
}): Promise<{ cancelled: boolean }> {
|
||||
const beforeResult = await this.emitBeforeSwitch("new");
|
||||
if (beforeResult.cancelled) {
|
||||
return beforeResult;
|
||||
}
|
||||
|
||||
const previousSessionFile = this.runtime.session.sessionFile;
|
||||
const sessionDir = this.runtime.sessionManager.getSessionDir();
|
||||
const sessionManager = SessionManager.create(this.runtime.cwd, sessionDir);
|
||||
if (options?.parentSession) {
|
||||
sessionManager.newSession({ parentSession: options.parentSession });
|
||||
}
|
||||
await this.replace({
|
||||
cwd: this.runtime.cwd,
|
||||
sessionManager,
|
||||
sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile },
|
||||
});
|
||||
if (options?.setup) {
|
||||
await options.setup(this.runtime.sessionManager);
|
||||
this.runtime.session.agent.state.messages = this.runtime.sessionManager.buildSessionContext().messages;
|
||||
}
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the active runtime with a fork rooted at the given user-message
|
||||
* entry.
|
||||
*
|
||||
* Returns the selected user text so UIs can restore it into the editor after
|
||||
* the fork completes.
|
||||
*/
|
||||
async fork(entryId: string): Promise<{ cancelled: boolean; selectedText?: string }> {
|
||||
const beforeResult = await this.emitBeforeFork(entryId);
|
||||
if (beforeResult.cancelled) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
const selectedEntry = this.runtime.sessionManager.getEntry(entryId);
|
||||
if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
|
||||
throw new Error("Invalid entry ID for forking");
|
||||
}
|
||||
|
||||
const previousSessionFile = this.runtime.session.sessionFile;
|
||||
const selectedText = extractUserMessageText(selectedEntry.message.content);
|
||||
if (this.runtime.sessionManager.isPersisted()) {
|
||||
const currentSessionFile = this.runtime.session.sessionFile!;
|
||||
const sessionDir = this.runtime.sessionManager.getSessionDir();
|
||||
if (!selectedEntry.parentId) {
|
||||
const sessionManager = SessionManager.create(this.runtime.cwd, sessionDir);
|
||||
sessionManager.newSession({ parentSession: currentSessionFile });
|
||||
await this.replace({
|
||||
cwd: this.runtime.cwd,
|
||||
sessionManager,
|
||||
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
|
||||
});
|
||||
return { cancelled: false, selectedText };
|
||||
}
|
||||
|
||||
const sourceManager = SessionManager.open(currentSessionFile, sessionDir);
|
||||
const forkedSessionPath = sourceManager.createBranchedSession(selectedEntry.parentId)!;
|
||||
const sessionManager = SessionManager.open(forkedSessionPath, sessionDir);
|
||||
await this.replace({
|
||||
cwd: sessionManager.getCwd(),
|
||||
sessionManager,
|
||||
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
|
||||
});
|
||||
return { cancelled: false, selectedText };
|
||||
}
|
||||
|
||||
const sessionManager = this.runtime.sessionManager;
|
||||
if (!selectedEntry.parentId) {
|
||||
sessionManager.newSession({ parentSession: this.runtime.session.sessionFile });
|
||||
} else {
|
||||
sessionManager.createBranchedSession(selectedEntry.parentId);
|
||||
}
|
||||
await this.replace({
|
||||
cwd: this.runtime.cwd,
|
||||
sessionManager,
|
||||
sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
|
||||
});
|
||||
return { cancelled: false, selectedText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a JSONL session file into the current session directory and replace
|
||||
* the active runtime with the imported session.
|
||||
*/
|
||||
async importFromJsonl(inputPath: string): Promise<{ cancelled: boolean }> {
|
||||
const resolvedPath = resolve(inputPath);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
throw new Error(`File not found: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
const sessionDir = this.runtime.sessionManager.getSessionDir();
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
|
||||
const destinationPath = join(sessionDir, basename(resolvedPath));
|
||||
const beforeResult = await this.emitBeforeSwitch("resume", destinationPath);
|
||||
if (beforeResult.cancelled) {
|
||||
return beforeResult;
|
||||
}
|
||||
|
||||
const previousSessionFile = this.runtime.session.sessionFile;
|
||||
if (resolve(destinationPath) !== resolvedPath) {
|
||||
copyFileSync(resolvedPath, destinationPath);
|
||||
}
|
||||
|
||||
const sessionManager = SessionManager.open(destinationPath, sessionDir);
|
||||
await this.replace({
|
||||
cwd: sessionManager.getCwd(),
|
||||
sessionManager,
|
||||
sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
|
||||
});
|
||||
return { cancelled: false };
|
||||
}
|
||||
|
||||
/** Emit session shutdown for the active runtime and dispose it permanently. */
|
||||
async dispose(): Promise<void> {
|
||||
await emitSessionShutdownEvent(this.runtime.session.extensionRunner);
|
||||
this.runtime.session.dispose();
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
* Modes use this class and add their own I/O layer on top.
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -29,7 +29,7 @@ import { getDocsPath } from "../config.js";
|
||||
import { theme } from "../modes/interactive/theme/theme.js";
|
||||
import { stripFrontmatter } from "../utils/frontmatter.js";
|
||||
import { sleep } from "../utils/sleep.js";
|
||||
import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js";
|
||||
import { type BashResult, executeBashWithOperations } from "./bash-executor.js";
|
||||
import {
|
||||
type CompactionResult,
|
||||
calculateContextTokens,
|
||||
@@ -54,9 +54,8 @@ import {
|
||||
type MessageStartEvent,
|
||||
type MessageUpdateEvent,
|
||||
type SessionBeforeCompactResult,
|
||||
type SessionBeforeForkResult,
|
||||
type SessionBeforeSwitchResult,
|
||||
type SessionBeforeTreeResult,
|
||||
type SessionStartEvent,
|
||||
type ShutdownHandler,
|
||||
type ToolDefinition,
|
||||
type ToolExecutionEndEvent,
|
||||
@@ -78,7 +77,7 @@ import type { SettingsManager } from "./settings-manager.js";
|
||||
import type { SlashCommandInfo } from "./slash-commands.js";
|
||||
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js";
|
||||
import { buildSystemPrompt } from "./system-prompt.js";
|
||||
import type { BashOperations } from "./tools/bash.js";
|
||||
import { type BashOperations, createLocalBashOperations } from "./tools/bash.js";
|
||||
import { createAllToolDefinitions } from "./tools/index.js";
|
||||
import { createToolDefinitionFromAgentTool, wrapToolDefinition } from "./tools/tool-definition-wrapper.js";
|
||||
|
||||
@@ -160,6 +159,8 @@ export interface AgentSessionConfig {
|
||||
baseToolsOverride?: Record<string, AgentTool>;
|
||||
/** Mutable ref used by Agent to access the current ExtensionRunner */
|
||||
extensionRunnerRef?: { current?: ExtensionRunner };
|
||||
/** Session start event metadata emitted when extensions bind to this runtime. */
|
||||
sessionStartEvent?: SessionStartEvent;
|
||||
}
|
||||
|
||||
export interface ExtensionBindings {
|
||||
@@ -276,6 +277,7 @@ export class AgentSession {
|
||||
private _extensionRunnerRef?: { current?: ExtensionRunner };
|
||||
private _initialActiveToolNames?: string[];
|
||||
private _baseToolsOverride?: Record<string, AgentTool>;
|
||||
private _sessionStartEvent: SessionStartEvent;
|
||||
private _extensionUIContext?: ExtensionUIContext;
|
||||
private _extensionCommandContextActions?: ExtensionCommandContextActions;
|
||||
private _extensionShutdownHandler?: ShutdownHandler;
|
||||
@@ -306,6 +308,7 @@ export class AgentSession {
|
||||
this._extensionRunnerRef = config.extensionRunnerRef;
|
||||
this._initialActiveToolNames = config.initialActiveToolNames;
|
||||
this._baseToolsOverride = config.baseToolsOverride;
|
||||
this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
|
||||
|
||||
// Always subscribe to agent events for internal handling
|
||||
// (session persistence, extensions, auto-compaction, retry logic)
|
||||
@@ -1346,66 +1349,6 @@ export class AgentSession {
|
||||
await this.agent.waitForIdle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new session, optionally with initial messages and parent tracking.
|
||||
* Clears all messages and starts a new session.
|
||||
* Listeners are preserved and will continue receiving events.
|
||||
* @param options.parentSession - Optional parent session path for tracking
|
||||
* @param options.setup - Optional callback to initialize session (e.g., append messages)
|
||||
* @returns true if completed, false if cancelled by extension
|
||||
*/
|
||||
async newSession(options?: {
|
||||
parentSession?: string;
|
||||
setup?: (sessionManager: SessionManager) => Promise<void>;
|
||||
}): Promise<boolean> {
|
||||
const previousSessionFile = this.sessionFile;
|
||||
|
||||
// Emit session_before_switch event with reason "new" (can be cancelled)
|
||||
if (this._extensionRunner?.hasHandlers("session_before_switch")) {
|
||||
const result = (await this._extensionRunner.emit({
|
||||
type: "session_before_switch",
|
||||
reason: "new",
|
||||
})) as SessionBeforeSwitchResult | undefined;
|
||||
|
||||
if (result?.cancel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this._disconnectFromAgent();
|
||||
await this.abort();
|
||||
this.agent.reset();
|
||||
this.sessionManager.newSession({ parentSession: options?.parentSession });
|
||||
this.agent.sessionId = this.sessionManager.getSessionId();
|
||||
this._steeringMessages = [];
|
||||
this._followUpMessages = [];
|
||||
this._pendingNextTurnMessages = [];
|
||||
|
||||
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
||||
|
||||
// Run setup callback if provided (e.g., to append initial messages)
|
||||
if (options?.setup) {
|
||||
await options.setup(this.sessionManager);
|
||||
// Sync agent state with session manager after setup
|
||||
const sessionContext = this.sessionManager.buildSessionContext();
|
||||
this.agent.state.messages = sessionContext.messages;
|
||||
}
|
||||
|
||||
this._reconnectToAgent();
|
||||
|
||||
// Emit session_switch event with reason "new" to extensions
|
||||
if (this._extensionRunner) {
|
||||
await this._extensionRunner.emit({
|
||||
type: "session_switch",
|
||||
reason: "new",
|
||||
previousSessionFile,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit session event to custom tools
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Model Management
|
||||
// =========================================================================
|
||||
@@ -1460,12 +1403,8 @@ export class AgentSession {
|
||||
return this._cycleAvailableModel(direction);
|
||||
}
|
||||
|
||||
private _getScopedModelsWithAuth(): Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {
|
||||
return this._scopedModels.filter((scoped) => this._modelRegistry.hasConfiguredAuth(scoped.model));
|
||||
}
|
||||
|
||||
private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
||||
const scopedModels = this._getScopedModelsWithAuth();
|
||||
const scopedModels = this._scopedModels.filter((scoped) => this._modelRegistry.hasConfiguredAuth(scoped.model));
|
||||
if (scopedModels.length <= 1) return undefined;
|
||||
|
||||
const currentModel = this.model;
|
||||
@@ -2081,8 +2020,8 @@ export class AgentSession {
|
||||
|
||||
if (this._extensionRunner) {
|
||||
this._applyExtensionBindings(this._extensionRunner);
|
||||
await this._extensionRunner.emit({ type: "session_start" });
|
||||
await this.extendResourcesFromExtensions("startup");
|
||||
await this._extensionRunner.emit(this._sessionStartEvent);
|
||||
await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2426,7 +2365,7 @@ export class AgentSession {
|
||||
this._extensionShutdownHandler ||
|
||||
this._extensionErrorListener;
|
||||
if (this._extensionRunner && hasBindings) {
|
||||
await this._extensionRunner.emit({ type: "session_start" });
|
||||
await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
|
||||
await this.extendResourcesFromExtensions("reload");
|
||||
}
|
||||
}
|
||||
@@ -2596,15 +2535,15 @@ export class AgentSession {
|
||||
const resolvedCommand = prefix ? `${prefix}\n${command}` : command;
|
||||
|
||||
try {
|
||||
const result = options?.operations
|
||||
? await executeBashWithOperations(resolvedCommand, process.cwd(), options.operations, {
|
||||
onChunk,
|
||||
signal: this._bashAbortController.signal,
|
||||
})
|
||||
: await executeBashCommand(resolvedCommand, {
|
||||
onChunk,
|
||||
signal: this._bashAbortController.signal,
|
||||
});
|
||||
const result = await executeBashWithOperations(
|
||||
resolvedCommand,
|
||||
this.sessionManager.getCwd(),
|
||||
options?.operations ?? createLocalBashOperations(),
|
||||
{
|
||||
onChunk,
|
||||
signal: this._bashAbortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
this.recordBashResult(command, result, options);
|
||||
return result;
|
||||
@@ -2682,86 +2621,6 @@ export class AgentSession {
|
||||
// Session Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Switch to a different session file.
|
||||
* Aborts current operation, loads messages, restores model/thinking.
|
||||
* Listeners are preserved and will continue receiving events.
|
||||
* @returns true if switch completed, false if cancelled by extension
|
||||
*/
|
||||
async switchSession(sessionPath: string): Promise<boolean> {
|
||||
const previousSessionFile = this.sessionManager.getSessionFile();
|
||||
|
||||
// Emit session_before_switch event (can be cancelled)
|
||||
if (this._extensionRunner?.hasHandlers("session_before_switch")) {
|
||||
const result = (await this._extensionRunner.emit({
|
||||
type: "session_before_switch",
|
||||
reason: "resume",
|
||||
targetSessionFile: sessionPath,
|
||||
})) as SessionBeforeSwitchResult | undefined;
|
||||
|
||||
if (result?.cancel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this._disconnectFromAgent();
|
||||
await this.abort();
|
||||
this._steeringMessages = [];
|
||||
this._followUpMessages = [];
|
||||
this._pendingNextTurnMessages = [];
|
||||
|
||||
// Set new session
|
||||
this.sessionManager.setSessionFile(sessionPath);
|
||||
this.agent.sessionId = this.sessionManager.getSessionId();
|
||||
|
||||
// Reload messages
|
||||
const sessionContext = this.sessionManager.buildSessionContext();
|
||||
|
||||
// Emit session_switch event to extensions
|
||||
if (this._extensionRunner) {
|
||||
await this._extensionRunner.emit({
|
||||
type: "session_switch",
|
||||
reason: "resume",
|
||||
previousSessionFile,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit session event to custom tools
|
||||
|
||||
this.agent.state.messages = sessionContext.messages;
|
||||
|
||||
// Restore model if saved
|
||||
if (sessionContext.model) {
|
||||
const previousModel = this.model;
|
||||
const availableModels = await this._modelRegistry.getAvailable();
|
||||
const match = availableModels.find(
|
||||
(m) => m.provider === sessionContext.model!.provider && m.id === sessionContext.model!.modelId,
|
||||
);
|
||||
if (match) {
|
||||
this.agent.state.model = match;
|
||||
await this._emitModelSelect(match, previousModel, "restore");
|
||||
}
|
||||
}
|
||||
|
||||
const hasThinkingEntry = this.sessionManager.getBranch().some((entry) => entry.type === "thinking_level_change");
|
||||
const defaultThinkingLevel = this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
|
||||
|
||||
if (hasThinkingEntry) {
|
||||
// Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
|
||||
this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel);
|
||||
} else {
|
||||
const availableLevels = this.getAvailableThinkingLevels();
|
||||
const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
|
||||
? defaultThinkingLevel
|
||||
: this._clampThinkingLevel(defaultThinkingLevel, availableLevels);
|
||||
this.agent.state.thinkingLevel = effectiveLevel;
|
||||
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
||||
}
|
||||
|
||||
this._reconnectToAgent();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a display name for the current session.
|
||||
*/
|
||||
@@ -2769,70 +2628,6 @@ export class AgentSession {
|
||||
this.sessionManager.appendSessionInfo(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fork from a specific entry.
|
||||
* Emits before_fork/fork session events to extensions.
|
||||
*
|
||||
* @param entryId ID of the entry to fork from
|
||||
* @returns Object with:
|
||||
* - selectedText: The text of the selected user message (for editor pre-fill)
|
||||
* - cancelled: True if an extension cancelled the fork
|
||||
*/
|
||||
async fork(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
|
||||
const previousSessionFile = this.sessionFile;
|
||||
const selectedEntry = this.sessionManager.getEntry(entryId);
|
||||
|
||||
if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
|
||||
throw new Error("Invalid entry ID for forking");
|
||||
}
|
||||
|
||||
const selectedText = this._extractUserMessageText(selectedEntry.message.content);
|
||||
|
||||
let skipConversationRestore = false;
|
||||
|
||||
// Emit session_before_fork event (can be cancelled)
|
||||
if (this._extensionRunner?.hasHandlers("session_before_fork")) {
|
||||
const result = (await this._extensionRunner.emit({
|
||||
type: "session_before_fork",
|
||||
entryId,
|
||||
})) as SessionBeforeForkResult | undefined;
|
||||
|
||||
if (result?.cancel) {
|
||||
return { selectedText, cancelled: true };
|
||||
}
|
||||
skipConversationRestore = result?.skipConversationRestore ?? false;
|
||||
}
|
||||
|
||||
// Clear pending messages (bound to old session state)
|
||||
this._pendingNextTurnMessages = [];
|
||||
|
||||
if (!selectedEntry.parentId) {
|
||||
this.sessionManager.newSession({ parentSession: previousSessionFile });
|
||||
} else {
|
||||
this.sessionManager.createBranchedSession(selectedEntry.parentId);
|
||||
}
|
||||
this.agent.sessionId = this.sessionManager.getSessionId();
|
||||
|
||||
// Reload messages from entries (works for both file and in-memory mode)
|
||||
const sessionContext = this.sessionManager.buildSessionContext();
|
||||
|
||||
// Emit session_fork event to extensions (after fork completes)
|
||||
if (this._extensionRunner) {
|
||||
await this._extensionRunner.emit({
|
||||
type: "session_fork",
|
||||
previousSessionFile,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit session event to custom tools (with reason "fork")
|
||||
|
||||
if (!skipConversationRestore) {
|
||||
this.agent.state.messages = sessionContext.messages;
|
||||
}
|
||||
|
||||
return { selectedText, cancelled: false };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tree Navigation
|
||||
// =========================================================================
|
||||
@@ -3165,6 +2960,7 @@ export class AgentSession {
|
||||
const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({
|
||||
getToolDefinition: (name) => this.getToolDefinition(name),
|
||||
theme,
|
||||
cwd: this.sessionManager.getCwd(),
|
||||
});
|
||||
|
||||
return await exportSessionToHtml(this.sessionManager, this.state, {
|
||||
@@ -3210,32 +3006,6 @@ export class AgentSession {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a JSONL session file.
|
||||
* Copies the file into the session directory and switches to it (like /resume).
|
||||
* @param inputPath Path to the JSONL file to import.
|
||||
* @returns true if the session was switched successfully.
|
||||
*/
|
||||
async importFromJsonl(inputPath: string): Promise<boolean> {
|
||||
const resolved = resolve(inputPath);
|
||||
if (!existsSync(resolved)) {
|
||||
throw new Error(`File not found: ${resolved}`);
|
||||
}
|
||||
|
||||
// Copy into the session directory so we don't modify the original
|
||||
const sessionDir = this.sessionManager.getSessionDir();
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
const destPath = join(sessionDir, basename(resolved));
|
||||
// Avoid overwriting if source and destination are the same file
|
||||
if (resolve(destPath) !== resolved) {
|
||||
copyFileSync(resolved, destPath);
|
||||
}
|
||||
|
||||
return this.switchSession(destPath);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Utilities
|
||||
// =========================================================================
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ToolHtmlRendererDeps {
|
||||
getToolDefinition: (name: string) => ToolDefinition | undefined;
|
||||
/** Theme for styling */
|
||||
theme: Theme;
|
||||
/** Working directory for render context */
|
||||
cwd: string;
|
||||
/** Terminal width for rendering (default: 100) */
|
||||
width?: number;
|
||||
}
|
||||
@@ -40,7 +42,7 @@ export interface ToolHtmlRenderer {
|
||||
* methods, converting the resulting TUI Component output (ANSI) to HTML.
|
||||
*/
|
||||
export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRenderer {
|
||||
const { getToolDefinition, theme, width = 100 } = deps;
|
||||
const { getToolDefinition, theme, cwd, width = 100 } = deps;
|
||||
|
||||
const renderedCallComponents = new Map<string, Component>();
|
||||
const renderedResultComponents = new Map<string, Component>();
|
||||
@@ -69,7 +71,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend
|
||||
invalidate: () => {},
|
||||
lastComponent,
|
||||
state: getState(toolCallId),
|
||||
cwd: process.cwd(),
|
||||
cwd,
|
||||
executionStarted: true,
|
||||
argsComplete: true,
|
||||
isPartial,
|
||||
|
||||
@@ -120,11 +120,9 @@ export type {
|
||||
SessionDirectoryHandler,
|
||||
SessionDirectoryResult,
|
||||
SessionEvent,
|
||||
SessionForkEvent,
|
||||
SessionShutdownEvent,
|
||||
// Events - Session
|
||||
SessionStartEvent,
|
||||
SessionSwitchEvent,
|
||||
SessionTreeEvent,
|
||||
SetActiveToolsHandler,
|
||||
SetLabelHandler,
|
||||
|
||||
@@ -432,9 +432,13 @@ export interface SessionDirectoryEvent {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
/** Fired on initial session load */
|
||||
/** Fired when a session is started, loaded, or reloaded */
|
||||
export interface SessionStartEvent {
|
||||
type: "session_start";
|
||||
/** Why this session start happened. */
|
||||
reason: "startup" | "reload" | "new" | "resume" | "fork";
|
||||
/** Previously active session file. Present for "new", "resume", and "fork". */
|
||||
previousSessionFile?: string;
|
||||
}
|
||||
|
||||
/** Fired before switching to another session (can be cancelled) */
|
||||
@@ -444,25 +448,12 @@ export interface SessionBeforeSwitchEvent {
|
||||
targetSessionFile?: string;
|
||||
}
|
||||
|
||||
/** Fired after switching to another session */
|
||||
export interface SessionSwitchEvent {
|
||||
type: "session_switch";
|
||||
reason: "new" | "resume";
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Fired before forking a session (can be cancelled) */
|
||||
export interface SessionBeforeForkEvent {
|
||||
type: "session_before_fork";
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
/** Fired after forking a session */
|
||||
export interface SessionForkEvent {
|
||||
type: "session_fork";
|
||||
previousSessionFile: string | undefined;
|
||||
}
|
||||
|
||||
/** Fired before context compaction (can be cancelled or customized) */
|
||||
export interface SessionBeforeCompactEvent {
|
||||
type: "session_before_compact";
|
||||
@@ -519,9 +510,7 @@ export type SessionEvent =
|
||||
| SessionDirectoryEvent
|
||||
| SessionStartEvent
|
||||
| SessionBeforeSwitchEvent
|
||||
| SessionSwitchEvent
|
||||
| SessionBeforeForkEvent
|
||||
| SessionForkEvent
|
||||
| SessionBeforeCompactEvent
|
||||
| SessionCompactEvent
|
||||
| SessionShutdownEvent
|
||||
@@ -1008,9 +997,7 @@ export interface ExtensionAPI {
|
||||
event: "session_before_switch",
|
||||
handler: ExtensionHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>,
|
||||
): void;
|
||||
on(event: "session_switch", handler: ExtensionHandler<SessionSwitchEvent>): void;
|
||||
on(event: "session_before_fork", handler: ExtensionHandler<SessionBeforeForkEvent, SessionBeforeForkResult>): void;
|
||||
on(event: "session_fork", handler: ExtensionHandler<SessionForkEvent>): void;
|
||||
on(
|
||||
event: "session_before_compact",
|
||||
handler: ExtensionHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ExecFileException, execFile, spawnSync } from "child_process";
|
||||
import { existsSync, type FSWatcher, readFileSync, statSync, watch } from "fs";
|
||||
import { existsSync, type FSWatcher, readFileSync, statSync, unwatchFile, watch, watchFile } from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
|
||||
type GitPaths = {
|
||||
@@ -12,8 +12,8 @@ type GitPaths = {
|
||||
* Find git metadata paths by walking up from cwd.
|
||||
* Handles both regular git repos (.git is a directory) and worktrees (.git is a file).
|
||||
*/
|
||||
function findGitPaths(): GitPaths | null {
|
||||
let dir = process.cwd();
|
||||
function findGitPaths(cwd: string): GitPaths | null {
|
||||
let dir = cwd;
|
||||
while (true) {
|
||||
const gitPath = join(dir, ".git");
|
||||
if (existsSync(gitPath)) {
|
||||
@@ -84,6 +84,7 @@ function resolveBranchWithGitAsync(repoDir: string): Promise<string | null> {
|
||||
* Token stats, model info available via ctx.sessionManager and ctx.model.
|
||||
*/
|
||||
export class FooterDataProvider {
|
||||
private cwd: string;
|
||||
private static readonly WATCH_DEBOUNCE_MS = 500;
|
||||
|
||||
private extensionStatuses = new Map<string, string>();
|
||||
@@ -91,6 +92,8 @@ export class FooterDataProvider {
|
||||
private gitPaths: GitPaths | null | undefined = undefined;
|
||||
private headWatcher: FSWatcher | null = null;
|
||||
private reftableWatcher: FSWatcher | null = null;
|
||||
private reftableTablesListWatcher: FSWatcher | null = null;
|
||||
private reftableTablesListPath: string | null = null;
|
||||
private branchChangeCallbacks = new Set<() => void>();
|
||||
private availableProviderCount = 0;
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -98,8 +101,9 @@ export class FooterDataProvider {
|
||||
private refreshPending = false;
|
||||
private disposed = false;
|
||||
|
||||
constructor() {
|
||||
this.gitPaths = findGitPaths();
|
||||
constructor(cwd: string = process.cwd()) {
|
||||
this.cwd = cwd;
|
||||
this.gitPaths = findGitPaths(cwd);
|
||||
this.setupGitWatcher();
|
||||
}
|
||||
|
||||
@@ -146,6 +150,38 @@ export class FooterDataProvider {
|
||||
this.availableProviderCount = count;
|
||||
}
|
||||
|
||||
setCwd(cwd: string): void {
|
||||
if (this.cwd === cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cwd = cwd;
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
if (this.headWatcher) {
|
||||
this.headWatcher.close();
|
||||
this.headWatcher = null;
|
||||
}
|
||||
if (this.reftableWatcher) {
|
||||
this.reftableWatcher.close();
|
||||
this.reftableWatcher = null;
|
||||
}
|
||||
if (this.reftableTablesListWatcher) {
|
||||
this.reftableTablesListWatcher.close();
|
||||
this.reftableTablesListWatcher = null;
|
||||
}
|
||||
if (this.reftableTablesListPath) {
|
||||
unwatchFile(this.reftableTablesListPath);
|
||||
this.reftableTablesListPath = null;
|
||||
}
|
||||
this.cachedBranch = undefined;
|
||||
this.gitPaths = findGitPaths(cwd);
|
||||
this.setupGitWatcher();
|
||||
this.notifyBranchChange();
|
||||
}
|
||||
|
||||
/** Internal: cleanup */
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
@@ -161,6 +197,14 @@ export class FooterDataProvider {
|
||||
this.reftableWatcher.close();
|
||||
this.reftableWatcher = null;
|
||||
}
|
||||
if (this.reftableTablesListWatcher) {
|
||||
this.reftableTablesListWatcher.close();
|
||||
this.reftableTablesListWatcher = null;
|
||||
}
|
||||
if (this.reftableTablesListPath) {
|
||||
unwatchFile(this.reftableTablesListPath);
|
||||
this.reftableTablesListPath = null;
|
||||
}
|
||||
this.branchChangeCallbacks.clear();
|
||||
}
|
||||
|
||||
@@ -169,9 +213,10 @@ export class FooterDataProvider {
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
if (this.disposed) return;
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
if (this.disposed || this.refreshTimer) return;
|
||||
if (this.refreshInFlight) {
|
||||
this.refreshPending = true;
|
||||
return;
|
||||
}
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshTimer = null;
|
||||
@@ -262,6 +307,27 @@ export class FooterDataProvider {
|
||||
} catch {
|
||||
// Silently fail if we can't watch
|
||||
}
|
||||
|
||||
const tablesListPath = join(reftableDir, "tables.list");
|
||||
if (existsSync(tablesListPath)) {
|
||||
this.reftableTablesListPath = tablesListPath;
|
||||
try {
|
||||
this.reftableTablesListWatcher = watch(tablesListPath, () => {
|
||||
this.scheduleRefresh();
|
||||
});
|
||||
} catch {
|
||||
// Silently fail if we can't watch
|
||||
}
|
||||
watchFile(tablesListPath, { interval: 250 }, (current, previous) => {
|
||||
if (
|
||||
current.mtimeMs !== previous.mtimeMs ||
|
||||
current.ctimeMs !== previous.ctimeMs ||
|
||||
current.size !== previous.size
|
||||
) {
|
||||
this.scheduleRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ export {
|
||||
type PromptOptions,
|
||||
type SessionStats,
|
||||
} from "./agent-session.js";
|
||||
export {
|
||||
type AgentSessionRuntimeBootstrap,
|
||||
AgentSessionRuntimeHost,
|
||||
type CreateAgentSessionRuntimeOptions,
|
||||
createAgentSessionRuntime,
|
||||
} from "./agent-session-runtime.js";
|
||||
export { type BashExecutorOptions, type BashResult, executeBash, executeBashWithOperations } from "./bash-executor.js";
|
||||
export type { CompactionResult } from "./compaction/index.js";
|
||||
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.js";
|
||||
@@ -45,10 +51,8 @@ export {
|
||||
type SessionBeforeSwitchEvent,
|
||||
type SessionBeforeTreeEvent,
|
||||
type SessionCompactEvent,
|
||||
type SessionForkEvent,
|
||||
type SessionShutdownEvent,
|
||||
type SessionStartEvent,
|
||||
type SessionSwitchEvent,
|
||||
type SessionTreeEvent,
|
||||
type ToolCallEvent,
|
||||
type ToolCallEventResult,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getAgentDir, getDocsPath } from "../config.js";
|
||||
import { AgentSession } from "./agent-session.js";
|
||||
import { AuthStorage } from "./auth-storage.js";
|
||||
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
|
||||
import type { ExtensionRunner, LoadExtensionsResult, ToolDefinition } from "./extensions/index.js";
|
||||
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.js";
|
||||
import { convertToLlm } from "./messages.js";
|
||||
import { ModelRegistry } from "./model-registry.js";
|
||||
import { findInitialModel } from "./model-resolver.js";
|
||||
@@ -70,6 +70,8 @@ export interface CreateAgentSessionOptions {
|
||||
|
||||
/** Settings manager. Default: SettingsManager.create(cwd, agentDir) */
|
||||
settingsManager?: SettingsManager;
|
||||
/** Session start event metadata for extension runtime startup. */
|
||||
sessionStartEvent?: SessionStartEvent;
|
||||
}
|
||||
|
||||
/** Result from createAgentSession */
|
||||
@@ -84,6 +86,12 @@ export interface CreateAgentSessionResult {
|
||||
|
||||
// Re-exports
|
||||
|
||||
export {
|
||||
type AgentSessionRuntimeBootstrap,
|
||||
AgentSessionRuntimeHost,
|
||||
type CreateAgentSessionRuntimeOptions,
|
||||
createAgentSessionRuntime,
|
||||
} from "./agent-session-runtime.js";
|
||||
export type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
@@ -349,6 +357,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
modelRegistry,
|
||||
initialActiveToolNames,
|
||||
extensionRunnerRef,
|
||||
sessionStartEvent: options.sessionStartEvent,
|
||||
});
|
||||
const extensionsResult = resourceLoader.getExtensions();
|
||||
|
||||
|
||||
@@ -103,10 +103,8 @@ export type {
|
||||
SessionBeforeSwitchEvent,
|
||||
SessionBeforeTreeEvent,
|
||||
SessionCompactEvent,
|
||||
SessionForkEvent,
|
||||
SessionShutdownEvent,
|
||||
SessionStartEvent,
|
||||
SessionSwitchEvent,
|
||||
SessionTreeEvent,
|
||||
SlashCommandInfo,
|
||||
SlashCommandSource,
|
||||
@@ -157,10 +155,14 @@ export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./co
|
||||
export { DefaultResourceLoader } from "./core/resource-loader.js";
|
||||
// SDK for programmatic usage
|
||||
export {
|
||||
type AgentSessionRuntimeBootstrap,
|
||||
AgentSessionRuntimeHost,
|
||||
type CreateAgentSessionOptions,
|
||||
type CreateAgentSessionResult,
|
||||
type CreateAgentSessionRuntimeOptions,
|
||||
// Factory
|
||||
createAgentSession,
|
||||
createAgentSessionRuntime,
|
||||
createBashTool,
|
||||
// Tool factories (for custom cwd)
|
||||
createCodingTools,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* createAgentSession() options. The SDK does the heavy lifting.
|
||||
*/
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
||||
import chalk from "chalk";
|
||||
import { createInterface } from "readline";
|
||||
@@ -15,6 +16,11 @@ import { buildInitialMessage } from "./cli/initial-message.js";
|
||||
import { listModels } from "./cli/list-models.js";
|
||||
import { selectSession } from "./cli/session-picker.js";
|
||||
import { APP_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
|
||||
import {
|
||||
type AgentSessionRuntimeBootstrap,
|
||||
AgentSessionRuntimeHost,
|
||||
createAgentSessionRuntime,
|
||||
} from "./core/agent-session-runtime.js";
|
||||
import { AuthStorage } from "./core/auth-storage.js";
|
||||
import { exportFromFile } from "./core/export-html/index.js";
|
||||
import type { LoadExtensionsResult } from "./core/extensions/index.js";
|
||||
@@ -24,7 +30,7 @@ import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/mod
|
||||
import { restoreStdout, takeOverStdout } from "./core/output-guard.js";
|
||||
import { DefaultPackageManager } from "./core/package-manager.js";
|
||||
import { DefaultResourceLoader } from "./core/resource-loader.js";
|
||||
import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js";
|
||||
import type { CreateAgentSessionOptions } from "./core/sdk.js";
|
||||
import { SessionManager } from "./core/session-manager.js";
|
||||
import { SettingsManager } from "./core/settings-manager.js";
|
||||
import { printTimings, resetTimings, time } from "./core/timings.js";
|
||||
@@ -597,6 +603,40 @@ function buildSessionOptions(
|
||||
return { options, cliThinkingFromModel };
|
||||
}
|
||||
|
||||
function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined {
|
||||
return paths?.map((value) => resolve(cwd, value));
|
||||
}
|
||||
|
||||
function buildRuntimeBootstrap(
|
||||
parsed: Args,
|
||||
cwd: string,
|
||||
agentDir: string,
|
||||
authStorage: AuthStorage,
|
||||
sessionOptions: CreateAgentSessionOptions,
|
||||
): AgentSessionRuntimeBootstrap {
|
||||
return {
|
||||
agentDir,
|
||||
authStorage,
|
||||
model: sessionOptions.model,
|
||||
thinkingLevel: sessionOptions.thinkingLevel,
|
||||
scopedModels: sessionOptions.scopedModels,
|
||||
tools: sessionOptions.tools,
|
||||
customTools: sessionOptions.customTools,
|
||||
resourceLoader: {
|
||||
additionalExtensionPaths: resolveCliPaths(cwd, parsed.extensions),
|
||||
additionalSkillPaths: resolveCliPaths(cwd, parsed.skills),
|
||||
additionalPromptTemplatePaths: resolveCliPaths(cwd, parsed.promptTemplates),
|
||||
additionalThemePaths: resolveCliPaths(cwd, parsed.themes),
|
||||
noExtensions: parsed.noExtensions,
|
||||
noSkills: parsed.noSkills,
|
||||
noPromptTemplates: parsed.noPromptTemplates,
|
||||
noThemes: parsed.noThemes,
|
||||
systemPrompt: parsed.systemPrompt,
|
||||
appendSystemPrompt: parsed.appendSystemPrompt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleConfigCommand(args: string[]): Promise<boolean> {
|
||||
if (args[0] !== "config") {
|
||||
return false;
|
||||
@@ -818,11 +858,7 @@ export async function main(args: string[]) {
|
||||
modelRegistry,
|
||||
settingsManager,
|
||||
);
|
||||
sessionOptions.authStorage = authStorage;
|
||||
sessionOptions.modelRegistry = modelRegistry;
|
||||
sessionOptions.resourceLoader = resourceLoader;
|
||||
|
||||
// Handle CLI --api-key as runtime override (not persisted)
|
||||
if (parsed.apiKey) {
|
||||
if (!sessionOptions.model) {
|
||||
console.error(
|
||||
@@ -833,7 +869,16 @@ export async function main(args: string[]) {
|
||||
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
|
||||
}
|
||||
|
||||
const { session, modelFallbackMessage } = await createAgentSession(sessionOptions);
|
||||
const runtimeBootstrap = buildRuntimeBootstrap(parsed, cwd, agentDir, authStorage, sessionOptions);
|
||||
const runtime = await createAgentSessionRuntime(runtimeBootstrap, {
|
||||
cwd: sessionManager?.getCwd() ?? cwd,
|
||||
sessionManager,
|
||||
});
|
||||
if (process.cwd() !== runtime.cwd) {
|
||||
process.chdir(runtime.cwd);
|
||||
}
|
||||
const runtimeHost = new AgentSessionRuntimeHost(runtimeBootstrap, runtime);
|
||||
const { session, modelFallbackMessage } = runtime;
|
||||
time("createAgentSession");
|
||||
|
||||
if (!isInteractive && !session.model) {
|
||||
@@ -861,7 +906,7 @@ export async function main(args: string[]) {
|
||||
|
||||
if (mode === "rpc") {
|
||||
printTimings();
|
||||
await runRpcMode(session);
|
||||
await runRpcMode(runtimeHost);
|
||||
} else if (isInteractive) {
|
||||
if (scopedModels.length > 0 && (parsed.verbose || !settingsManager.getQuietStartup())) {
|
||||
const modelList = scopedModels
|
||||
@@ -873,7 +918,7 @@ export async function main(args: string[]) {
|
||||
console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
|
||||
}
|
||||
|
||||
const interactiveMode = new InteractiveMode(session, {
|
||||
const interactiveMode = new InteractiveMode(runtimeHost, {
|
||||
migratedProviders,
|
||||
modelFallbackMessage,
|
||||
initialMessage,
|
||||
@@ -900,7 +945,7 @@ export async function main(args: string[]) {
|
||||
await interactiveMode.run();
|
||||
} else {
|
||||
printTimings();
|
||||
const exitCode = await runPrintMode(session, {
|
||||
const exitCode = await runPrintMode(runtimeHost, {
|
||||
mode,
|
||||
messages: parsed.messages,
|
||||
initialMessage,
|
||||
|
||||
@@ -38,6 +38,10 @@ export class FooterComponent implements Component {
|
||||
private footerData: ReadonlyFooterDataProvider,
|
||||
) {}
|
||||
|
||||
setSession(session: AgentSession): void {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
setAutoCompactEnabled(enabled: boolean): void {
|
||||
this.autoCompactEnabled = enabled;
|
||||
}
|
||||
@@ -86,7 +90,7 @@ export class FooterComponent implements Component {
|
||||
const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
|
||||
|
||||
// Replace home directory with ~
|
||||
let pwd = process.cwd();
|
||||
let pwd = this.session.sessionManager.getCwd();
|
||||
const home = process.env.HOME || process.env.USERPROFILE;
|
||||
if (home && pwd.startsWith(home)) {
|
||||
pwd = `~${pwd.slice(home.length)}`;
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
VERSION,
|
||||
} from "../../config.js";
|
||||
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
|
||||
import type { AgentSessionRuntimeHost } from "../../core/agent-session-runtime.js";
|
||||
import type {
|
||||
ExtensionContext,
|
||||
ExtensionRunner,
|
||||
@@ -144,7 +145,7 @@ export interface InteractiveModeOptions {
|
||||
}
|
||||
|
||||
export class InteractiveMode {
|
||||
private session: AgentSession;
|
||||
private runtimeHost: AgentSessionRuntimeHost;
|
||||
private ui: TUI;
|
||||
private chatContainer: Container;
|
||||
private pendingMessagesContainer: Container;
|
||||
@@ -242,6 +243,9 @@ export class InteractiveMode {
|
||||
private customHeader: (Component & { dispose?(): void }) | undefined = undefined;
|
||||
|
||||
// Convenience accessors
|
||||
private get session(): AgentSession {
|
||||
return this.runtimeHost.session;
|
||||
}
|
||||
private get agent() {
|
||||
return this.session.agent;
|
||||
}
|
||||
@@ -253,10 +257,10 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
constructor(
|
||||
session: AgentSession,
|
||||
runtimeHost: AgentSessionRuntimeHost,
|
||||
private options: InteractiveModeOptions = {},
|
||||
) {
|
||||
this.session = session;
|
||||
this.runtimeHost = runtimeHost;
|
||||
this.version = VERSION;
|
||||
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
|
||||
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
|
||||
@@ -277,9 +281,9 @@ export class InteractiveMode {
|
||||
this.editor = this.defaultEditor;
|
||||
this.editorContainer = new Container();
|
||||
this.editorContainer.addChild(this.editor as Component);
|
||||
this.footerDataProvider = new FooterDataProvider();
|
||||
this.footer = new FooterComponent(session, this.footerDataProvider);
|
||||
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
|
||||
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
|
||||
this.footer = new FooterComponent(this.session, this.footerDataProvider);
|
||||
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
|
||||
|
||||
// Load hide thinking block setting
|
||||
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
||||
@@ -412,7 +416,7 @@ export class InteractiveMode {
|
||||
// Setup autocomplete
|
||||
this.autocompleteProvider = new CombinedAutocompleteProvider(
|
||||
[...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],
|
||||
process.cwd(),
|
||||
this.sessionManager.getCwd(),
|
||||
fdPath,
|
||||
);
|
||||
this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider);
|
||||
@@ -524,7 +528,7 @@ export class InteractiveMode {
|
||||
this.isInitialized = true;
|
||||
|
||||
// Initialize extensions first so resources are shown before messages
|
||||
await this.initExtensions();
|
||||
await this.bindCurrentSessionExtensions();
|
||||
|
||||
// Render initial messages AFTER showing loaded resources
|
||||
this.renderInitialMessages();
|
||||
@@ -555,7 +559,7 @@ export class InteractiveMode {
|
||||
* Update terminal title with session name and cwd.
|
||||
*/
|
||||
private updateTerminalTitle(): void {
|
||||
const cwdBasename = path.basename(process.cwd());
|
||||
const cwdBasename = path.basename(this.sessionManager.getCwd());
|
||||
const sessionName = this.sessionManager.getSessionName();
|
||||
if (sessionName) {
|
||||
this.ui.terminal.setTitle(`π - ${sessionName} - ${cwdBasename}`);
|
||||
@@ -673,7 +677,7 @@ export class InteractiveMode {
|
||||
|
||||
try {
|
||||
const packageManager = new DefaultPackageManager({
|
||||
cwd: process.cwd(),
|
||||
cwd: this.sessionManager.getCwd(),
|
||||
agentDir: getAgentDir(),
|
||||
settingsManager: this.settingsManager,
|
||||
});
|
||||
@@ -1161,7 +1165,7 @@ export class InteractiveMode {
|
||||
/**
|
||||
* Initialize the extension system with TUI-based UI context.
|
||||
*/
|
||||
private async initExtensions(): Promise<void> {
|
||||
private async bindCurrentSessionExtensions(): Promise<void> {
|
||||
const uiContext = this.createExtensionUIContext();
|
||||
await this.session.bindExtensions({
|
||||
uiContext,
|
||||
@@ -1173,39 +1177,23 @@ export class InteractiveMode {
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
// Delegate to AgentSession (handles setup + agent state sync)
|
||||
const success = await this.session.newSession(options);
|
||||
if (!success) {
|
||||
return { cancelled: true };
|
||||
const result = await this.runtimeHost.newSession(options);
|
||||
if (!result.cancelled) {
|
||||
await this.handleRuntimeSessionChange();
|
||||
this.renderCurrentSessionState();
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
// Clear UI state
|
||||
this.chatContainer.clear();
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.compactionQueuedMessages = [];
|
||||
this.streamingComponent = undefined;
|
||||
this.streamingMessage = undefined;
|
||||
this.pendingTools.clear();
|
||||
|
||||
// Render any messages added via setup, or show empty session
|
||||
this.renderInitialMessages();
|
||||
this.ui.requestRender();
|
||||
|
||||
return { cancelled: false };
|
||||
return result;
|
||||
},
|
||||
fork: async (entryId) => {
|
||||
const result = await this.session.fork(entryId);
|
||||
if (result.cancelled) {
|
||||
return { cancelled: true };
|
||||
const result = await this.runtimeHost.fork(entryId);
|
||||
if (!result.cancelled) {
|
||||
await this.handleRuntimeSessionChange();
|
||||
this.renderCurrentSessionState();
|
||||
this.editor.setText(result.selectedText ?? "");
|
||||
this.showStatus("Forked to new session");
|
||||
}
|
||||
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages();
|
||||
this.editor.setText(result.selectedText);
|
||||
this.showStatus("Forked to new session");
|
||||
|
||||
return { cancelled: false };
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
navigateTree: async (targetId, options) => {
|
||||
const result = await this.session.navigateTree(targetId, {
|
||||
@@ -1224,7 +1212,6 @@ export class InteractiveMode {
|
||||
this.editor.setText(result.editorText);
|
||||
}
|
||||
this.showStatus("Navigated to selected point");
|
||||
|
||||
return { cancelled: false };
|
||||
},
|
||||
switchSession: async (sessionPath) => {
|
||||
@@ -1259,6 +1246,45 @@ export class InteractiveMode {
|
||||
this.showLoadedResources({ force: false });
|
||||
}
|
||||
|
||||
private applyRuntimeSettings(): void {
|
||||
this.footer.setSession(this.session);
|
||||
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
|
||||
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
|
||||
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
|
||||
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
|
||||
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
|
||||
const editorPaddingX = this.settingsManager.getEditorPaddingX();
|
||||
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
|
||||
this.defaultEditor.setPaddingX(editorPaddingX);
|
||||
this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);
|
||||
if (this.editor !== this.defaultEditor) {
|
||||
this.editor.setPaddingX?.(editorPaddingX);
|
||||
this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRuntimeSessionChange(): Promise<void> {
|
||||
this.resetExtensionUI();
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = undefined;
|
||||
this.applyRuntimeSettings();
|
||||
await this.bindCurrentSessionExtensions();
|
||||
this.subscribeToAgent();
|
||||
await this.updateAvailableProviderCount();
|
||||
this.updateEditorBorderColor();
|
||||
this.updateTerminalTitle();
|
||||
}
|
||||
|
||||
private renderCurrentSessionState(): void {
|
||||
this.chatContainer.clear();
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.compactionQueuedMessages = [];
|
||||
this.streamingComponent = undefined;
|
||||
this.streamingMessage = undefined;
|
||||
this.pendingTools.clear();
|
||||
this.renderInitialMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered tool definition by name (for custom rendering).
|
||||
*/
|
||||
@@ -1277,7 +1303,7 @@ export class InteractiveMode {
|
||||
const createContext = (): ExtensionContext => ({
|
||||
ui: this.createExtensionUIContext(),
|
||||
hasUI: true,
|
||||
cwd: process.cwd(),
|
||||
cwd: this.sessionManager.getCwd(),
|
||||
sessionManager: this.sessionManager,
|
||||
modelRegistry: this.session.modelRegistry,
|
||||
model: this.session.model,
|
||||
@@ -2305,6 +2331,7 @@ export class InteractiveMode {
|
||||
},
|
||||
this.getRegisteredToolDefinition(content.name),
|
||||
this.ui,
|
||||
this.sessionManager.getCwd(),
|
||||
);
|
||||
component.setExpanded(this.toolOutputExpanded);
|
||||
this.chatContainer.addChild(component);
|
||||
@@ -2372,6 +2399,7 @@ export class InteractiveMode {
|
||||
},
|
||||
this.getRegisteredToolDefinition(event.toolName),
|
||||
this.ui,
|
||||
this.sessionManager.getCwd(),
|
||||
);
|
||||
component.setExpanded(this.toolOutputExpanded);
|
||||
this.chatContainer.addChild(component);
|
||||
@@ -2681,6 +2709,7 @@ export class InteractiveMode {
|
||||
{ showImages: this.settingsManager.getShowImages() },
|
||||
this.getRegisteredToolDefinition(content.name),
|
||||
this.ui,
|
||||
this.sessionManager.getCwd(),
|
||||
);
|
||||
component.setExpanded(this.toolOutputExpanded);
|
||||
this.chatContainer.addChild(component);
|
||||
@@ -2779,14 +2808,7 @@ export class InteractiveMode {
|
||||
private async shutdown(): Promise<void> {
|
||||
if (this.isShuttingDown) return;
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// Emit shutdown event to extensions
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
if (extensionRunner?.hasHandlers("session_shutdown")) {
|
||||
await extensionRunner.emit({
|
||||
type: "session_shutdown",
|
||||
});
|
||||
}
|
||||
await this.runtimeHost.dispose();
|
||||
|
||||
// Wait for any pending renders to complete
|
||||
// requestRender() uses process.nextTick(), so we wait one tick
|
||||
@@ -3605,17 +3627,15 @@ export class InteractiveMode {
|
||||
const selector = new UserMessageSelectorComponent(
|
||||
userMessages.map((m) => ({ id: m.entryId, text: m.text })),
|
||||
async (entryId) => {
|
||||
const result = await this.session.fork(entryId);
|
||||
const result = await this.runtimeHost.fork(entryId);
|
||||
if (result.cancelled) {
|
||||
// Extension cancelled the fork
|
||||
done();
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages();
|
||||
this.editor.setText(result.selectedText);
|
||||
await this.handleRuntimeSessionChange();
|
||||
this.renderCurrentSessionState();
|
||||
this.editor.setText(result.selectedText ?? "");
|
||||
done();
|
||||
this.showStatus("Branched to new session");
|
||||
},
|
||||
@@ -3792,26 +3812,17 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
private async handleResumeSession(sessionPath: string): Promise<void> {
|
||||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
// Clear UI state
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.compactionQueuedMessages = [];
|
||||
this.streamingComponent = undefined;
|
||||
this.streamingMessage = undefined;
|
||||
this.pendingTools.clear();
|
||||
|
||||
// Switch session via AgentSession (emits extension session events)
|
||||
await this.session.switchSession(sessionPath);
|
||||
|
||||
// Clear and re-render the chat
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages();
|
||||
const result = await this.runtimeHost.switchSession(sessionPath);
|
||||
if (result.cancelled) {
|
||||
return;
|
||||
}
|
||||
await this.handleRuntimeSessionChange();
|
||||
this.renderCurrentSessionState();
|
||||
this.showStatus("Resumed session");
|
||||
}
|
||||
|
||||
@@ -4063,29 +4074,18 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
// Clear UI state
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.compactionQueuedMessages = [];
|
||||
this.streamingComponent = undefined;
|
||||
this.streamingMessage = undefined;
|
||||
this.pendingTools.clear();
|
||||
|
||||
const success = await this.session.importFromJsonl(inputPath);
|
||||
if (!success) {
|
||||
this.showWarning("Import cancelled");
|
||||
const result = await this.runtimeHost.importFromJsonl(inputPath);
|
||||
if (result.cancelled) {
|
||||
this.showStatus("Import cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear and re-render the chat
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages();
|
||||
await this.handleRuntimeSessionChange();
|
||||
this.renderCurrentSessionState();
|
||||
this.showStatus(`Session imported from: ${inputPath}`);
|
||||
} catch (error: unknown) {
|
||||
this.showError(`Failed to import session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
@@ -4427,25 +4427,17 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
private async handleClearCommand(): Promise<void> {
|
||||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
// New session via session (emits extension session events)
|
||||
await this.session.newSession();
|
||||
|
||||
// Clear UI state
|
||||
this.headerContainer.clear();
|
||||
this.chatContainer.clear();
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.compactionQueuedMessages = [];
|
||||
this.streamingComponent = undefined;
|
||||
this.streamingMessage = undefined;
|
||||
this.pendingTools.clear();
|
||||
|
||||
const result = await this.runtimeHost.newSession();
|
||||
if (result.cancelled) {
|
||||
return;
|
||||
}
|
||||
await this.handleRuntimeSessionChange();
|
||||
this.renderCurrentSessionState();
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
|
||||
this.ui.requestRender();
|
||||
@@ -4511,7 +4503,7 @@ export class InteractiveMode {
|
||||
type: "user_bash",
|
||||
command,
|
||||
excludeFromContext,
|
||||
cwd: process.cwd(),
|
||||
cwd: this.sessionManager.getCwd(),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentSession } from "../core/agent-session.js";
|
||||
import type { AgentSessionRuntimeHost } from "../core/agent-session-runtime.js";
|
||||
import { flushRawStdout, writeRawStdout } from "../core/output-guard.js";
|
||||
|
||||
/**
|
||||
@@ -28,44 +28,46 @@ export interface PrintModeOptions {
|
||||
* Run in print (single-shot) mode.
|
||||
* Sends prompts to the agent and outputs the result.
|
||||
*/
|
||||
export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise<number> {
|
||||
export async function runPrintMode(runtimeHost: AgentSessionRuntimeHost, options: PrintModeOptions): Promise<number> {
|
||||
const { mode, messages = [], initialMessage, initialImages } = options;
|
||||
let exitCode = 0;
|
||||
let session = runtimeHost.session;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
try {
|
||||
if (mode === "json") {
|
||||
const header = session.sessionManager.getHeader();
|
||||
if (header) {
|
||||
writeRawStdout(`${JSON.stringify(header)}\n`);
|
||||
}
|
||||
}
|
||||
// Set up extensions for print mode (no UI)
|
||||
const rebindSession = async (): Promise<void> => {
|
||||
session = runtimeHost.session;
|
||||
await session.bindExtensions({
|
||||
commandContextActions: {
|
||||
waitForIdle: () => session.agent.waitForIdle(),
|
||||
newSession: async (options) => {
|
||||
const success = await session.newSession({ parentSession: options?.parentSession });
|
||||
if (success && options?.setup) {
|
||||
await options.setup(session.sessionManager);
|
||||
newSession: async (newSessionOptions) => {
|
||||
const result = await runtimeHost.newSession(newSessionOptions);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return { cancelled: !success };
|
||||
return result;
|
||||
},
|
||||
fork: async (entryId) => {
|
||||
const result = await session.fork(entryId);
|
||||
const result = await runtimeHost.fork(entryId);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
navigateTree: async (targetId, options) => {
|
||||
navigateTree: async (targetId, navigateOptions) => {
|
||||
const result = await session.navigateTree(targetId, {
|
||||
summarize: options?.summarize,
|
||||
customInstructions: options?.customInstructions,
|
||||
replaceInstructions: options?.replaceInstructions,
|
||||
label: options?.label,
|
||||
summarize: navigateOptions?.summarize,
|
||||
customInstructions: navigateOptions?.customInstructions,
|
||||
replaceInstructions: navigateOptions?.replaceInstructions,
|
||||
label: navigateOptions?.label,
|
||||
});
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
switchSession: async (sessionPath) => {
|
||||
const success = await session.switchSession(sessionPath);
|
||||
return { cancelled: !success };
|
||||
const result = await runtimeHost.switchSession(sessionPath);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
reload: async () => {
|
||||
await session.reload();
|
||||
@@ -76,38 +78,42 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
||||
},
|
||||
});
|
||||
|
||||
// Always subscribe to enable session persistence via _handleAgentEvent
|
||||
session.subscribe((event) => {
|
||||
// In JSON mode, output all events
|
||||
unsubscribe?.();
|
||||
unsubscribe = session.subscribe((event) => {
|
||||
if (mode === "json") {
|
||||
writeRawStdout(`${JSON.stringify(event)}\n`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
if (mode === "json") {
|
||||
const header = session.sessionManager.getHeader();
|
||||
if (header) {
|
||||
writeRawStdout(`${JSON.stringify(header)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
await rebindSession();
|
||||
|
||||
// Send initial message with attachments
|
||||
if (initialMessage) {
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
}
|
||||
|
||||
// Send remaining messages
|
||||
for (const message of messages) {
|
||||
await session.prompt(message);
|
||||
}
|
||||
|
||||
// In text mode, output final response
|
||||
if (mode === "text") {
|
||||
const state = session.state;
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
|
||||
if (lastMessage?.role === "assistant") {
|
||||
const assistantMsg = lastMessage as AssistantMessage;
|
||||
|
||||
// Check for error/aborted
|
||||
if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
|
||||
console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
|
||||
exitCode = 1;
|
||||
} else {
|
||||
// Output text content
|
||||
for (const content of assistantMsg.content) {
|
||||
if (content.type === "text") {
|
||||
writeRawStdout(`${content.text}\n`);
|
||||
@@ -119,13 +125,8 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
||||
|
||||
return exitCode;
|
||||
} finally {
|
||||
const extensionRunner = session.extensionRunner;
|
||||
if (extensionRunner?.hasHandlers("session_shutdown")) {
|
||||
await extensionRunner.emit({ type: "session_shutdown" });
|
||||
}
|
||||
|
||||
// Ensure stdout is fully flushed before returning
|
||||
// This prevents race conditions where the process exits before all output is written
|
||||
unsubscribe?.();
|
||||
await runtimeHost.dispose();
|
||||
await flushRawStdout();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import type { AgentSession } from "../../core/agent-session.js";
|
||||
import type { AgentSessionRuntimeHost } from "../../core/agent-session-runtime.js";
|
||||
import type {
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
@@ -43,8 +43,10 @@ export type {
|
||||
* Run in RPC mode.
|
||||
* Listens for JSON commands on stdin, outputs events and responses on stdout.
|
||||
*/
|
||||
export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
export async function runRpcMode(runtimeHost: AgentSessionRuntimeHost): Promise<never> {
|
||||
takeOverStdout();
|
||||
let session = runtimeHost.session;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
|
||||
writeRawStdout(serializeJsonLine(obj));
|
||||
@@ -280,49 +282,61 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
},
|
||||
});
|
||||
|
||||
// Set up extensions with RPC-based UI context
|
||||
await session.bindExtensions({
|
||||
uiContext: createExtensionUIContext(),
|
||||
commandContextActions: {
|
||||
waitForIdle: () => session.agent.waitForIdle(),
|
||||
newSession: async (options) => {
|
||||
// Delegate to AgentSession (handles setup + agent state sync)
|
||||
const success = await session.newSession(options);
|
||||
return { cancelled: !success };
|
||||
const rebindSession = async (): Promise<void> => {
|
||||
session = runtimeHost.session;
|
||||
await session.bindExtensions({
|
||||
uiContext: createExtensionUIContext(),
|
||||
commandContextActions: {
|
||||
waitForIdle: () => session.agent.waitForIdle(),
|
||||
newSession: async (options) => {
|
||||
const result = await runtimeHost.newSession(options);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
fork: async (entryId) => {
|
||||
const result = await runtimeHost.fork(entryId);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
navigateTree: async (targetId, options) => {
|
||||
const result = await session.navigateTree(targetId, {
|
||||
summarize: options?.summarize,
|
||||
customInstructions: options?.customInstructions,
|
||||
replaceInstructions: options?.replaceInstructions,
|
||||
label: options?.label,
|
||||
});
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
switchSession: async (sessionPath) => {
|
||||
const result = await runtimeHost.switchSession(sessionPath);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
reload: async () => {
|
||||
await session.reload();
|
||||
},
|
||||
},
|
||||
fork: async (entryId) => {
|
||||
const result = await session.fork(entryId);
|
||||
return { cancelled: result.cancelled };
|
||||
shutdownHandler: () => {
|
||||
shutdownRequested = true;
|
||||
},
|
||||
navigateTree: async (targetId, options) => {
|
||||
const result = await session.navigateTree(targetId, {
|
||||
summarize: options?.summarize,
|
||||
customInstructions: options?.customInstructions,
|
||||
replaceInstructions: options?.replaceInstructions,
|
||||
label: options?.label,
|
||||
});
|
||||
return { cancelled: result.cancelled };
|
||||
onError: (err) => {
|
||||
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
|
||||
},
|
||||
switchSession: async (sessionPath) => {
|
||||
const success = await session.switchSession(sessionPath);
|
||||
return { cancelled: !success };
|
||||
},
|
||||
reload: async () => {
|
||||
await session.reload();
|
||||
},
|
||||
},
|
||||
shutdownHandler: () => {
|
||||
shutdownRequested = true;
|
||||
},
|
||||
onError: (err) => {
|
||||
output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Output all agent events as JSON
|
||||
session.subscribe((event) => {
|
||||
output(event);
|
||||
});
|
||||
unsubscribe?.();
|
||||
unsubscribe = session.subscribe((event) => {
|
||||
output(event);
|
||||
});
|
||||
};
|
||||
|
||||
await rebindSession();
|
||||
|
||||
// Handle a single command
|
||||
const handleCommand = async (command: RpcCommand): Promise<RpcResponse> => {
|
||||
@@ -364,8 +378,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
|
||||
case "new_session": {
|
||||
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
|
||||
const cancelled = !(await session.newSession(options));
|
||||
return success(id, "new_session", { cancelled });
|
||||
const result = await runtimeHost.newSession(options);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return success(id, "new_session", result);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
@@ -505,12 +522,18 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
}
|
||||
|
||||
case "switch_session": {
|
||||
const cancelled = !(await session.switchSession(command.sessionPath));
|
||||
return success(id, "switch_session", { cancelled });
|
||||
const result = await runtimeHost.switchSession(command.sessionPath);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return success(id, "switch_session", result);
|
||||
}
|
||||
|
||||
case "fork": {
|
||||
const result = await session.fork(command.entryId);
|
||||
const result = await runtimeHost.fork(command.entryId);
|
||||
if (!result.cancelled) {
|
||||
await rebindSession();
|
||||
}
|
||||
return success(id, "fork", { text: result.selectedText, cancelled: result.cancelled });
|
||||
}
|
||||
|
||||
@@ -592,11 +615,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
let detachInput = () => {};
|
||||
|
||||
async function shutdown(): Promise<never> {
|
||||
const currentRunner = session.extensionRunner;
|
||||
if (currentRunner?.hasHandlers("session_shutdown")) {
|
||||
await currentRunner.emit({ type: "session_shutdown" });
|
||||
}
|
||||
|
||||
unsubscribe?.();
|
||||
await runtimeHost.dispose();
|
||||
detachInput();
|
||||
process.stdin.pause();
|
||||
process.exit(0);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
172
packages/coding-agent/test/agent-session-runtime-events.test.ts
Normal file
172
packages/coding-agent/test/agent-session-runtime-events.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ function createSession(options: {
|
||||
sessionManager: {
|
||||
getEntries: () => entries,
|
||||
getSessionName: () => options.sessionName,
|
||||
getCwd: () => "/tmp/project",
|
||||
},
|
||||
getContextUsage: () => ({ contextWindow: 200_000, percent: 12.3 }),
|
||||
modelRegistry: {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user