# Four Run Modes — Interactive, Print, RPC, and ACP

> Same engine, four wrappers: the TUI interactive mode, one-shot print mode, NDJSON RPC over stdio, and ACP/JSON-RPC for editors. Understand which mode owns the I/O boundary, what framing it uses, and when tool calls route through the host.

- Repository: can1357/oh-my-pi
- GitHub: https://github.com/can1357/oh-my-pi
- Human wiki: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45
- Complete Markdown: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/llms-full.txt

## Source Files

- `packages/coding-agent/src/main.ts`
- `packages/coding-agent/src/modes/index.ts`
- `packages/coding-agent/src/modes/interactive-mode.ts`
- `packages/coding-agent/src/modes/print-mode.ts`
- `packages/coding-agent/src/modes/rpc`
- `packages/coding-agent/src/modes/acp`

---

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

- [packages/coding-agent/src/main.ts](packages/coding-agent/src/main.ts)
- [packages/coding-agent/src/modes/index.ts](packages/coding-agent/src/modes/index.ts)
- [packages/coding-agent/src/modes/interactive-mode.ts](packages/coding-agent/src/modes/interactive-mode.ts)
- [packages/coding-agent/src/modes/print-mode.ts](packages/coding-agent/src/modes/print-mode.ts)
- [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/host-tools.ts](packages/coding-agent/src/modes/rpc/host-tools.ts)
- [packages/coding-agent/src/modes/acp/acp-mode.ts](packages/coding-agent/src/modes/acp/acp-mode.ts)
- [packages/coding-agent/src/modes/acp/acp-agent.ts](packages/coding-agent/src/modes/acp/acp-agent.ts)
- [packages/coding-agent/src/modes/acp/acp-client-bridge.ts](packages/coding-agent/src/modes/acp/acp-client-bridge.ts)
- [packages/coding-agent/src/cli/args.ts](packages/coding-agent/src/cli/args.ts)
</details>

# Four Run Modes — Interactive, Print, RPC, and ACP

The coding agent exposes a single shared `AgentSession` engine behind four distinct I/O wrappers, each with a different framing contract, ownership model, and surface for host-supplied tools. Choosing the right mode is not just a UI preference — it determines which process owns the terminal, how bytes flow between the agent and its caller, and whether tool calls can be delegated back to an external host.

The mode is selected with `--mode <value>` at the CLI. The valid values are `text`, `json`, `rpc`, `rpc-ui`, and `acp`. When no `--mode` flag is given and no `--print`/`-p` flag is present, the process falls into interactive (TUI) mode automatically. Piped stdin input without an explicit `--print` also triggers a one-shot print run via the `autoPrint` path.

Sources: [packages/coding-agent/src/cli/args.ts:10](packages/coding-agent/src/cli/args.ts), [packages/coding-agent/src/main.ts:797-799](packages/coding-agent/src/main.ts)

---

## Mode Selection and Dispatch

`runRootCommand` in `main.ts` is the single dispatch point. After creating an `AgentSession`, it branches on the resolved mode:

```
parsed.mode
  "acp"         → runAcpMode (session factory, not a pre-built session)
  "rpc" / "rpc-ui" → runRpcMode(session, setToolUIContext?)
  isInteractive   → runInteractiveMode(session, …)
  else (text/json)→ runPrintMode(session, { mode, … })
```

ACP is special: it receives a **session factory** (`createAcpSessionFactory`) instead of a ready session, because it manages multiple concurrent sessions over the lifetime of a single connection. All other modes receive a single pre-created `AgentSession`.

Sources: [packages/coding-agent/src/main.ts:932-1026](packages/coding-agent/src/main.ts)

### RPC-specific setting overrides

When the mode is `rpc`, `rpc-ui`, or `acp`, the startup code calls `applyRpcDefaultSettingOverrides`, which resets a fixed list of settings (async jobs, todo subsystem, bash auto-backgrounding, task isolation, memory backends) to their factory defaults. This prevents operator-configured preferences from leaking into headless embedding contexts that have not opted into those features.

Sources: [packages/coding-agent/src/main.ts:87-115](packages/coding-agent/src/main.ts)

---

