Skip to content

feat: add Pi Coding Agent support#862

Open
zhushanwen321 wants to merge 64 commits into
tiann:mainfrom
zhushanwen321:feat-pi-support
Open

feat: add Pi Coding Agent support#862
zhushanwen321 wants to merge 64 commits into
tiann:mainfrom
zhushanwen321:feat-pi-support

Conversation

@zhushanwen321

Copy link
Copy Markdown

Summary

Closes #840

Add Pi Coding Agent as a first-class agent flavor. Pi has a built-in --mode rpc that 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

Feature Status Key files
Register as agent flavor shared/modes.ts flavors.ts schemas.ts
CLI command (hapi pi) cli/commands/pi.ts registry.ts
Session create / resume / kill cli/pi/session.ts loop.ts runPi.ts
Text messaging + streaming output piTransport.ts piMessageAccumulator.ts piEventConverter.ts
Abort generation loop.ts
Model switching runPi.ts — provider learned from get_state
Tool call display piEventConverter.ts
Dynamic model discovery CLI auto-discovery → hub callPiRpc → web usePiModels.ts → model dropdown grouped by provider
Thinking Level (6 levels) PiThinkingLevelPanel.tsx — auto-filters levels per model's capability
Permission mode (default / yolo) PiPermissionPanel.tsx
Dedicated Pi composer toolbar Two Pi-specific pill buttons in composer toolbar (model selector + thinking level) with dropdown panels (PiModelPanel.tsx + PiThinkingLevelPanel.tsx). PiPermissionPanel.tsx in settings popover
Web UI agent icon AgentFlavorIcon.tsx
Context budget bar modelConfig.ts — 200K window

Extended features

Feature Status Notes
Skills / slash commands ✅ Full stack CLI: get_commands → generic listSlashCommands → web autocomplete
Steer (mid-stream steering) ✅ Transparent User messages auto-routed as steer when Pi is streaming

Not implemented (out of scope)

Follow-up, Queue modes, History replay, Compact, Fork, Clone, Switch session, Session stats, HTML export. Wireable via callPiRpc / callPiEndpoint generics when needed.

New vs Modified Files

21 new files:

  • cli/src/pi/ — 8 source files + 5 test files
  • web/ — 6 Pi UI components + usePiModels.ts hook

28 modified existing files across all 4 packages. All changes follow one of three zero-impact patterns:

  1. Enum / Record extension — add 'pi' key, no existing entries changed
  2. If-else branch addition=== 'pi' appended at chain tail
  3. New method additioncallPiRpc<T> / callPiEndpoint<T>, no existing methods modified

Notable hub-side changes (affect all agents)

  • sessions.ts effort endpoint uses supportsEffort() from shared flavors instead of hardcoded flavor !== 'claude'
  • syncEngine.applySessionConfig surfaces actual CLI error messages instead of generic "Missing applied session config"
  • sessionCache normalizes { provider, modelId } model object to plain string for DB storage (prevents [object Object])

Notable shared changes

  • SessionModelRequestSchema accepts { provider, modelId } object form in addition to plain string
  • MetadataSchema adds optional piAvailableModels for offline model list fallback
  • PiModelSummary includes reasoning and thinkingLevelMap fields for per-model thinking level filtering

Known 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 default and yolo only.

Test Coverage

  • Unit tests: 29 cases in cli/src/pi/, all pass
  • E2E protocol tests: 15 cases against live Pi RPC, 12 pass
  • Automated: bun run typecheck all 3 packages pass, bun run build:web succeeds

zhushanwen321 and others added 30 commits June 6, 2026 01:53
- 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)
…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
zhushanwen321 and others added 25 commits June 8, 2026 23:37
- 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)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Startup --model is never applied to Pi — runPi stores opts.model in HAPI state, but the subprocess is launched only with --mode rpc / --session-id and the startup sequence sends only new_session, get_state, get_available_models, and get_commands. get_state can then overwrite piSession.currentModel with Pi's default before any set_model is sent, so hapi 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.0 entry reuses the same package key immediately after the existing 0.20.1 entry, 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: bun is not installed in this Actions environment (/bin/bash: bun: command not found).

HAPI Bot

Comment thread cli/src/pi/runPi.ts
model: opts.model,
});

const transportArgs = ['--mode', 'rpc'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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' })
}

Comment thread bun.lock Outdated

"@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=="],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Pi abort ends the whole session instead of cancelling the current turn — the abort RPC sends Pi's abort command and then immediately starts lifecycle cleanup, which archives/ends the active HAPI session. The composer abort button is wired to /sessions/:id/abort for 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 null when the selected level is clicked again, and the CLI returns that value as applied, but the forwarding block only sends set_thinking_level when currentThinkingLevel is 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.ts was deleted with no replacement found in the PR, so scratchlist browser coverage is reduced.

HAPI Bot

Comment thread cli/src/pi/runPi.ts
// --- Abort handler ---
apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => {
transport.send({ type: 'abort' });
void lifecycle.cleanupAndExit();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 }
})

Comment thread cli/src/pi/runPi.ts
} else if (piSession.currentModel && !piSession.currentProvider) {
logger.debug('[pi] set_model suppressed: provider unknown until get_state');
}
if (piSession.currentThinkingLevel) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 })
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Pi abort ends the whole HAPI session instead of cancelling the active turn — the web composer abort action routes to /sessions/:id/abort to stop an in-flight generation, but this handler sends Pi abort and 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 null when the selected thinking level is clicked again, and this handler returns effort: null as applied, but the outbound command is guarded by if (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

Comment thread cli/src/pi/runPi.ts
// --- Abort handler ---
apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Abort, async () => {
transport.send({ type: 'abort' });
void lifecycle.cleanupAndExit();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 }
})

Comment thread cli/src/pi/runPi.ts
} else if (piSession.currentModel && !piSession.currentProvider) {
logger.debug('[pi] set_model suppressed: provider unknown until get_state');
}
if (piSession.currentThinkingLevel) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 })
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pi Coding Agent Support

1 participant