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

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

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

View File

@@ -226,9 +226,8 @@ Run `npm install` in the extension directory, then imports from `node_modules/`
### Lifecycle Overview
```
pi starts (CLI only)
pi starts
├─► session_directory (CLI startup only, no ctx)
├─► session_start { reason: "startup" }
└─► resources_discover { reason: "startup" }
@@ -311,27 +310,6 @@ pi.on("resources_discover", async (event, _ctx) => {
See [session.md](session.md) for session storage internals and the SessionManager API.
#### session_directory
Fired by the `pi` CLI during startup session resolution, before the initial session manager is created.
This event is:
- CLI-only. It is not emitted in SDK mode.
- Startup-only. It is not emitted for later interactive `/new` or `/resume` actions.
- Lower priority than `--session-dir` and `sessionDir` in `settings.json`.
- Special-cased to receive no `ctx` argument.
If multiple extensions return `sessionDir`, the last one wins.
Combined precedence is: `--session-dir` CLI flag, then `sessionDir` in settings, then extension `session_directory` hooks.
```typescript
pi.on("session_directory", async (event) => {
return {
sessionDir: `/tmp/pi-sessions/${encodeURIComponent(event.cwd)}`,
};
});
```
#### session_start
Fired when a session is started, loaded, or reloaded.
@@ -759,9 +737,7 @@ Transforms chain across handlers. See [input-transform.ts](../examples/extension
## ExtensionContext
All handlers except `session_directory` receive `ctx: ExtensionContext`.
`session_directory` is a CLI startup hook and receives only the event.
All handlers receive `ctx: ExtensionContext`.
### ctx.ui

View File

@@ -115,33 +115,46 @@ interface AgentSession {
}
```
Session replacement APIs such as new-session, resume, fork, and import live on `AgentSessionRuntimeHost`, not on `AgentSession`.
Session replacement APIs such as new-session, resume, fork, and import live on `AgentSessionRuntime`, not on `AgentSession`.
### createAgentSessionRuntime() and AgentSessionRuntimeHost
### createAgentSessionRuntime() and AgentSessionRuntime
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.
`createAgentSessionRuntime()` takes a runtime factory plus the initial cwd/session target. The factory closes over process-global fixed inputs, recreates cwd-bound services for the effective cwd, resolves session options against those services, and returns a full runtime result.
```typescript
import {
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const bootstrap = {
// Optional: authStorage, model, thinkingLevel, tools, customTools, resourceLoader
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(bootstrap, {
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
```
`createAgentSessionRuntime()` returns an internal runtime bundle. `AgentSessionRuntimeHost` owns replacement of that bundle across:
`AgentSessionRuntime` owns replacement of the active runtime across:
- `newSession()`
- `switchSession()`
@@ -150,18 +163,20 @@ const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
Important behavior:
- `runtimeHost.session` changes after those operations
- `runtime.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
- if you use extensions, call `runtime.session.bindExtensions(...)` again for the new session
- creation returns diagnostics on `runtime.diagnostics`
- if runtime creation or replacement fails, the method throws and the caller decides how to handle it
```typescript
let session = runtimeHost.session;
let session = runtime.session;
let unsubscribe = session.subscribe(() => {});
await runtimeHost.newSession();
await runtime.newSession();
unsubscribe();
session = runtimeHost.session;
session = runtime.session;
unsubscribe = session.subscribe(() => {});
```
@@ -646,9 +661,12 @@ Sessions use a tree structure with `id`/`parentId` linking, enabling in-place br
```typescript
import {
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSession,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
SessionManager,
} from "@mariozechner/pi-coding-agent";
@@ -680,21 +698,33 @@ const currentProjectSessions = await SessionManager.list(process.cwd());
const allSessions = await SessionManager.listAll(process.cwd());
// Session replacement API for /new, /resume, /fork, and import flows.
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
// Replace the active session with a fresh one
await runtimeHost.newSession();
await runtime.newSession();
// Replace the active session with another saved session
await runtimeHost.switchSession("/path/to/session.jsonl");
await runtime.switchSession("/path/to/session.jsonl");
// Replace the active session with a fork from a specific entry
await runtimeHost.fork("entry-id");
await runtime.fork("entry-id");
```
**SessionManager tree API:**
@@ -916,20 +946,30 @@ Full TUI interactive mode with editor, chat history, and all built-in commands:
```typescript
import {
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
InteractiveMode,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
const mode = new InteractiveMode(runtimeHost, {
const mode = new InteractiveMode(runtime, {
migratedProviders: [],
modelFallbackMessage: undefined,
initialMessage: "Hello",
@@ -946,20 +986,30 @@ Single-shot mode: send prompts, output result, exit:
```typescript
import {
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
runPrintMode,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runPrintMode(runtimeHost, {
await runPrintMode(runtime, {
mode: "text",
initialMessage: "Hello",
initialImages: [],
@@ -973,20 +1023,30 @@ JSON-RPC mode for subprocess integration:
```typescript
import {
AgentSessionRuntimeHost,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
runRpcMode,
SessionManager,
} from "@mariozechner/pi-coding-agent";
const bootstrap = {};
const runtime = await createAgentSessionRuntime(bootstrap, {
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
await runRpcMode(runtimeHost);
await runRpcMode(runtime);
```
See [RPC documentation](rpc.md) for the JSON protocol.
@@ -1020,7 +1080,7 @@ The main entry point exports:
// Factory
createAgentSession
createAgentSessionRuntime
AgentSessionRuntimeHost
AgentSessionRuntime
// Auth and Models
AuthStorage

View File

@@ -137,7 +137,7 @@ When a provider requests a retry delay longer than `maxDelayMs` (e.g., Google's
{ "sessionDir": ".pi/sessions" }
```
When multiple sources specify a session directory, `--session-dir` CLI flag takes precedence, then `sessionDir` in settings.json, then extension hooks.
When multiple sources specify a session directory, `--session-dir` CLI flag takes precedence over `sessionDir` in settings.json.
### Model Cycling