## Interactive Mode

Interactive mode is the full TUI experience. It is activated when neither `--print` nor `--mode` is provided and stdin is a TTY.

### TUI ownership

The `InteractiveMode` class owns a `TUI` instance (from `@oh-my-pi/pi-tui`) along with a hierarchy of `Container` and component objects: `chatContainer`, `statusContainer`, `todoContainer`, `editor`, `statusLine`, widget areas above and below the editor, and transient overlays for tool execution, bash, and Python. The class implements `InteractiveModeContext`, an interface that the session and controllers use to push rendering updates without taking a direct dependency on the TUI.

Sources: [packages/coding-agent/src/modes/interactive-mode.ts:185-244](packages/coding-agent/src/modes/interactive-mode.ts)

### Input loop

`runInteractiveMode` in `main.ts` constructs the `InteractiveMode`, calls `mode.init()`, optionally fires an `initialMessage` prompt, then enters an unbounded loop:

```typescript
while (true) {
    const input = await mode.getUserInput();
    await submitInteractiveInput(mode, session, input);
}
```

`submitInteractiveInput` routes to `session.prompt()` or `session.promptCustomMessage()` based on whether `input.customType` is set, and calls `mode.checkShutdownRequested()` after each turn.

Sources: [packages/coding-agent/src/main.ts:317-321](packages/coding-agent/src/main.ts), [packages/coding-agent/src/main.ts:133-167](packages/coding-agent/src/main.ts)

### What interactive mode owns uniquely

- Full terminal title management (`pushTerminalTitle`, `setSessionTerminalTitle`)
- STT (speech-to-text) controller
- Loop-limit and goal-mode state
- Plan-mode UI (plan file approval dialogs)
- Hook editor/selector/input overlay components
- Version-check notification banner
- Changelog display on first launch after upgrade

None of these exist in the other modes.

---

## Print Mode

Print mode is the one-shot, process-and-exit wrapper. It handles both `--print` / `-p` (text output) and `--mode json` (NDJSON event stream).

### Activation

`runRootCommand` sets `autoPrint = true` when stdin is not a TTY and no explicit `--mode` or `--print` flag is present. Both `autoPrint` and `--print` result in the `runPrintMode` call path.

Sources: [packages/coding-agent/src/main.ts:797-798](packages/coding-agent/src/main.ts)

### Output contract

```
--mode text (default)   → only the final assistant text block is written to stdout
--mode json             → every AgentSessionEvent is serialized as a JSON line (NDJSON)
```

In `text` mode, `runPrintMode` reads `session.state.messages` after all prompts complete and writes the last assistant message's text content. In `json` mode, a session header is emitted first, then a `session.subscribe` callback writes every event as it fires.

Error cases: if the final assistant message has `stopReason === "error"` or `"aborted"` (and is not a silent-abort plan compaction transition), the error text goes to stderr and the process exits with code 1.

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

### Extension initialization in print mode

Print mode still calls `initializeExtensions(session, …)`, passing lightweight error reporters to stderr. There is no UI context — extension `sendMessage`/`sendUserMessage` failures are logged to stderr as plain strings and the process continues.

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

---

## RPC Mode

RPC mode turns the agent into a long-running subprocess controlled over NDJSON on stdin/stdout. It is intended for embedding the agent inside editors, IDE extensions, or other processes that want to drive it programmatically.

### Framing

```
stdin  → newline-delimited JSON RpcCommand objects
stdout → newline-delimited JSON (RpcResponse | AgentSessionEvent | RpcExtensionUIRequest | host-tool frames)
```

On startup, `runRpcMode` immediately writes `{"type":"ready"}\n` so the parent process can detect when the server is listening. It also sets `process.env.PI_NOTIFICATIONS = "off"` to suppress any OSC/BEL sequences that would corrupt the JSON channel.

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

### Command dispatch

`readJsonl(Bun.stdin.stream())` drives an async for-loop. Each parsed object is classified:

| Frame type | Handler |
|---|---|
| `extension_ui_response` | Resolves a pending `RpcExtensionUIContext` promise |
| `host_tool_result` | `RpcHostToolBridge.handleResult` |
| `host_tool_update` | `RpcHostToolBridge.handleUpdate` |
| `host_uri_result` | `RpcHostUriBridge.handleResult` |
| Everything else | `handleCommand` → `output(response)` |

