12 KiB
Pi Web - Development Notes
Quick Start
npm run dev # port 30141
Typecheck: node_modules/.bin/tsc --noEmit
Lint: npm run lint
Never run next build during dev — pollutes .next/ and breaks npm run dev.
Architecture
Browser Next.js frontend Pi Agent Server
│ │ │
├─ GET/POST /api/* ────▶│ rewrite to :30142 ─────▶│ Hono routes
├─ SSE connect ────────▶│ transparent proxy ─────▶│ AgentSession events
│◀── data: {...} ───────│◀────────────────────────│
pi-web/ owns only React/Next.js UI code. All filesystem access, authentication,
configuration, Pi packages, API routes, SSE streams, and AgentSession state live
in ../server/. The frontend proxies /api/* through next.config.ts; restarting
or hot-reloading Next.js must not interrupt an active Agent session.
Session browsing (read-only): the backend reads .jsonl files through SDK
SessionManager helpers and server/src/lib/session-reader.ts.
Sending a message: startRpcSession() in server/src/lib/rpc-manager.ts
creates and retains the AgentSession in the standalone backend process.
File Map
../server/src/
index.ts Hono server, health endpoint, route registration
security.ts host/origin gate for every /api request
routes/agent.ts Agent creation, commands, state, and SSE
routes/auth.ts OAuth/device-code and API-key routes
routes/config.ts extensions, MCP, vision, and plugin configuration
routes/files.ts guarded file reading, preview, download, and upload
routes/misc.ts cwd, runtime, file index, worktrees, and project trust
routes/models.ts Git state, models, discovery, catalog, and tests
routes/sessions.ts session listing, context, naming, export, and deletion
routes/skills.ts skill listing, search, install, and update
lib/rpc-manager.ts AgentSessionWrapper registry and startRpcSession()
lib/session-reader.ts SessionManager wrappers and session path cache
lib/
agent-client.ts typed fetch helper for /api/agent commands
draft-store.ts local draft persistence helpers
file-paths.ts client-side path encoding helpers
markdown.ts shared markdown helpers
pi-types.ts structural copies of API/session types; no Pi dependency
tool-presets.ts PRESET_NONE/DEFAULT/FULL + getPresetFromTools()
types.ts shared TypeScript types
normalize.ts normalizeToolCalls() — field name mismatch between file format and our types
components/
AppShell.tsx layout + URL state + tab management
SessionSidebar.tsx session tree + FileExplorer
ChatWindow.tsx chat composition + completion sound wrapper
ChatInput.tsx input bar + model/thinking/tools/compact controls
MessageView.tsx renders one message (user/assistant/toolCall/toolResult)
BranchNavigator.tsx in-session branch switcher
ChatMinimap.tsx scroll minimap alongside the message list
MarkdownBody.tsx markdown renderer
ModelsConfig.tsx modal for editing models.json (opened from sidebar bottom)
PluginsConfig.tsx modal for installed package plugins
SkillsConfig.tsx modal for loaded/search/installable skills
FileExplorer.tsx file tree inside sidebar
FileIcons.tsx file icon helpers
FileViewer.tsx file content in a tab
TabBar.tsx tab bar (Chat + open file tabs)
hooks/
useAgentSession.ts messages + streaming + SSE + fork/navigate/reconciliation logic
useAudio.ts completion sound + browser AudioContext unlock
useDragDrop.ts shared drag/drop state
useIsMobile.ts responsive breakpoint hook
useTheme.ts theme state
Key Design Decisions & Traps
AgentSession lifecycle (server/src/lib/rpc-manager.ts)
- One
AgentSessionWrapperper session id, keyed inglobalThis.__piSessions - The standalone backend process survives Next.js hot reloads and restarts
- Idle timeout: 10 minutes. Concurrent
startRpcSession()calls share a single start Promise (globalThis.__piStartLocks)
Fork must destroy the wrapper immediately
AgentSession.fork() mutates the wrapper's inner state in-place — after fork, inner.sessionId is the new session's id. If the wrapper stays alive in the registry under the old id, the next request gets the already-forked state and subsequent forks produce a corrupt parentSession chain.
Fix: send("fork") captures newSessionId, then calls this.destroy() before returning. The next request for the original session reloads a clean AgentSession from the original file.
Two kinds of branching — don't confuse them
- Fork (Fork button on user message): creates a new independent
.jsonlfile. Shown as a child in the sidebar tree viaparentSessionheader field. - In-session branch (Continue button / BranchNavigator): calls
navigate_treewithin the same file. Multiple entries share the sameparentId. Switching between them calls/api/sessions/[id]/context?leafId=.
Session files can be fully rewritten
parentSession in the header is display metadata only — has zero effect on chat content. Safe to writeFileSync the entire file (pi does this itself during migrations). Used when cascade-reparenting children on delete.
ToolCall field normalization
Pi stores toolCall blocks as {type:"toolCall", id, name, arguments} but ToolCallContent uses {toolCallId, toolName, input}. normalizeToolCalls() in lib/normalize.ts handles this — called in both session-reader.ts (file load) and ChatWindow.handleAgentEvent() (streaming).
New session tool preset
Tool names are passed at session creation (POST /api/agent/new → toolNames[]). For existing sessions, the active preset is inferred on mount via get_tools → getPresetFromTools(). When tools are fully disabled (toolNames = []), rpc-manager.ts passes an empty tool allow-list and forces agent.state.systemPrompt = "" after startup/reload/resource discovery.
Model defaults for new sessions
GET /api/models returns defaultModel read from ~/.pi/agent/settings.json. ChatWindow pre-selects this on mount for new sessions.
SSE reconnect on page refresh mid-stream
On ChatWindow mount, GET /api/agent/[id] is called. If state.isStreaming === true, SSE is reconnected automatically. thinkingLevel and isCompacting are also synced from this response.
Compaction SSE events
Newer pi emits compaction_start / compaction_end; older versions emitted auto_compaction_start / auto_compaction_end. handleAgentEvent accepts both sets to keep isCompacting in sync. Manual compact is a blocking POST — the button stays disabled until the response returns.
Running state SSE + reconciliation
- The sidebar listens to
/api/agent/running/events, backed bysubscribeRunningSessions()inserver/src/lib/rpc-manager.ts, so running badges update without polling. useAgentSessionstill treats per-session SSE as primary for chat events, but while a run is active it periodically callsGET /api/agent/[id]and also reconciles onvisibilitychange/online. This fixes missedagent_endevents from background tabs or half-open connections.- Prompt runs use a monotonic run id; late SSE or slow reconciliation responses from an old run must be ignored so they cannot resurrect stale streaming bubbles.
Worktrees and project grouping
server/src/lib/worktree.tsresolves linked worktree top-levels back to the main repoprojectRoot;listAllSessions()attaches that to eachSessionInfoso all worktrees for one repo are grouped together in the sidebar.- Worktree operations are served by
/api/worktreesand guarded by the same allowed-root rules as/api/files. - New worktrees are created under
<repoRoot>-worktrees/<sanitized-branch>. Existing branches are reused; otherwisegit worktree add -bcreates the branch. - Removing a dirty worktree returns
409with{ dirty: true }so the UI can ask before retrying withforce. - Sessions whose cwd points at a removed worktree are inferred back into the main project instead of becoming a phantom project row.
File access allow-list
/api/filesis intentionally not a general filesystem browser. Allowed roots come from session cwds, their resolved project roots,~/pi-cwd-*, and roots explicitly added withallowFileRoot()./api/cwd/validate,/api/default-cwd, and/api/worktreescallallowFileRoot()when they make a new location browsable.
Plugins and skills
/api/pluginsuses pi'sSettingsManager+DefaultPackageManagerfor global/project package install, remove, update, enable, and disable. Disabling writes emptyextensions/skills/prompts/themesarrays for that package entry./api/skillsusesDefaultResourceLoaderso settings paths, package skills, and project.agents/skillsare listed the same way the runtime sees them.- Skill toggling edits only the
disable-model-invocationfrontmatter key on the targetSKILL.md; keep that surgical so user formatting survives. /api/skills/installshells throughnpx skills add ... --agent pi; project installs run with the selected cwd.
Auth and model config
ModelsConfigcombines models from~/.pi/agent/models.jsonwith provider auth status from pi'sAuthStorage/ModelRegistry.- OAuth/device-code/manual-code flows are streamed by
GET /api/auth/login/[provider]; manual code responses POST back with a short-lived token stored inglobalThis.__piLoginCallbacks. - API-key routes store and remove keys through
AuthStorage. Status endpoints must never return the raw key. - The model test endpoint is registered in
server/src/routes/models.tsas/api/models-config/test;/api/models/testis not a real route.
Completion sound
hooks/useAudio.tsstores the toggle inlocalStorageaspi-sound-enabledand reuses oneAudioContext.- Browser autoplay policy means sound must be unlocked from a user gesture;
ChatInputcalls the unlock hook from interactive controls, andChatWindowplays the tone fromonAgentEnd.
Exported session HTML
/api/sessions/[id]/exportdelegates to pi's export helper, then patches recursive tree helpers in the generated HTML to iterative versions so very deep linear sessions do not overflow the browser call stack.
Pi Session File Format
Location: ~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl
{"type":"session","version":3,"id":"<uuid>","timestamp":"...","cwd":"/path","parentSession":"/abs/path/to/parent.jsonl"}
{"type":"model_change","id":"<8hex>","parentId":null,"provider":"zenmux","modelId":"claude-sonnet-4-6","timestamp":"..."}
{"type":"message","id":"<8hex>","parentId":"<8hex>","message":{"role":"user","content":"..."}}
{"type":"message","id":"<8hex>","parentId":"<8hex>","message":{"role":"assistant","content":[...],...}}
{"type":"message","id":"<8hex>","parentId":"<8hex>","message":{"role":"toolResult","toolCallId":"...","content":[...]}}
{"type":"compaction","id":"<8hex>","parentId":"<8hex>","summary":"...","firstKeptEntryId":"<8hex>","tokensBefore":N}
{"type":"session_info","id":"...","parentId":"...","name":"user-defined name"}
entryIds[] in SessionContext is a parallel array to messages[] — maps each displayed message back to its .jsonl entry id, used for fork and navigate_tree calls.
CSS Variables (app/globals.css)
--bg --bg-panel --bg-hover --bg-selected --border
--text --text-muted --text-dim
--accent --user-bg --tool-bg
--font-mono