Pi Web - Development Notes
August 9, 2026 · View on GitHub
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 Server AgentSession (in-process)
│ │ │
├─ GET /api/sessions ────▶ reads ~/.pi/agent/sessions/ │
├─ GET /api/sessions/[id] reads .jsonl file directly │
├─ GET /api/agent/running ───────▶ running id snapshot │
│ │ │
├─ send message ─────────▶ POST /api/agent/[id] │
│ │ startRpcSession() ─────────▶│ createAgentSession()
│ │ session.send(cmd) ─────────▶│ session.prompt()
│ │ │
├─ SSE connect ──────────▶ GET /api/agent/[id]/events │
│ │ session.onEvent() ◀─────────│ session.subscribe()
│◀── data: {...} ─────────│ │
Session browsing (read-only): reads .jsonl files through SDK SessionManager helpers and lib/session-reader.ts — no AgentSession created.
Sending a message: startRpcSession() in lib/rpc-manager.ts creates an AgentSession in-process.
File Map
app/api/
sessions/route.ts GET list all sessions
sessions/[id]/route.ts GET/PATCH/DELETE session
sessions/[id]/context/route.ts GET ?leafId= — context for a specific leaf
sessions/[id]/export/route.ts GET exported HTML for a session
agent/new/route.ts POST { cwd, message, toolNames?, provider?, modelId? }
agent/[id]/route.ts GET state | POST any command
agent/[id]/events/route.ts GET SSE stream
agent/running/route.ts GET currently-running session ids
agent/running/events/route.ts GET SSE stream of currently-running session ids
auth/all-providers/route.ts GET API-key provider list
auth/api-key/[provider]/route.ts GET/POST/DELETE provider API key status/storage
auth/login/[provider]/route.ts GET OAuth/device-code SSE | POST manual code
auth/logout/[provider]/route.ts POST OAuth logout
auth/providers/route.ts GET OAuth provider list
cwd/validate/route.ts POST validate/select a cwd
default-cwd/route.ts POST create ~/pi-cwd-YYYYMMDD
files/[...path]/route.ts GET file contents for viewer
home/route.ts GET user home directory
models/route.ts GET { models, modelList, defaultModel }
models-config/route.ts GET/PUT — read/write ~/.pi/agent/models.json
models-config/catalog/route.ts GET models.dev pricing presets
models-config/discover/route.ts POST fetch a configured provider's upstream model list
models-config/test/route.ts POST test a configured model/provider
plugins/route.ts GET/POST package plugin management
skills/route.ts GET/PATCH loaded skills and disable-model-invocation
skills/install/route.ts POST install skills through npx skills add
skills/search/route.ts GET/POST skills.sh search
worktrees/route.ts GET/POST/DELETE git worktrees
lib/
agent-client.ts typed fetch helper for /api/agent commands
draft-store.ts local draft persistence helpers
file-access.ts allowed file roots for /api/files and worktrees
file-paths.ts client/server path encoding helpers
markdown.ts shared markdown helpers
npx.ts npx runner used by skill install
pi-types.ts local structural types for pi SDK objects
rpc-manager.ts AgentSessionWrapper + registry + startRpcSession
session-reader.ts SessionManager wrappers + path cache + buildSessionContext adapter
tool-presets.ts PRESET_NONE/READ_ONLY/DEFAULT/FULL + getPresetFromTools()
tool-preset-preference.ts browser-persisted default for fresh sessions
types.ts shared TypeScript types
normalize.ts normalizeToolCalls() — field name mismatch between file format and our types
worktree.ts project/worktree resolution and git worktree operations
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 (lib/rpc-manager.ts)
- One
AgentSessionWrapperper session id, keyed inglobalThis.__piSessions globalThissurvives Next.js hot-reload; plain module-level Map does not- 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.
The last preset explicitly selected by the user is stored in browser localStorage and initializes fresh-session composers only. Existing sessions never trust that preference; they use their live get_tools state or pi's default when no wrapper exists.
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. Explicit browser model/thinking selections are applied atomically during AgentSession construction, then lib/startup-preferences.ts persists their effective values without replaying set_model/set_thinking_level; implicit enabledModels fallbacks and thinking pins are not persisted.
enabledModels scoping
The enabledModels setting uses pi's --models syntax: minimatch globs against provider/modelId or a bare modelId, fuzzy matching for non-glob patterns, and an optional :thinkingLevel suffix. Never compare those patterns as literal strings — lib/model-scope.ts delegates to the SDK's resolveModelScopeWithDiagnostics() so pi-web and the TUI agree on the visible model list, and falls back to all available models when patterns resolve to nothing. startRpcSession() resolves that scope before creating an AgentSession and passes the selected initial model, thinking pin, and SDK-native scopedModels atomically; GET /api/models reuses the helper only for selector data, thinkingLevelPins, and modelScopeWarnings display.
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 polling + reconciliation
- The sidebar polls
/api/agent/runningevery 2.5 seconds while the tab is visible and pauses polling in background tabs. The session-list response remains the initial fallback. useAgentSessiontreats per-session SSE as primary for chat events and opens it before each prompt.prompt_donecompletes the current UI stage and notification immediately, but the idle SSE stays open for a 30-second grace window and is reused by the next prompt.agent_startcancels that close timer;agent_settledfinishes extension-injected runs that have no wrapper-levelprompt_doneand starts a fresh grace window. Do not close on the firstagent_end: retries, compaction, and extension-queued messages can continue the same logical prompt.- While a run is active,
useAgentSessionperiodically callsGET /api/agent/[id]and also reconciles onvisibilitychange/online. This fixes missed terminal events 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
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.
- git prints POSIX-style absolute paths even on Windows, so every path read out of git goes through
toNativePath()(lib/paths.ts) before it is compared or returned. Compare paths withsamePath(), never===— raw equality madeisTopLevelpermanently false on Windows and hid the worktree switcher entirely. Branch names are not paths and must keep their forward slashes. Browser code cannot apply Node path rules, so/api/worktreesresolvescurrentWorktreePathserver-side; the sidebar must use that identity for highlighting and removal fallback.
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.- Allowed roots are stored slash-normalized, but that is a Set-key convention, not a correctness requirement:
isPathWithinRoots()(lib/path-security.ts, the single implementation behindisFilePathAllowed()) re-resolves and case-folds both sides, so either path form authorizes correctly. Keep that one implementation — it is the security boundary.
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.- Provider listing is capability-driven, never id-driven:
lib/provider-listing.tsdecides membership fromauth.apiKey.login/auth.oauthplus the stored credential type, so dual-auth providers (anthropic and github-copilot today — which providers declare both changes between SDK releases, so never assume it from an id) appear exactly once and never fall through both lists (#309).lib/provider-listing-runtime.tsadaptsModelRuntimeto those pure helpers. - auth.json holds one credential per provider and
ModelRuntime.logout()deletes whichever it is. The delete routes therefore useremoveStoredCredentialIfType()to compare and delete under the same file lock used by pi's auth storage.ModelsConfigalso refreshes both provider lists after any auth change — refreshing one leaves a dual-auth provider rendered twice. - 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 route is
app/api/models-config/test/route.ts;app/api/models/test/is 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