# Session runtime

> AgentSessionRuntime services, lifecycle events (settled, start notify, event bus), and embedding sessions without the interactive TUI.

- Repository: earendil-works/pi
- GitHub: https://github.com/earendil-works/pi
- Human docs: https://grok-wiki.com/public/docs/earendil-works-pi-7860a70e44d1
- Complete Markdown: https://grok-wiki.com/public/docs/earendil-works-pi-7860a70e44d1/llms-full.txt

## Source Files

- `packages/coding-agent/src/core/agent-session-runtime.ts`
- `packages/coding-agent/src/core/agent-session-services.ts`
- `packages/coding-agent/examples/sdk/13-session-runtime.ts`
- `packages/coding-agent/test/suite/agent-session-runtime.test.ts`
- `packages/coding-agent/test/agent-session-runtime-events.test.ts`
- `packages/coding-agent/test/suite/regressions/6363-agent-settled-event.test.ts`

---

---
title: "Session runtime"
description: "AgentSessionRuntime services, lifecycle events (settled, start notify, event bus), and embedding sessions without the interactive TUI."
---

`AgentSessionRuntime` owns the active `AgentSession` plus its cwd-bound services. Session replacement (`newSession`, `switchSession`, `fork`, `importFromJsonl`) lives on the runtime, not on `AgentSession`. Interactive, print/JSON, and RPC modes all build through `createAgentSessionRuntime`; embedders use the same factories without the TUI.

## Layers

| Layer | Role |
| --- | --- |
| `AgentSessionServices` | Cwd-bound infrastructure: `cwd`, `agentDir`, `modelRuntime`, `settingsManager`, `resourceLoader`, `diagnostics` |
| `AgentSession` | One conversation: prompt queue, tools, compaction, tree navigation, `subscribe` events |
| `AgentSessionRuntime` | Current session + services; tears down and recreates both on replace |

```mermaid
flowchart TB
  subgraph Host["Host / mode / SDK"]
    Factory["CreateAgentSessionRuntimeFactory"]
    Rebind["setRebindSession / bindExtensions / subscribe"]
  end

  subgraph Runtime["AgentSessionRuntime"]
    Sess["session: AgentSession"]
    Svc["services: AgentSessionServices"]
    Diag["diagnostics"]
  end

  subgraph Services["AgentSessionServices"]
    MR["modelRuntime"]
    SM["settingsManager"]
    RL["resourceLoader"]
  end

  Factory -->|"createRuntime(cwd, sessionManager, …)"| Runtime
  Svc --> Services
  Sess -->|"subscribe(AgentSessionEvent)"| Rebind
  Runtime -->|"new / resume / fork / import"| Factory
  Rebind -->|"after replace"| Sess
```

Services are **recreated** when the effective session cwd changes. CLI resource paths should be absolute before they reach service creation so later switches do not reinterpret them.

## Factories

### `createAgentSessionServices`

Builds infrastructure only (no `AgentSession`):

```typescript
const services = await createAgentSessionServices({
  cwd,
  agentDir,                 // optional; default agent dir
  settingsManager,          // optional
  modelRuntime,             // optional; else auth.json + models.json under agentDir
  modelRuntimeSignal,       // optional AbortSignal
  extensionFlagValues,      // Map of --flag values for extensions
  resourceLoaderOptions,    // extensions, skills, themes, context files, …
  resourceLoaderReloadOptions,
});
```

Side effects during creation:

- Loads extensions via `DefaultResourceLoader.reload`
- Registers pending providers / native providers on `modelRuntime`
- Refreshes model runtime with `{ allowNetwork: false }`
- Applies extension flag values; unknown or mistyped flags become diagnostics

### `createAgentSessionFromServices`

Builds an `AgentSession` after model, thinking, and tool options are resolved against those services:

```typescript
const created = await createAgentSessionFromServices({
  services,
  sessionManager,
  sessionStartEvent,  // optional; default { type: "session_start", reason: "startup" }
  model,
  thinkingLevel,
  scopedModels,
  tools,
  excludeTools,
  noTools,
  customTools,
});
```

### `createAgentSessionRuntime`

Stores a `CreateAgentSessionRuntimeFactory` and runs it for the initial target, then reuses it for every replacement:

```typescript
type CreateAgentSessionRuntimeFactory = (options: {
  cwd: string;
  agentDir: string;
  sessionManager: SessionManager;
  sessionStartEvent?: SessionStartEvent;
  projectTrustContext?: ProjectTrustContext;
}) => Promise<CreateAgentSessionRuntimeResult>;
```

`CreateAgentSessionRuntimeResult` extends `CreateAgentSessionResult` with `services` and `diagnostics`.

Minimal embed pattern (`examples/sdk/13-session-runtime.ts`):

```typescript
import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

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()),
});
```

For a single fixed session without replace, `createAgentSession()` is enough. Prefer the runtime when the host must support new / resume / fork / import.

## Runtime API

