# Three Modes, One AgentSession: What Changes Between Interactive, Print, and RPC?

> The coding agent runs in three surface modes: interactive (full TUI), print (stdout-only for scripting), and RPC (JSONL protocol for IDE integration). All three share AgentSession; each adds its own I/O adapter. This page examines rpc-mode.ts and rpc-types.ts to understand the JSONL protocol, contrasts it with interactive-mode.ts component wiring, and asks what the RPC mode reveals about the true API surface of the agent.

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

## Source Files

- `packages/coding-agent/src/modes/rpc/rpc-mode.ts`
- `packages/coding-agent/src/modes/rpc/rpc-types.ts`
- `packages/coding-agent/src/modes/rpc/jsonl.ts`
- `packages/coding-agent/src/modes/interactive/interactive-mode.ts`
- `packages/coding-agent/src/modes/print-mode.ts`
- `packages/coding-agent/src/modes/index.ts`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [packages/coding-agent/src/modes/rpc/rpc-mode.ts](packages/coding-agent/src/modes/rpc/rpc-mode.ts)
- [packages/coding-agent/src/modes/rpc/rpc-types.ts](packages/coding-agent/src/modes/rpc/rpc-types.ts)
- [packages/coding-agent/src/modes/rpc/jsonl.ts](packages/coding-agent/src/modes/rpc/jsonl.ts)
- [packages/coding-agent/src/modes/rpc/rpc-client.ts](packages/coding-agent/src/modes/rpc/rpc-client.ts)
- [packages/coding-agent/src/modes/print-mode.ts](packages/coding-agent/src/modes/print-mode.ts)
- [packages/coding-agent/src/modes/index.ts](packages/coding-agent/src/modes/index.ts)
- [packages/coding-agent/src/core/output-guard.ts](packages/coding-agent/src/core/output-guard.ts)
- [packages/coding-agent/src/core/agent-session-runtime.ts](packages/coding-agent/src/core/agent-session-runtime.ts)
- [packages/coding-agent/src/core/extensions/types.ts](packages/coding-agent/src/core/extensions/types.ts)
</details>

# Three Modes, One AgentSession: What Changes Between Interactive, Print, and RPC?

The coding agent exposes three surface modes — interactive (full TUI), print (stdout-only for scripting), and RPC (JSONL protocol for IDE integration) — yet all three operate the same `AgentSession` core. The mode boundary is thin: each surface provides a different I/O adapter and a different implementation of `ExtensionUIContext`, but the session's business logic — prompting, compaction, model selection, session forking, bash execution — never changes. This page uses RPC mode as a lens to reveal what that shared API surface actually is, how the JSONL wire protocol works, and what interactive mode adds that RPC deliberately omits.

Understanding this boundary matters if you are embedding the agent in an IDE extension, a CI pipeline, or any headless host: the RPC surface is effectively the machine-readable contract for the agent's capabilities.

---

## What Is the Simplest Version?

Imagine stripping the agent to its minimum: a function that accepts a text prompt, forwards it to a language model, and writes the assistant's reply to stdout. That is print mode.

```ts
// packages/coding-agent/src/modes/print-mode.ts:32-46
export async function runPrintMode(
  runtimeHost: AgentSessionRuntime,
  options: PrintModeOptions,
): Promise<number> {
  const { mode, messages = [], initialMessage, initialImages } = options;
  let session = runtimeHost.session;
```

Print mode runs once: it fires `session.prompt(...)` for each message, then writes the final assistant text (in `"text"` mode) or every `AgentSessionEvent` as a JSON line (in `"json"` mode), then exits. It subscribes to session events but uses no UI context at all — `bindExtensions` receives only `commandContextActions` and `onError`, with no `uiContext`.

Sources: [packages/coding-agent/src/modes/print-mode.ts:71-108]()

---

## Where Complexity Becomes Necessary

Print mode cannot serve an IDE host that needs to:
- remain alive across multiple user turns,
- react to agent events in real time,
- request interactive input from the user (select, confirm, text input, editor), and
- receive typed, structured data rather than raw text.

All of these require a long-lived, bidirectional channel with a defined protocol. That is RPC mode.

---

## The JSONL Wire Protocol

### Framing

The transport is newline-delimited JSON (JSONL) on `stdin`/`stdout`. `jsonl.ts` implements this with a deliberate choice: it does **not** use Node's `readline`, because `readline` splits on additional Unicode line separators (U+2028, U+2029) that are legal inside JSON strings and would corrupt payloads.

```ts
// packages/coding-agent/src/modes/rpc/jsonl.ts:10-12
export function serializeJsonLine(value: unknown): string {
  return `${JSON.stringify(value)}\n`;
}
```