The `prompt` command is fire-and-forget — it calls `session.prompt()` without awaiting and returns `{success: true}` immediately, so events stream back asynchronously. All other commands (`steer`, `follow_up`, `abort`, `get_state`, etc.) are awaited before the response is written.

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:807-845](packages/coding-agent/src/modes/rpc/rpc-mode.ts), [packages/coding-agent/src/modes/rpc/rpc-mode.ts:462-473](packages/coding-agent/src/modes/rpc/rpc-mode.ts)

### RPC command surface

```
Prompting:   prompt · steer · follow_up · abort · abort_and_prompt · new_session
State:       get_state · set_todos · set_host_tools · set_host_uri_schemes
Model:       set_model · cycle_model · get_available_models
Thinking:    set_thinking_level · cycle_thinking_level
Queue modes: set_steering_mode · set_follow_up_mode · set_interrupt_mode
Compaction:  compact · set_auto_compaction
Retry:       set_auto_retry · abort_retry
Bash:        bash · abort_bash
Session:     get_session_stats · export_html · switch_session · branch ·
             get_branch_messages · get_last_assistant_text · set_session_name · handoff
Messages:    get_messages
Login:       get_login_providers · login
```

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

### Host tools

The host process can inject custom tools into the agent's tool registry via the `set_host_tools` command. Each tool definition supplies a name, description, and JSON Schema for parameters. When the agent calls a host tool, the server emits a `host_tool_call` frame on stdout; the host executes it and replies with a `host_tool_result` (or streams partial results via `host_tool_update`). `RpcHostToolBridge` manages pending call state and propagates cancellations when the RPC client disconnects.

Sources: [packages/coding-agent/src/modes/rpc/rpc-types.ts:270-307](packages/coding-agent/src/modes/rpc/rpc-types.ts), [packages/coding-agent/src/modes/rpc/host-tools.ts:1-60](packages/coding-agent/src/modes/rpc/host-tools.ts)

### Host URI schemes

The `set_host_uri_schemes` command registers custom URI schemes (e.g. `db://`, `notion://`). When the agent's read or write tools encounter a matching URI, the server emits a `host_uri_request` frame; the host resolves it and replies with `host_uri_result`. This lets the host expose arbitrary read/write backends without implementing a full MCP server.

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

### Extension UI over RPC

The `RpcExtensionUIContext` class (defined inline in `rpc-mode.ts`) implements the `ExtensionUIContext` interface. UI primitives — `select`, `confirm`, `input`, `editor`, `notify`, `setStatus`, `setWidget`, `setTitle` — are translated into `extension_ui_request` frames emitted on stdout with a unique snowflake `id`. The host replies with an `extension_ui_response` frame carrying the same `id` to settle the waiting promise. `setFooter`, `setHeader`, `custom`, and raw terminal input are stubs.

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

### `rpc-ui` variant

`--mode rpc-ui` behaves identically to `rpc` except `setToolUIContext` is wired into the session, giving extension tool calls (the `ask` tool, permission dialogs) access to the same `RpcExtensionUIContext`. Plain `--mode rpc` does not pass `setToolUIContext`, leaving those paths unrouted.

Sources: [packages/coding-agent/src/main.ts:974-975](packages/coding-agent/src/main.ts)

---

## ACP Mode

ACP (Agent Client Protocol) mode implements the `@agentclientprotocol/sdk` wire protocol, a JSON-RPC-like framing designed for editor integrations (Zed, VS Code extensions, etc.). Unlike RPC mode — which manages one session per process — ACP mode manages **multiple named sessions** over a single long-lived connection.

### Transport

`runAcpMode` builds an NDJSON duplex transport from the swapped stdin/stdout pair and instantiates an `AgentSideConnection`. Note the swap: `process.stdout` → `input` (writable side of the ACP transport), `process.stdin` → `output` (readable side). The `ndJsonStream` helper from the SDK handles framing.

