PiNative does not call an LLM provider directly. It starts pi --mode rpc, sends Pi newline-delimited JSON commands through stdin, and interprets Pi's newline-delimited responses/events from stdout. Pi owns the provider request, model authentication, agent loop, tool execution, retries, compaction, and canonical session write.
- Native input/rendering: PiConversationView.swift
- Turn state/event interpretation: PiConversationModel.swift
- Process/JSONL transport: PiRPCClient.swift
- Owning-chat persistence: AppModel.swift
┌──────────────┐
│ User presses │
│ Enter │
└──────┬───────┘
│ keyDown → onSubmit
▼
┌─────────────────────────────── PiNative process ───────────────────────────────┐
│ PasteAwareTextView / AttachmentComposerShell │
│ │ │
│ ▼ │
│ PiConversationModel.sendDraft() │
│ │ │
│ ├─ PromptAttachmentAssembler.prepare() │
│ │ ├─ text + file paths → message │
│ │ └─ image bytes → RPC image payloads │
│ │ │
│ ├─ append .user TranscriptItem │
│ ├─ AppModel callback persists title + cached transcript │
│ ├─ set isRunning = true │
│ │ │
│ ▼ │
│ PiRPCClient.prompt() actor │
│ │ writes one JSON object + newline to child-process stdin │
└───────┼───────────────────────────────────────────────────────────────────────┘
│ {"id":42,"type":"prompt","message":"…","images":[…]}\n
▼
┌────────────────────────────── pi --mode rpc ──────────────────────────────────┐
│ RPC command router → Pi AgentSession │
│ │ │
│ ├─ immediately returns response #42: prompt accepted │
│ │ │
│ ▼ │
│ Agent loop → selected provider/model │
│ │ │ │
│ │ └── authenticated provider request ──► LLM │
│ │ │ │
│ │ ◄── streamed text / reasoning / tool calls ───────────────┘
│ │ │
│ ├─ executes requested tools when enabled │
│ ├─ may call the LLM again with tool results │
│ ├─ writes canonical Pi session JSONL │
│ └─ emits agent/message/tool lifecycle events to stdout │
└───────┼───────────────────────────────────────────────────────────────────────┘
│ agent_start
│ message_update { assistantMessageEvent: { type:"text_delta", … } }
│ tool_execution_start / update / end (zero or more)
│ message_update … (zero or more)
│ agent_end
▼
┌─────────────────────────────── PiNative process ───────────────────────────────┐
│ PiRPCClient │
│ ├─ buffers stdout until newline │
│ ├─ decodes RPCEnvelope │
│ ├─ resolves matching response IDs │
│ └─ forwards non-response envelopes through onEvent │
│ │ │
│ ▼ @MainActor │
│ PiConversationModel.handle(event) │
│ ├─ agent_start/end → running state │
│ ├─ text_delta → append/update .assistantText │
│ ├─ tool events → correlate ActivityGroup by toolCallId │
│ └─ items didSet → keyed AppModel persistence callback │
│ │ │
│ ▼ @Published │
│ PiConversationView re-renders transcript and scrolls to the new bottom │
└────────────────────────────────────────────────────────────────────────────────┘
These are separate channels and must not be conflated:
stdin command #42 ─────► Pi
├─► stdout response #42 = “accepted”
└─► stdout async events = actual turn lifecycle/content
PiRPCClient.prompt()completes when responseid: 42arrives; it does not wait for the LLM answer.PiConversationModel.isRunningis driven byagent_startandagent_end, not by the prompt method returning.- Streaming text arrives through
message_updateevents and mutates one stable assistant transcript item incrementally. - Tools can interleave with text and trigger additional provider turns before the final
agent_end.
When Enter is pressed while isRunning is true, sendDraft() retains the full prepared payload in the conversation's ordered pendingSteering queue and sends Pi's steer RPC command instead of appending a delivered user transcript item. The view renders those entries inline after delivered chat content as subdued gray Steering: rows. Pi decides the next model-turn boundary and configured one-at-a-time/all delivery behavior.
When Pi emits message_start for the queued user input, the model removes the oldest pending entry and appends exactly one normal user transcript item using the locally retained display payload. Steering RPC calls are serialized, and generation checks prevent an acknowledgement from an obsolete process from changing a replacement runtime's queue.
- Brand-new chat:
AppModelcreates a localSessionwithpendingInitialPrompt; the model starts Pi, sendsnew_session, asksget_statefor the resolved session file, then flushes the prompt. - Existing chat: the model sends
switch_session, callsget_messages, hydrates history, and then accepts/flushes queued input. - Cached transcript: visible immediately while either path completes; live hydration replaces it only when the response belongs to the current session generation.
- Quick Chat: the same flow launches Pi with
--no-toolsand wraps the message in a planning-only instruction.
- Request IDs are correlated inside the actor; each command races a timeout and all pending continuations fail if Pi exits.
- Process-exited/not-running failures get one client restart plus session rehydrate attempt.
- Catastrophic startup/session failure becomes visible chat status and disables the composer; transient notices are not persisted.
- Stop clears selected-chat UI state immediately, sends Pi's real
abortcommand, suppresses late events from that turn, and restarts the client after a short abort window. - If steering is pending, Stop retains it across replacement: the first entry becomes the replacement prompt and the remaining entries are re-enqueued with
steerin original order. Failed replay remains visible and retryable rather than silently losing input. - Because every chat has its own model/client/process, stopping one chat does not interrupt another.