```ts
// packages/coding-agent/src/modes/rpc/jsonl.ts:21-42
export function attachJsonlLineReader(
  stream: Readable,
  onLine: (line: string) => void,
): () => void {
  const decoder = new StringDecoder("utf8");
  let buffer = "";
  // Splits only on \n, not on U+2028/U+2029
  ...
}
```

Framing rule: records are separated by LF (`\n`) only. CRLF is handled by stripping a trailing `\r` before the `\n` boundary.

Sources: [packages/coding-agent/src/modes/rpc/jsonl.ts:1-58]()

### The Three Message Kinds

All messages on the wire fall into three distinct categories:

| Direction | Type field | Purpose |
|-----------|-----------|---------|
| stdin → agent | `RpcCommand["type"]` values | Commands: `prompt`, `abort`, `get_state`, `set_model`, `bash`, etc. |
| agent → stdout | `"response"` | Replies to commands, keyed by command type and optional `id` |
| agent → stdout | `AgentSessionEvent` | Streaming events from the session (token chunks, tool calls, etc.) |
| agent → stdout | `"extension_ui_request"` | Agent asks host for user input |
| stdin → agent | `"extension_ui_response"` | Host replies to a UI request with `id` correlation |

The `id` field on commands is optional but enables correlation: a client can tag a command with `id: "req_1"` and the matching response will carry the same `id`.

Sources: [packages/coding-agent/src/modes/rpc/rpc-types.ts:19-69](), [packages/coding-agent/src/modes/rpc/rpc-types.ts:111-206]()

### Command Groups

`RpcCommand` is a tagged union of 27 command variants grouped by concern:

| Group | Commands |
|-------|----------|
| Prompting | `prompt`, `steer`, `follow_up`, `abort`, `new_session` |
| State | `get_state` |
| Model | `set_model`, `cycle_model`, `get_available_models` |
| Thinking | `set_thinking_level`, `cycle_thinking_level` |
| Queue modes | `set_steering_mode`, `set_follow_up_mode` |
| Compaction | `compact`, `set_auto_compaction` |
| Retry | `set_auto_retry`, `abort_retry` |
| Bash | `bash`, `abort_bash` |
| Session | `get_session_stats`, `export_html`, `switch_session`, `fork`, `clone`, `get_fork_messages`, `get_last_assistant_text`, `set_session_name` |
| Messages | `get_messages` |
| Commands | `get_commands` |

Sources: [packages/coding-agent/src/modes/rpc/rpc-types.ts:19-69]()

---

## How RPC Mode Bootstraps

### stdout takeover

The first act of `runRpcMode` is `takeOverStdout()`. This patches `process.stdout.write` so that any code that naively calls `console.log` or writes to stdout gets silently redirected to stderr. Only calls through `writeRawStdout(serializeJsonLine(obj))` reach real stdout. This ensures the JSONL stream is never polluted by debug output from extensions or library code.

```ts
// packages/coding-agent/src/modes/rpc/rpc-mode.ts:48-55
export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<never> {
  takeOverStdout();
  let session = runtimeHost.session;
  ...
  const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
    writeRawStdout(serializeJsonLine(obj));
  };
```

Sources: [packages/coding-agent/src/core/output-guard.ts:9-34](), [packages/coding-agent/src/modes/rpc/rpc-mode.ts:48-55]()

### Session subscription

After binding extensions, RPC mode subscribes to `session.subscribe(event => output(event))`. Every `AgentSessionEvent` — token chunks, tool-call starts and ends, errors, idle signals — is forwarded verbatim as a JSONL line. The host does not need to poll; events arrive in real time.

```ts
// packages/coding-agent/src/modes/rpc/rpc-mode.ts:346-348
unsubscribe?.();
unsubscribe = session.subscribe((event) => {
  output(event);
});
```

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:344-349]()

### Infinite loop

The function returns `Promise<never>` and resolves only on shutdown. The process stays alive via a never-resolving promise at the end, keeping stdin open for commands.

```ts
// packages/coding-agent/src/modes/rpc/rpc-mode.ts:752-753
// Keep process alive forever
return new Promise(() => {});
```

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:752-753]()

---

## The Extension UI Adapter: What RPC Can and Cannot Do

All three modes call `session.bindExtensions({ uiContext, ... })`. Print mode passes no `uiContext` at all. Interactive mode wires `uiContext` to concrete TUI components — overlays, selectors, the Monaco-style editor. RPC mode creates a synthetic `ExtensionUIContext` that translates each UI method into either a `extension_ui_request` JSONL line or a no-op.