| Member | Description |
| --- | --- |
| `session` | Active `AgentSession` (identity changes after replace) |
| `services` | Current `AgentSessionServices` |
| `cwd` | `services.cwd` |
| `diagnostics` | Last creation diagnostics (`info` \| `warning` \| `error`) |
| `modelFallbackMessage` | Optional restore warning from session creation |
| `setRebindSession(fn)` | Async hook after a successful replace; re-attach host bindings |
| `setBeforeSessionInvalidate(fn)` | Sync hook after `session_shutdown`, before dispose; for non-yielding host teardown |
| `newSession(options?)` | New empty session (persisted or in-memory matching current manager) |
| `switchSession(path, options?)` | Open/resume another session JSONL |
| `fork(entryId, options?)` | Branch; `position: "before"` (default) or `"at"` (clone leaf) |
| `importFromJsonl(path, cwdOverride?)` | Copy import into session dir and resume |
| `dispose()` | `session_shutdown` reason `"quit"`, then dispose session |

Replacement methods return `{ cancelled: boolean, … }`. Cancellation from `session_before_switch` / `session_before_fork` leaves the current session intact.

### Teardown order

For every replace:

1. `session_before_switch` or `session_before_fork` (cancellable)
2. `session.abort()` then wait until idle — persists aborted tool results on the outgoing session
3. `session_shutdown` with reason `new` \| `resume` \| `fork`
4. `beforeSessionInvalidate` (sync)
5. `session.dispose()`
6. `createRuntime(...)` for the next target
7. `rebindSession` then optional `withSession(ctx)`

`dispose()` on the runtime uses shutdown reason `"quit"` (no abort of an in-flight run first).

### Host rebind

After replace, `runtime.session` is a new instance. Re-subscribe and re-`bindExtensions`:

```typescript
let unsubscribe: (() => void) | undefined;

async function bindSession() {
  unsubscribe?.();
  const session = runtime.session;
  await session.bindExtensions({
    mode: "rpc", // or "print" / "json" / interactive UI context
    commandContextActions: {
      waitForIdle: () => session.waitForIdle(),
      newSession: (opts) => runtime.newSession(opts),
      fork: (entryId, opts) => runtime.fork(entryId, opts),
      switchSession: (path, opts) => runtime.switchSession(path, opts),
      navigateTree: (id, opts) => session.navigateTree(id, opts),
      reload: () => session.reload(),
    },
  });
  unsubscribe = session.subscribe((event) => {
    /* stream UI / JSON */
  });
}

runtime.setRebindSession(async () => {
  await bindSession();
});

await bindSession();
```

Print and RPC modes call `setRebindSession` the same way. Extension code that captures `pi` or command `ctx` across replace must move post-replace work into `withSession`; the old extension context throws if used after invalidation.

## Lifecycle events

### Session replace (extensions)

| Event | When | Cancel? |
| --- | --- | --- |
| `session_before_switch` | Before `new` / `resume` (including import) | Yes → `{ cancel: true }` |
| `session_before_fork` | Before fork/clone | Yes |
| `session_shutdown` | Old runtime teardown (`new`, `resume`, `fork`, `reload`, `quit`) | No |
| `session_start` | New session ready (`startup`, `reload`, `new`, `resume`, `fork`) | No |

Typical order for `/new`:

```text
session_before_switch { reason: "new" }
session_shutdown      { reason: "new", targetSessionFile? }
session_start         { reason: "new", previousSessionFile? }
```

`session_start` for initial process start uses `reason: "startup"` when no `sessionStartEvent` is passed into the factory.

### Agent run: start notify vs settled

| Signal | Surface | Meaning |
| --- | --- | --- |
| `preflightResult(success)` | `PromptOptions` on `session.prompt` | Prompt accepted/queued (`true`) or rejected before acceptance (`false`). Fires before `prompt()` resolves. Used by RPC to ACK the `prompt` command. |
| `before_agent_start` | Extension | After acceptance, before the agent loop; can adjust system prompt / inject messages |
| `agent_start` / `agent_end` | Extension + public stream | Low-level agent loop; may still retry, compact, or drain follow-ups |
| `agent_settled` | Extension + `session.subscribe` | No automatic retry, compaction retry, or queued continuation remains |
| `waitForIdle()` / `isIdle` | Session + extension `ctx` | Promise/API for the same idle boundary as `agent_settled` |

```mermaid
sequenceDiagram
  participant Host
  participant Session as AgentSession
  participant Ext as Extensions
  participant Agent as Agent loop

  Host->>Session: prompt(text, { preflightResult })
  Session-->>Host: preflightResult(true)
  Session->>Ext: before_agent_start
  Session->>Agent: run (+ retries / follow-ups)
  Agent-->>Session: agent_end (may willRetry)
  Note over Session: continue while retry / queued follow-up
  Session->>Ext: agent_settled
  Session-->>Host: subscribe({ type: "agent_settled" })
  Session-->>Host: waitForIdle resolves
  Session-->>Host: prompt() resolves
```

Rules verified by tests:

- One `agent_settled` after a successful auto-retry sequence (not one per failed attempt)
- Follow-ups queued from `agent_end` handlers run **before** settle
- `ctx.waitForIdle()` waits for session-level settlement, not merely the first `agent_end`
- Switching sessions mid-tool aborts the turn first so the outgoing session gets a tool result, not a dangling call