```typescript
const input = stream.Writable.toWeb(process.stdout);
const output = stream.Readable.toWeb(process.stdin);
const transport = ndJsonStream(input, output);
```

Sources: [packages/coding-agent/src/modes/acp/acp-mode.ts:16-23](packages/coding-agent/src/modes/acp/acp-mode.ts)

### Session lifecycle

`AcpAgent` (the `Agent` implementation handed to `AgentSideConnection`) owns a `Map<sessionId, ManagedSessionRecord>`. Each record holds the `AgentSession`, an optional `MCPManager`, a prompt-turn state object, and a prompt queue. Session IDs are generated by `SessionManager`.

The agent responds to the following ACP lifecycle requests:

| Request | Behaviour |
|---|---|
| `initialize` | Advertises capabilities; returns auth methods |
| `authenticate` | Validates `methodId` against advertised methods |
| `session/new` | Creates a fresh session via the factory, skips on-disk MCP discovery (`enableMCP: false`) |
| `session/load` | Opens an existing session file and replays message history |
| `session/resume` | Resumes the most-recent session for a given cwd |
| `unstable_session/fork` | Forks a session at a specific entry ID |
| `session/close` | Disposes the session record |
| `session/list` | Returns stored sessions for a cwd with pagination |

Sources: [packages/coding-agent/src/modes/acp/acp-agent.ts:385-465](packages/coding-agent/src/modes/acp/acp-agent.ts)

### Why ACP sessions disable MCP discovery

When a client creates a session via `session/new`, it supplies `mcpServers` directly. The factory therefore sets `enableMCP: false` on every session to prevent on-disk `.mcp.json` discovery from registering duplicate or conflicting tools alongside the client-supplied servers.

Sources: [packages/coding-agent/src/main.ts:219-238](packages/coding-agent/src/main.ts)

### Host tool and permission routing via ClientBridge

ACP clients that advertise `fs.readTextFile`, `fs.writeTextFile`, or `terminal` capabilities at `initialize` time get a `ClientBridge` backed by `AgentSideConnection` calls (`connection.readTextFile`, `connection.writeTextFile`). This lets the editor's file system and terminal serve as the I/O backends for agent tool calls, replacing the agent's local filesystem access when the client opts in.

Sources: [packages/coding-agent/src/modes/acp/acp-client-bridge.ts:25-60](packages/coding-agent/src/modes/acp/acp-client-bridge.ts)

### Extension UI via ACP elicitations

`createAcpExtensionUiContext` bridges `ExtensionUIContext` calls to `connection.unstable_createElicitation`. Each `select`, `confirm`, or `input` call becomes a one-property form elicitation with a `value` field typed to `string` (enum-constrained for `select`), `boolean` (for `confirm`), or free `string` (for `input`). Clients that do not advertise `elicitation.form` get `undefined`/`false` defaults silently, rather than a runtime error.

Sources: [packages/coding-agent/src/modes/acp/acp-agent.ts:291-363](packages/coding-agent/src/modes/acp/acp-agent.ts)

### Race-guard bootstrap delay

After a `session/new` (or load/resume/fork) response is sent, ACP emits initial `sessionNotification` events on a 50 ms delayed schedule (`ACP_BOOTSTRAP_RACE_GUARD_MS`). This mitigates a Zed client bug where notifications arrive before the client has registered the new session ID.

Sources: [packages/coding-agent/src/modes/acp/acp-agent.ts:88-92](packages/coding-agent/src/modes/acp/acp-agent.ts)

---

## Comparison Table

