feat: add Pi Coding Agent support#862
Conversation
- PiTransport: spawn pi --mode rpc, JSONL stdio, ENOENT/EPIPE handling - PiEventConverter: Pi AgentEvent → HAPI AgentMessage conversion - runPi: session lifecycle, dual-track event routing, model switching - pi command: CLI registration with PI_PERMISSION_MODES - Shared: add 'pi' to AGENT_FLAVORS, FLAVOR_CAPS, FLAVOR_LABELS 30 tests passing (15 transport + 15 converter)
…safety net - Add cli/src/pi/types.ts with PiAgentEvent/PiResponseEvent discriminated unions - PiTransport: constructor uses options object, double-start guard, drop log - PiEventConverter: typed events via type assertions, top-level try/catch - runPi: safeCleanup guard prevents double-cleanup race, sendAgentMessage for converted events, keepAlive() for session pings - 33 tests passing
- Business logic review: pass (0 must_fix) - Standards review: pass (0 must_fix) - Taste review: P0 types issue fixed in code - Robustness review v2: pass (v1 3 MUST_FIX all fixed) - Integration review: pass (0 must_fix) - Test results: 33 passing, all type errors resolved
- PiTransport: buffer cross-chunk reassembly test - PiEventConverter: tool_execution_end with missing result/toolCallId - handleResponse: 10 tests covering all branches (error, get_state, set_model, new_session, abort, prompt, unknown command) - Extract handleResponse to accept onUpdate callback for testability - Total: 46 tests passing (was 33)
…no native tools needed
…i RPC capture - TC-4-01 to TC-4-15: manual tests covering tool execution, thinking lifecycle, multi-turn, abort, error scenarios, model switch, cleanup - Priority: P0 (tool fields, failure, thinking, multi-turn, abort) > P1 (basic conversation, write tool, model switch, usage) > P2 (edge cases) - Includes actual Pi RPC event sequence from live capture as reference - e2e-test-plan.md updated with test environment setup instructions - Total test cases: 35 (6 unit + 14 integration + 15 manual)
P0/P1 automated tests (8/8 pass): - TC-4-01: Basic text conversation ✓ - TC-4-02: Tool read (field names verified) ✓ - TC-4-03: Tool write (file created) ✓ - TC-4-04: Tool failure (isError=true) ✓ - TC-4-05: Thinking lifecycle + usage ✓ - TC-4-06: Multi-turn context retention ✓ - TC-4-07: Abort generation ✓ - TC-4-14: Token count ✓ - TC-4-15: Extension UI events ignored ✓ P2 results: - TC-4-10: Invalid token → 401 ✓ - TC-4-12: Ctrl+C cleanup, no orphans ✓ - TC-4-08: ENOENT (harness issue, exit code correct) - TC-4-11: set_model not supported by Pi (success=false) - TC-4-13: Pi crash (harness output capture issue)
…odelId Previous test used invalid provider='' + modelId='deepseek-chat'. Re-tested with provider='deepseek' + modelId='deepseek-v4-flash': - set_model success=true - model switched glm-5.1 → deepseek-v4-flash - subsequent prompt confirmed working Final E2E results: 12/15 PASS, 2 FAIL (test harness), 1 SKIP
Local harness workflow artifacts should not be tracked in the repo.
Five bugs fixed for end-to-end pi session via hapi web UI:
1. runner buildCliArgs: add 'pi' branch to spawn correct command
(was falling back to 'claude', launching wrong agent)
2. runPi: implement real keep-alive (2s interval) to prevent hub
30s timeout marking session inactive
3. runPi: bump keep-alive to active state during agent/turn_start
4. sessionResume: add 'pi' to flavor switch and resume condition
(was returning undefined, causing 'cannotResume' on inactive session)
5. PiEventConverter: emit codex-compatible {type:'message',message:...}
/{type:'reasoning',message:...} with streamId; dedup by skipping
text_start/text_end (only send deltas) to avoid triple-rendered text
6. PiTransport: fallback to stdout 'end' event when child process
close event doesn't fire (bun spawn quirk)
Verified end-to-end: web UI shows pi reasoning + reply correctly,
session stays online, no duplicate text.
Three of four follow-up bugs reported after the initial fix (6c28949): 1. Stuck in 'queued' status — fix Pi's runner doesn't use MessageQueue2, so the base session's onBatchConsumed hook never fires. Add a FIFO of pending localIds in runPi and emit messages-consumed on agent_start. turn_start is intentionally skipped (it can fire multiple times per agent run after tool calls). A prompt rejection from Pi also consumes the localId so the next prompt isn't poisoned. 2. AI thinking only displays ':' — fix Pi emits pure incremental deltas (text_delta / thinking_delta) per token. The web reducer dedupes reasoning by streamId WITHIN one message's content array only — separate wire messages produce separate renders. Without accumulation, 50 deltas = 50 reasoning renders, of which the reducer keeps only the last delta (a single character like ':'). 3. Output text on separate lines — fix Same root cause as tiann#2 but for text: the reducer appends each text AgentMessage as a new agent-text block (no dedup), so 50 deltas become a 50-row character-by-character column. 4. Tool call execution status (in_progress -> completed) The tool result wire CodexMessage type is 'tool-call-result' (with callId + is_error?); the internal AgentMessage 'tool_result' is converted to that. Status mapping is preserved. Implementation: extract a PiMessageAccumulator class (testable in isolation) that mirrors codex's ReasoningProcessor pattern: - message_start resets state and streamId - text_delta / thinking_delta append to internal text / reasoning - text_start/thinking_start/text_end/thinking_end ignored (they carry full partial state — would duplicate) - message_end flushes (max 1 reasoning + 1 text message, in order) - turn_end safety net flushes if active - flushIfActive() exposed for transport close / crash The converter now routes AgentMessage through convertAgentMessage so the wire format is codex-shaped (matches opencode/gemini/kimi path). AgentMessage 'text' and CodexMessage 'message' both gain optional id; convertAgentMessage preserves caller-provided id for streamId-based dedup on the web side. Tests: 16 new PiMessageAccumulator tests + 5 updated PiEventConverter tests + 4 messageConverter tests, all passing. Full suite: 909/910 (1 unrelated macOS path normalization). tsc clean.
The web session-resume helper referenced metadata.piSessionId, but the shared MetadataSchema does not define the field, and the back-end has no path to populate it (Pi session resume is out of scope per spec.md). This caused web typecheck to fail and would also have produced a runtime 'resume_unavailable' from the hub if a user tried to resume a Pi session that had any user messages (the stale 'flavor === pi' branch in inactiveSessionCanResume claimed resume was supported). Revert the two early Pi branches from the web resume helper. Add a comment pointing at the spec and noting what to undo when back-end resume ships (re-add 'case pi' + 'piSessionId' on MetadataSchema + extend hub resolveAgentResumeId).
1. cli/src/runner/run.ts buildCliArgs: stop forwarding --resume to the pi binary. Pi session resume is out of scope (no piSessionId on Metadata), so forwarding would create an orphan session the hub can't track. Hub already returns null from resolveAgentResumeId for flavor='pi' and falls through to fresh spawn; this just hardens the runner layer to match. 2. cli/src/pi/runPi.ts: cache currentProvider from get_state and use it for subsequent set_model RPCs. Pi's set_model requires both provider and modelId, but the bootstrap-time code emitted provider: '' which Pi rejects. The bootstrap-time model is still applied by Pi at startup, so suppressing set_model until get_state arrives is a no-op for same-model configs rather than a wrong-model emit. 3. web/src/components/AssistantChat/modelOptions.ts: add explicit pi branches to getModelOptionsForFlavor and getNextModelForFlavor. Without them, Pi sessions fell through to the Claude preset cycler, which would push sonnet/opus ids into a Pi session via set-session-config. Mirrors the opencode handling introduced earlier. Tests added/updated: buildCliArgs covers pi + claude resume; handleResponse mirror test covers provider caching; modelOptions tests cover pi no-fallback behavior for both option list and cycler.
- Add piSessionId to MetadataSchema (shared/src/schemas.ts) - Persist piSessionId from get_state response to metadata (cli/src/pi/runPi.ts) - Pass --session-id to Pi spawn on resume (cli/src/pi/runPi.ts) - Add pi branch to resolveAgentResumeId (hub/src/sync/syncEngine.ts) - Add case 'pi' to resolveAgentSessionIdFromMetadata (web/src/lib/sessionResume.ts) - Replace pi resume skip guard with --session-id forwarding (cli/src/runner/run.ts) - Preserve piSessionId in pickExistingSessionMetadata (cli/src/agent/sessionFactory.ts) - Add pi badge to AgentFlavorIcon (web/src/components/AgentFlavorIcon.tsx) - Fix transport.onClose crash-marking on normal shutdown (cli/src/pi/runPi.ts)
- resume.ts: add pi branch to dispatchLocalResume() so hapi resume dispatches to runPi instead of falling through to cursor - runPi.ts: accept existingSessionId and use bootstrapExistingSession when resuming, matching other agents' pattern - agentCommandOptions.ts: parse --session-id in addition to --resume so runner-spawned pi resume actually forwards the session ID - types.ts: export PiPermissionMode alongside other agent permission mode types for consistent import convention
- Switch from structured output to file-based JSON output for reliability
- Replace per-round file limit (20→30) with clear wording (remove misleading split-commits instruction)
- Return { data, error } from readResultFile() to surface parse/validation failures in abortReason
- Fix lastMustFix sentinel: initialize to null, use ?? for explicit N/A reporting
- Add getAgentDirs() to dynamically discover agent dirs from cli/src/
- Document rollbackTo() atomic-round design intent
- Add isValidIssue() validation, runFinalCleanup() helper, git repo pre-check
- Remove extractPiImages helper and PiImageContent type: all attachments now use @path text references via formatMessageWithAttachments, consistent with every other agent - Remove images field from prompt/steer/follow_up RPC commands - Remove unused readFileSync import - Restore cli/package.json version from test pollution (0.0.0-integration-test-should-be-auto-cleaned-up-51369 → 0.20.0) Typecheck: all 3 packages pass Tests: 1286 pass, 0 failures
- Hub: extract withPiSession helper eliminating boilerplate across 15 Pi REST endpoints (~400 lines → ~150 lines) - Web: unify usePiForkMessages and usePiSessionStats to return destructured typed fields matching usePiModels/usePiCommands pattern - Web: move 15 Pi response types from inline import() to top-level named imports in api/client.ts - CLI: remove duplicate PiCommandSummary/PiCommandsResponse from types.ts, re-export from @hapi/protocol/apiTypes Typecheck: all 3 packages pass Tests: 1286 pass, 0 failures
Pi agent now self-handles SetSessionConfig RPC (like Claude) using the existing field, instead of adding a parallel field to the shared sessionConfigRpc helper which only knows about . - Remove effort/effortMode from sessionConfigRpc types and logic - runPi.ts: self-register RPC handler with PiThinkingLevel validation - Reuse resolveSessionConfigPermissionMode from sessionConfigRpc
rpcGateway: 12 methods → callPiRpc<T> syncEngine: 12 passthroughs → callPiRpc<T> delegate web client: 12 methods → callPiEndpoint<T> routes: use engine.callPiRpc with RPC_METHODS constants hooks: use callPiEndpoint, add missing type imports
Pi's thinking level is an effort variant, not a separate capability. The ThinkingLevel constant and supportsThinkingLevel() had zero callers — the frontend uses flavor-based branching for effort option rendering.
Steer: already handled by onUserMessage auto-routing Follow-up: redundant with HAPI message queue ListPiCommands/GetMessages/ForkMessages/SessionStats: no UI Compact/SetAutoCompaction/Fork/Clone/SwitchSession/ExportHtml: no UI SetSteeringMode/SetFollowUpMode: no UI Kept: ListPiModels (has UI), SetSessionConfig, ListSlashCommands, Abort, Switch Deleted: 4 web hooks, 13 RPC handlers, 12 REST routes, 13 rpcMethods entries Net: -730 lines
Restructure Pi agent following Codex pattern (without Local/Remote
splitting since Pi only has remote mode):
- session.ts: PiSession class managing state + hub communication
- loop.ts: response parsing, RPC resolver, transport event wiring
- runPi.ts: thin entry (bootstrap, RPC handlers, lifecycle)
Changes from review:
- Encapsulate RPC resolver in PiRpcResolver class (session-scoped,
not module-level singleton)
- Remove unused extractTextFromPiMessage export
- Fix inline import('./types') → top-level import
- Rename PiTransport.ts → piTransport.ts, PiEventConverter.ts → piEventConverter.ts, PiMessageAccumulator.ts → piMessageAccumulator.ts (match project-wide camelCase convention) - Delete handleResponse.test.ts (tested stale copy of inline function) - Add loop.test.ts with 20 tests covering parsePiModels, parsePiCommands, wireTransportEvents integration, and sendPiRpcAndWait - Total Pi tests: 73 (was 53)
Helper functions in e2e/harness.ts capture the four non-obvious interactions discovered during the 2026-06-09 retest: - longPress: SessionActionMenu is triggered by 500ms press, not click - mockOffline: useOnlineStatus hook listens to navigator.onLine + window offline event, not CDP Network.emulateNetworkConditions - pollForText: thinking indicator flickers in <1s, 3s polling misses - isVisible: element.offsetParent returns null for position:fixed dialogs even when visible; use getBoundingClientRect Plus Chrome lifecycle (startChrome/stopChrome, never pkill chrome) and hub API helpers (loginWithToken, listSessions). 5 integration specs (e2e/integration/) cover: - yolo-permission: toggle + localStorage persistence (4 cases) - codex-dialog: pre-flight check + dialog render (3 cases) - stress: 10 concurrent + invalid JWT + malformed + unknown endpoint (5 cases, all PASS) All 12 integration cases pass. Full E2E results in .xzy-harness/2026-06-09-full-e2e-retest/ (67 cases, 0 functional bugs found). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Fix PiModelPanel: use provider+modelId composite for selection check and React key, preventing duplicate highlights for same-name models across different providers - Fix PiThinkingLevelPanel: unify thinkingLevelMap filtering logic by extracting shared isThinkingLevelSupported utility - Fix HappyComposer: auto-reset effort to highest supported level when switching models, update label to reflect effective level
Remove unused types, methods, and re-exports identified by dead code audit: shared/src/apiTypes.ts (19): - SessionModelIdentifier, ListPiCommandsResponse - PiSteeringMode, PiFollowUpMode, PiSteerResponse, PiFollowUpResponse - PiQueueModeResponse, PiMessageEntry, PiMessagesResponse - PiCompactResponse, PiSetAutoCompactionResponse - PiForkResponse, PiForkMessageEntry, PiForkMessagesResponse - PiCloneResponse, PiSwitchSessionResponse - PiSessionStats, PiSessionStatsResponse, PiExportHtmlResponse cli/src/pi/types.ts (6): - PiSessionStats, PiCompactionResult, PiForkMessageEntry (dead local duplicates) - PiCommandsResponse, PI_THINKING_LEVELS, PI_THINKING_LEVEL_LABELS (dead re-exports) cli/src/pi/piMessageAccumulator.ts (1): - flushIfActive() method (comment claimed runPi calls it, but it doesn't) cli/src/pi/piTransport.ts (1): - isRunning() method (never called in production code) web/ (2): - ProviderGroup, PiThinkingLevelOption (unnecessary exports, made local) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tiann#3 Remove duplicated PI_THINKING_LEVELS in schemas.ts, import from @hapi/protocol tiann#2 Add piAvailableModels field to MetadataSchema (schema-runtime consistency) tiann#6 Replace hardcoded flavor names with supportsEffort() in effort route #1 Move PiRpcResolver from module-level singleton to PiSession instance tiann#4 Add piCachedModels fallback in piModelOptions useMemo tiann#7 Merge message_update dead branch into unified not-converted case tiann#10 Fix misleading Pi model list comments in modelOptions.ts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, remove extra blank line in rpcGateway (tiann#8) tiann#5: applySessionConfig now extracts modelId from { provider, modelId } before passing to setSessionModel / session.model, preventing [object Object] from being stored in SQLite when Pi switches models. tiann#8: Remove double blank line before RpcGateway class declaration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fig divergence
- sessionFactory: preserve piAvailableModels in pickExistingSessionMetadata
so web shows cached models on inactive-session view without RPC round-trip
- sessionConfigRpc: extend resolveNullableSessionModel to accept
{provider, modelId} object form for schema consistency
- runPi: document why Pi manually registers SetSessionConfig instead of
reusing registerSessionConfigRpc (wire protocol needs separate fields)
- package.json: restore version to 0.20.0
…view findings - Remove 13 unused PiRpcCommand variants and PiStreamingBehavior type (YAGNI) - Remove unnecessary exports on 3 internal Zod schemas in pi/schemas.ts - Extract JsonLineParser base class to utils/, shared by PiTransport, CodexAppServerClient, and AcpStdioTransport (eliminates 3x duplicate handleStdout buffer logic) - Remove DEV-only duplicate session ID detection from SessionList.tsx (debug code unrelated to Pi support scope) - Add comments explaining key prefix rationale in SessionChat.tsx
E2E harness (codex-dialog, stress, yolo-permission, scratchlist specs) was introduced in this branch but tests generic HAPI behavior unrelated to Pi agent support. Should live in a separate PR.
Keep both sides for all conflicts: - shared: Pi + Cursor modes/types/rpcMethods additions - hub: Pi callPiRpc + opencode reasoning effort routing - sessionCache: normalizedModel + markRuntimeConfigUpdated - web: Pi model/thinking/permission panels + cursor catalog readiness + scratchlist drawer + opencode reasoning effort - AcpStdioTransport: take upstream (guard logic, JsonLineParser not needed)
There was a problem hiding this comment.
Findings
- [Major] Startup
--modelis never applied to Pi —runPistoresopts.modelin HAPI state, but the subprocess is launched only with--mode rpc/--session-idand the startup sequence sends onlynew_session,get_state,get_available_models, andget_commands.get_statecan then overwritepiSession.currentModelwith Pi's default before anyset_modelis sent, sohapi pi --model ...or a runner-spawned Pi session with a model starts on the wrong model. Evidence:cli/src/pi/runPi.ts:63,cli/src/pi/runPi.ts:286.
Suggested fix:const startupModel = opts.model?.trim() || null transport.start() transport.send({ type: 'new_session' }) transport.send({ type: 'get_state' }) if (startupModel) { const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_available_models' }, 120_000) const models = parsePiModels(data) piSession.cachedPiModels = models const selected = models.find((model) => model.modelId === startupModel) if (!selected) throw new Error(`Pi model not found: ${startupModel}`) piSession.currentModel = selected.modelId piSession.currentProvider = selected.provider transport.send({ type: 'set_model', provider: selected.provider, modelId: selected.modelId }) } else { transport.send({ type: 'get_available_models' }) }
- [Major] Lockfile keeps two versions for the same Windows artifact — the added
@twsxtd/hapi-win32-x64@0.20.0entry reuses the same package key immediately after the existing0.20.1entry, so parsers that take the last key can resolve the Windows optional package back to the older artifact. This makes installs non-reproducible and can ship the wrong binary on Windows. Evidence:bun.lock:1079.
Suggested fix:- "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-1GWfncMeaZvBIfSB0RY4UI4ywiKUtOAi41nRHxqUI/VdWS9Rw3syCRa4bH2gFJzrdRtDdi0kfSib9YRHs1uQgg=="],
Summary
- Review mode: initial
- Found two major issues introduced by the diff: Pi startup model selection is ignored, and the lockfile has a duplicate Windows package entry.
Testing
- Not run:
bunis not installed in this Actions environment (/bin/bash: bun: command not found).
HAPI Bot
| model: opts.model, | ||
| }); | ||
|
|
||
| const transportArgs = ['--mode', 'rpc']; |
There was a problem hiding this comment.
[MAJOR] Startup --model is never applied to Pi. opts.model initializes HAPI's session state, but the Pi subprocess receives only --mode rpc / --session-id; the later startup sends do not send set_model, and get_state can overwrite piSession.currentModel with Pi's default before any model switch happens. This means hapi pi --model ... and runner-spawned Pi sessions with a model can silently start on the wrong model.
Suggested fix:
const startupModel = opts.model?.trim() || null
transport.start()
transport.send({ type: 'new_session' })
transport.send({ type: 'get_state' })
if (startupModel) {
const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_available_models' }, 120_000)
const models = parsePiModels(data)
piSession.cachedPiModels = models
const selected = models.find((model) => model.modelId === startupModel)
if (!selected) throw new Error(`Pi model not found: ${startupModel}`)
piSession.currentModel = selected.modelId
piSession.currentProvider = selected.provider
transport.send({ type: 'set_model', provider: selected.provider, modelId: selected.modelId })
} else {
transport.send({ type: 'get_available_models' })
}|
|
||
| "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.1", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-kHsA3aV9LlIbI0kpqeF8oFeSCLCubaGVHnC3l5AH51KbvlDGeYzXyr1S8KEdgVgd1Gg3cS6LmwYL/xnyr6WO5Q=="], | ||
|
|
||
| "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-1GWfncMeaZvBIfSB0RY4UI4ywiKUtOAi41nRHxqUI/VdWS9Rw3syCRa4bH2gFJzrdRtDdi0kfSib9YRHs1uQgg=="], |
There was a problem hiding this comment.
[MAJOR] This adds a second @twsxtd/hapi-win32-x64 package entry with version 0.20.0 right after the existing 0.20.1 entry. Because the key is identical, parsers that keep the last value can resolve the Windows optional package back to the older artifact, making installs non-reproducible and shipping the wrong Windows binary.
Suggested fix:
- "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-1GWfncMeaZvBIfSB0RY4UI4ywiKUtOAi41nRHxqUI/VdWS9Rw3syCRa4bH2gFJzrdRtDdi0kfSib9YRHs1uQgg=="],1. --model startup bug: - Add initialModel to PiSession to preserve startup model - handleGetState preserves initialModel instead of overwriting with Pi default - get_available_models handler resolves provider from cached models and sends set_model 2. bun.lock duplicate key: - Remove duplicate @twsxtd/hapi-win32-x64@0.20.0 entry - Fixes CI lockfile regeneration that caused hono type errors
There was a problem hiding this comment.
Findings
- [Major] Pi abort ends the whole session instead of cancelling the current turn — the abort RPC sends Pi's
abortcommand and then immediately starts lifecycle cleanup, which archives/ends the active HAPI session. The composer abort button is wired to/sessions/:id/abortfor stopping a running generation, and existing Claude/OpenCode handlers keep the session alive after abort. Evidence:cli/src/pi/runPi.ts:271.
Suggested fix:apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => { transport.send({ type: 'abort' }) piSession.piIsStreaming = false piSession.updateThinkingState(false) if (pendingLocalIds.length > 0) { piSession.emitMessagesConsumed([pendingLocalIds.shift()!], { clearQueuedThinkingGrace: true }) } return { success: true } })
- [Major] Clearing Pi thinking level is acknowledged but not applied — the Pi panel sends
nullwhen the selected level is clicked again, and the CLI returns that value as applied, but the forwarding block only sendsset_thinking_levelwhencurrentThinkingLevelis truthy. The hub/web will show the level as cleared while Pi keeps using the previous level. Evidence:cli/src/pi/runPi.ts:186,web/src/components/AssistantChat/PiThinkingLevelPanel.tsx:52.
Suggested fix:const shouldUpdateThinkingLevel = config.effort !== undefined if (config.effort !== undefined) { if (config.effort === null) { piSession.currentThinkingLevel = 'off' } else { const result = PiThinkingLevelSchema.safeParse( typeof config.effort === 'string' ? config.effort.trim().toLowerCase() : config.effort, ) if (!result.success) throw new Error('Invalid effort') piSession.currentThinkingLevel = result.data } } if (shouldUpdateThinkingLevel && piSession.currentThinkingLevel) { transport.send({ type: 'set_thinking_level', level: piSession.currentThinkingLevel }) }
Summary
- Review mode: follow-up after new commits
- The prior lockfile duplicate was fixed. The new Pi control path still has two major state-sync problems: abort tears down the session, and clearing thinking level only updates HAPI state.
Testing
- Not run (automation review; did not execute PR code). Also note:
e2e/scratchlist.spec.tswas deleted with no replacement found in the PR, so scratchlist browser coverage is reduced.
HAPI Bot
| // --- Abort handler --- | ||
| apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => { | ||
| transport.send({ type: 'abort' }); | ||
| void lifecycle.cleanupAndExit(); |
There was a problem hiding this comment.
[MAJOR] This makes the abort button end the Pi session instead of just cancelling the current generation. /sessions/:id/abort is the composer stop action, and the existing Claude/OpenCode handlers cancel the turn while leaving the session alive; calling cleanupAndExit() here marks the session ended immediately after sending Pi's abort command.
Suggested fix:
apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => {
transport.send({ type: 'abort' })
piSession.piIsStreaming = false
piSession.updateThinkingState(false)
if (pendingLocalIds.length > 0) {
piSession.emitMessagesConsumed([pendingLocalIds.shift()!], { clearQueuedThinkingGrace: true })
}
return { success: true }
})| } else if (piSession.currentModel && !piSession.currentProvider) { | ||
| logger.debug('[pi] set_model suppressed: provider unknown until get_state'); | ||
| } | ||
| if (piSession.currentThinkingLevel) { |
There was a problem hiding this comment.
[MAJOR] Clearing a Pi thinking level is acknowledged but never forwarded to Pi. The UI sends null when the selected level is clicked again, this handler stores currentThinkingLevel = null and returns it in applied, but the send block only calls set_thinking_level for truthy values. The web/hub will show the level as cleared while the Pi process keeps using the previous level.
Suggested fix:
const shouldUpdateThinkingLevel = config.effort !== undefined
if (config.effort !== undefined) {
if (config.effort === null) {
piSession.currentThinkingLevel = 'off'
} else {
const result = PiThinkingLevelSchema.safeParse(
typeof config.effort === 'string' ? config.effort.trim().toLowerCase() : config.effort,
)
if (!result.success) throw new Error('Invalid effort')
piSession.currentThinkingLevel = result.data
}
}
if (shouldUpdateThinkingLevel && piSession.currentThinkingLevel) {
transport.send({ type: 'set_thinking_level', level: piSession.currentThinkingLevel })
}There was a problem hiding this comment.
Findings
- [Major] Pi abort ends the whole HAPI session instead of cancelling the active turn — the web composer abort action routes to
/sessions/:id/abortto stop an in-flight generation, but this handler sends Piabortand then immediately runs lifecycle cleanup, which marks the session ended/archived. That makes a normal stop button terminate the remote session instead of leaving it available for the next prompt. Evidence:cli/src/pi/runPi.ts:271.
Suggested fix:apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => { transport.send({ type: 'abort' }) piSession.piIsStreaming = false piSession.updateThinkingState(false) if (pendingLocalIds.length > 0) { piSession.emitMessagesConsumed([pendingLocalIds.shift()!], { clearQueuedThinkingGrace: true }) } return { success: true } })
- [Major] Clearing Pi thinking level updates HAPI but never tells Pi — the UI sends
nullwhen the selected thinking level is clicked again, and this handler returnseffort: nullas applied, but the outbound command is guarded byif (piSession.currentThinkingLevel). The hub/web will show thinking cleared while the Pi process keeps using the previous level. Evidence:cli/src/pi/runPi.ts:186.
Suggested fix:const shouldUpdateThinkingLevel = config.effort !== undefined if (config.effort !== undefined) { if (config.effort === null) { piSession.currentThinkingLevel = 'off' } else { const result = PiThinkingLevelSchema.safeParse( typeof config.effort === 'string' ? config.effort.trim().toLowerCase() : config.effort, ) if (!result.success) throw new Error('Invalid effort') piSession.currentThinkingLevel = result.data } } if (shouldUpdateThinkingLevel) { transport.send({ type: 'set_thinking_level', level: piSession.currentThinkingLevel }) }
Summary
- Review mode: follow-up after new commits
- The latest commit only changed an effort endpoint test expectation. The two Pi control-path bugs from the prior bot review remain in the current head.
Testing
- Not run (automation review; static inspection only). Existing tests found for Pi parsing/loop behavior, but not for the abort lifecycle or null thinking-level RPC paths.
HAPI Bot
| // --- Abort handler --- | ||
| apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => { | ||
| transport.send({ type: 'abort' }); | ||
| void lifecycle.cleanupAndExit(); |
There was a problem hiding this comment.
[MAJOR] This makes the composer stop button archive/end the Pi session. /sessions/:id/abort is used to cancel the active generation, but calling cleanupAndExit() tears down the HAPI session after sending Pi abort.
Suggested fix:
apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => {
transport.send({ type: 'abort' })
piSession.piIsStreaming = false
piSession.updateThinkingState(false)
if (pendingLocalIds.length > 0) {
piSession.emitMessagesConsumed([pendingLocalIds.shift()!], { clearQueuedThinkingGrace: true })
}
return { success: true }
})| } else if (piSession.currentModel && !piSession.currentProvider) { | ||
| logger.debug('[pi] set_model suppressed: provider unknown until get_state'); | ||
| } | ||
| if (piSession.currentThinkingLevel) { |
There was a problem hiding this comment.
[MAJOR] Clearing the Pi thinking level is acknowledged locally but no command is sent to Pi. When the UI sends effort: null, line 170 stores null, then this truthy guard suppresses set_thinking_level, so the backend keeps the previous level while HAPI reports it cleared.
Suggested fix:
const shouldUpdateThinkingLevel = config.effort !== undefined
if (config.effort !== undefined) {
if (config.effort === null) {
piSession.currentThinkingLevel = 'off'
} else {
const result = PiThinkingLevelSchema.safeParse(
typeof config.effort === 'string' ? config.effort.trim().toLowerCase() : config.effort,
)
if (!result.success) throw new Error('Invalid effort')
piSession.currentThinkingLevel = result.data
}
}
if (shouldUpdateThinkingLevel) {
transport.send({ type: 'set_thinking_level', level: piSession.currentThinkingLevel })
}
Summary
Add Pi Coding Agent as a first-class agent flavor. Pi has a built-in
--mode rpcthat speaks JSONL over stdin/stdout. The integration follows the same subprocess pattern as Codex — spawn agent process, communicate via stdio pipes, receive real-time event stream. No ACP dependency.Feature Coverage
Core features
shared/modes.tsflavors.tsschemas.tshapi pi)cli/commands/pi.tsregistry.tscli/pi/session.tsloop.tsrunPi.tspiTransport.tspiMessageAccumulator.tspiEventConverter.tsloop.tsrunPi.ts— provider learned fromget_statepiEventConverter.tscallPiRpc→ webusePiModels.ts→ model dropdown grouped by providerPiThinkingLevelPanel.tsx— auto-filters levels per model's capabilityPiPermissionPanel.tsxPiModelPanel.tsx+PiThinkingLevelPanel.tsx).PiPermissionPanel.tsxin settings popoverAgentFlavorIcon.tsxmodelConfig.ts— 200K windowExtended features
get_commands→ genericlistSlashCommands→ web autocompletesteerwhen Pi is streamingNot implemented (out of scope)
Follow-up, Queue modes, History replay, Compact, Fork, Clone, Switch session, Session stats, HTML export. Wireable via
callPiRpc/callPiEndpointgenerics when needed.New vs Modified Files
21 new files:
cli/src/pi/— 8 source files + 5 test filesweb/— 6 Pi UI components +usePiModels.tshook28 modified existing files across all 4 packages. All changes follow one of three zero-impact patterns:
'pi'key, no existing entries changed=== 'pi'appended at chain tailcallPiRpc<T>/callPiEndpoint<T>, no existing methods modifiedNotable hub-side changes (affect all agents)
sessions.tseffort endpoint usessupportsEffort()from shared flavors instead of hardcodedflavor !== 'claude'syncEngine.applySessionConfigsurfaces actual CLI error messages instead of generic "Missing applied session config"sessionCachenormalizes{ provider, modelId }model object to plain string for DB storage (prevents[object Object])Notable shared changes
SessionModelRequestSchemaaccepts{ provider, modelId }object form in addition to plain stringMetadataSchemaadds optionalpiAvailableModelsfor offline model list fallbackPiModelSummaryincludesreasoningandthinkingLevelMapfields for per-model thinking level filteringKnown Limitation
Pi's RPC mode has no tool permission approval mechanism — tools execute automatically. HAPI's "approve tool calls from phone" won't work for Pi. The permission selector offers
defaultandyoloonly.Test Coverage
cli/src/pi/, all passbun run typecheckall 3 packages pass,bun run build:websucceeds