```ts
// packages/coding-agent/src/modes/rpc/rpc-mode.ts:129-133
const createExtensionUIContext = (): ExtensionUIContext => ({
  select: (title, options, opts) =>
    createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, (r) =>
      "cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
    ),
```

The `createDialogPromise` helper emits an `extension_ui_request` to stdout, registers a pending entry keyed by a UUID, then suspends until the host sends back an `extension_ui_response` with the same `id`. Timeout and abort-signal support are built in.

### RPC vs Interactive UI capability matrix

| Capability | Interactive | Print | RPC |
|------------|-------------|-------|-----|
| `select`, `confirm`, `input` | TUI overlay | — | `extension_ui_request` / response roundtrip |
| `editor` | Full TUI editor overlay | — | `extension_ui_request` / response roundtrip |
| `notify` | TUI notification | — | Fire-and-forget `extension_ui_request` |
| `setStatus` | Footer status line | — | Fire-and-forget `extension_ui_request` |
| `setWidget` | TUI widget panel | — | String arrays only via `extension_ui_request`; React component factories are silently dropped |
| `setWorkingMessage` / `setWorkingVisible` | TUI spinner | — | No-op (requires TUI loader) |
| `setHiddenThinkingLabel` | TUI label | — | No-op (requires TUI renderer) |
| `setFooter`, `setHeader` | TUI custom components | — | No-op |
| `setTheme` | Live theme switch | — | Returns `{ success: false }` |
| `getToolsExpanded` / `setToolsExpanded` | TUI state | — | Always false / no-op |
| `addAutocompleteProvider` | Autocomplete integration | — | No-op |
| `pasteToEditor` | Clipboard → editor | — | Redirects to `setEditorText` |
| `getEditorText` | Returns live text | — | Always returns `""` |

The pattern is: anything requiring access to TUI component state is silently ignored in RPC mode; anything that can be represented as a structured JSON message is forwarded to the host.

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:129-304]()

---

## The `prompt` Command: Asynchronous by Design

The `prompt` command is special. When the agent receives it, the session starts processing immediately but the acknowledgment response is not emitted until a "preflight" check inside the session has passed. This prevents the host from treating a queued or immediately-handled prompt as a failure.

```ts
// packages/coding-agent/src/modes/rpc/rpc-mode.ts:379-400
case "prompt": {
  let preflightSucceeded = false;
  void session
    .prompt(command.message, {
      ...
      preflightResult: (didSucceed) => {
        if (didSucceed) {
          preflightSucceeded = true;
          output(success(id, "prompt"));
        }
      },
    })
    .catch((e) => {
      if (!preflightSucceeded) {
        output(error(id, "prompt", e.message));
      }
    });
  return undefined; // response emitted later via preflightResult callback
}
```

After the `{ type: "response", command: "prompt", success: true }` line, the session streams `AgentSessionEvent` objects — token chunks, tool calls, idle signals — which the host observes via its `onEvent` listener. The host must not assume the agent is idle after the `prompt` response; it must wait for an `agent_end` event.

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:379-401]()

---

## The RpcClient: Protocol from the Other Side

`rpc-client.ts` provides a typed TypeScript wrapper that spawns the agent with `--mode rpc` and drives the protocol from the host side. It demonstrates the intended usage pattern clearly.

```ts
// packages/coding-agent/src/modes/rpc/rpc-client.ts:77-93
const args = ["--mode", "rpc"];
...
this.process = spawn("node", [cliPath, ...args], {
  stdio: ["pipe", "pipe", "pipe"],
});
this.stopReadingStdout = attachJsonlLineReader(this.process.stdout!, (line) => {
  this.handleLine(line);
});
```

Internally, `RpcClient.send()` assigns a sequential `req_N` id to every command, stores a resolve/reject in `pendingRequests`, writes the serialized command to stdin, and resolves when the matching `{ type: "response", id: "req_N" }` arrives. Events without a matching id fall through to `eventListeners`.

```ts
// packages/coding-agent/src/modes/rpc/rpc-client.ts:456-475
private handleLine(line: string): void {
  const data = JSON.parse(line);
  if (data.type === "response" && data.id && this.pendingRequests.has(data.id)) {
    const pending = this.pendingRequests.get(data.id)!;
    this.pendingRequests.delete(data.id);
    pending.resolve(data as RpcResponse);
    return;
  }
  for (const listener of this.eventListeners) {
    listener(data as AgentEvent);
  }
}
```

`RpcClient` also provides `waitForIdle()` (resolves on `agent_end` event), `collectEvents()` (accumulates all events until idle), and the convenience `promptAndWait()` that races collection and prompt delivery.