| Dimension | Interactive | Print (`text`/`json`) | RPC / RPC-UI | ACP |
|---|---|---|---|---|
| CLI flag | _(none / default)_ | `--print`, `--mode text`, `--mode json` | `--mode rpc`, `--mode rpc-ui` | `--mode acp` |
| Session count | 1 | 1 | 1 | N (managed map) |
| I/O framing | Raw terminal (TUI) | Plain text / NDJSON | NDJSON (bidirectional) | NDJSON via ACP SDK |
| Stdout usage | TUI rendering | Agent output only | JSON protocol channel | ACP protocol channel |
| Stdin usage | Keyboard / TTY | Initial prompt | JSON commands | ACP commands |
| Process lifetime | Until user quits | Exits after last prompt | Until stdin closes | Until connection closes |
| Host tools | No | No | `set_host_tools` command | Via `session/new` mcpServers |
| Host URI schemes | No | No | `set_host_uri_schemes` | No |
| Extension UI | Full TUI dialogs | stderr errors only | `extension_ui_request` frames | ACP `unstable_createElicitation` |
| Terminal title | Yes | No | No (suppress via `PI_NO_TITLE`) | No |
| hasUI flag | `true` | `false` | `true` (rpc-ui only) | `false` |
| MCP discovery | On-disk `.mcp.json` | On-disk `.mcp.json` | On-disk `.mcp.json` | Client-supplied only |
| RPC default overrides | No | No | Yes | Yes |

Sources: [packages/coding-agent/src/main.ts:774-799](packages/coding-agent/src/main.ts), [packages/coding-agent/src/main.ts:904-975](packages/coding-agent/src/main.ts)

---

## Data Flow Diagram

```text
┌──────────────────────────────────────────────────────────────────┐
│                        CLI Entry (main.ts)                       │
│  parseArgs → mode flag / autoPrint / isInteractive detection     │
└──────────────────────┬───────────────────────────────────────────┘
                       │
          ┌────────────┼──────────────────────────────┐
          │            │                              │
          ▼            ▼                              ▼
  ┌──────────┐  ┌──────────────┐           ┌──────────────────┐
  │ ACP mode │  │ Single-session│           │  Interactive     │
  │          │  │  path        │           │  mode            │
  │factory → │  └──────┬───────┘           │  (TUI)           │
  │AcpAgent  │         │                   └──────────────────┘
  │(N sessions│        ├── mode=="rpc"/"rpc-ui"
  │over ACP  │         │    └→ runRpcMode
  │ SDK conn)│         │         stdin: NDJSON RpcCommands
  └──────────┘         │         stdout: RpcResponse + events
                       │         host_tool_call ↔ host_tool_result
                       │
                       └── isInteractive==false (text/json)
                                └→ runPrintMode
                                      text: final assistant text
                                      json: all events as NDJSON
```

---

## Failure Modes

**RPC stdout pollution:** Any code that writes directly to `process.stdout` without a newline (OSC sequences, BEL) corrupts the JSON stream. RPC mode guards this by setting `PI_NOTIFICATIONS=off` immediately on entry, but extensions or native modules that bypass this can silently break JSON parsing on the host side.

**ACP session notification race:** If the client processes `session/new` but has not yet registered the session before the first `sessionNotification` arrives, events are silently dropped. The 50 ms bootstrap guard mitigates this but does not eliminate it under heavy load.

**ACP MCP server shadowing:** If `enableMCP: false` is not set when creating an ACP session (e.g. in the `initialSession` fast-path), on-disk MCP tool registrations could conflict with client-supplied servers, causing duplicate tool names. The factory explicitly sets this flag for all dynamically created sessions.

**Print mode stderr/exit ordering:** In text mode, the exit-code-1 path after a stream error uses `process.stderr.once("drain", ...)` to avoid truncating the error line when the write is async. If neither path is taken, a final empty `process.stdout.write("")` flushes the stream before `session.dispose()`.

Sources: [packages/coding-agent/src/modes/rpc/rpc-mode.ts:172-178](packages/coding-agent/src/modes/rpc/rpc-mode.ts), [packages/coding-agent/src/modes/acp/acp-agent.ts:88-92](packages/coding-agent/src/modes/acp/acp-agent.ts), [packages/coding-agent/src/modes/print-mode.ts:80-118](packages/coding-agent/src/modes/print-mode.ts)

---

The four modes share one engine but own strictly different I/O boundaries: interactive holds the terminal, print drains output and exits, RPC keeps the channel open for bidirectional command/event framing, and ACP adds session multiplexing and a richer editor-integration contract on top of the same NDJSON transport. Understanding which mode owns stdout — and what else may write there — is the key invariant a maintainer needs to reason about when adding any new output path.

Sources: [packages/coding-agent/src/modes/index.ts:1-34](packages/coding-agent/src/modes/index.ts)