Public `AgentSessionEvent` types include agent stream events plus session-only:

- `agent_settled`
- `queue_update` (`steering`, `followUp`)
- `compaction_start` / `compaction_end`
- `auto_retry_start` / `auto_retry_end`
- `entry_appended`, `session_info_changed`, `thinking_level_changed`, `bash_execution_update`
- `agent_end` with `willRetry: boolean`

## Event bus

`createEventBus()` is a small channel bus for **inter-extension** messaging (`pi.events`), not a substitute for `session.subscribe`.

```typescript
// EventBus
emit(channel: string, data: unknown): void
on(channel: string, handler: (data: unknown) => void): () => void  // unsubscribe
// EventBusController also: clear()
```

- Shared per resource loader / extension load; handlers log errors and continue
- Subscriptions are tracked and cleared on extension unload
- Example: `examples/extensions/event-bus.ts` uses `pi.events.on` / `pi.events.emit`

```typescript
pi.events.on("my:notification", (data) => { /* ... */ });
pi.events.emit("my:notification", { message: "Session started", from: "my-ext" });
```

## Embedding without the TUI

| Need | API |
| --- | --- |
| Single session | `createAgentSession` or services + `createAgentSessionFromServices` |
| Replace sessions | `createAgentSessionRuntime` + factory |
| Stream output | `session.subscribe` |
| Idle / completion | `await session.prompt(...)` and/or `await session.waitForIdle()` / `agent_settled` |
| Extension commands + UI dialogs | `session.bindExtensions({ mode, uiContext?, commandContextActions })` |
| Shutdown | `await runtime.dispose()` |

Provider and auth stay BYOK: pass a custom `ModelRuntime`, model, and credentials; no hosted connector is required.

`SessionManager.create(cwd)` for disk-backed JSONL, or `SessionManager.inMemory(cwd)` for ephemeral runs. Runtime `newSession` preserves that choice.

## Diagnostics

```typescript
interface AgentSessionRuntimeDiagnostic {
  type: "info" | "warning" | "error";
  message: string;
}
```

Returned on services and on the runtime after each create/replace. The app layer decides whether to print, fail startup, or ignore. Runtime creation throws on hard failures (missing import path, invalid fork entry, missing session cwd); the caller owns UX.

| Error | Cause |
| --- | --- |
| `SessionImportFileNotFoundError` | `importFromJsonl` path missing |
| `MissingSessionCwdError` / assert | Imported or resumed session cwd unusable without override |
| `Invalid entry ID for forking` | Fork target not found / not a user message when `position: "before"` |
| Unsaved session fork | Persisted session file not flushed yet — wait for first assistant response before fork/clone |

## Constraints

- **Do not** call session-replace methods on `AgentSession`; use `AgentSessionRuntime`.
- **Do** re-`subscribe` and re-`bindExtensions` after every successful replace.
- **Do not** keep using a captured extension `ctx` after replace/reload.
- Prompt during compaction throws until compaction finishes.
- Streaming `prompt` without `streamingBehavior: "steer" | "followUp"` throws.
- `preflightResult(false)` only covers pre-acceptance failures; later stream errors use normal events/messages.

## Verification

<Steps>
  <Step title="Construct a runtime">
    Use the factory pattern from `examples/sdk/13-session-runtime.ts`. Confirm `runtime.session` and `runtime.services.cwd`.
  </Step>
  <Step title="Subscribe and prompt">
    `session.subscribe` for `message_update` / `agent_settled`. `await session.prompt("...")` should resolve only after settle.
  </Step>
  <Step title="Replace and rebind">
    `await runtime.newSession()` then rebind. Confirm event order `session_before_switch` → `session_shutdown` → `session_start` if extensions listen.
  </Step>
  <Step title="Idle wait">
    Mid-tool, `waitForIdle` must not resolve until tools and follow-ups finish; one `agent_settled` after retries.
  </Step>
  <Step title="Dispose">
    `await runtime.dispose()` emits `session_shutdown` with reason `quit`.
  </Step>
</Steps>

## Related pages

<CardGroup cols={2}>
  <Card title="Agent sessions" href="/agent-sessions">
    Prompt queue, concurrent behavior, and turn ownership on AgentSession.
  </Card>
  <Card title="SDK" href="/sdk">
    Package main export, createAgentSession, and embed options.
  </Card>
  <Card title="SDK examples" href="/sdk-examples">
    Copy-paste recipes including session runtime example 13.
  </Card>
  <Card title="RPC mode" href="/rpc-mode">
    Process integration that streams AgentSessionEvent and ACKs via preflightResult.
  </Card>
  <Card title="Run modes" href="/run-modes">
    Interactive, print/JSON, RPC, and SDK entry points.
  </Card>
  <Card title="Extensions" href="/extensions">
    Extension handlers, bindExtensions, and shutdown cleanup contracts.
  </Card>
  <Card title="Branching and session trees" href="/branching-and-tree">
    Fork/clone semantics and tree navigation.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Settlement issues, SIGTERM cleanup, and related failure modes.
  </Card>
</CardGroup>