Sources: [packages/coding-agent/src/modes/rpc/rpc-client.ts:477-505](), [packages/coding-agent/src/modes/rpc/rpc-client.ts:404-450]()

---

## Session Rebinding Across Mode Changes

All three modes implement a `rebindSession` callback registered with the runtime via `runtimeHost.setRebindSession(...)`. When the user forks, clones, or switches sessions — which replaces the underlying `AgentSession` — the runtime calls this callback, prompting the mode to re-subscribe to the new session's event stream and re-bind extensions.

```ts
// packages/coding-agent/src/modes/rpc/rpc-mode.ts:306-349
runtimeHost.setRebindSession(async () => {
  await rebindSession();
});

const rebindSession = async (): Promise<void> => {
  session = runtimeHost.session;
  await session.bindExtensions({ uiContext: createExtensionUIContext(), ... });
  unsubscribe?.();
  unsubscribe = session.subscribe((event) => { output(event); });
};
```

Print mode does the same pattern. Interactive mode handles this within its class lifecycle. The rebind contract is part of the shared runtime interface, not mode-specific.

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:306-349](), [packages/coding-agent/src/modes/print-mode.ts:67-108]()

---

## Protocol Flow Diagram

```
Host process                            Agent process (--mode rpc)
────────────────                        ──────────────────────────
                    stdin (JSONL)
{"type":"prompt","message":"...","id":"req_1"}  ──────────────────▶  handleInputLine()
                                                                           │
                                                                     session.prompt()
                                                                     preflightResult()
{"type":"response","command":"prompt","success":true,"id":"req_1"} ◀──── output()
                                                                           │ (streaming)
{"type":"token","text":"Hello"}                                    ◀──── output(event)
{"type":"token","text":" world"}                                   ◀──── output(event)
{"type":"agent_end"}                                               ◀──── output(event)

    (extension needs user pick)
{"type":"extension_ui_request","id":"ui-uuid","method":"select",...} ◀── output()
{"type":"extension_ui_response","id":"ui-uuid","value":"option-A"} ──────▶ handleInputLine()
                                                                           pending.resolve()
```

---

## What RPC Mode Reveals About the True API Surface

The RPC command set is the most honest inventory of `AgentSession`'s public capabilities. Every command maps directly to a session or runtime method:

- `session.prompt()`, `session.steer()`, `session.followUp()`, `session.abort()`
- `session.setModel()`, `session.cycleModel()`, `session.modelRegistry.getAvailable()`
- `session.setThinkingLevel()`, `session.cycleThinkingLevel()`
- `session.compact()`, `session.setAutoCompactionEnabled()`
- `session.executeBash()`, `session.abortBash()`
- `session.getSessionStats()`, `session.exportToHtml()`
- `runtimeHost.newSession()`, `runtimeHost.fork()`, `runtimeHost.switchSession()`
- `session.messages`, `session.extensionRunner.getRegisteredCommands()`, `session.promptTemplates`, `session.resourceLoader.getSkills()`

Interactive mode adds TUI rendering, keyboard shortcuts, OAuth login flows, clipboard integration, theming, and extension autocomplete — none of which changes how the session runs. Print mode removes persistence and stays single-shot. RPC mode proves that all of those are presentation concerns: the underlying API fits in 27 typed command variants.

The implication for extension authors: everything an extension can do in interactive mode that does not require direct TUI component access is available to RPC hosts via the `extension_ui_request` / `extension_ui_response` roundtrip, because `ExtensionUIContext` is the only abstraction between extension code and the surface mode.

Sources: [packages/coding-agent/src/modes/rpc/rpc-types.ts:19-69](), [packages/coding-agent/src/modes/rpc/rpc-mode.ts:370-660]()

---

## Summary

All three modes share the same `AgentSession` and `AgentSessionRuntime`. Interactive mode wraps the session in a full TUI class with dozens of imported components; print mode is a thin single-shot wrapper; RPC mode is a long-lived JSONL gateway. The key implementation work in each mode is the `ExtensionUIContext` adapter: RPC translates every UI request into a structured `extension_ui_request` line and suspends until the host responds, while TUI-specific methods (spinners, custom components, theme switching) are silently no-opped because they require direct renderer access that the subprocess boundary cannot provide. The `jsonl.ts` framing layer deliberately avoids `readline` to ensure U+2028/U+2029 inside JSON strings never split a record — a subtle invariant that any re-implementation must preserve.

Sources: [packages/coding-agent/src/modes/rpc/jsonl.ts:14-19]()
