From e23ae1b265095f4dc9c7362451f5937ecadba6b2 Mon Sep 17 00:00:00 2001 From: ZzzzSsssWwww Date: Thu, 18 Jun 2026 10:01:10 +0800 Subject: [PATCH 01/34] feat: add Pi Coding Agent support (#862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: spec for hapi-pi-agent-backend * docs: spec retrospect for hapi-pi-agent-backend * docs: plan for hapi-pi-agent-backend * docs: plan retrospect for hapi-pi-agent-backend * feat(pi): add hapi pi command with JSONL transport and event converter - 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) * fix(pi): add Pi RPC types, fix double-cleanup/double-start/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 * docs: dev phase reviews and test results for hapi-pi-agent-backend - 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 * docs: taste review v2 pass after type definition fixes * docs: dev retrospect for hapi-pi-agent-backend * test: test execution for hapi-pi-agent-backend (20/20 pass) * fix: add taste_review symlink for gate pattern match * docs: test retrospect for hapi-pi-agent-backend * fix(web): add pi to MODEL_OPTIONS Record type * ci: PR and CI evidence for hapi-pi-agent-backend * docs: overall retrospect for hapi-pi-agent-backend (all 5 phases) * test(pi): add buffer split, missing fields, and handleResponse tests - 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) * fix(pi): set requiresRuntimeAssets to false — pi runs as subprocess, no native tools needed * refactor(cli): lazy import ensureRuntimeAssets to reduce startup overhead * docs: add 15 manual E2E protocol test cases (TC-4-xx) based on real Pi 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) * test: E2E protocol test results for hapi-pi (11/15 pass) 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) * test: fix TC-4-11 result — Pi set_model works with correct provider/modelId 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 * chore: remove .xyz-harness/ from git tracking, add to .gitignore Local harness workflow artifacts should not be tracked in the repo. * fix(pi): resolve web UI bugs for hapi-pi integration 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. * fix(pi): address 4 web UI display bugs in hapi-pi integration 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 #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. * fix(pi): review round 1 - 1 must-fix issue 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). * fix(pi): review round 2 - 4 must-fix issues 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. * fix(pi): add session resume support and fix review issues - 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) * fix(pi): review round 1 - 3 must-fix issues - 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 * fix(pi): review round 2 - 2 must-fix issues * refactor(workflow): improve pi-adaptation-review-loop robustness - 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 * test(pi): add coverage for pi flavor across shared, cli, and web - shared/flavors.test.ts: pi/kimi capability, label, known, supports - shared/modes.test.ts: PI_PERMISSION_MODES contract, per-mode checks (7-mode allowed/denied matrix) - web/AssistantChat/modelOptions.test.ts: pi shortcut vs Claude cycler, normalize filter (auto/default/whitespace), kimi/cursor/ opencode cross-flavor consistency - web/lib/sessionResume.test.ts: piSessionId resolver, cross-flavor stale-id protection, inactiveSessionCanResume for pi, regression coverage for all 6 other flavors - web/components/AgentFlavorIcon.test.tsx: pi badge styling (bg-[#5b21b6]), Un fallback, case/whitespace normalize, className override - cli/commands/agentCommandOptions.test.ts: --session-id (pi-specific flag), --resume alias, PI mode validation, --yolo vs explicit-mode priority 137 new test cases, all passing. Full suite: 96 files / 933 tests green (unrelated apiMachine.test.ts macOS /private/var path issue remains as documented in handoff). * feat(pi): implement P0 — context budget bar + dynamic model discovery P0-1: Context Budget Bar - Add pi branch to modelConfig.ts getContextBudgetTokens() - Conservative 200K default context window for Pi sessions P0-2: CLI-side model discovery - Add get_available_models to PiRpcCommand type - Auto-send get_available_models after get_state in runPi.ts - Cache model list and push to session metadata - Register ListPiModels RPC handler with promise-based transport query P0-3: Hub-side routing - Add listPiModelsForSession to rpcGateway and syncEngine - Add REST endpoint GET /sessions/:id/pi-models (pi sessions only) P0-4: Web-side rendering - Add PiModelSummary type to shared apiTypes - Add usePiModels hook (TanStack Query, stale 60s) - Add getSessionPiModels to API client - Add sessionPiModels query key - Wire piModelOptions into SessionChat availableModelOptions - Model dropdown renders discovered models or falls back to Default * fix(pi): address code review findings + pre-existing test issue Review fixes: - Fix race condition in sendPiRpcAndWait: use incremental id as key instead of command type, preventing resolver overwrite on concurrent calls (e.g. auto-discovery + ListPiModels RPC) - Extract parsePiModels() to eliminate duplicated model parsing logic between handleResponse and ListPiModels RPC handler (DRY) - Add resolvePendingRpc() call in error response path to prevent promise leaks when Pi rejects an RPC with an id - Add piModelsState.error guard to onModelChange in SessionChat, matching the pattern used by codex and cursor flavors Pre-existing fix: - Fix apiMachine.test.ts symlink assertion on macOS (/var vs /private/var) by applying realpathSync to the expected path * feat(pi): P1 — session rename sync, thinking level UI, skills/commands P1-1: Session Rename → Pi notification - Add set_session_name to PiRpcCommand - Register RenamePiSession RPC handler in CLI - Hub syncEngine.renameSession now forwards to Pi CLI for active sessions - Hub rpcGateway + REST endpoint added P1-2: Thinking Level support - Add Pi thinking level constants to shared/src/piThinkingLevel.ts (off/minimal/low/medium/high/xhigh) - Add ThinkingLevel capability to Pi flavor in flavors.ts - sessionConfigRpc now supports effortMode for Pi thinking level - runPi captures thinkingLevel from get_state and forwards via set_thinking_level - Hub effort endpoint accepts pi sessions (was claude-only) - Web: piThinkingLevelOptions.ts + HappyComposer renders Pi options when flavor=pi P1-3: Skills/Commands discovery - Add get_commands to PiRpcCommand, auto-discover after get_state - Register ListPiCommands + ListSlashCommands RPC handlers in CLI (maps Pi commands to HAPI SlashCommand format) - Hub: listPiCommandsForSession + REST GET /sessions/:id/pi-commands - Web: usePiCommands hook + api client + query keys Also fixes: - Pre-existing ZodError.errors → ZodError.issues in hub/socket/server.ts - Updated test expectation for effort endpoint error message * feat(pi): implement P2 features — steer, queue modes, history, native images P2-1: Steer/Follow-up - Track piIsStreaming state from agent_start/turn_start/turn_end/agent_end - When streaming, onUserMessage sends steer instead of prompt - Added PiSteer/PiFollowUp RPC methods + hub routing + REST endpoints P2-2: Queue modes - Added set_steering_mode/set_follow_up_mode to PiRpcCommand - CLI RPC handlers with mode state tracking - Hub routing + REST POST endpoints - Web API client methods P2-3: History replay - Added get_messages to PiRpcCommand - CLI handler converts Pi AgentMessage to PiMessageEntry format - Hub RPC routing + REST GET /sessions/:id/pi-messages - Web usePiMessages hook + query key P2-4: Native image passing - Added PiImageContent type for base64 image data - extractPiImages() helper reads attachment files as base64 - prompt/steer commands now include images field - Falls back to @path text reference for non-image/unreadable files * feat(pi): implement P3 advanced features — compact, fork, clone, switch, stats, export P3 features for Pi agent integration: - Compact: compact RPC with custom instructions, set_auto_compaction toggle - Fork: fork at entry ID, get_fork_messages for fork context - Clone: clone current Pi session - Switch Session: switch Pi to a different session by path - Session Stats: get token counts, message counts, cost - HTML Export: export session as HTML file All features follow existing P2 pattern: - CLI: RPC handlers in runPi.ts with sendPiRpcAndWait - Hub: rpcGateway + syncEngine routing + REST endpoints - Web: API client methods + query keys + type exports + hooks (stats, fork messages) Total: 8 new REST endpoints, 9 RPC handlers, 6 web API methods Typecheck: all 3 packages pass (cli+hub+web) Tests: 1155 pass (263 hub + 803 web + 89 shared), 0 failures * refactor(pi): clean up runPi.ts imports and readability - Replace require('fs') with top-level import { readFileSync } from 'fs' - Extract handleGetState() as standalone function from handleResponse switch case (get_state case: 35 lines → 4 lines dispatch) Typecheck: all 3 packages pass Tests: 1066 pass (263 hub + 803 web), 0 failures * fix(pi): remove native image passing, fix version pollution - 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 * refactor(pi): extract hub helper, unify web hooks, fix import style - 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 * chore: untrack .agents/skills and .pi, fix .xyz-harness in gitignore * refactor: remove unused text message id from converter layer, update gitignore * fix: update tests for pi resume support and text id removal * fix: restore cursor resume branch in buildCliArgs * refactor: remove pi-specific rename from syncEngine, align with other agents * refactor: remove effort field from sessionConfigRpc, Pi self-handles RPC 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 * refactor: consolidate Pi RPC layer from 36 methods to 3 generics rpcGateway: 12 methods → callPiRpc syncEngine: 12 passthroughs → callPiRpc delegate web client: 12 methods → callPiEndpoint routes: use engine.callPiRpc with RPC_METHODS constants hooks: use callPiEndpoint, add missing type imports * chore: revert unrelated apiMachine test change * refactor: remove unused ThinkingLevel capability from flavors 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. * refactor: drop Pi prefix from generic RPC method names * refactor: remove 13 Pi RPC methods with no UI consumers 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 * refactor: extract session.ts and loop.ts from runPi.ts 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 * refactor: normalize Pi file naming and improve test coverage - 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) * test: add E2E harness with 4 core helpers and integration specs 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 * fix: resolve Pi model selection and thinking level issues - 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 * refactor: remove 29 dead exports from feat-pi-support 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 * fix: resolve 7 PR review issues in Pi support #3 Remove duplicated PI_THINKING_LEVELS in schemas.ts, import from @hapi/protocol #2 Add piAvailableModels field to MetadataSchema (schema-runtime consistency) #6 Replace hardcoded flavor names with supportsEffort() in effort route #1 Move PiRpcResolver from module-level singleton to PiSession instance #4 Add piCachedModels fallback in piModelOptions useMemo #7 Merge message_update dead branch into unified not-converted case #10 Fix misleading Pi model list comments in modelOptions.ts Co-Authored-By: Claude Opus 4.8 * fix: normalize Pi model object to string in hub sessionCache (#5), remove extra blank line in rpcGateway (#8) #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. #8: Remove double blank line before RpcGateway class declaration. Co-Authored-By: Claude Opus 4.8 * fix(pi): preserve piAvailableModels on resume, document SetSessionConfig 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 * refactor: remove unused Pi types, extract JsonLineParser, clean up review 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 * chore: remove unrelated E2E test harness from Pi support PR 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. * fix: wrap cursor model change handler for union type compatibility * fix: apply startup --model to Pi and remove duplicate lockfile entry 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 * fix: update test expectation for effort endpoint error message * fix(pi): resolve 8 link-review defects + abort session termination - W1C-D-1: hasSameAgentSessionIds missing piSessionId/kimiSessionId + extractAgentSessionId also needs piSessionId recognition - D-1: dispatchLocalResume pi branch missing effort param - W1B-1-01: buildCliArgs only passes --effort for claude, not pi - W2B-D-2: effort=null does not send set_thinking_level to Pi - D-3: turn_start does not consume pendingLocalIds - D-7: keep_alive falls into default case in convertPiEvent - D-9: finally overwrites sessionEndReason set by Switch/Abort - W2B-D-3: ListPiModels RPC does not update metadata - Abort handler: remove cleanupAndExit, only cancel current turn Also: Switch handler returns { success: true } for consistency Test coverage: 13 new test cases across 5 files * fix: restore cli version from integration test placeholder * fix(pi): send restored thinking level to Pi subprocess on startup opts.effort was stored in piSession.currentThinkingLevel but never forwarded via set_thinking_level during the startup sequence, causing runner-spawned and resumed sessions to show the restored effort in HAPI while Pi kept its default. * fix: restore cli package version from integration test residue * fix(pi): switch-to-remote handler preserves session instead of terminating Replace lifecycle.cleanupAndExit() with createModeChangeHandler + keepAlive in the Switch RPC handler. Pi runs as a single long-lived subprocess without BaseLocalLauncher's restart loop, so cleanupAndExit() permanently destroyed the session on mode switch. The web handoff button now correctly changes control mode while keeping Pi alive. * fix(pi): remove permission mode selector (Pi RPC has no runtime switching) Pi's --mode rpc is non-interactive and auto-approves all tool execution; there is no set_permission_mode command in the protocol. The selector reported success without changing Pi's behavior, misleading users. Remove the concept across all four packages: - shared: getPermissionModesForFlavor('pi') returns [] (cascades to hub 400 + web UI auto-hide via length===0 guards); drop PI_PERMISSION_MODES / PiPermissionMode - cli: strip permissionMode from PiSession/runPi/pi command/resume; drop the no-op SetSessionConfig permission branch that stored state without forwarding to the subprocess - web: delete PiPermissionPanel.tsx; remove panel block + imports from HappyComposer * fix(cli): realpath workspace root in apiMachine test assertion The handler realpaths the cwd as a symlink-escape guard, so on macOS /var/folders/... resolves to /private/var/folders/... The test compared against the un-resolved path and failed on macOS. Use realpathSync on the expected value for cross-platform consistency (no-op on Linux where /tmp has no symlink prefix). * fix(pi): keepalive reads current mode instead of constructor-time startingMode The Switch handler updated controlledByUser but PiSession.pushKeepAlive() still emitted the readonly startingMode every 2s, so a runner-started session switched to local would flip back to remote on the next keepalive. Replace readonly startingMode with a mutable mode field; add setMode() that updates it and re-pushes keepAlive immediately. The Switch RPC handler now calls setMode() before handleModeChange. * fix(pi): runner no longer passes permission flags to Pi subprocess After removing the Pi permission selector, the Pi command parser rejects --permission-mode and ignores --yolo. But the shared buildCliArgs tail in the runner still appended these flags for Pi sessions, making runner- spawned Pi children exit before registering a session. Guard the permission/yolo append with agent !== 'pi'. * fix(pi): preserve provider identity when persisting selected Pi model The hub's applySessionConfig normalized Pi's { provider, modelId } object down to a plain modelId string for the shared session.model field, losing the provider. On reload or next render, web's selectedPiModel lookup matched by modelId alone — if two providers share a modelId, the wrong one was highlighted, and subsequent model/thinking-level changes sent the wrong provider to the Pi subprocess. Add a provider-qualified piSelectedModel field to session metadata (schema + persistPiSelectedModel mirroring persistPreferredPermissionMode). Web's selectedPiModel now prefers the provider-qualified match and only falls back to modelId-only matching when absent. * fix(pi): model picker checkmark follows provider-qualified selection selectedPiModel already resolves via provider+modelId, but the model panel's currentPiModel still matched by modelId alone — so with two providers sharing a modelId the checkmark pointed at the wrong row. Reuse selectedPiModel directly. * fix(pi): steer messages consumed immediately, not queued in pendingLocalIds onUserMessage unconditionally pushed localId into pendingLocalIds, but a steer (sent while piIsStreaming) does not start a new turn — so the steer's localId was never drained by turn_start. The next normal prompt's turn_start would consume the stale steer localId instead, leaving the new prompt's bubble stuck in the queued bar. Only queue localId for the prompt path. Steer path emits messages-consumed immediately. * fix(pi): clear stale thinking level when switching to non-reasoning model The model-change effect early-returned when selectedPiModel.reasoning === false, leaving the previously-set effort (e.g. 'high') persisted on the session. The UI hid the thinking picker for the non-reasoning model, but the hub still forwarded the stale effort as set_thinking_level — with no visible control to clear it. Call onEffortChange(null) for non-reasoning models. * fix(pi): return provider-qualified model in SetSessionConfig applied The CLI handler returned only currentModel (bare string), so the hub's applySessionConfig saw a non-object model and cleared metadata.piSelectedModel via persistPiSelectedModel(session, null) — undoing the provider that was just stored on the inbound config. Return { provider, modelId } when both are known so the hub keeps the provider-qualified metadata intact across active model changes. * fix(pi): preserve piSelectedModel in bootstrapExistingSession metadata The metadata whitelist rebuild kept piAvailableModels but omitted piSelectedModel, so the first resume/local-handoff update dropped the provider identity — after which web fell back to modelId-only matching and could select the wrong provider for duplicate modelIds. * fix(pi): await Pi confirmation before reporting model/effort applied SetSessionConfig was fire-and-forget — transport.send wrote JSONL to stdin and returned immediately. If Pi rejected an invalid provider/model or thinking level, the hub still persisted the new value and the UI reported success while Pi kept the old runtime state. Use sendPiRpcAndWait so a failed set_model/set_thinking_level rejects the web request and leaves the session config unchanged. * fix(pi): resolve set_model RPC so awaited model switch does not time out SetSessionConfig awaits sendPiRpcAndWait(set_model) before reporting the model applied, but handleResponse's set_model branch updated state and fell through without calling resolvePendingRpc. The pending RPC promise then waited the full 10s timeout and rejected, making /sessions/:id/model return 409 even though Pi accepted the change. Mirror every other branch by resolving the pending RPC after updating currentModel/currentProvider. * fix(pi): drain pending localId on turn_start only; throw when set_model suppressed - loop.ts: split agent_start/turn_start branches. Pi emits both per prompt; draining on both popped the FIFO twice and shipped an undefined localId to the hub. agent_start now only sets thinking state; turn_start drains. - runPi.ts: when set_model is suppressed (provider unknown), throw instead of silently returning applied, so the hub returns 409 rather than persisting a piSelectedModel Pi never received. - loop.test.ts: assert agent_start does not drain; add regression test that a single turn drains exactly one real localId. * fix(pi): exclude Pi from generic Ctrl/Cmd+M model cycler SessionChat fed piModelOptions into HappyComposer.availableModelOptions, so the global Ctrl/Cmd+M shortcut ran getNextModelForFlavor over the Pi list and called onModelChange with a bare modelId string. Pi needs { provider, modelId } to disambiguate duplicate model IDs across providers; a bare string made runPi fall back to the first cached provider match (wrong provider) or throw when the provider was unknown. Drop the piModelOptions useMemo and pass undefined for Pi, mirroring modelOptions.ts where the Pi branch already returns the current model unchanged (no-op) when no custom options are supplied. Pi model changes now go only through the dedicated provider-qualified picker (piModels). * fix(pi): commit PiSession config only after Pi confirms the RPC SetSessionConfig previously mutated piSession.currentModel / currentProvider / currentThinkingLevel BEFORE awaiting sendPiRpcAndWait(set_model / set_thinking_level). When Pi rejected the value or the RPC timed out, the handler threw and the route returned 409, but PiSession kept the unconfirmed values; the 2s keepalive then reported them back to the hub, where handleSessionAlive persisted a model/effort Pi never accepted. Resolve the requested model/effort into locals first, send the RPCs, and only commit to PiSession after each await resolves. The null (clear-model) path needs no RPC so it still commits immediately; the unknown-provider path still throws without committing. * fix(pi): apply startup model only after Pi confirms set_model Two startup paths persisted the requested --model before Pi confirmed it: 1. handleGetState set session.currentModel = session.initialModel as soon as get_state returned, using the unconfirmed startup model instead of Pi's actual default. If the model was unavailable or rejected, the 2s keepAlive reported it to the hub, which persisted/showed a model Pi never accepted. 2. get_available_models then sent set_model fire-and-forget, so a Pi rejection was never observed and currentModel stayed on the bad value. Fix: handleGetState now reports Pi's real current model (newModel) while a startup model is merely requested. get_available_models resolves the provider from the cached list, awaits set_model, and commits currentModel/currentProvider only on success — on rejection it logs and keeps Pi's default. The await is fired detached so the get_available_models RPC itself still resolves for ListPiModels. * fix(pi): do not persist startup model before Pi confirms set_model The startup --model still reached the hub unconfirmed via two paths the previous Fix #13 left open: 1. bootstrapSession({ model: opts.model }) seeded the hub session model at creation time, and SessionCache.handleSessionAlive persists every non-undefined keepAlive model — so an unavailable/rejected model was stored and shown before get_available_models/set_model ran. 2. PiSession constructor set this.currentModel = opts.model, so the very first keepAlive (sent by startKeepAlive before any RPC confirms the model) reported the unconfirmed value. Pass model: undefined to bootstrapSession and start PiSession.currentModel at null; opts.model is still captured as initialModel and applied/committed only after get_available_models confirms it exists and set_model succeeds (Fix #13). The hub now sees Pi's real current model from the first get_state keepAlive and switches to the requested model only once accepted. Also add sendPiRpcAndWait contract tests pinning the await<->resolve symmetry (Fix #10): set_model/set_thinking_level/get_available_models must resolve before timeout on a success response, and reject on a Pi error. * fix(pi): apply startup effort only after Pi confirms set_thinking_level runPi restored opts.effort straight into piSession.currentThinkingLevel before startKeepAlive ran, and pushKeepAlive persists effort — so a resumed/runner-spawned session could store/show a thinking level Pi rejected or ignored. This is the effort analog of the startup-model confirmation contract (Fix #13/#14). Capture the requested effort into a local startupThinkingLevel instead of mutating currentThinkingLevel up front. After transport.start() and the get_state/get_available_models/get_commands sends, await set_thinking_level and commit currentThinkingLevel + push a keepAlive only on success; on rejection keep Pi's default (already reported by get_state). The await is detached so the run loop is not blocked, and get_state is sent before the set so its authoritative baseline lands first and cannot clobber the confirmed value. * fix(pi): omit unknown runtime config from keepalive, don't clear persisted state Fix #14 changed PiSession.currentModel to start at null so the startup --model was not leaked before confirmation. But the hub treats keepAlive model:null as an explicit clear (sessionCache.ts only skips when the field is undefined), so the first heartbeat (startKeepAlive runs before get_state) now erased a resumed Pi session's persisted model/effort before Pi reported its real state. Distinguish "unknown" from "clear": currentModel/currentThinkingLevel start undefined and keepAlive omits undefined fields (via getKeepAliveRuntime), so the hub leaves persisted values alone until Pi confirms. null remains an explicit clear and is still forwarded. Once get_state/set_model/set_thinking_level confirm a value it is set and reported normally. * fix(pi): disable Ctrl/Cmd+M model cycler for Pi entirely Fix #11 removed piModelOptions from availableModelOptions, assuming getNextModelForFlavor('pi', model, undefined) was a no-op. It is not: the Pi branch returns normalizeCurrentModel(model), i.e. the current modelId as a bare string, so the shortcut still called onModelChange with a bare modelId. That loses the provider and can pick the wrong cached match, clear the model when session.model is empty, or hit 'provider is not yet known'. Short-circuit the handler for Pi so model changes go only through the dedicated provider-qualified PiModelPanel. * fix(pi): persist piSelectedModel from get_state and startup set_model paths Pi stores session.model as the bare modelId and relies on metadata.piSelectedModel ({ provider, modelId }) to disambiguate duplicate modelId values across providers in the web picker and thinking-level filtering. But piSelectedModel was only written by the web /sessions/:id/model path (hub persistPiSelectedModel). The runtime paths that set currentModel/currentProvider — get_state, the startup get_available_models set_model, and the set_model response — only keepAlive'd the bare modelId, so a Pi session on Pi's default model, resumed from CLI, or started with --model had no provider identity in metadata and could render/filter against the wrong provider. Add persistSelectedPiModel(session) (no-op unless both fields are known) and call it after get_state, after a successful startup set_model, and after the set_model response updates the fields. This mirrors what the web picker already does. * fix(pi): default startingMode to remote — Pi has no local TUI path A terminal `hapi pi` launch defaulted to startingMode 'local' and marked the session controlledByUser, but Pi only runs as `pi --mode rpc` with piped stdio — there is no local terminal/TUI input path like Claude/Codex have. The terminal user could not drive the session and the web treated it as local-controlled, so the first terminal Pi session was stuck until manually switched from the web. Default to 'remote' so the session is immediately drivable from the web. An explicit opts.startingMode (runner path) still takes precedence. * fix(pi): resume with remote startingMode — no local TUI path The previous Fix #19 changed the `hapi pi` default to remote, but `hapi resume` still passed startingMode: 'local' into runPi for Pi sessions, re-introducing the same unsupported local-control state on the resume path: setControlledByUser publishes controlledByUser while Pi has no terminal/TUI input, hiding/rejecting remote-only controls until a web switch. Pass 'remote' here too and update the resume test accordingly. * fix: restore e2e/scratchlist.spec.ts deleted from main by mistake The earlier "remove unrelated E2E harness" commit (d1e5b4c) deleted the whole e2e/ directory this branch had added, but scratchlist.spec.ts is a main-branch Playwright spec (the only file under playwright testDir ./e2e). Its removal left `bun run test:e2e` with no tests to run while the script and playwright.config.ts still point at that directory. Restore scratchlist.spec.ts from main; the unrelated harness files (HARNESS.md, harness.*, integration/*.mts) that were genuinely branch-only additions stay removed. --------- Co-authored-by: pi Co-authored-by: Claude --- .gitignore | 3 + bun.lock | 2 + cli/package.json | 2 +- cli/src/agent/localHandoff.test.ts | 1 + cli/src/agent/runnerLifecycle.test.ts | 87 +++ cli/src/agent/runnerLifecycle.ts | 6 + cli/src/agent/sessionConfigRpc.ts | 12 + cli/src/agent/sessionFactory.ts | 6 + cli/src/api/apiMachine.test.ts | 6 +- cli/src/codex/codexAppServerClient.ts | 26 +- cli/src/codex/runCodex.test.ts | 3 +- cli/src/commands/agentCommandOptions.test.ts | 108 ++++ cli/src/commands/agentCommandOptions.ts | 14 + cli/src/commands/pi.ts | 32 ++ cli/src/commands/registry.ts | 2 + cli/src/commands/resume.test.ts | 34 ++ cli/src/commands/resume.ts | 17 + cli/src/commands/runCli.ts | 2 +- cli/src/gemini/runGemini.test.ts | 3 +- cli/src/opencode/runOpencode.test.ts | 3 +- cli/src/pi/loop.test.ts | 509 ++++++++++++++++++ cli/src/pi/loop.ts | 312 +++++++++++ cli/src/pi/piEventConverter.test.ts | 224 ++++++++ cli/src/pi/piEventConverter.ts | 88 +++ cli/src/pi/piMessageAccumulator.test.ts | 211 ++++++++ cli/src/pi/piMessageAccumulator.ts | 96 ++++ cli/src/pi/piTransport.test.ts | 210 ++++++++ cli/src/pi/piTransport.ts | 123 +++++ cli/src/pi/runPi.ts | 403 ++++++++++++++ cli/src/pi/schemas.ts | 203 +++++++ cli/src/pi/session.ts | 127 +++++ cli/src/pi/types.ts | 119 ++++ cli/src/runner/buildCliArgs.test.ts | 40 ++ cli/src/runner/run.ts | 21 +- cli/src/utils/jsonLineParser.ts | 35 ++ hub/src/sync/rpcGateway.ts | 8 +- hub/src/sync/sessionCache.ts | 51 +- hub/src/sync/sessionModel.test.ts | 56 ++ hub/src/sync/syncEngine.ts | 14 +- hub/src/web/routes/sessions.test.ts | 2 +- hub/src/web/routes/sessions.ts | 41 +- shared/src/apiTypes.ts | 43 +- shared/src/flavors.test.ts | 18 + shared/src/flavors.ts | 2 + shared/src/index.ts | 1 + shared/src/modes.test.ts | 66 ++- shared/src/modes.ts | 7 +- shared/src/piThinkingLevel.ts | 13 + shared/src/rpcMethods.ts | 1 + shared/src/schemas.ts | 11 +- web/src/api/client.ts | 10 +- web/src/chat/modelConfig.ts | 9 + web/src/components/AgentFlavorIcon.test.tsx | 101 ++++ web/src/components/AgentFlavorIcon.tsx | 4 + .../AssistantChat/ComposerButtons.tsx | 49 ++ .../AssistantChat/HappyComposer.tsx | 306 ++++++++--- .../components/AssistantChat/PiModelPanel.tsx | 72 +++ .../AssistantChat/PiThinkingLevelPanel.tsx | 76 +++ .../AssistantChat/modelOptions.test.ts | 91 ++++ .../components/AssistantChat/modelOptions.ts | 13 + .../components/AssistantChat/piModelGroups.ts | 35 ++ .../AssistantChat/piThinkingLevelOptions.ts | 82 +++ web/src/components/NewSession/types.ts | 1 + web/src/components/SessionChat.tsx | 38 +- web/src/hooks/mutations/useSessionActions.ts | 4 +- web/src/hooks/queries/usePiModels.ts | 49 ++ web/src/lib/query-keys.ts | 1 + web/src/lib/sessionResume.test.ts | 129 +++++ web/src/lib/sessionResume.ts | 4 +- web/src/types/api.ts | 3 + 70 files changed, 4359 insertions(+), 142 deletions(-) create mode 100644 cli/src/agent/runnerLifecycle.test.ts create mode 100644 cli/src/commands/pi.ts create mode 100644 cli/src/pi/loop.test.ts create mode 100644 cli/src/pi/loop.ts create mode 100644 cli/src/pi/piEventConverter.test.ts create mode 100644 cli/src/pi/piEventConverter.ts create mode 100644 cli/src/pi/piMessageAccumulator.test.ts create mode 100644 cli/src/pi/piMessageAccumulator.ts create mode 100644 cli/src/pi/piTransport.test.ts create mode 100644 cli/src/pi/piTransport.ts create mode 100644 cli/src/pi/runPi.ts create mode 100644 cli/src/pi/schemas.ts create mode 100644 cli/src/pi/session.ts create mode 100644 cli/src/pi/types.ts create mode 100644 cli/src/utils/jsonLineParser.ts create mode 100644 shared/src/piThinkingLevel.ts create mode 100644 web/src/components/AgentFlavorIcon.test.tsx create mode 100644 web/src/components/AssistantChat/PiModelPanel.tsx create mode 100644 web/src/components/AssistantChat/PiThinkingLevelPanel.tsx create mode 100644 web/src/components/AssistantChat/piModelGroups.ts create mode 100644 web/src/components/AssistantChat/piThinkingLevelOptions.ts create mode 100644 web/src/hooks/queries/usePiModels.ts diff --git a/.gitignore b/.gitignore index 1ab099e267..7c12eb7360 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ cli/npm/main/ test-results/ playwright-report/ e2e-output/ +.xyz-harness +.agents/ +.pi/ diff --git a/bun.lock b/bun.lock index 8400dfe307..7a40e43908 100644 --- a/bun.lock +++ b/bun.lock @@ -1074,6 +1074,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.2", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-AWFK3ERb6oY0tOzGaNrKEOqSFWBb/HjJ90Q8TOOLZIlckSVFSa5l5ortDOpiTlLf5fTIgfx3hRlR56eOrVfP4Q=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.2", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-o4O/q+vvVrOt4kLy2uBcR/ubQChQeDvq1TtybGkyPq9u1Y4LZkBbM36++TBzAXXaCNn86hQDOUjZs9seXoi18A=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/cli/package.json b/cli/package.json index ebfd922781..0cfc660224 100644 --- a/cli/package.json +++ b/cli/package.json @@ -83,4 +83,4 @@ "@types/parse-path": "7.0.3" }, "packageManager": "bun@1.3.14" -} +} \ No newline at end of file diff --git a/cli/src/agent/localHandoff.test.ts b/cli/src/agent/localHandoff.test.ts index 1405a9441c..d1c1f4be13 100644 --- a/cli/src/agent/localHandoff.test.ts +++ b/cli/src/agent/localHandoff.test.ts @@ -12,6 +12,7 @@ describe('registerLocalHandoffHandler', () => { const lifecycle = { setArchiveReason: vi.fn(), setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false), cleanupAndExit: vi.fn(async () => {}) } diff --git a/cli/src/agent/runnerLifecycle.test.ts b/cli/src/agent/runnerLifecycle.test.ts new file mode 100644 index 0000000000..ec9c3d99c6 --- /dev/null +++ b/cli/src/agent/runnerLifecycle.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createRunnerLifecycle } from './runnerLifecycle'; +import type { RunnerLifecycle } from './runnerLifecycle'; + +// Mock heavy deps +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + getLogPath: vi.fn(() => '/tmp/test.log'), + }, +})); + +vi.mock('@/ui/terminalState', () => ({ + restoreTerminalState: vi.fn(), +})); + +function createMockApiSession() { + return { + updateMetadata: vi.fn(), + sendSessionDeath: vi.fn(), + flush: vi.fn(), + close: vi.fn(), + } as unknown as Parameters[0]['session']; +} + +describe('createRunnerLifecycle', () => { + let lifecycle: RunnerLifecycle; + + beforeEach(() => { + vi.clearAllMocks(); + lifecycle = createRunnerLifecycle({ + session: createMockApiSession(), + logTag: 'test', + }); + }); + + // --- D-9: hasExplicitSessionEndReason --- + + describe('hasExplicitSessionEndReason', () => { + it('returns false initially', () => { + expect(lifecycle.hasExplicitSessionEndReason()).toBe(false); + }); + + it('returns true after setSessionEndReason is called', () => { + lifecycle.setSessionEndReason('completed'); + expect(lifecycle.hasExplicitSessionEndReason()).toBe(true); + }); + + it('returns false after markCrash — markCrash does NOT set explicit flag', () => { + lifecycle.markCrash(new Error('boom')); + expect(lifecycle.hasExplicitSessionEndReason()).toBe(false); + }); + + it('stays true once set — subsequent markCrash does not clear it', () => { + lifecycle.setSessionEndReason('handoff'); + lifecycle.markCrash(new Error('late crash')); + expect(lifecycle.hasExplicitSessionEndReason()).toBe(true); + }); + }); + + // --- markCrash sets reason to 'error' but not explicit --- + + describe('markCrash', () => { + it('sets sessionEndReason to error via sendSessionDeath during cleanup', async () => { + const session = createMockApiSession(); + const lc = createRunnerLifecycle({ session, logTag: 'test' }); + lc.markCrash(new Error('fatal')); + + // cleanup triggers sendSessionDeath — verify 'error' reason + await lc.cleanup(); + expect(session.sendSessionDeath).toHaveBeenCalledWith('error'); + }); + }); + + // --- setSessionEndReason + cleanup propagates correct reason --- + + describe('setSessionEndReason + cleanup', () => { + it('sends explicit reason via sendSessionDeath during cleanup', async () => { + const session = createMockApiSession(); + const lc = createRunnerLifecycle({ session, logTag: 'test' }); + lc.setSessionEndReason('completed'); + + await lc.cleanup(); + expect(session.sendSessionDeath).toHaveBeenCalledWith('completed'); + }); + }); +}); diff --git a/cli/src/agent/runnerLifecycle.ts b/cli/src/agent/runnerLifecycle.ts index 0ae8faa9e2..d632c17a02 100644 --- a/cli/src/agent/runnerLifecycle.ts +++ b/cli/src/agent/runnerLifecycle.ts @@ -15,6 +15,7 @@ export type RunnerLifecycle = { setExitCode: (code: number) => void setArchiveReason: (reason: string) => void setSessionEndReason: (reason: SessionEndReason) => void + hasExplicitSessionEndReason: () => boolean markCrash: (error: unknown) => void cleanup: () => Promise cleanupAndExit: (codeOverride?: number) => Promise @@ -25,6 +26,7 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi let exitCode = 0 let archiveReason = 'User terminated' let sessionEndReason: SessionEndReason = 'terminated' + let sessionEndReasonExplicit = false let cleanupStarted = false let cleanupPromise: Promise | null = null @@ -95,8 +97,11 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi const setSessionEndReason = (reason: SessionEndReason) => { sessionEndReason = reason + sessionEndReasonExplicit = true } + const hasExplicitSessionEndReason = () => sessionEndReasonExplicit + const markCrash = (error: unknown) => { logger.debug(`${logPrefix} Unhandled error:`, error) exitCode = 1 @@ -128,6 +133,7 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi setExitCode, setArchiveReason, setSessionEndReason, + hasExplicitSessionEndReason, markCrash, cleanup, cleanupAndExit, diff --git a/cli/src/agent/sessionConfigRpc.ts b/cli/src/agent/sessionConfigRpc.ts index c8e72e795f..6795e5e74c 100644 --- a/cli/src/agent/sessionConfigRpc.ts +++ b/cli/src/agent/sessionConfigRpc.ts @@ -31,10 +31,22 @@ export function resolveSessionConfigPermissionMode 0) { + return modelObj.modelId.trim() + } + throw new Error('Invalid model') + } if (typeof value !== 'string' || value.trim().length === 0) { throw new Error('Invalid model') } diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index c6e2643125..fcc2629648 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -100,9 +100,15 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId + if (metadata.piSessionId !== undefined) preserved.piSessionId = metadata.piSessionId if (metadata.tools !== undefined) preserved.tools = metadata.tools if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands if (metadata.worktree !== undefined) preserved.worktree = metadata.worktree + // Preserve cached Pi model list so the web can show models immediately + // on inactive-session view without waiting for an RPC round-trip. + if (metadata.piAvailableModels !== undefined) preserved.piAvailableModels = metadata.piAvailableModels + // Preserve provider-qualified Pi model selection (disambiguates duplicate modelIds). + if (metadata.piSelectedModel !== undefined) preserved.piSelectedModel = metadata.piSelectedModel return preserved } diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 9560394118..5af3186cee 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, mkdirSync } from 'node:fs' +import { mkdtempSync, rmSync, mkdirSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -135,7 +135,9 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { availableModels: [{ modelId: 'x/y' }], currentModelId: 'x/y' }) - expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(secondWorkspaceRoot) + // The handler realpaths the cwd (security: prevents symlink escape), + // so on macOS /var/folders/... resolves to /private/var/folders/... + expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(realpathSync(secondWorkspaceRoot)) } finally { rmSync(secondWorkspaceRoot, { recursive: true, force: true }) client.shutdown() diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index f0f8510fa8..0aa2e72bf0 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { logger } from '@/ui/logger'; +import { JsonLineParser } from '@/utils/jsonLineParser'; import { killProcessByChildProcess } from '@/utils/process'; import type { CollaborationModeListResponse, @@ -69,10 +70,9 @@ function createAbortError(): Error { return error; } -export class CodexAppServerClient { +export class CodexAppServerClient extends JsonLineParser { private process: ChildProcessWithoutNullStreams | null = null; private connected = false; - private buffer = ''; private nextId = 1; private readonly pending = new Map(); private readonly requestHandlers = new Map(); @@ -103,7 +103,7 @@ export class CodexAppServerClient { }); this.process.stdout.setEncoding('utf8'); - this.process.stdout.on('data', (chunk) => this.handleStdout(chunk)); + this.process.stdout.on('data', (chunk) => this.feed(chunk)); this.process.stderr.setEncoding('utf8'); this.process.stderr.on('data', (chunk) => { @@ -354,23 +354,7 @@ export class CodexAppServerClient { this.writePayload(payload); } - private handleStdout(chunk: string): void { - this.buffer += chunk; - let newlineIndex = this.buffer.indexOf('\n'); - - while (newlineIndex >= 0) { - const line = this.buffer.slice(0, newlineIndex).trim(); - this.buffer = this.buffer.slice(newlineIndex + 1); - - if (line.length > 0) { - this.handleLine(line); - } - - newlineIndex = this.buffer.indexOf('\n'); - } - } - - private handleLine(line: string): void { + protected handleLine(line: string): void { if (this.protocolError) { return; } @@ -482,7 +466,7 @@ export class CodexAppServerClient { } private resetParserState(): void { - this.buffer = ''; + this.reset(); this.protocolError = null; } diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts index 0dca6b8f70..7c138bddb2 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -60,7 +60,8 @@ const lifecycleMock = vi.hoisted(() => ({ markCrash: vi.fn(), setExitCode: vi.fn(), setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn() + setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false) })) vi.mock('@/agent/runnerLifecycle', () => ({ diff --git a/cli/src/commands/agentCommandOptions.test.ts b/cli/src/commands/agentCommandOptions.test.ts index 7561774f28..f70c3e4ad3 100644 --- a/cli/src/commands/agentCommandOptions.test.ts +++ b/cli/src/commands/agentCommandOptions.test.ts @@ -69,3 +69,111 @@ describe('parseRemoteAgentCommandOptions', () => { expect(() => parseRemoteAgentCommandOptions(['--model-reasoning-effort'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --model-reasoning-effort value') }) }) + +describe('parseRemoteAgentCommandOptions — pi flavor', () => { + // Pi RPC mode has no permission switching, so the command passes an empty + // allow-list. These tests cover the non-permission flags using a non-empty + // allow-list purely as a parser fixture — the parser's behavior is + // independent of the modes' contents. + const ALLOWED = OPENCODE_PERMISSION_MODES + + it('accepts --model and stores it on options', () => { + const result = parseRemoteAgentCommandOptions( + ['--model', 'claude-sonnet-4-5'], + ALLOWED + ) + expect(result.model).toBe('claude-sonnet-4-5') + }) + + it('--session-id stores the value as resumeSessionId (Pi-specific flag)', () => { + // Pi uses --session-id for exact session resume (RPC mode), not the + // generic --resume that other flavors use. + const result = parseRemoteAgentCommandOptions( + ['--session-id', 'pi-sess-123'], + ALLOWED + ) + expect(result.resumeSessionId).toBe('pi-sess-123') + }) + + it('--resume is also accepted as an alias for session resume', () => { + // Some flavor paths pass --resume; the parser should accept it + // uniformly so callers do not need to branch on flavor. + const result = parseRemoteAgentCommandOptions( + ['--resume', 'sess-id'], + ALLOWED + ) + expect(result.resumeSessionId).toBe('sess-id') + }) + + it('a later --resume overrides a prior --session-id (last-write-wins)', () => { + const result = parseRemoteAgentCommandOptions( + ['--session-id', 'first', '--resume', 'second'], + ALLOWED + ) + expect(result.resumeSessionId).toBe('second') + }) + + it('rejects --session-id with no value', () => { + expect(() => parseRemoteAgentCommandOptions( + ['--session-id'], + ALLOWED + )).toThrow('Missing --session-id value') + }) + + it('parses --started-by runner', () => { + const result = parseRemoteAgentCommandOptions( + ['--started-by', 'runner'], + ALLOWED + ) + expect(result.startedBy).toBe('runner') + }) + + it('parses --started-by terminal', () => { + const result = parseRemoteAgentCommandOptions( + ['--started-by', 'terminal'], + ALLOWED + ) + expect(result.startedBy).toBe('terminal') + }) + + it('parses --hapi-starting-mode remote', () => { + const result = parseRemoteAgentCommandOptions( + ['--hapi-starting-mode', 'remote'], + ALLOWED + ) + expect(result.startingMode).toBe('remote') + }) + + it('parses --hapi-starting-mode local', () => { + const result = parseRemoteAgentCommandOptions( + ['--hapi-starting-mode', 'local'], + ALLOWED + ) + expect(result.startingMode).toBe('local') + }) + + it('rejects invalid --hapi-starting-mode', () => { + expect(() => parseRemoteAgentCommandOptions( + ['--hapi-starting-mode', 'invalid'], + ALLOWED + )).toThrow('Invalid --hapi-starting-mode') + }) + + it('handles a full pi invocation end-to-end', () => { + const result = parseRemoteAgentCommandOptions( + [ + '--started-by', 'runner', + '--hapi-starting-mode', 'remote', + '--model', 'claude-sonnet-4-5', + '--session-id', 'pi-sess-full', + ], + ALLOWED + ) + expect(result).toEqual({ + startedBy: 'runner', + startingMode: 'remote', + model: 'claude-sonnet-4-5', + resumeSessionId: 'pi-sess-full', + }) + }) +}) diff --git a/cli/src/commands/agentCommandOptions.ts b/cli/src/commands/agentCommandOptions.ts index 0e2e271b8e..f7e8b29c5e 100644 --- a/cli/src/commands/agentCommandOptions.ts +++ b/cli/src/commands/agentCommandOptions.ts @@ -5,6 +5,7 @@ export type RemoteAgentCommandOptions = startingMode?: 'local' | 'remote' permissionMode?: TPermissionMode model?: string + effort?: string modelReasoningEffort?: string resumeSessionId?: string } @@ -42,12 +43,25 @@ export function parseRemoteAgentCommandOptions { + try { + // Pi RPC mode has no runtime permission switching; pass an empty + // allow-list so --permission-mode is rejected and no permissionMode + // leaks into the session state. + const options = parseRemoteAgentCommandOptions(commandArgs, []) + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + + const { runPi } = await import('@/pi/runPi') + await runPi(options) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 6ff36916af..7e93ca4fca 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -9,6 +9,7 @@ import { doctorCommand } from './doctor' import { geminiCommand } from './gemini' import { kimiCommand } from './kimi' import { opencodeCommand } from './opencode' +import { piCommand } from './pi' import { hookForwarderCommand } from './hookForwarder' import { mcpCommand } from './mcp' import { notifyCommand } from './notify' @@ -23,6 +24,7 @@ const COMMANDS: CommandDefinition[] = [ geminiCommand, kimiCommand, opencodeCommand, + piCommand, mcpCommand, hubCommand, { ...hubCommand, name: 'server' }, diff --git a/cli/src/commands/resume.test.ts b/cli/src/commands/resume.test.ts index fa0f254d73..73b0de0a2a 100644 --- a/cli/src/commands/resume.test.ts +++ b/cli/src/commands/resume.test.ts @@ -10,6 +10,7 @@ const { renderMock, runCodexMock, runClaudeMock, + runPiMock, assertCodexLocalSupportedMock, existsSyncMock } = vi.hoisted(() => ({ @@ -22,6 +23,7 @@ const { renderMock: vi.fn(), runCodexMock: vi.fn(async () => {}), runClaudeMock: vi.fn(async () => {}), + runPiMock: vi.fn(async () => {}), assertCodexLocalSupportedMock: vi.fn(), existsSyncMock: vi.fn(() => true) })) @@ -44,6 +46,7 @@ vi.mock('@/ui/ink/ResumeSessionPicker', () => ({ })) vi.mock('@/codex/runCodex', () => ({ runCodex: runCodexMock })) vi.mock('@/claude/runClaude', () => ({ runClaude: runClaudeMock })) +vi.mock('@/pi/runPi', () => ({ runPi: runPiMock })) vi.mock('@/codex/utils/codexVersion', () => ({ assertCodexLocalSupported: assertCodexLocalSupportedMock })) vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) @@ -72,6 +75,7 @@ describe('resumeCommand', () => { }) runCodexMock.mockClear() runClaudeMock.mockClear() + runPiMock.mockClear() assertCodexLocalSupportedMock.mockClear() existsSyncMock.mockReturnValue(true) }) @@ -247,6 +251,36 @@ describe('resumeCommand', () => { } }) + it('resumes a Pi target with effort', async () => { + getLocalResumeTargetMock.mockResolvedValue({ + sessionId: 'hapi-session-pi', + flavor: 'pi', + directory: '/tmp/project', + machineId: 'machine-1', + active: false, + thinking: false, + controlledByUser: false, + agentSessionId: 'pi-session-123', + model: 'deepseek-v3', + effort: 'high', + permissionMode: 'yolo' + }) + + await resumeCommand.run(createContext(['hapi-session-pi'])) + + expect(handoffSessionToLocalMock).not.toHaveBeenCalled() + expect(runPiMock).toHaveBeenCalledWith({ + existingSessionId: 'hapi-session-pi', + workingDirectory: '/tmp/project', + resumeSessionId: 'pi-session-123', + startedBy: 'terminal', + // Pi has no local TUI input path, so resume defaults to remote control. + startingMode: 'remote', + model: 'deepseek-v3', + effort: 'high' + }) + }) + it('keeps the non-TTY fallback and asks for an explicit session id', async () => { const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index 353f7cab57..cf2af593fd 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -145,6 +145,23 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { return } + if (target.flavor === 'pi') { + const { runPi } = await import('@/pi/runPi') + await runPi({ + existingSessionId: base.existingSessionId, + workingDirectory: base.workingDirectory, + resumeSessionId: base.resumeSessionId, + startedBy: base.startedBy, + // Pi runs as `pi --mode rpc` with piped stdio and no local TUI input + // path, so 'local' would advertise local-control that cannot be used + // and hide/reject remote-only controls until a web switch. + startingMode: 'remote', + model: target.model ?? undefined, + effort: target.effort ?? undefined, + }) + return + } + const { runCursor } = await import('@/cursor/runCursor') await runCursor({ existingSessionId: base.existingSessionId, diff --git a/cli/src/commands/runCli.ts b/cli/src/commands/runCli.ts index 3cc404c40c..7a7d7bb97c 100644 --- a/cli/src/commands/runCli.ts +++ b/cli/src/commands/runCli.ts @@ -1,5 +1,4 @@ import packageJson from '../../package.json' -import { ensureRuntimeAssets } from '@/runtime/assets' import { isBunCompiled } from '@/projectPath' import { logger } from '@/ui/logger' import { getCliArgs } from '@/utils/cliArgs' @@ -23,6 +22,7 @@ export async function runCli(): Promise { const { command, context } = resolveCommand(args) if (command.requiresRuntimeAssets) { + const { ensureRuntimeAssets } = await import('@/runtime/assets') await ensureRuntimeAssets() logger.debug('Starting hapi CLI with args: ', process.argv) } diff --git a/cli/src/gemini/runGemini.test.ts b/cli/src/gemini/runGemini.test.ts index b6ef8e58b3..9b9b3be4b5 100644 --- a/cli/src/gemini/runGemini.test.ts +++ b/cli/src/gemini/runGemini.test.ts @@ -54,7 +54,8 @@ const lifecycleMock = vi.hoisted(() => ({ markCrash: vi.fn(), setExitCode: vi.fn(), setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn() + setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false) })); vi.mock('@/agent/runnerLifecycle', () => ({ diff --git a/cli/src/opencode/runOpencode.test.ts b/cli/src/opencode/runOpencode.test.ts index 41ef11adc9..4537ca0a06 100644 --- a/cli/src/opencode/runOpencode.test.ts +++ b/cli/src/opencode/runOpencode.test.ts @@ -58,7 +58,8 @@ const lifecycleMock = vi.hoisted(() => ({ markCrash: vi.fn(), setExitCode: vi.fn(), setArchiveReason: vi.fn(), - setSessionEndReason: vi.fn() + setSessionEndReason: vi.fn(), + hasExplicitSessionEndReason: vi.fn(() => false) })); vi.mock('@/agent/runnerLifecycle', () => ({ diff --git a/cli/src/pi/loop.test.ts b/cli/src/pi/loop.test.ts new file mode 100644 index 0000000000..626abcd0c1 --- /dev/null +++ b/cli/src/pi/loop.test.ts @@ -0,0 +1,509 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { parsePiModels, parsePiCommands, sendPiRpcAndWait, wireTransportEvents } from './loop'; +import type { PiResponseEvent } from './types'; +import { PiSession } from './session'; +import { PiTransport } from './piTransport'; +import type { PiThinkingLevel } from './types'; + +// Mock logger +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +// Mock message converter chain +vi.mock('@/agent/messageConverter', () => ({ + convertAgentMessage: vi.fn((msg) => msg), +})); + +vi.mock('./PiEventConverter', () => ({ + convertPiEvent: vi.fn(() => []), +})); + +vi.mock('./piMessageAccumulator', () => { + return { + PiMessageAccumulator: class { + handleEvent = vi.fn(() => []); + }, + }; +}); + +function createMockSession(): PiSession { + return new PiSession({ + api: {} as any, + client: { + keepAlive: vi.fn(), + updateMetadata: vi.fn(), + sendAgentMessage: vi.fn(), + emitMessagesConsumed: vi.fn(), + sendSessionEvent: vi.fn(), + } as any, + path: '/tmp/test', + logPath: '/tmp/test.log', + startedBy: 'terminal', + startingMode: 'local', + }); +} + +// --- parsePiModels --- + +describe('parsePiModels', () => { + it('returns empty for non-array input', () => { + expect(parsePiModels(null)).toEqual([]); + expect(parsePiModels({})).toEqual([]); + expect(parsePiModels('not array')).toEqual([]); + }); + + it('parses valid model list', () => { + const data = { + models: [ + { id: 'gpt-4o', provider: 'openai', name: 'GPT-4o', contextWindow: 128000 }, + { id: 'claude-3', provider: 'anthropic' }, + ], + }; + const result = parsePiModels(data); + expect(result).toEqual([ + { provider: 'openai', modelId: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000 }, + { provider: 'anthropic', modelId: 'claude-3' }, + ]); + }); + + it('parses reasoning and thinkingLevelMap', () => { + const data = { + models: [ + { + id: 'claude-sonnet-4', + provider: 'anthropic', + name: 'Claude Sonnet 4', + reasoning: true, + thinkingLevelMap: { off: null, low: 'low', medium: 'medium', high: 'high' }, + }, + { id: 'gpt-4o', provider: 'openai', reasoning: false }, + { id: 'deepseek-r1', provider: 'deepseek', thinkingLevelMap: {} }, + ], + }; + const result = parsePiModels(data); + expect(result).toEqual([ + { + provider: 'anthropic', + modelId: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + reasoning: true, + thinkingLevelMap: { off: null, low: 'low', medium: 'medium', high: 'high' }, + }, + { provider: 'openai', modelId: 'gpt-4o', reasoning: false }, + { provider: 'deepseek', modelId: 'deepseek-r1' }, + ]); + }); + + it('ignores non-boolean reasoning and invalid thinkingLevelMap', () => { + const data = { + models: [ + { id: 'm1', reasoning: 'yes', thinkingLevelMap: 'not-an-object' }, + ], + }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'm1' }, + ]); + }); + + it('filters out models with empty id', () => { + const data = { + models: [ + { id: '', provider: 'openai' }, + { id: 'gpt-4o', provider: 'openai' }, + ], + }; + expect(parsePiModels(data)).toEqual([ + { provider: 'openai', modelId: 'gpt-4o' }, + ]); + }); + + it('defaults unknown provider', () => { + const data = { models: [{ id: 'model-1' }] }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'model-1' }, + ]); + }); + + it('skips non-object entries', () => { + const data = { models: [null, 'string', 42, { id: 'valid' }] }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'valid' }, + ]); + }); + + it('ignores non-string name and non-number contextWindow', () => { + const data = { + models: [ + { id: 'm1', name: 123, contextWindow: 'big' }, + ], + }; + expect(parsePiModels(data)).toEqual([ + { provider: 'unknown', modelId: 'm1' }, + ]); + }); +}); + +// --- parsePiCommands --- + +describe('parsePiCommands', () => { + it('returns empty for non-array input', () => { + expect(parsePiCommands(null)).toEqual([]); + expect(parsePiCommands({})).toEqual([]); + }); + + it('parses valid command list', () => { + const data = { + commands: [ + { name: 'analyze', description: 'Analyze code', source: 'skill' }, + { name: 'review', description: 'Review code', source: 'extension' }, + { name: 'custom', description: 'Custom prompt', source: 'prompt' }, + ], + }; + const result = parsePiCommands(data); + expect(result).toEqual([ + { name: 'analyze', description: 'Analyze code', source: 'skill' }, + { name: 'review', description: 'Review code', source: 'extension' }, + { name: 'custom', description: 'Custom prompt', source: 'prompt' }, + ]); + }); + + it('defaults unknown source to skill', () => { + const data = { commands: [{ name: 'cmd', source: 'unknown_source' }] }; + expect(parsePiCommands(data)).toEqual([ + { name: 'cmd', source: 'skill' }, + ]); + }); + + it('filters out commands with empty name', () => { + const data = { commands: [{ name: '', source: 'skill' }, { name: 'valid', source: 'skill' }] }; + expect(parsePiCommands(data)).toEqual([ + { name: 'valid', source: 'skill' }, + ]); + }); + + it('omits non-string description', () => { + const data = { commands: [{ name: 'cmd', description: 123 }] }; + expect(parsePiCommands(data)).toEqual([{ name: 'cmd', source: 'skill' }]); + }); +}); + +// --- wireTransportEvents (integration) --- + +describe('wireTransportEvents', () => { + let session: PiSession; + let eventHandlers: Map void>; + + function createMockTransport(): PiTransport { + eventHandlers = new Map(); + return { + onEvent: vi.fn((handler) => { eventHandlers.set('event', handler); }), + send: vi.fn(), + } as unknown as PiTransport; + } + + beforeEach(() => { + vi.clearAllMocks(); + session = createMockSession(); + }); + + function emitEvent(event: Record): void { + const handler = eventHandlers.get('event'); + expect(handler).toBeDefined(); + handler!(event); + } + + it('handles get_state response — updates model, provider, thinkingLevel', () => { + const transport = createMockTransport(); + const pendingLocalIds: string[] = []; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ + type: 'response', + command: 'get_state', + success: true, + data: { + model: { modelId: 'gpt-4o', provider: 'openai' }, + sessionId: 'pi-session-1', + thinkingLevel: 'high', + steeringMode: 'one-at-a-time', + }, + }); + + expect(session.currentModel).toBe('gpt-4o'); + expect(session.currentProvider).toBe('openai'); + expect(session.currentThinkingLevel).toBe('high'); + expect(session.currentSteeringMode).toBe('one-at-a-time'); + expect(session.client.updateMetadata).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('handles error response — sends session event', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'prompt', + success: false, + error: 'Pi crashed', + }); + + expect(session.client.sendSessionEvent).toHaveBeenCalledWith({ + type: 'message', + message: 'Pi crashed', + }); + }); + + it('handles agent_start — sets thinking state, does NOT drain pending localId', () => { + const transport = createMockTransport(); + const pendingLocalIds = ['id-1', 'id-2']; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ type: 'agent_start' }); + + // agent_start precedes turn_start in a real Pi turn; draining here + // would double-pop the FIFO (see regression test below). + expect(pendingLocalIds).toEqual(['id-1', 'id-2']); + expect(session.client.emitMessagesConsumed).not.toHaveBeenCalled(); + }); + + it('handles turn_start — pops pending localId', () => { + const transport = createMockTransport(); + const pendingLocalIds = ['id-turn-1']; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ type: 'turn_start' }); + + expect(pendingLocalIds).toEqual([]); + expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['id-turn-1'], undefined); + }); + + it('regression: agent_start + turn_start in one turn drains exactly one localId', () => { + // Pi emits agent_start then turn_start back-to-back per prompt. + // Only turn_start should drain — agent_start must not. + const transport = createMockTransport(); + const pendingLocalIds = ['prompt-1']; + wireTransportEvents(transport, session, pendingLocalIds); + + emitEvent({ type: 'agent_start' }); + emitEvent({ type: 'turn_start' }); + + expect(pendingLocalIds).toEqual([]); + // Exactly one drain call with a real id — never an undefined. + expect(session.client.emitMessagesConsumed).toHaveBeenCalledTimes(1); + expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['prompt-1'], undefined); + }); + + it('handles turn_end — stops streaming', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + session.piIsStreaming = true; + emitEvent({ type: 'turn_end' }); + + expect(session.piIsStreaming).toBe(false); + }); + + it('handles agent_end — stops streaming', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + session.piIsStreaming = true; + emitEvent({ type: 'agent_end' }); + + expect(session.piIsStreaming).toBe(false); + }); + + it('handles get_available_models response — caches models', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'get_available_models', + success: true, + data: { + models: [ + { id: 'gpt-4o', provider: 'openai' }, + { id: 'claude-3', provider: 'anthropic' }, + ], + }, + }); + + expect(session.cachedPiModels).toEqual([ + { provider: 'openai', modelId: 'gpt-4o' }, + { provider: 'anthropic', modelId: 'claude-3' }, + ]); + }); + + it('handles get_commands response — caches commands', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'get_commands', + success: true, + data: { + commands: [ + { name: 'analyze', source: 'skill' }, + ], + }, + }); + + expect(session.cachedPiCommands).toEqual([ + { name: 'analyze', source: 'skill' }, + ]); + }); + + it('handles keep_alive — no side effects', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + session.piIsStreaming = false; + emitEvent({ type: 'keep_alive' }); + + // keep_alive should not trigger any session mutations + expect(session.client.sendAgentMessage).not.toHaveBeenCalled(); + expect(session.piIsStreaming).toBe(false); + }); + + it('handles set_model response — updates model and provider', () => { + const transport = createMockTransport(); + wireTransportEvents(transport, session, []); + + emitEvent({ + type: 'response', + command: 'set_model', + success: true, + data: { modelId: 'new-model', provider: 'new-provider' }, + }); + + expect(session.currentModel).toBe('new-model'); + expect(session.currentProvider).toBe('new-provider'); + }); +}); + +// --- sendPiRpcAndWait (contract: await <-> resolve symmetry) --- +// +// SetSessionConfig awaits set_model and set_thinking_level. Fix #9 was caused +// by a switch branch that updated state but never resolved the pending RPC - +// the promise hit the 10s timeout and /sessions/:id/model returned 409 even +// though Pi accepted the change. These tests pin the contract: every awaited +// command must resolve before the timeout when Pi emits a success response. + +describe('sendPiRpcAndWait', () => { + it('throws synchronously when resolver not initialized', () => { + // sendPiRpcAndWait is a sync wrapper (not async), so the guard at + // loop.ts throws before a promise is created — assert with toThrow, + // not rejects. + const mockTransport = { send: vi.fn(), onEvent: vi.fn() } as unknown as PiTransport; + const session = createMockSession(); + // No wireTransportEvents -> resolver is null + expect(() => sendPiRpcAndWait(session, mockTransport, { type: 'test' }, 100)) + .toThrow('Pi RPC resolver not initialized'); + }); + + // Helper: a transport whose send() captures the outgoing id so the test can + // emit the matching response, simulating Pi's reply. + function recordingTransport(onEventHandlers: Map void>) { + const sent: Array> = []; + return { + transport: { + onEvent: vi.fn((handler) => { onEventHandlers.set('event', handler); }), + send: vi.fn((msg: Record) => { sent.push(msg); }), + } as unknown as PiTransport, + sent, + // Emit the Pi response for the last sent command, echoing its id. + reply(response: { command: string; success: boolean; data?: unknown; error?: string }) { + const last = sent[sent.length - 1]; + const handler = onEventHandlers.get('event'); + expect(handler).toBeDefined(); + handler!({ type: 'response', id: last.id, ...response }); + }, + }; + } + + it('set_model response resolves the awaited promise before timeout', async () => { + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { + type: 'set_model', provider: 'openai', modelId: 'gpt-4o', + }, 10_000); + + // Simulate Pi confirming the model change. + reply({ command: 'set_model', success: true, data: { modelId: 'gpt-4o', provider: 'openai' } }); + + // Must resolve (not reject with 'timed out') - the contract Fix #9 restored. + await expect(promise).resolves.toEqual({ modelId: 'gpt-4o', provider: 'openai' }); + expect(session.currentModel).toBe('gpt-4o'); + expect(session.currentProvider).toBe('openai'); + }); + + it('set_thinking_level response resolves the awaited promise before timeout', async () => { + // Fix #9 symmetry: set_thinking_level is awaited by SetSessionConfig. + // Without an explicit resolve it fell to the `default` branch; if anyone + // later adds business logic to a new case without resolving first, the + // effort switch would time out and /sessions/:id/effort would 409. + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { + type: 'set_thinking_level', level: 'high', + }, 10_000); + + reply({ command: 'set_thinking_level', success: true }); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('get_available_models response resolves the awaited promise before timeout', async () => { + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { type: 'get_available_models' }, 10_000); + + reply({ command: 'get_available_models', success: true, data: { models: [{ id: 'gpt-4o', provider: 'openai' }] } }); + + await expect(promise).resolves.toEqual({ models: [{ id: 'gpt-4o', provider: 'openai' }] }); + }); + + it('Pi error response rejects the awaited promise', async () => { + // SetSessionConfig awaits so a rejected set_model bubbles up to the web + // request (409) instead of reporting success while Pi kept old state. + const handlers = new Map void>(); + const { transport, reply } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + const promise = sendPiRpcAndWait(session, transport, { + type: 'set_model', provider: 'bad', modelId: 'nope', + }, 10_000); + + reply({ command: 'set_model', success: false, error: 'Unknown provider: bad' }); + + await expect(promise).rejects.toThrow('Unknown provider: bad'); + }); + + it('rejects with timeout when Pi never responds', async () => { + const handlers = new Map void>(); + const { transport } = recordingTransport(handlers); + const session = createMockSession(); + wireTransportEvents(transport, session, []); + + // No reply emitted -> must time out (guards against hangs). + await expect(sendPiRpcAndWait(session, transport, { type: 'test' }, 100)) + .rejects.toThrow('timed out'); + }); +}); diff --git a/cli/src/pi/loop.ts b/cli/src/pi/loop.ts new file mode 100644 index 0000000000..d4f48256f4 --- /dev/null +++ b/cli/src/pi/loop.ts @@ -0,0 +1,312 @@ +import { logger } from '@/ui/logger'; +import { convertAgentMessage } from '@/agent/messageConverter'; +import { PiTransport } from './piTransport'; +import { convertPiEvent } from './piEventConverter'; +import { PiMessageAccumulator } from './piMessageAccumulator'; +import { parsePiModels, parsePiCommands, PiResponseEventSchema, PiStateDataSchema, PiSetModelDataSchema } from './schemas'; +import type { PiResponseEvent, PiRpcCommand, PiThinkingLevel } from './types'; +import type { PiSession } from './session'; + +// --- Response parsers: re-exported from schemas.ts --- +export { parsePiModels, parsePiCommands } from './schemas'; + +// --- Pending RPC resolver --- +// Instance-scoped: created once by wireTransportEvents, stored on PiSession. +export class PiRpcResolver { + private idCounter = 0; + private readonly pending = new Map void; + reject: (error: Error) => void; + }>(); + + sendAndWait(transport: PiTransport, command: Record, timeoutMs = 10_000): Promise { + const id = ++this.idCounter; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Pi RPC ${command.type} (id=${id}) timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + this.pending.set(id, { + resolve: (data) => { clearTimeout(timer); this.pending.delete(id); resolve(data); }, + reject: (error) => { clearTimeout(timer); this.pending.delete(id); reject(error); }, + }); + + transport.send({ ...command, id: String(id) } as unknown as PiRpcCommand); + }); + } + + resolveResponse(raw: unknown): void { + const parsed = PiResponseEventSchema.safeParse(raw); + if (!parsed.success) return; + const response = parsed.data; + const rawId = response.id; + if (rawId !== undefined) { + const numericId = Number(rawId); + if (!Number.isNaN(numericId)) { + const resolver = this.pending.get(numericId); + if (resolver) { + if (response.success) { + resolver.resolve(response.data); + } else { + resolver.reject(new Error(response.error ?? 'Unknown error')); + } + } + } + } + } +} + +export function sendPiRpcAndWait(session: PiSession, transport: PiTransport, command: Record, timeoutMs = 10_000): Promise { + if (!session.rpcResolver) throw new Error('Pi RPC resolver not initialized'); + return session.rpcResolver.sendAndWait(transport, command, timeoutMs); +} + +function resolvePendingRpc(resolver: PiRpcResolver, response: PiResponseEvent): void { + resolver.resolveResponse(response); +} + +// Mirror the web picker's provider-qualified selection into metadata so the hub +// and web can disambiguate duplicate modelId values across providers. The web +// /sessions/:id/model path already writes piSelectedModel via persistPiSelectedModel; +// these runtime paths (get_state, startup set_model, successful set_model response) +// previously only keepAlive'd the bare modelId, so a Pi session on Pi's default model +// or started with --model could render/filter against the wrong provider. +function persistSelectedPiModel(session: PiSession): void { + const modelId = session.currentModel; + const provider = session.currentProvider; + if (!modelId || !provider) return; + session.updateMetadata((meta) => ({ + ...meta, + piSelectedModel: { provider, modelId }, + })); +} + +// --- Response handler --- + +function handleGetState( + rawData: unknown, + session: PiSession, +): void { + const parsed = PiStateDataSchema.safeParse(rawData); + if (!parsed.success) return; + const data = parsed.data; + + if (data.model) { + // Pi returns model.id (not modelId). Fallback to modelId for forward compat. + const newModel = data.model.id ?? data.model.modelId ?? session.currentModel; + if (data.model.provider && data.model.provider.length > 0) { + session.currentProvider = data.model.provider; + } + // Do NOT overwrite currentModel with the unconfirmed startup model here. + // The requested startup model is applied (and committed) only after + // get_available_models confirms it exists and Pi accepts set_model; + // reporting Pi's actual current model until then keeps the hub in sync + // if the requested model is unavailable or rejected. + session.currentModel = newModel ?? session.currentModel; + if (session.initialModel) { + logger.debug(`[pi] Startup model requested: ${session.initialModel} (will apply once available models arrive); Pi default model: ${newModel ?? 'unknown'}`); + } else if (newModel) { + logger.debug(`[pi] Initial model: ${newModel} (provider=${session.currentProvider ?? 'unknown'})`); + } + // Pi reported its actual model+provider; persist the provider-qualified + // selection so the web can disambiguate (a startup --model overrides this + // once get_available_models confirms and applies it below). + persistSelectedPiModel(session); + } + + if (data.sessionId) { + session.updateMetadata((meta) => ({ ...meta, piSessionId: data.sessionId })); + logger.debug(`[pi] Session ID persisted to metadata: ${data.sessionId}`); + } + + if (data.thinkingLevel) { + session.currentThinkingLevel = data.thinkingLevel as PiThinkingLevel; + logger.debug(`[pi] Initial thinking level: ${data.thinkingLevel}`); + } + + if (data.steeringMode) { + session.currentSteeringMode = data.steeringMode; + } +} + +function handleResponse( + response: PiResponseEvent, + session: PiSession, + pendingLocalIds: string[], + transport?: PiTransport, +): void { + const { command, success } = response; + const resolver = session.rpcResolver!; + + if (!success) { + const error = response.error ?? 'Unknown Pi error'; + logger.debug(`[pi] RPC error for ${command}: ${error}`); + resolvePendingRpc(resolver, response); + session.sendSessionEvent({ type: 'message', message: error }); + if (command === 'prompt' && pendingLocalIds.length > 0) { + const oldestLocalId = pendingLocalIds.shift()!; + session.emitMessagesConsumed([oldestLocalId], { clearQueuedThinkingGrace: true }); + } + return; + } + + switch (command) { + case 'get_state': { + handleGetState(response.data, session); + break; + } + case 'set_model': { + const parsed = PiSetModelDataSchema.safeParse(response.data); + if (parsed.success) { + const data = parsed.data; + const modelId = data.id ?? data.modelId; + if (modelId) { + session.currentModel = modelId; + } + if (data.provider && data.provider.length > 0) { + session.currentProvider = data.provider; + } + persistSelectedPiModel(session); + logger.debug(`[pi] Model changed to: ${modelId ?? session.currentModel}`); + } + // set_model is awaited by SetSessionConfig (Fix #9); without this + // the awaited RPC would time out and /sessions/:id/model return 409. + resolvePendingRpc(resolver, response); + break; + } + case 'set_thinking_level': { + // Awaited by SetSessionConfig (Fix #9 symmetry with set_model). + // currentThinkingLevel is maintained by the SetSessionConfig + // handler, so this branch only resolves the pending RPC — without + // it the awaited call times out and /sessions/:id/effort returns 409. + resolvePendingRpc(resolver, response); + break; + } + case 'get_available_models': { + const models = parsePiModels(response.data); + if (models.length > 0) { + session.cachedPiModels = models; + logger.debug(`[pi] Available models: ${models.map((m) => m.modelId).join(', ')}`); + session.updateMetadata((meta) => ({ + ...meta, + piAvailableModels: models, + })); + + // Apply the requested startup model only after confirming it exists + // in Pi's available models and Pi accepts set_model. Commit + // currentModel/currentProvider only on success so the hub does not + // persist a model Pi rejected or never had. Fire-and-forget the + // await so resolving the get_available_models RPC itself is not + // blocked (it may be awaited by ListPiModels). + if (session.initialModel && transport) { + const match = models.find((m) => m.modelId === session.initialModel); + if (match) { + void (async () => { + try { + await sendPiRpcAndWait(session, transport, { + type: 'set_model', + provider: match.provider, + modelId: match.modelId, + }); + session.currentModel = match.modelId; + session.currentProvider = match.provider; + persistSelectedPiModel(session); + logger.debug(`[pi] Startup model applied: ${match.provider}/${match.modelId}`); + } catch (error) { + logger.debug(`[pi] Startup model set_model rejected, keeping Pi default: ${error instanceof Error ? error.message : String(error)}`); + } + })(); + } else { + logger.debug(`[pi] Startup model not found in available models: ${session.initialModel}`); + } + } + } + resolvePendingRpc(resolver, response); + break; + } + case 'get_commands': { + const commands = parsePiCommands(response.data); + if (commands.length > 0) { + session.cachedPiCommands = commands; + logger.debug(`[pi] Available commands: ${commands.map((c) => c.name).join(', ')}`); + } + resolvePendingRpc(resolver, response); + break; + } + case 'new_session': + logger.debug('[pi] Pi session initialized'); + break; + case 'abort': + logger.debug('[pi] Abort confirmed'); + break; + case 'prompt': + logger.debug('[pi] Prompt accepted'); + break; + default: + logger.debug(`[pi] Response for ${command}`); + resolvePendingRpc(resolver, response); + break; + } +} + +// --- Wire transport events to session --- + +export function wireTransportEvents( + transport: PiTransport, + session: PiSession, + pendingLocalIds: string[], +): void { + session.rpcResolver = new PiRpcResolver(); + const assistantMessageAccumulator = new PiMessageAccumulator(); + + transport.onEvent((event) => { + // Debug: log all event types to diagnose missing Pi output + if (event.type !== 'keep_alive') { + logger.debug(`[pi][event] ${event.type}`); + } + if (event.type === 'response') { + handleResponse(event as unknown as PiResponseEvent, session, pendingLocalIds, transport); + return; + } + + // Accumulate text/thinking deltas into snapshots, flush on message_end + const accumulated = assistantMessageAccumulator.handleEvent(event); + if (accumulated.length > 0) { + for (const msg of accumulated) { + const converted = convertAgentMessage(msg); + if (converted) session.sendAgentMessage(converted); + } + } + + // message_start/update/end handled by accumulator — skip converter + if (event.type !== 'message_start' && event.type !== 'message_update' && event.type !== 'message_end') { + const messages = convertPiEvent(event); + for (const msg of messages) { + const converted = convertAgentMessage(msg); + if (converted) session.sendAgentMessage(converted); + } + } + + // Keep-alive + streaming state tracking + // + // Pi emits agent_start and turn_start back-to-back for each prompt. + // Only turn_start marks "my prompt was accepted and a turn began", so + // the pending localId is drained there. Draining on both would pop the + // FIFO twice per prompt — once with the real id, then once with + // undefined — and ship a garbage localId to the hub. + if (event.type === 'agent_start') { + session.updateThinkingState(true); + } else if (event.type === 'turn_start') { + session.updateThinkingState(true); + if (pendingLocalIds.length > 0) { + const oldestLocalId = pendingLocalIds.shift()!; + session.emitMessagesConsumed([oldestLocalId]); + } + } else if (event.type === 'turn_end') { + session.updateThinkingState(false); + } else if (event.type === 'agent_end') { + session.piIsStreaming = false; + } + }); +} diff --git a/cli/src/pi/piEventConverter.test.ts b/cli/src/pi/piEventConverter.test.ts new file mode 100644 index 0000000000..aaa0a5d088 --- /dev/null +++ b/cli/src/pi/piEventConverter.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { convertPiEvent } from './piEventConverter'; +import type { PiAgentEvent } from './types'; + +describe('convertPiEvent', () => { + it('should return empty for message_update with text_delta (accumulated in runPi)', () => { + // The converter intentionally emits nothing for message_update + // — runPi accumulates text/thinking deltas and flushes a single + // snapshot on `message_end`. This avoids the web UI rendering + // every delta as a separate block (character-by-character column) + // and the reducer's per-content streamId dedup showing only the + // last delta as the whole reasoning. + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'hello world' } + }); + expect(result).toEqual([]); + }); + + it('should return empty for message_update with thinking_delta (accumulated in runPi)', () => { + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'thinking_delta', delta: 'let me think...' } + }); + expect(result).toEqual([]); + }); + + it('should return empty for message_update with start sub-type', () => { + // text_start/thinking_start carry the full partial state and + // would cause the web UI to render the same text multiple + // times. The accumulator only listens to deltas. + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'start' } + }); + expect(result).toEqual([]); + }); + + it('should return empty array for message_update with start sub-type', () => { + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'start' } + }); + expect(result).toEqual([]); + }); + + it('should return empty array for message_update with done sub-type', () => { + const result = convertPiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'done', reason: 'stop' } + }); + expect(result).toEqual([]); + }); + + it('should return empty array for message_update without assistantMessageEvent', () => { + const result = convertPiEvent({ type: 'message_update' }); + expect(result).toEqual([]); + }); + + it('should convert tool_execution_start to tool_call AgentMessage', () => { + const result = convertPiEvent({ + type: 'tool_execution_start', + toolCallId: 'tc-1', + toolName: 'read_file', + args: { path: '/foo.ts' } + }); + expect(result).toEqual([{ + type: 'tool_call', + id: 'tc-1', + name: 'read_file', + input: { path: '/foo.ts' }, + status: 'in_progress' + }]); + }); + + it('should convert tool_execution_end (success) to tool_result AgentMessage', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolCallId: 'tc-1', + toolName: 'read_file', + result: 'file content', + isError: false + }); + expect(result).toEqual([{ + type: 'tool_result', + id: 'tc-1', + output: 'file content', + status: 'completed' + }]); + }); + + it('should convert tool_execution_end (error) to failed tool_result AgentMessage', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolCallId: 'tc-1', + toolName: 'read_file', + result: 'file not found', + isError: true + }); + expect(result).toEqual([{ + type: 'tool_result', + id: 'tc-1', + output: 'file not found', + status: 'failed' + }]); + }); + + it('should handle tool_execution_end with missing result', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolCallId: 'tc-1', + toolName: 'read_file', + isError: false + } as any); + expect(result).toEqual([{ + type: 'tool_result', + id: 'tc-1', + output: undefined, + status: 'completed' + }]); + }); + + it('should handle tool_execution_end with missing toolCallId', () => { + const result = convertPiEvent({ + type: 'tool_execution_end', + toolName: 'read_file', + result: 'ok', + isError: false + } as any); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('tool_result'); + expect((result[0] as any).id).toBeUndefined(); + }); + + it('should convert turn_end to usage + turn_complete (2 messages)', () => { + const result = convertPiEvent({ + type: 'turn_end', + message: { + usage: { + input: 100, + output: 200, + cacheRead: 10, + cacheWrite: 5, + totalTokens: 315 + }, + stopReason: 'stop' + }, + toolResults: [] + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'usage', + inputTokens: 100, + outputTokens: 200, + totalTokens: 315, + cacheReadTokens: 10 + }); + expect(result[1]).toEqual({ + type: 'turn_complete', + stopReason: 'stop' + }); + }); + + it('should convert turn_end with toolUse stopReason', () => { + const result = convertPiEvent({ + type: 'turn_end', + message: { + usage: { input: 50, output: 100, cacheRead: 0, cacheWrite: 0, totalTokens: 150 }, + stopReason: 'toolUse' + }, + toolResults: [] + }); + + expect(result).toHaveLength(2); + expect(result[1]).toEqual({ + type: 'turn_complete', + stopReason: 'toolUse' + }); + }); + + it('should convert turn_end without usage data', () => { + const result = convertPiEvent({ + type: 'turn_end' + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'turn_complete', + stopReason: 'stop' + }); + }); + + it('should return empty array for agent_start', () => { + expect(convertPiEvent({ type: 'agent_start' })).toEqual([]); + }); + + it('should return empty array for agent_end', () => { + expect(convertPiEvent({ type: 'agent_end', messages: [] })).toEqual([]); + }); + + it('should return empty array for response events', () => { + // Response events use a different type, but we handle gracefully + expect(convertPiEvent({ type: 'response', command: 'prompt', success: true } as unknown as PiAgentEvent)).toEqual([]); + }); + + it('should return empty array for turn_start', () => { + expect(convertPiEvent({ type: 'turn_start' })).toEqual([]); + }); + + it('should return empty array for unknown event types', () => { + expect(convertPiEvent({ type: 'something_else' })).toEqual([]); + }); + + it('should not crash on unexpected data structure (safety net)', () => { + // Simulate a malformed event that somehow passes through + const weird = Object.create(null); + weird.type = 'message_update'; + weird.assistantMessageEvent = undefined; + // Should not throw + expect(() => convertPiEvent(weird as unknown as PiAgentEvent)).not.toThrow(); + expect(convertPiEvent(weird as unknown as PiAgentEvent)).toEqual([]); + }); +}); diff --git a/cli/src/pi/piEventConverter.ts b/cli/src/pi/piEventConverter.ts new file mode 100644 index 0000000000..360b83a9ba --- /dev/null +++ b/cli/src/pi/piEventConverter.ts @@ -0,0 +1,88 @@ +import { logger } from '@/ui/logger'; +import type { AgentMessage } from '@/agent/types'; +import type { + PiAgentEvent, + PiToolExecutionStartEvent, + PiToolExecutionEndEvent, + PiTurnEndEvent +} from './types'; + +/** + * Converts Pi AgentEvent to HAPI AgentMessage array. + * + * Pi events come from `pi --mode rpc` stdout as JSONL. + * Not all Pi events map to HAPI AgentMessages — response/ack events + * are handled directly by the runner, not by this converter. + */ +export function convertPiEvent(event: PiAgentEvent): AgentMessage[] { + try { + switch (event.type) { + case 'tool_execution_start': { + const e = event as PiToolExecutionStartEvent; + return [{ + type: 'tool_call', + id: e.toolCallId, + name: e.toolName, + input: e.args, + status: 'in_progress' + }]; + } + + case 'tool_execution_end': { + const e = event as PiToolExecutionEndEvent; + return [{ + type: 'tool_result', + id: e.toolCallId, + output: e.result, + status: e.isError ? 'failed' : 'completed' + }]; + } + + case 'turn_end': { + const e = event as PiTurnEndEvent; + const messages: AgentMessage[] = []; + const usage = e.message?.usage; + + if (usage) { + messages.push({ + type: 'usage', + inputTokens: usage.input ?? 0, + outputTokens: usage.output ?? 0, + totalTokens: usage.totalTokens, + cacheReadTokens: usage.cacheRead + }); + } + + messages.push({ + type: 'turn_complete', + stopReason: e.message?.stopReason ?? 'stop' + }); + + return messages; + } + + // Lifecycle and other events — not converted to AgentMessage. + // message_start/update/end are handled by PiMessageAccumulator + // in loop.ts before this converter is called — they never reach here, + // but are listed for exhaustive matching. + case 'agent_start': + case 'agent_end': + case 'turn_start': + case 'message_start': + case 'message_update': + case 'message_end': + case 'tool_execution_update': + case 'extension_ui_request': + case 'keep_alive': + case 'response': + return []; + + default: + logger.debug(`[pi] Unknown event type: ${event.type}`); + return []; + } + } catch (err) { + logger.debug(`[pi] convertPiEvent failed for type=${event.type}: ${err}`); + return []; + } +} diff --git a/cli/src/pi/piMessageAccumulator.test.ts b/cli/src/pi/piMessageAccumulator.test.ts new file mode 100644 index 0000000000..1631599aff --- /dev/null +++ b/cli/src/pi/piMessageAccumulator.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from 'vitest'; +import { PiMessageAccumulator } from './piMessageAccumulator'; + +describe('PiMessageAccumulator', () => { + function makeEvent(type: string, extra: Record = {}): any { + return { type, ...extra }; + } + + it('returns empty for events that are not handled', () => { + const acc = new PiMessageAccumulator(); + expect(acc.handleEvent(makeEvent('agent_start'))).toEqual([]); + expect(acc.handleEvent(makeEvent('turn_start'))).toEqual([]); + expect(acc.handleEvent(makeEvent('turn_end'))).toEqual([]); + expect(acc.handleEvent(makeEvent('agent_end'))).toEqual([]); + }); + + it('accumulates text deltas and flushes one text message on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + expect(acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'hello ' } + }))).toEqual([]); + expect(acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'world' } + }))).toEqual([]); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'hello world' } + ]); + }); + + it('accumulates thinking deltas and flushes one reasoning message on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: 'let me ' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: 'think...' } + })); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'reasoning', text: 'let me think...', id: 'pi-stream' } + ]); + }); + + it('flushes both reasoning and text in order on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: 'thinking' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'reply' } + })); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'reasoning', text: 'thinking', id: 'pi-stream' }, + { type: 'text', text: 'reply' } + ]); + }); + + it('skips empty content on flush', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'only text' } + })); + + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'only text' } + ]); + }); + + it('drops empty/missing deltas silently', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: '' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_delta', delta: ' ' } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'reasoning', text: ' ', id: 'pi-stream' } + ]); + }); + + it('uses contentIndex as streamId when provided', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'x', contentIndex: 2 } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'x' } + ]); + }); + + it('updates streamId from later deltas', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'a' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'b', contentIndex: 7 } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'ab' } + ]); + }); + + it('resets state on the next message_start', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'first' } + })); + acc.handleEvent(makeEvent('message_end', { message: {} })); + + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'second' } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'second' } + ]); + }); + + it('flushes on turn_end as a safety net (no message_end received)', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'incomplete' } + })); + // No message_end — older Pi builds, partial streams, etc. + const flushed = acc.handleEvent(makeEvent('turn_end', { + message: { usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 3 } } + })); + expect(flushed).toEqual([ + { type: 'text', text: 'incomplete' } + ]); + }); + + it('does not flush on turn_end if no message_start was seen', () => { + const acc = new PiMessageAccumulator(); + const flushed = acc.handleEvent(makeEvent('turn_end', { message: {} })); + expect(flushed).toEqual([]); + }); + + it('does not flush twice on message_end', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'once' } + })); + expect(acc.handleEvent(makeEvent('message_end', { message: {} }))).toEqual([ + { type: 'text', text: 'once' } + ]); + // Second message_end with no content buffered — must be empty, + // not a duplicate. + expect(acc.handleEvent(makeEvent('message_end', { message: {} }))).toEqual([]); + }); + + it('ignores text_start / thinking_start / text_end / thinking_end (full snapshots cause duplicates)', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_start' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_start' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_end' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'thinking_end' } + })); + acc.handleEvent(makeEvent('message_update', { + assistantMessageEvent: { type: 'text_delta', delta: 'real content' } + })); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([ + { type: 'text', text: 'real content' } + ]); + }); + + it('handles message_update without assistantMessageEvent', () => { + const acc = new PiMessageAccumulator(); + acc.handleEvent(makeEvent('message_start', { message: {} })); + expect(() => acc.handleEvent(makeEvent('message_update'))).not.toThrow(); + const flushed = acc.handleEvent(makeEvent('message_end', { message: {} })); + expect(flushed).toEqual([]); + }); +}); diff --git a/cli/src/pi/piMessageAccumulator.ts b/cli/src/pi/piMessageAccumulator.ts new file mode 100644 index 0000000000..c8ffc1898f --- /dev/null +++ b/cli/src/pi/piMessageAccumulator.ts @@ -0,0 +1,96 @@ +import type { AgentMessage } from '@/agent/types' +import type { PiAgentEvent, PiAssistantMessageEvent } from './types' +import { PiAssistantMessageEventSchema } from './schemas' + +/** + * Accumulates Pi assistant-message text/thinking deltas into a single + * snapshot, flushed on `message_end` (with a `turn_end` safety net). + * + * Without this, every delta would become a separate hub message, and + * the web's reducer would render the last delta as the whole reasoning + * block (the per-message content-array dedup by streamId would only + * see one snapshot) while stacking every text delta as a new agent-text + * block, producing a character-by-character column. + * + * Mirrors codex's `ReasoningProcessor`: accumulate deltas locally, + * emit one reasoning + one text message per assistant message. + */ +export class PiMessageAccumulator { + private active = false + private text = '' + private reasoning = '' + private streamId = 'pi-stream' + + /** + * Apply a Pi event to the accumulator. + * + * @returns AgentMessages to forward to the hub, if this event + * represents a flush point (`message_end` or `turn_end` with + * pending content). Returns an empty array otherwise. + */ + handleEvent(event: PiAgentEvent): AgentMessage[] { + if (event.type === 'message_start') { + this.active = true + this.text = '' + this.reasoning = '' + this.streamId = 'pi-stream' + return [] + } + + if (event.type === 'message_update') { + const updateEvent = event as { assistantMessageEvent?: PiAssistantMessageEvent } + const rawAme = updateEvent.assistantMessageEvent + if (!rawAme) return [] + const ameResult = PiAssistantMessageEventSchema.safeParse(rawAme) + if (!ameResult.success) return [] + const ame = ameResult.data + const streamId = ame.contentIndex?.toString() ?? 'pi-stream' + this.streamId = streamId + if (ame.type === 'text_delta' && ame.delta) { + this.text += ame.delta + } else if (ame.type === 'thinking_delta' && ame.delta) { + this.reasoning += ame.delta + } + // Other assistant message events (text_start/thinking_start/ + // text_end/thinking_end) carry the full partial state — we + // already have the deltas, so we ignore them. + return [] + } + + if (event.type === 'message_end') { + if (this.active) return this.flush() + return [] + } + + // Safety net: turn_end with pending content means the assistant + // message ended without a clean `message_end` (older Pi builds, + // partial streams, or a stream that crashed mid-flight). + if (event.type === 'turn_end' && this.active) { + return this.flush() + } + + return [] + } + + private flush(): AgentMessage[] { + const streamId = this.streamId + const reasoning = this.reasoning + const text = this.text + this.active = false + this.text = '' + this.reasoning = '' + this.streamId = 'pi-stream' + + const out: AgentMessage[] = [] + // Reasoning comes before text in the Pi event sequence, so emit + // in that order. Empty content is dropped so the web doesn't + // render empty bubbles. + if (reasoning) { + out.push({ type: 'reasoning', text: reasoning, id: streamId }) + } + if (text) { + out.push({ type: 'text', text }) + } + return out + } +} diff --git a/cli/src/pi/piTransport.test.ts b/cli/src/pi/piTransport.test.ts new file mode 100644 index 0000000000..be9c8c0b26 --- /dev/null +++ b/cli/src/pi/piTransport.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +const mockSpawn = vi.fn(); +vi.mock('node:child_process', () => ({ + get spawn() { return mockSpawn; } +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn() + } +})); + +function createMockProcess(): ChildProcessWithoutNullStreams & EventEmitter { + const emitter = new EventEmitter() as ChildProcessWithoutNullStreams & EventEmitter; + const stdin = new EventEmitter() as any; + stdin.write = vi.fn().mockReturnValue(true); + stdin.end = vi.fn(); + const stdout = new EventEmitter() as any; + stdout.setEncoding = vi.fn(); + const stderr = new EventEmitter() as any; + stderr.setEncoding = vi.fn(); + + emitter.stdin = stdin; + emitter.stdout = stdout; + emitter.stderr = stderr; + emitter.kill = vi.fn().mockReturnValue(true); + // pid is read-only in ChildProcess, use type assertion for mock + (emitter as any).pid = 12345; + + return emitter; +} + +const { PiTransport } = await import('./piTransport'); + +describe('PiTransport', () => { + let mockProcess: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + }); + + describe('start()', () => { + it('should spawn pi with correct args', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + expect(mockSpawn).toHaveBeenCalledWith('pi', ['--mode', 'rpc'], expect.objectContaining({ + cwd: '/work', + stdio: ['pipe', 'pipe', 'pipe'] + })); + }); + + it('should emit error event on ENOENT', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const errorSpy = vi.fn(); + transport.onError(errorSpy); + + const spawnError = new Error('spawn pi ENOENT') as NodeJS.ErrnoException; + spawnError.code = 'ENOENT'; + mockProcess.emit('error', spawnError); + + expect(errorSpy).toHaveBeenCalledWith(expect.any(Error)); + expect(errorSpy.mock.calls[0][0].message).toContain('not found'); + }); + + it('should ignore double-start call', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + expect(mockSpawn).toHaveBeenCalledTimes(1); + + transport.start(); + expect(mockSpawn).toHaveBeenCalledTimes(1); + }); + }); + + describe('send()', () => { + it('should write JSON to stdin', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + transport.send({ type: 'prompt', message: 'hello' }); + expect(mockProcess.stdin.write).toHaveBeenCalledWith( + JSON.stringify({ type: 'prompt', message: 'hello' }) + '\n' + ); + }); + + it('should handle EPIPE gracefully without throwing', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + mockProcess.stdin.write = vi.fn().mockImplementation(() => { + const err = new Error('write EPIPE') as NodeJS.ErrnoException; + err.code = 'EPIPE'; + throw err; + }); + + expect(() => transport.send({ type: 'prompt', message: 'test' })).not.toThrow(); + }); + }); + + describe('onEvent()', () => { + it('should parse valid JSONL from stdout and call handler', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + const event = { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'hello' } }; + mockProcess.stdout.emit('data', JSON.stringify(event) + '\n'); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('should skip malformed JSON and not crash', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + mockProcess.stdout.emit('data', 'not-json\n'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should handle multiple JSONL lines in one chunk', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + const event1 = { type: 'turn_start' }; + const event2 = { type: 'turn_end', message: {} }; + mockProcess.stdout.emit('data', JSON.stringify(event1) + '\n' + JSON.stringify(event2) + '\n'); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledWith(event1); + expect(handler).toHaveBeenCalledWith(event2); + }); + + it('should buffer and reassemble split JSONL across chunks', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const handler = vi.fn(); + transport.onEvent(handler); + + const event = { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'hello' } }; + const fullLine = JSON.stringify(event) + '\n'; + + // Split the line into two chunks — no newline in first chunk + mockProcess.stdout.emit('data', fullLine.slice(0, 20)); + expect(handler).not.toHaveBeenCalled(); + + // Send the rest with newline + mockProcess.stdout.emit('data', fullLine.slice(20)); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(event); + }); + }); + + describe('kill()', () => { + it('should send SIGTERM to the process', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + transport.kill(); + expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should be a no-op when process is not running', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + expect(() => transport.kill()).not.toThrow(); + }); + }); + + describe('onClose()', () => { + it('should call handler when subprocess exits', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const closeHandler = vi.fn(); + transport.onClose(closeHandler); + + mockProcess.emit('close', 1, null); + expect(closeHandler).toHaveBeenCalledWith(1, null); + }); + + it('should call handler with signal when killed by signal', () => { + const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' }); + transport.start(); + + const closeHandler = vi.fn(); + transport.onClose(closeHandler); + + mockProcess.emit('close', null, 'SIGTERM'); + expect(closeHandler).toHaveBeenCalledWith(null, 'SIGTERM'); + }); + }); +}); diff --git a/cli/src/pi/piTransport.ts b/cli/src/pi/piTransport.ts new file mode 100644 index 0000000000..8d4a99ce70 --- /dev/null +++ b/cli/src/pi/piTransport.ts @@ -0,0 +1,123 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { logger } from '@/ui/logger'; +import { JsonLineParser } from '@/utils/jsonLineParser'; +import { PiAgentEventSchema } from './schemas'; +import type { PiAgentEvent, PiRpcCommand } from './types'; + +export interface PiTransportOptions { + command: string; + args: string[]; + cwd: string; +} + +export class PiTransport extends JsonLineParser { + private process: ChildProcessWithoutNullStreams | null = null; + private eventHandler: ((event: PiAgentEvent) => void) | null = null; + private closeHandler: ((code: number | null, signal: string | null) => void) | null = null; + private errorHandler: ((error: Error) => void) | null = null; + private killed = false; + private started = false; + private exited = false; + private readonly options: PiTransportOptions; + + constructor(options: PiTransportOptions) { + super(); + this.options = options; + } + + start(): void { + if (this.started) { + logger.warn('[pi] PiTransport.start() called twice — ignoring'); + return; + } + this.started = true; + + logger.debug(`[pi] Starting Pi process: ${this.options.command} ${this.options.args.join(' ')}`); + + this.process = spawn(this.options.command, this.options.args, { + cwd: this.options.cwd, + stdio: ['pipe', 'pipe', 'pipe'] + }) as ChildProcessWithoutNullStreams; + + this.process.stdout.setEncoding('utf8'); + this.process.stdout.on('data', (chunk: string) => this.feed(chunk)); + this.process.stdout.on('end', () => { + if (!this.exited && !this.killed) { + logger.debug('[pi] stdout ended before process close — treating as exit'); + this.exited = true; + this.closeHandler?.(null, null); + } + }); + + this.process.stderr.setEncoding('utf8'); + this.process.stderr.on('data', (chunk: string) => { + logger.debug(`[pi][stderr] ${chunk.toString().trim()}`); + }); + + this.process.on('close', (code, signal) => { + logger.debug(`[pi] Process exited (code=${code}, signal=${signal})`); + this.exited = true; + this.closeHandler?.(code, signal); + }); + + this.process.on('error', (err) => { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'ENOENT') { + this.errorHandler?.(new Error( + `Pi was not found on PATH. Please install Pi and retry.` + )); + } else { + this.errorHandler?.(new Error( + `Failed to start Pi: ${nodeErr.message}` + )); + } + }); + } + + send(message: PiRpcCommand): void { + if (!this.process || this.killed) { + logger.debug('[pi] Dropping message: transport not running'); + return; + } + try { + this.process.stdin.write(JSON.stringify(message) + '\n'); + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'EPIPE') { + logger.debug('[pi] EPIPE on write — process likely exited'); + } else { + throw err; + } + } + } + + onEvent(handler: (event: PiAgentEvent) => void): void { + this.eventHandler = handler; + } + + onClose(handler: (code: number | null, signal: string | null) => void): void { + this.closeHandler = handler; + } + + onError(handler: (error: Error) => void): void { + this.errorHandler = handler; + } + + kill(): void { + if (!this.process || this.killed) return; + this.killed = true; + this.process.kill('SIGTERM'); + } + + protected handleLine(line: string): void { + try { + const parsed = JSON.parse(line); + const result = PiAgentEventSchema.safeParse(parsed); + if (result.success) { + this.eventHandler?.(result.data as PiAgentEvent); + } + } catch { + logger.debug(`[pi] Skipping malformed JSON: ${line.slice(0, 100)}`); + } + } +} diff --git a/cli/src/pi/runPi.ts b/cli/src/pi/runPi.ts new file mode 100644 index 0000000000..430a047902 --- /dev/null +++ b/cli/src/pi/runPi.ts @@ -0,0 +1,403 @@ +import { logger } from '@/ui/logger'; +import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import { registerLocalHandoffHandler } from '@/agent/localHandoff'; +import { createRunnerLifecycle, createModeChangeHandler, setControlledByUser } from '@/agent/runnerLifecycle'; +import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getInvokedCwd } from '@/utils/invokedCwd'; +import { PiTransport } from './piTransport'; +import { PiSession } from './session'; +import { parsePiModels, parsePiCommands, sendPiRpcAndWait, wireTransportEvents } from './loop'; +import { PiThinkingLevelSchema, SetSessionConfigPayloadSchema } from './schemas'; +import type { PiThinkingLevel } from './types'; +import type { SlashCommandsResponse } from '@hapi/protocol/apiTypes'; +import type { ListPiModelsResponse } from '@hapi/protocol/apiTypes'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; + +export async function runPi(opts: { + startedBy?: 'runner' | 'terminal'; + startingMode?: 'local' | 'remote'; + model?: string; + effort?: string; + resumeSessionId?: string; + existingSessionId?: string; + workingDirectory?: string; +} = {}): Promise { + const workingDirectory = opts.workingDirectory ?? getInvokedCwd(); + const startedBy = opts.startedBy ?? 'terminal'; + // Pi only runs as `pi --mode rpc` with piped stdio — there is no local + // terminal/TUI input path (unlike Claude/Codex). Defaulting a terminal + // launch to 'local' would mark the session local-controlled while the user + // cannot drive it from the terminal, leaving it stuck until a web switch. + // Default to 'remote' so the session is immediately drivable from the web; + // an explicit opts.startingMode (e.g. runner) still takes precedence. + const startingMode: 'local' | 'remote' = opts.startingMode ?? 'remote'; + + logger.debug(`[pi] Starting with options: startedBy=${startedBy}, startingMode=${startingMode}`); + + const bootstrap = opts.existingSessionId + ? await bootstrapExistingSession({ + sessionId: opts.existingSessionId, + flavor: 'pi', + startedBy, + workingDirectory, + }) + : await bootstrapSession({ + flavor: 'pi', + startedBy, + workingDirectory, + // Do not seed the hub session model from opts.model: it is unconfirmed + // until get_available_models/set_model accept it. The hub's + // handleSessionAlive persists every non-undefined keepAlive model, so + // passing it here would store/show a model Pi may reject. PiSession + // carries opts.model as initialModel and applies it once confirmed. + model: undefined + }); + const { session: apiSession } = bootstrap; + + setControlledByUser(apiSession, startingMode); + + const piSession = new PiSession({ + api: bootstrap.api, + client: apiSession, + path: workingDirectory, + logPath: logger.getLogPath(), + startedBy, + startingMode, + model: opts.model, + }); + + const transportArgs = ['--mode', 'rpc']; + if (opts.resumeSessionId) { + transportArgs.push('--session-id', opts.resumeSessionId); + } + const transport = new PiTransport({ command: 'pi', args: transportArgs, cwd: workingDirectory }); + + piSession.startKeepAlive(); + + let killedByCleanup = false; + const lifecycle = createRunnerLifecycle({ + session: apiSession, + logTag: 'pi', + stopKeepAlive: () => piSession.stopKeepAlive(), + onAfterClose: () => { + piSession.stopKeepAlive(); + killedByCleanup = true; + transport.kill(); + } + }); + + lifecycle.registerProcessHandlers(); + registerKillSessionHandler(apiSession.rpcHandlerManager, lifecycle.cleanupAndExit); + registerLocalHandoffHandler(apiSession.rpcHandlerManager, lifecycle); + + let cleanupInitiated = false; + const safeCleanup = async () => { + if (cleanupInitiated) return; + cleanupInitiated = true; + await lifecycle.cleanupAndExit(); + }; + + // Pending user-message localIds in FIFO order + const pendingLocalIds: string[] = []; + + // --- Transport error/close handlers --- + transport.onError((error) => { + logger.debug(`[pi] Transport error: ${error.message}`); + lifecycle.markCrash(error); + lifecycle.setExitCode(1); + lifecycle.setArchiveReason(error.message.slice(0, 200)); + lifecycle.setSessionEndReason('error'); + void safeCleanup(); + }); + + transport.onClose((code, signal) => { + if (killedByCleanup) { + logger.debug(`[pi] Pi process closed during lifecycle cleanup (code=${code}, signal=${signal})`); + void safeCleanup(); + return; + } + const reason = signal + ? `Pi process killed by signal ${signal}` + : `Pi process exited with code ${code ?? 'null'}`; + logger.debug(`[pi] ${reason}`); + lifecycle.markCrash(new Error(reason)); + lifecycle.setExitCode(1); + lifecycle.setArchiveReason(reason.slice(0, 200)); + lifecycle.setSessionEndReason('error'); + void safeCleanup(); + }); + + // --- Wire transport events to session --- + // Capture the requested startup effort WITHOUT mutating currentThinkingLevel. + // It is applied (and committed) only after Pi confirms set_thinking_level, + // mirroring the startup-model contract; seeding it here would leak an + // unconfirmed/rejected value via the first keepAlive (pushKeepAlive persists + // effort) before the RPC runs. get_state's thinkingLevel is the authoritative + // source until set_thinking_level succeeds. + let startupThinkingLevel: PiThinkingLevel | null = null; + if (opts.effort) { + const result = PiThinkingLevelSchema.safeParse(opts.effort.trim().toLowerCase()); + if (result.success) { + startupThinkingLevel = result.data; + } else { + logger.debug(`[pi] Ignoring invalid effort value on resume: ${opts.effort}`); + } + } + + wireTransportEvents(transport, piSession, pendingLocalIds); + + // --- Session config RPC --- + // + // Pi manually registers SetSessionConfig instead of using + // registerSessionConfigRpc() because Pi's wire protocol requires + // separate provider + modelId fields (transport.send({ type: + // 'set_model', provider, modelId })), while registerSessionConfigRpc + // only handles model as a simple string. The hub sends model as + // { provider, modelId } for Pi sessions. + + apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (rawPayload: unknown) => { + const parsed = SetSessionConfigPayloadSchema.safeParse(rawPayload); + if (!parsed.success) { + throw new Error('Invalid session config payload'); + } + const config = parsed.data; + logger.debug(`[pi] SetSessionConfig received: ${JSON.stringify(config)}`); + + // Resolve requested values WITHOUT mutating PiSession yet. Commit them + // only after Pi confirms via sendPiRpcAndWait, otherwise a rejected + // set_model/set_thinking_level would leave PiSession holding unconfirmed + // values that the 2s keepalive reports back to the hub, persisting a + // model/effort Pi never accepted. + let requestedModel: { modelId: string | null; provider: string | null } | undefined; + if (config.model !== undefined) { + const modelValue = config.model; + logger.debug(`[pi] SetSessionConfig model: ${JSON.stringify(modelValue)}`); + + if (modelValue === null) { + requestedModel = { modelId: null, provider: null }; + } else if (typeof modelValue === 'string') { + const trimmed = modelValue.trim(); + if (!trimmed) throw new Error('Invalid model'); + // Fallback: search cached models for provider + const cached = piSession.cachedPiModels.find(m => m.modelId === trimmed); + requestedModel = { modelId: trimmed, provider: cached?.provider ?? null }; + } else { + // { provider, modelId } form + requestedModel = { modelId: modelValue.modelId, provider: modelValue.provider }; + } + logger.debug(`[pi] SetSessionConfig resolved: model=${requestedModel.modelId}, provider=${requestedModel.provider}`); + } + let requestedThinkingLevel: PiThinkingLevel | null | undefined; + if (config.effort !== undefined) { + if (config.effort === null) { + requestedThinkingLevel = null; + } else { + const result = PiThinkingLevelSchema.safeParse( + typeof config.effort === 'string' ? config.effort.trim().toLowerCase() : config.effort, + ); + if (!result.success) throw new Error('Invalid effort'); + requestedThinkingLevel = result.data; + } + } + + // Forward changes to Pi process — wait for Pi to confirm before + // committing to PiSession or reporting applied, so the hub does not + // persist a model/effort that Pi rejected (e.g. invalid provider/model + // or thinking level) or that the RPC timed out on. + if (requestedModel) { + if (requestedModel.modelId && requestedModel.provider) { + await sendPiRpcAndWait(piSession, transport, { + type: 'set_model', + provider: requestedModel.provider, + modelId: requestedModel.modelId, + }); + piSession.currentModel = requestedModel.modelId; + piSession.currentProvider = requestedModel.provider; + } else if (requestedModel.modelId && !requestedModel.provider) { + // Provider is unknown until get_state/get_available_models resolve. + // Committing now would persist piSelectedModel while Pi never received + // set_model — contradicting the "await Pi confirmation" contract above. + // Throw so the hub returns 409 and the web client can retry once the + // provider is known. + logger.debug('[pi] set_model suppressed: provider unknown until get_state'); + throw new Error('Model cannot be applied yet: provider is not yet known'); + } else if (requestedModel.modelId === null) { + // Clearing the model needs no Pi RPC (nothing to confirm), so commit + // immediately. This path is not reachable from the web Pi picker today. + piSession.currentModel = null; + piSession.currentProvider = null; + } + } + if (requestedThinkingLevel !== undefined) { + const level = requestedThinkingLevel ?? 'off'; + await sendPiRpcAndWait(piSession, transport, { type: 'set_thinking_level', level }); + piSession.currentThinkingLevel = requestedThinkingLevel; + } + piSession.pushKeepAlive(); + + // Return provider-qualified model so the hub persists piSelectedModel. + // A bare modelId string would make applySessionConfig clear the + // provider metadata (object check fails), defeating Fix #3. + const appliedModel = piSession.currentModel && piSession.currentProvider + ? { provider: piSession.currentProvider, modelId: piSession.currentModel } + : piSession.currentModel; + + return { + applied: { + model: appliedModel, + effort: piSession.currentThinkingLevel, + }, + }; + }); + + // --- Pi model discovery RPC --- + apiSession.rpcHandlerManager.registerHandler, ListPiModelsResponse>( + RPC_METHODS.ListPiModels, + async () => { + if (piSession.cachedPiModels.length > 0) { + return { + success: true, + availableModels: piSession.cachedPiModels, + currentModelId: piSession.currentModel, + }; + } + try { + const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_available_models' }); + const models = parsePiModels(data); + if (models.length > 0) { + piSession.cachedPiModels = models; + piSession.updateMetadata(meta => ({ ...meta, piAvailableModels: models })); + } + return { success: true, availableModels: models, currentModelId: piSession.currentModel }; + } catch (error) { + logger.debug('[pi] ListPiModels RPC failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list Pi models', + }; + } + } + ); + + // --- Slash commands (Pi skills/commands) --- + apiSession.rpcHandlerManager.registerHandler<{ agent?: string }, SlashCommandsResponse>( + RPC_METHODS.ListSlashCommands, + async () => { + let commands = piSession.cachedPiCommands; + if (commands.length === 0) { + try { + const data = await sendPiRpcAndWait(piSession, transport, { type: 'get_commands' }); + commands = parsePiCommands(data); + if (commands.length > 0) { + piSession.cachedPiCommands = commands; + } + } catch { + // Fall through to return empty + } + } + return { + success: true, + commands: commands.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + source: cmd.source === 'skill' ? 'plugin' as const + : cmd.source === 'prompt' ? 'user' as const + : 'plugin' as const, + })), + }; + } + ); + + // --- User message handler --- + apiSession.onUserMessage((message, localId) => { + const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + if (piSession.piIsStreaming) { + // Steer does not start a new turn, so the localId would never be + // drained by turn_start. Mark it consumed immediately so it does + // not poison the FIFO for the next real prompt. + transport.send({ type: 'steer', message: formattedText }); + if (localId) piSession.emitMessagesConsumed([localId]); + } else { + if (localId) pendingLocalIds.push(localId); + transport.send({ type: 'prompt', message: formattedText }); + } + }); + + // --- Abort handler --- + // Only cancel the current turn, keep session alive for next prompt. + // Pi's `abort` command cancels the active turn but the process stays in RPC mode. + 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()!]); + } + return { success: true }; + }); + + // --- Switch handler --- + // Unlike Claude/Codex (which use BaseLocalLauncher's restart loop), Pi runs + // as a single long-lived subprocess. Switching mode should change control + // ownership without killing the process or archiving the session. + const handleModeChange = createModeChangeHandler(apiSession); + apiSession.rpcHandlerManager.registerHandler(RPC_METHODS.Switch, async (payload: { to?: 'local' | 'remote' } = {}) => { + const mode = payload.to ?? 'remote'; + piSession.setMode(mode); + handleModeChange(mode); + return { success: true }; + }); + + // --- Run --- + let crashed = false; + try { + transport.start(); + transport.send({ type: 'new_session' }); + transport.send({ type: 'get_state' }); + transport.send({ type: 'get_available_models' }); + transport.send({ type: 'get_commands' }); + + // Apply the requested startup effort only after Pi confirms + // set_thinking_level. Commit currentThinkingLevel on success and push a + // keepAlive so the hub sees the accepted value; on rejection keep Pi's + // default (already reported by get_state). Detached so the run loop is + // not blocked; sent after get_state so the authoritative baseline lands + // first and a late get_state response does not clobber the confirmed + // value (get_state runs on the wire before this await resolves). + if (startupThinkingLevel) { + void (async () => { + try { + await sendPiRpcAndWait(piSession, transport, { + type: 'set_thinking_level', + level: startupThinkingLevel, + }); + piSession.currentThinkingLevel = startupThinkingLevel; + piSession.pushKeepAlive(); + logger.debug(`[pi] Startup effort applied: ${startupThinkingLevel}`); + } catch (error) { + logger.debug(`[pi] Startup effort rejected, keeping Pi default: ${error instanceof Error ? error.message : String(error)}`); + } + })(); + } + + // Block until cleanup is triggered by error/close handler + await new Promise((resolve) => { + const origCleanup = lifecycle.cleanupAndExit.bind(lifecycle); + lifecycle.cleanupAndExit = async (codeOverride?: number) => { + resolve(); + await origCleanup(codeOverride); + }; + }); + } catch (error) { + crashed = true; + lifecycle.markCrash(error); + lifecycle.setSessionEndReason('error'); + logger.debug('[pi] Loop error:', error); + } finally { + if (!crashed && !lifecycle.hasExplicitSessionEndReason()) { + lifecycle.setSessionEndReason('completed'); + } + await safeCleanup(); + } +} diff --git a/cli/src/pi/schemas.ts b/cli/src/pi/schemas.ts new file mode 100644 index 0000000000..3532265af2 --- /dev/null +++ b/cli/src/pi/schemas.ts @@ -0,0 +1,203 @@ +/** + * Zod schemas for Pi RPC protocol parsing. + * + * All unknown→typed conversions happen here via Zod schemas, + * so downstream code works with validated data only. + * + * Pi 协议无版本保证 — 字段级容错策略: + * 用 z.unknown().transform() / .catch() 确保非法类型字段静默丢弃, + * 而非拒绝整个对象。 + */ + +import { z } from 'zod'; +import { PI_THINKING_LEVELS } from '@hapi/protocol'; +import type { PiModelSummary } from '@hapi/protocol/apiTypes'; + +// ============================================================================ +// 字段级容错 schema +// ============================================================================ + +/** 提取 string 值,非 string 返回 undefined */ +const asOptStr = z.unknown().transform(v => typeof v === 'string' ? v : undefined); + +/** 提取 number 值,非 number 返回 undefined */ +const asOptNum = z.unknown().transform(v => typeof v === 'number' ? v : undefined); + +/** 提取 boolean 值,非 boolean 返回 undefined */ +const asOptBool = z.unknown().transform(v => typeof v === 'boolean' ? v : undefined); + +/** 提取 string 值,非 string 返回指定默认值 */ +const asStrOrDef = (def: string) => z.unknown().transform(v => typeof v === 'string' ? v : def); + +/** 提取合法的 thinkingLevelMap,非法结构返回 undefined */ +const asOptThinkingLevelMap = z.unknown().transform((v): Record | undefined => { + if (typeof v !== 'object' || v === null) return undefined; + const map: Record = {}; + for (const [key, val] of Object.entries(v as Record)) { + if (typeof val === 'string') map[key] = val; + else if (val === null) map[key] = null; + } + return Object.keys(map).length > 0 ? map : undefined; +}); + +// ============================================================================ +// Pi Agent Event (stdin JSONL → event) +// ============================================================================ + +/** Minimal shape: must be an object with a string `type` field. */ +export const PiAgentEventSchema = z.object({ + type: z.string(), +}).passthrough(); + +// ============================================================================ +// Pi Response Event (stdout response) +// ============================================================================ + +export const PiResponseEventSchema = z.object({ + type: z.literal('response'), + command: z.string(), + success: z.boolean(), + error: z.string().optional(), + data: z.unknown().optional(), + // RPC correlation id (sent by PiRpcResolver as string) + id: z.string().optional(), +}); + +// ============================================================================ +// Pi Command Summary +// ============================================================================ + +const VALID_COMMAND_SOURCES = ['extension', 'prompt', 'skill'] as const; +type PiCommandSource = (typeof VALID_COMMAND_SOURCES)[number]; + +const PiCommandSummarySchema = z.object({ + name: z.string(), + description: z.string().optional(), + source: z.enum(VALID_COMMAND_SOURCES), +}); + +/** 单条 command 的容错 schema:非法字段静默修正,空 name 返回 null */ +const PiCommandEntrySchema = z.object({ + name: asStrOrDef(''), + description: asOptStr, + source: z.unknown().transform(v => + VALID_COMMAND_SOURCES.includes(v as PiCommandSource) + ? (v as PiCommandSource) + : ('skill' as const), + ), +}).passthrough().transform((c) => { + if (!c.name) return null; + const entry: { name: string; description?: string; source: PiCommandSource } = { + name: c.name, + source: c.source, + }; + if (c.description !== undefined) entry.description = c.description; + return entry; +}); + +const PiCommandsResponseDataSchema = z.object({ + commands: z.array(z.unknown()).default([]), +}).transform(data => + data.commands + .map(c => PiCommandEntrySchema.safeParse(c)) + .filter((r): r is { success: true; data: NonNullable } => r.success && r.data !== null) + .map(r => r.data), +); + +// ============================================================================ +// Pi Model Summary +// ============================================================================ + +/** 单条 model 的容错 schema:非法字段静默丢弃,空 id 返回 null */ +const PiModelEntrySchema = z.object({ + id: asStrOrDef(''), + provider: asStrOrDef('unknown'), + name: asOptStr, + contextWindow: asOptNum, + reasoning: asOptBool, + thinkingLevelMap: asOptThinkingLevelMap, +}).passthrough().transform((m): PiModelSummary | null => { + if (!m.id) return null; + const entry: PiModelSummary = { provider: m.provider, modelId: m.id }; + if (m.name !== undefined) entry.name = m.name; + if (m.contextWindow !== undefined) entry.contextWindow = m.contextWindow; + if (m.reasoning !== undefined) entry.reasoning = m.reasoning; + if (m.thinkingLevelMap !== undefined) entry.thinkingLevelMap = m.thinkingLevelMap; + return entry; +}); + +const PiModelsResponseDataSchema = z.object({ + models: z.array(z.unknown()).default([]), +}).transform(data => + data.models + .map(m => PiModelEntrySchema.safeParse(m)) + .filter((r): r is { success: true; data: NonNullable } => r.success && r.data !== null) + .map(r => r.data), +); + +// ============================================================================ +// Pi State (get_state response data) +// ============================================================================ + +export const PiStateDataSchema = z.object({ + model: z.object({ + id: z.string().optional(), + modelId: z.string().optional(), + provider: z.string().optional(), + }).passthrough().optional(), + sessionId: z.string().optional(), + thinkingLevel: z.string().optional(), + steeringMode: z.enum(['all', 'one-at-a-time']).optional(), +}).passthrough(); + +// ============================================================================ +// Pi set_model response data +// ============================================================================ + +export const PiSetModelDataSchema = z.object({ + id: z.string().optional(), + modelId: z.string().optional(), + provider: z.string().optional(), +}).passthrough(); + +// ============================================================================ +// SetSessionConfig RPC payload +// ============================================================================ + +export const SetSessionConfigPayloadSchema = z.object({ + permissionMode: z.unknown().optional(), + model: z.union([ + z.string(), + z.object({ provider: z.string(), modelId: z.string() }), + z.null(), + ]).optional(), + effort: z.unknown().optional(), +}).passthrough(); + +// ============================================================================ +// Pi thinking level — enum sourced from @hapi/protocol (single definition) +// ============================================================================ + +export const PiThinkingLevelSchema = z.enum(PI_THINKING_LEVELS); + +// ============================================================================ +// message_update assistant message event — delta extraction +// ============================================================================ + +export const PiAssistantMessageEventSchema = z.object({ + type: z.string(), + delta: z.string().optional(), + contentIndex: z.number().optional(), +}).passthrough(); + +// ============================================================================ +// Parse helpers — replace hand-written type guards in loop.ts +// ============================================================================ + +export function parsePiCommands(data: unknown) { + return PiCommandsResponseDataSchema.safeParse(data).data ?? []; +} + +export function parsePiModels(data: unknown) { + return PiModelsResponseDataSchema.safeParse(data).data ?? []; +} diff --git a/cli/src/pi/session.ts b/cli/src/pi/session.ts new file mode 100644 index 0000000000..e5740fe32a --- /dev/null +++ b/cli/src/pi/session.ts @@ -0,0 +1,127 @@ +import type { ApiClient, ApiSessionClient } from '@/lib'; +import type { Metadata } from '@/api/types'; +import type { PiCommandSummary, PiThinkingLevel } from './types'; +import type { PiModelSummary } from '@hapi/protocol/apiTypes'; +import type { PiRpcResolver } from './loop'; + +/** + * Pi session state and hub communication wrapper. + * + * Unlike other agents that extend AgentSessionBase (which requires MessageQueue2), + * Pi sends messages directly via PiTransport RPC — no queue needed. + * This class manages Pi-specific runtime state and hub keepAlive. + */ +export class PiSession { + readonly api: ApiClient; + readonly client: ApiSessionClient; + readonly path: string; + readonly logPath: string; + readonly startedBy: 'runner' | 'terminal'; + // Mutable mode — updated by setMode() when the hub switches control + // (local ↔ remote). keepAlive reads this so the reported mode does not + // revert to the constructor-time startingMode every 2s tick. + mode: 'local' | 'remote'; + + // Config state — synced to hub via keepAlive. + // `undefined` means "not yet known" and is OMITTED from keepAlive so the hub + // does not clear a persisted value; `null` is an explicit clear. A value is + // only assigned once Pi confirms it (get_state / successful set_model / + // successful set_thinking_level). + currentModel: string | null | undefined; + currentThinkingLevel: PiThinkingLevel | null | undefined; + // Pi's set_model requires provider + modelId; learned from get_state + currentProvider: string | null = null; + // Startup model from opts.model — prevents get_state from overwriting it + // with Pi's default. Applied once when get_available_models returns. + readonly initialModel: string | null; + + // Streaming state + piIsStreaming = false; + currentSteeringMode: 'all' | 'one-at-a-time' = 'all'; + + // Cached data from Pi + cachedPiModels: PiModelSummary[] = []; + cachedPiCommands: PiCommandSummary[] = []; + + // RPC resolver — initialized by wireTransportEvents, session-scoped + rpcResolver: PiRpcResolver | null = null; + + private keepAliveInterval: NodeJS.Timeout | null = null; + + constructor(opts: { + api: ApiClient; + client: ApiSessionClient; + path: string; + logPath: string; + startedBy: 'runner' | 'terminal'; + startingMode: 'local' | 'remote'; + model?: string | null; + }) { + this.api = opts.api; + this.client = opts.client; + this.path = opts.path; + this.logPath = opts.logPath; + this.startedBy = opts.startedBy; + this.mode = opts.startingMode; + // currentModel/currentThinkingLevel start undefined ("not yet known") + // and are set only from Pi's confirmed state (get_state) or a successful + // set_model/set_thinking_level. Seeding from opts.model/opts.effort here + // would leak unconfirmed values via the first keepAlive; they are captured + // as initialModel/startupThinkingLevel and applied once Pi accepts them. + // undefined is distinct from null (explicit clear): keepAlive omits + // undefined fields so the hub does not wipe a persisted model/effort on + // resume before Pi reports its real state. + this.currentModel = undefined; + this.initialModel = opts.model?.trim() || null; + this.currentThinkingLevel = undefined; + } + + startKeepAlive(): void { + this.pushKeepAlive(); + this.keepAliveInterval = setInterval(() => this.pushKeepAlive(), 2000); + } + + stopKeepAlive(): void { + if (this.keepAliveInterval) { + clearInterval(this.keepAliveInterval); + this.keepAliveInterval = null; + } + } + + private getKeepAliveRuntime(): Parameters[2] { + const runtime: NonNullable[2]> = {}; + if (this.currentModel !== undefined) runtime.model = this.currentModel; + if (this.currentThinkingLevel !== undefined) runtime.effort = this.currentThinkingLevel; + return Object.keys(runtime).length > 0 ? runtime : undefined; + } + + pushKeepAlive(): void { + this.client.keepAlive(this.piIsStreaming, this.mode, this.getKeepAliveRuntime()); + } + + updateThinkingState(thinking: boolean): void { + this.piIsStreaming = thinking; + this.client.keepAlive(thinking, this.mode, this.getKeepAliveRuntime()); + } + + setMode(mode: 'local' | 'remote'): void { + this.mode = mode; + this.pushKeepAlive(); + } + + updateMetadata(updater: (meta: Metadata) => Metadata): void { + this.client.updateMetadata(updater); + } + + sendAgentMessage(message: unknown): void { + this.client.sendAgentMessage(message); + } + + emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean }): void { + this.client.emitMessagesConsumed(localIds, options); + } + + sendSessionEvent(event: Parameters[0]): void { + this.client.sendSessionEvent(event); + } +} diff --git a/cli/src/pi/types.ts b/cli/src/pi/types.ts new file mode 100644 index 0000000000..ded9607bad --- /dev/null +++ b/cli/src/pi/types.ts @@ -0,0 +1,119 @@ +/** + * Pi RPC protocol type definitions. + * + * Commands are sent as JSON lines on stdin. + * Responses and events are emitted as JSON lines on stdout. + * Based on Pi coding-agent's rpc-types.ts and agent/types.ts. + */ + +// ============================================================================ +// Pi Agent Events (stdout) — discriminated union on `type` +// ============================================================================ + +export interface PiTextDeltaEvent { + type: 'text_delta'; + delta: string; +} + +export interface PiThinkingDeltaEvent { + type: 'thinking_delta'; + delta: string; +} + +export type PiAssistantMessageEvent = + | PiTextDeltaEvent + | PiThinkingDeltaEvent + | { type: 'start' } + | { type: 'done'; reason: string } + | { type: 'error'; reason: string; error: unknown } + // Catch-all for text_start, text_end, thinking_start, thinking_end, toolcall_* etc. + | { type: string; [key: string]: unknown }; + +export interface PiUsage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; +} + +// Individual event types for proper type narrowing +export interface PiAgentStartEvent { type: 'agent_start' } +export interface PiAgentEndEvent { type: 'agent_end'; messages: unknown[] } +export interface PiTurnStartEvent { type: 'turn_start' } +export interface PiTurnEndEvent { + type: 'turn_end'; + message?: { usage?: PiUsage; stopReason?: string }; + toolResults?: unknown[]; +} +export interface PiMessageStartEvent { type: 'message_start'; message: unknown } +export interface PiMessageUpdateEvent { + type: 'message_update'; + assistantMessageEvent?: PiAssistantMessageEvent; + message?: unknown; +} +export interface PiMessageEndEvent { type: 'message_end'; message: unknown } +export interface PiToolExecutionStartEvent { + type: 'tool_execution_start'; + toolCallId: string; + toolName: string; + args: unknown; +} +export interface PiToolExecutionUpdateEvent { + type: 'tool_execution_update'; + toolCallId: string; + toolName: string; + args: unknown; + partialResult: unknown; +} +export interface PiToolExecutionEndEvent { + type: 'tool_execution_end'; + toolCallId: string; + toolName: string; + result: unknown; + isError: boolean; +} + +export type PiAgentEvent = + | PiAgentStartEvent + | PiAgentEndEvent + | PiTurnStartEvent + | PiTurnEndEvent + | PiMessageStartEvent + | PiMessageUpdateEvent + | PiMessageEndEvent + | PiToolExecutionStartEvent + | PiToolExecutionUpdateEvent + | PiToolExecutionEndEvent + | { type: string }; // fallback for unknown events + +// ============================================================================ +// Pi RPC Commands (stdin) +// ============================================================================ + +import type { PiThinkingLevel } from '@hapi/protocol' +import type { PiCommandSummary } from '@hapi/protocol/apiTypes' +export type { PiThinkingLevel, PiCommandSummary } + +export type PiRpcCommand = + | { type: 'prompt'; message: string } + | { type: 'steer'; message: string } + | { type: 'abort' } + | { type: 'new_session' } + | { type: 'get_state' } + | { type: 'set_model'; provider: string; modelId: string } + | { type: 'get_available_models' } + | { type: 'set_thinking_level'; level: PiThinkingLevel } + | { type: 'get_commands' }; + +// ============================================================================ +// Pi RPC Responses (stdout) +// ============================================================================ + +export interface PiResponseEvent { + type: 'response'; + command: string; + success: boolean; + error?: string; + data?: unknown; +} diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index f80b6e2298..fb6d764895 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -98,4 +98,44 @@ describe('buildCliArgs', () => { expect(args).toContain(mode) } }) + + it('uses --session-id for pi resume (not --resume)', () => { + const args = buildCliArgs('pi', { + directory: '/tmp', + resumeSessionId: 'some-pi-session-id', + }) + expect(args).not.toContain('--resume') + expect(args).toContain('--session-id') + expect(args).toContain('some-pi-session-id') + expect(args[0]).toBe('pi') + }) + + it('still passes --resume for claude when resumeSessionId is provided', () => { + // Guard against accidentally swallowing claude's --resume when + // the pi branch was added. + const args = buildCliArgs('claude', { + directory: '/tmp', + resumeSessionId: 'some-claude-session-id', + }) + expect(args).toContain('--resume') + expect(args).toContain('some-claude-session-id') + }) + + it('passes --effort for pi agent', () => { + const args = buildCliArgs('pi', { + directory: '/tmp', + effort: 'high', + }) + expect(args).toContain('--effort') + expect(args).toContain('high') + }) + + it('passes --effort for claude agent', () => { + const args = buildCliArgs('claude', { + directory: '/tmp', + effort: 'high', + }) + expect(args).toContain('--effort') + expect(args).toContain('high') + }) }) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 9a19a87f8a..ea827f878d 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1101,13 +1101,18 @@ export function buildCliArgs( ? 'kimi' : agent === 'opencode' ? 'opencode' - : 'claude'; + : agent === 'pi' + ? 'pi' + : 'claude'; const args = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { args.push('resume', options.resumeSessionId); } else if (agent === 'cursor') { args.push('--resume', options.resumeSessionId); + } else if (agent === 'pi') { + // Pi uses --session-id for exact session resume (RPC mode) + args.push('--session-id', options.resumeSessionId); } else { args.push('--resume', options.resumeSessionId); } @@ -1116,7 +1121,7 @@ export function buildCliArgs( if (options.model) { args.push('--model', options.model); } - if (options.effort && agent === 'claude') { + if (options.effort && (agent === 'claude' || agent === 'pi')) { args.push('--effort', options.effort); } if (options.modelReasoningEffort && (agent === 'codex' || agent === 'opencode')) { @@ -1125,10 +1130,14 @@ export function buildCliArgs( if (options.serviceTier && agent === 'codex') { args.push('--service-tier', options.serviceTier); } - if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) { - args.push('--permission-mode', options.permissionMode); - } else if (yolo) { - args.push('--yolo'); + // Pi RPC mode has no permission switching; never pass these flags to it + // (the Pi parser rejects --permission-mode and ignores --yolo). + if (agent !== 'pi') { + if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) { + args.push('--permission-mode', options.permissionMode); + } else if (yolo) { + args.push('--yolo'); + } } return args; } diff --git a/cli/src/utils/jsonLineParser.ts b/cli/src/utils/jsonLineParser.ts new file mode 100644 index 0000000000..c4b08d294b --- /dev/null +++ b/cli/src/utils/jsonLineParser.ts @@ -0,0 +1,35 @@ +/** + * JSONL line parser — shared by all stdio-based agent transports. + * + * Buffers raw stdout chunks, splits on newlines, and emits complete lines. + * Each transport provides its own `handleLine` to parse the JSON and + * dispatch domain-specific events. + */ +export abstract class JsonLineParser { + private buffer = ''; + + /** Feed a raw stdout chunk into the parser. */ + feed(chunk: string): void { + this.buffer += chunk; + let newlineIndex = this.buffer.indexOf('\n'); + + while (newlineIndex >= 0) { + const line = this.buffer.slice(0, newlineIndex).trim(); + this.buffer = this.buffer.slice(newlineIndex + 1); + + if (line.length > 0) { + this.handleLine(line); + } + + newlineIndex = this.buffer.indexOf('\n'); + } + } + + /** Reset internal buffer (e.g. on process restart). */ + reset(): void { + this.buffer = ''; + } + + /** Override to parse a complete JSON line and dispatch events. */ + protected abstract handleLine(line: string): void; +} diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 7811c90174..193eedd0bf 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -89,7 +89,7 @@ export class RpcGateway { sessionId: string, config: { permissionMode?: PermissionMode - model?: string | null + model?: { provider: string; modelId: string } | string | null modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode @@ -261,6 +261,12 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListOpencodeModelsForCwd, { cwd }) as RpcListOpencodeModelsResponse } + /** Generic Pi RPC call — routes all Pi-specific session RPCs through + * a single entry point instead of per-method wrappers. */ + async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { + return await this.sessionRpc(sessionId, method, params ?? {}, timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS) as T + } + async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.ListOpencodeReasoningEffortOptions, {}) as RpcListOpencodeReasoningEffortOptionsResponse } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index b498cac05a..86dd02a564 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -417,7 +417,7 @@ export class SessionCache { sessionId: string, config: { permissionMode?: PermissionMode - model?: string | null + model?: { provider: string; modelId: string } | string | null modelReasoningEffort?: string | null effort?: string | null serviceTier?: string | null @@ -436,15 +436,27 @@ export class SessionCache { this.markRuntimeConfigUpdated(sessionId, 'permissionMode', appliedAt) } if (config.model !== undefined) { - if (config.model !== session.model) { - const updated = this.store.sessions.setSessionModel(sessionId, config.model, session.namespace, { + const modelValue = config.model + // Normalize object form { provider, modelId } to plain string for DB storage + const piModelObject = modelValue !== null && typeof modelValue === 'object' + ? modelValue + : null + const normalizedModel: string | null = piModelObject ? piModelObject.modelId : modelValue as string | null + if (normalizedModel !== session.model) { + const updated = this.store.sessions.setSessionModel(sessionId, normalizedModel, session.namespace, { touchUpdatedAt: false }) if (!updated) { throw new Error('Failed to update session model') } } - session.model = config.model + session.model = normalizedModel + // Pi requires provider + modelId to uniquely identify a model. + // Persist the provider-qualified form in metadata so web can + // resolve the exact model even when two providers share a modelId. + if (session.metadata?.flavor === 'pi') { + this.persistPiSelectedModel(session, piModelObject) + } this.markRuntimeConfigUpdated(sessionId, 'model', appliedAt) } if (config.modelReasoningEffort !== undefined) { @@ -923,6 +935,34 @@ export class SessionCache { session.metadataVersion = result.version } + private persistPiSelectedModel(session: Session, piSelected: { provider: string; modelId: string } | null): void { + const currentMetadata = session.metadata + if (!currentMetadata || currentMetadata.piSelectedModel === piSelected) { + return + } + + const nextMetadata = { ...currentMetadata, piSelectedModel: piSelected } + const result = this.store.sessions.updateSessionMetadata( + session.id, + nextMetadata, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + return + } + + const parsed = MetadataSchema.safeParse(result.value) + if (!parsed.success) { + return + } + + session.metadata = parsed.data + session.metadataVersion = result.version + } + private mergeAgentState(oldState: unknown | null, newState: unknown | null): unknown | null { if (oldState === null) return newState if (newState === null) return oldState @@ -948,12 +988,13 @@ export class SessionCache { private extractAgentSessionId( metadata: NonNullable - ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'cursorSessionId'; value: string } | null { + ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'cursorSessionId' | 'piSessionId'; value: string } | null { if (metadata.codexSessionId) return { field: 'codexSessionId', value: metadata.codexSessionId } if (metadata.claudeSessionId) return { field: 'claudeSessionId', value: metadata.claudeSessionId } if (metadata.geminiSessionId) return { field: 'geminiSessionId', value: metadata.geminiSessionId } if (metadata.opencodeSessionId) return { field: 'opencodeSessionId', value: metadata.opencodeSessionId } if (metadata.cursorSessionId) return { field: 'cursorSessionId', value: metadata.cursorSessionId } + if (metadata.piSessionId) return { field: 'piSessionId', value: metadata.piSessionId } return null } diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 5de15ce2b1..8489ade921 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1830,6 +1830,62 @@ describe('session model', () => { // completedRequests has req-1 expect(state.completedRequests?.['req-1']).toBeDefined() }) + + it('merges duplicate when piSessionId collides', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const s1 = cache.getOrCreateSession( + 'tag-1', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-sess-A' }, + null, + 'default' + ) + + store.messages.addMessage(s1.id, { type: 'text', text: 'hello from s1' }, 'local-1') + + const s2 = cache.getOrCreateSession( + 'tag-2', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-sess-A' }, + null, + 'default' + ) + + expect(s1.id).not.toBe(s2.id) + + await cache.deduplicateByAgentSessionId(s2.id) + + expect(cache.getSession(s1.id)).toBeUndefined() + expect(cache.getSession(s2.id)).toBeDefined() + + const messages = store.messages.getMessages(s2.id, 100) + expect(messages.length).toBeGreaterThanOrEqual(1) + }) + + it('preserves sessions with different piSessionId', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const s1 = cache.getOrCreateSession( + 'tag-1', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-A' }, + null, + 'default' + ) + const s2 = cache.getOrCreateSession( + 'tag-2', + { path: '/tmp/project', host: 'localhost', flavor: 'pi', piSessionId: 'pi-B' }, + null, + 'default' + ) + + await cache.deduplicateByAgentSessionId(s2.id) + + expect(cache.getSession(s1.id)).toBeDefined() + expect(cache.getSession(s2.id)).toBeDefined() + }) }) describe('clearSessionArchiveMetadata', () => { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index a1e7368619..1d733cee5d 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -618,7 +618,7 @@ export class SyncEngine { sessionId: string, config: { permissionMode?: PermissionMode - model?: string | null + model?: { provider: string; modelId: string } | string | null modelReasoningEffort?: string | null effort?: string | null serviceTier?: string | null @@ -634,7 +634,7 @@ export class SyncEngine { return } - const result = await this.rpcGateway.requestSessionConfig(sessionId, config) + const result = await this.rpcGateway.requestSessionConfig(sessionId, config) as Record if (!result || typeof result !== 'object') { throw new Error('Invalid response from session config RPC') } @@ -654,7 +654,7 @@ export class SyncEngine { } const applied = obj.applied if (!applied || typeof applied !== 'object') { - throw new Error('Missing applied session config') + throw new Error(`Missing applied session config, got: ${JSON.stringify(result)}`) } const requestedKeys = Object.keys(config) as Array @@ -714,6 +714,7 @@ export class SyncEngine { if (flavor === 'opencode') return metadata.opencodeSessionId ?? null if (flavor === 'cursor') return metadata.cursorSessionId ?? null if (flavor === 'kimi') return metadata.kimiSessionId ?? null + if (flavor === 'pi') return metadata.piSessionId ?? null return metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(session.id, namespace) } @@ -1405,6 +1406,8 @@ export class SyncEngine { && (prev?.geminiSessionId ?? null) === (next.geminiSessionId ?? null) && (prev?.opencodeSessionId ?? null) === (next.opencodeSessionId ?? null) && (prev?.cursorSessionId ?? null) === (next.cursorSessionId ?? null) + && (prev?.piSessionId ?? null) === (next.piSessionId ?? null) + && (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null) } private triggerDedupIfNeeded(sessionId: string): void { @@ -1520,6 +1523,11 @@ export class SyncEngine { return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd) } + /** Generic Pi RPC — delegates to rpcGateway.callPiRpc. */ + async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { + return await this.rpcGateway.callPiRpc(sessionId, method, params, timeoutMs) + } + async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise { return await this.rpcGateway.listOpencodeReasoningEffortOptionsForSession(sessionId) } diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index fc1a7032c6..d1875c8800 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -576,7 +576,7 @@ describe('sessions routes', () => { expect(response.status).toBe(400) expect(await response.json()).toEqual({ - error: 'Effort selection is only supported for Claude sessions' + error: 'Effort selection is not supported for this session type' }) expect(applySessionConfigCalls).toEqual([]) }) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index b6f84e6657..8deb54d0d8 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -12,11 +12,13 @@ import { SessionModelRequestSchema, SessionPermissionModeRequestSchema, supportsModelChange, + supportsEffort, toSessionSummary, UploadFileRequestSchema } from '@hapi/protocol' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import type { SlashCommand } from '@hapi/protocol/apiTypes' -import { Hono } from 'hono' +import { Hono, type Context } from 'hono' import type { SyncEngine, Session } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireSessionFromParam, requireSyncEngine } from './guards' @@ -536,8 +538,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const flavor = sessionResult.session.metadata?.flavor ?? 'claude' - if (flavor !== 'claude') { - return c.json({ error: 'Effort selection is only supported for Claude sessions' }, 400) + if (!supportsEffort(flavor)) { + return c.json({ error: 'Effort selection is not supported for this session type' }, 400) } try { @@ -834,5 +836,38 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + // Helper: guard + flavor check + error handling for Pi session endpoints + async function withPiSession( + c: Context, + handler: (ctx: { sessionId: string; engine: SyncEngine }) => Promise + ): Promise { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) return sessionResult + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'pi') { + return c.json({ success: false, error: 'Not a Pi session' }, 400) + } + + try { + return await handler({ sessionId: sessionResult.sessionId, engine }) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Internal error' + }, 500) + } + } + + // --- Pi models --- + app.get('/sessions/:id/pi-models', (c) => + withPiSession(c, async ({ sessionId, engine }) => + c.json(await engine.callPiRpc(sessionId, RPC_METHODS.ListPiModels, {}, 120_000)) + ) + ) + return app } diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 7262c618d6..e6381da723 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -124,7 +124,13 @@ export const SessionCollaborationModeRequestSchema = z.object({ export type SessionCollaborationModeRequest = z.infer export const SessionModelRequestSchema = z.object({ - model: z.string().trim().min(1).nullable() + model: z.union([ + z.string().trim().min(1), + z.object({ + provider: z.string().trim().min(1), + modelId: z.string().trim().min(1), + }), + ]).nullable() }) export type SessionModelRequest = z.infer @@ -386,6 +392,41 @@ export type CursorModelsResponse = OpencodeModelsResponse export type ListCursorModelsResponse = CursorModelsResponse +/** Maps thinking levels to provider-specific values. null = unsupported. */ +export type PiThinkingLevelMap = Partial> + +export type PiModelSummary = { + provider: string + modelId: string + name?: string + contextWindow?: number + /** Whether the model supports reasoning/thinking */ + reasoning?: boolean + /** Maps Pi thinking levels to provider values; null = unsupported level */ + thinkingLevelMap?: PiThinkingLevelMap +} + +export type PiModelsResponse = { + success: boolean + availableModels?: PiModelSummary[] + currentModelId?: string | null + error?: string +} + +export type ListPiModelsResponse = PiModelsResponse + +export type PiCommandSummary = { + name: string + description?: string + source: 'extension' | 'prompt' | 'skill' +} + +export type PiCommandsResponse = { + success: boolean + commands?: PiCommandSummary[] + error?: string +} + export type SlashCommand = { name: string description?: string diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index a92595f195..0d74595000 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -37,6 +37,16 @@ describe('hasCapability', () => { expect(hasCapability('opencode', Capabilities.Effort)).toBe(false) }) + test('pi supports model-change and effort', () => { + expect(hasCapability('pi', Capabilities.ModelChange)).toBe(true) + expect(hasCapability('pi', Capabilities.Effort)).toBe(true) + }) + + test('kimi supports model-change but not effort', () => { + expect(hasCapability('kimi', Capabilities.ModelChange)).toBe(true) + expect(hasCapability('kimi', Capabilities.Effort)).toBe(false) + }) + test('unknown flavor returns false', () => { expect(hasCapability('unknown-flavor', Capabilities.ModelChange)).toBe(false) }) @@ -54,6 +64,8 @@ describe('getFlavorLabel', () => { expect(getFlavorLabel('codex')).toBe('Codex') expect(getFlavorLabel('cursor')).toBe('Cursor') expect(getFlavorLabel('opencode')).toBe('OpenCode') + expect(getFlavorLabel('pi')).toBe('Pi') + expect(getFlavorLabel('kimi')).toBe('Kimi') }) test('unknown flavor returns Unknown', () => { @@ -73,6 +85,8 @@ describe('isKnownFlavor', () => { expect(isKnownFlavor('codex')).toBe(true) expect(isKnownFlavor('cursor')).toBe(true) expect(isKnownFlavor('opencode')).toBe(true) + expect(isKnownFlavor('pi')).toBe(true) + expect(isKnownFlavor('kimi')).toBe(true) }) test('returns false for unknown/null/undefined', () => { @@ -89,6 +103,8 @@ describe('convenience functions', () => { expect(supportsModelChange('codex')).toBe(true) expect(supportsModelChange('opencode')).toBe(true) expect(supportsModelChange('cursor')).toBe(true) + expect(supportsModelChange('pi')).toBe(true) + expect(supportsModelChange('kimi')).toBe(true) expect(supportsModelChange(null)).toBe(false) }) @@ -96,6 +112,8 @@ describe('convenience functions', () => { expect(supportsEffort('claude')).toBe(true) expect(supportsEffort('codex')).toBe(false) expect(supportsEffort('gemini')).toBe(false) + expect(supportsEffort('pi')).toBe(true) + expect(supportsEffort('kimi')).toBe(false) expect(supportsEffort(null)).toBe(false) }) }) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index a4832e93cc..15c59df385 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -16,6 +16,7 @@ const FLAVOR_CAPS: Record> = { codex: new Set([Capabilities.ModelChange]), cursor: new Set([Capabilities.ModelChange]), opencode: new Set([Capabilities.ModelChange]), + pi: new Set([Capabilities.ModelChange, Capabilities.Effort]), } // --- Flavor display names --- @@ -26,6 +27,7 @@ const FLAVOR_LABELS: Record = { codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', + pi: 'Pi', } // --- Query functions --- diff --git a/shared/src/index.ts b/shared/src/index.ts index b8f5e291db..0aaf85f2aa 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -11,6 +11,7 @@ export * from './rpcMethods' export * from './socket' export * from './sessionSummary' export * from './sessionExport' +export * from './piThinkingLevel' export * from './slashCommands' export * from './utils' export * from './version' diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index 0b0a50c134..eb97fe10f5 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -1,10 +1,71 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, test } from 'bun:test' import { getPermissionModeLabel, + getPermissionModeOptionsForFlavor, getPermissionModeTone, - isPermissionModeAllowedForFlavor + getPermissionModesForFlavor, + isPermissionModeAllowedForFlavor, } from './modes' +describe('getPermissionModesForFlavor', () => { + test("returns [] for flavor 'pi' (RPC mode has no runtime permission switching)", () => { + expect(getPermissionModesForFlavor('pi')).toEqual([]) + }) + + test("returns [] for pi and does not fall back to Claude modes", () => { + // Ensure Pi is opt-in empty, not silently inheriting Claude defaults. + expect(getPermissionModesForFlavor('pi')).not.toEqual(getPermissionModesForFlavor('claude')) + expect(getPermissionModesForFlavor('pi')).not.toEqual(getPermissionModesForFlavor(null)) + }) + + test("unknown flavors fall back to Claude modes, not Pi's empty list", () => { + expect(getPermissionModesForFlavor(null)).not.toEqual([]) + expect(getPermissionModesForFlavor(undefined)).not.toEqual([]) + expect(getPermissionModesForFlavor('PI')).not.toEqual([]) + expect(getPermissionModesForFlavor('Pi')).not.toEqual([]) + }) +}) + +describe('getPermissionModeOptionsForFlavor', () => { + test("returns [] for pi (no permission options offered)", () => { + expect(getPermissionModeOptionsForFlavor('pi')).toEqual([]) + }) +}) + +describe('isPermissionModeAllowedForFlavor', () => { + test("no mode is allowed for pi", () => { + expect(isPermissionModeAllowedForFlavor('yolo', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('default', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('plan', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('acceptEdits', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('bypassPermissions', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('auto', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('read-only', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('safe-yolo', 'pi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('ask', 'pi')).toBe(false) + }) +}) + +describe('getPermissionModeLabel', () => { + test("yolo label is 'Yolo'", () => { + expect(getPermissionModeLabel('yolo')).toBe('Yolo') + }) + + test("default label is 'Default'", () => { + expect(getPermissionModeLabel('default')).toBe('Default') + }) +}) + +describe('getPermissionModeTone', () => { + test("yolo tone is danger", () => { + expect(getPermissionModeTone('yolo')).toBe('danger') + }) + + test("default tone is neutral", () => { + expect(getPermissionModeTone('default')).toBe('neutral') + }) +}) + describe('claude auto permission mode', () => { it('is allowed for claude only', () => { expect(isPermissionModeAllowedForFlavor('auto', 'claude')).toBe(true) @@ -13,6 +74,7 @@ describe('claude auto permission mode', () => { expect(isPermissionModeAllowedForFlavor('auto', 'cursor')).toBe(false) expect(isPermissionModeAllowedForFlavor('auto', 'opencode')).toBe(false) expect(isPermissionModeAllowedForFlavor('auto', 'kimi')).toBe(false) + expect(isPermissionModeAllowedForFlavor('auto', 'pi')).toBe(false) }) it('has a label and tone', () => { diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 06007c5f2e..a8d1c6659c 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -7,7 +7,7 @@ import { z } from 'zod' */ export const AGENT_MESSAGE_PAYLOAD_TYPE = 'codex' as const -export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'opencode'] as const +export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'opencode', 'pi'] as const export type AgentFlavor = typeof AGENT_FLAVORS[number] export const AgentFlavorSchema = z.enum(AGENT_FLAVORS) @@ -119,6 +119,11 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'cursor') { return CURSOR_PERMISSION_MODES } + if (flavor === 'pi') { + // Pi RPC mode has no runtime permission switching (always auto-approve); + // no permission modes are offered. + return [] + } return CLAUDE_PERMISSION_MODES } diff --git a/shared/src/piThinkingLevel.ts b/shared/src/piThinkingLevel.ts new file mode 100644 index 0000000000..6f70b40cb5 --- /dev/null +++ b/shared/src/piThinkingLevel.ts @@ -0,0 +1,13 @@ +// Pi thinking levels (from Pi's rpc-types.ts ThinkingLevel) +// Controls how much reasoning/thinking the model performs. +export const PI_THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const +export type PiThinkingLevel = typeof PI_THINKING_LEVELS[number] + +export const PI_THINKING_LEVEL_LABELS: Record = { + off: 'Off', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'XHigh', +} diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 0ac67faccb..c4284451ca 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -27,6 +27,7 @@ export const RPC_METHODS = { ListSkills: 'listSkills', ListCodexModels: 'listCodexModels', ListCursorModels: 'listCursorModels', + ListPiModels: 'listPiModels', ListOpencodeModels: 'listOpencodeModels', ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd', ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions' diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index f1c7f271a2..1760337e00 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -47,6 +47,7 @@ export const MetadataSchema = z.object({ // tiann/hapi#873. cursorMigrationState: z.enum(['in_progress', 'ambiguous']).optional(), kimiSessionId: z.string().optional(), + piSessionId: z.string().optional(), tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), homeDir: z.string().optional(), @@ -63,7 +64,15 @@ export const MetadataSchema = z.object({ preferredPermissionMode: PermissionModeSchema.optional(), flavor: z.string().nullish(), capabilities: SessionCapabilitiesSchema.optional(), - worktree: WorktreeMetadataSchema.optional() + worktree: WorktreeMetadataSchema.optional(), + // Cached Pi model list — written by CLI, read by web (inactive session fallback). + // Minimal shape: each entry must have modelId; other fields (provider, name, etc.) pass through. + piAvailableModels: z.array(z.object({ modelId: z.string() }).passthrough()).optional(), + // Pi-selected model with provider identity. The legacy `session.model` + // field stores only modelId (shared across all flavors); this preserves + // the provider so web can resolve the exact model when two providers + // share a modelId. + piSelectedModel: z.object({ provider: z.string(), modelId: z.string() }).nullable().optional() }) export type Metadata = z.infer diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ba33a89255..d37e0828bc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -497,7 +497,7 @@ export class ApiClient { }) } - async setModel(sessionId: string, model: string | null): Promise { + async setModel(sessionId: string, model: { provider: string; modelId: string } | string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, { method: 'POST', body: JSON.stringify({ model }) @@ -634,6 +634,14 @@ export class ApiClient { ) } + /** Generic Pi session endpoint — replaces per-method wrappers. */ + async callPiEndpoint(sessionId: string, path: string, init?: RequestInit): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/pi-${path}`, + init + ) + } + async getMachineCursorModels(machineId: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/cursor-models` diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index a079e030fd..65466a54fe 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -16,6 +16,11 @@ const LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS = 1_000_000 // Fallback for Codex sessions when the server has not reported an explicit modelContextWindow. // The value matches the context window currently reported by Codex App Server token-count events. const DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS = 258_400 +// Pi supports multiple providers with varying context windows. 200K is a +// conservative default (most Claude/GPT-4 class models). When the server +// reports an explicit modelContextWindow via usage events, that takes +// precedence over this fallback. +const DEFAULT_PI_CONTEXT_WINDOW_TOKENS = 200_000 function parseCursorWireContextWindow(model: string): number | null { const match = model.match(/\[([^\]]+)\]/) @@ -47,6 +52,10 @@ export function getContextBudgetTokens(model: string | null | undefined, flavor? return Math.max(1, DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS - CONTEXT_HEADROOM_TOKENS) } + if (flavor === 'pi') { + return Math.max(1, DEFAULT_PI_CONTEXT_WINDOW_TOKENS - CONTEXT_HEADROOM_TOKENS) + } + if (flavor === 'cursor') { const trimmedModel = model?.trim() const windowTokens = trimmedModel ? parseCursorWireContextWindow(trimmedModel) : null diff --git a/web/src/components/AgentFlavorIcon.test.tsx b/web/src/components/AgentFlavorIcon.test.tsx new file mode 100644 index 0000000000..5dc776cbdf --- /dev/null +++ b/web/src/components/AgentFlavorIcon.test.tsx @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@testing-library/react' +import { AgentFlavorIcon } from './AgentFlavorIcon' + +function getBadge(container: HTMLElement): HTMLElement { + const badge = container.querySelector('span') + if (!badge) throw new Error('AgentFlavorIcon did not render a ') + return badge +} + +describe('AgentFlavorIcon', () => { + it('renders the "Pi" label and purple background for the pi flavor', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe('Pi') + // The Pi badge uses a specific purple; if the literal ever drifts, + // the test should fail and force an intentional design update. + expect(badge.className).toContain('bg-[#5b21b6]') + expect(badge.className).toContain('text-white') + }) + + it('matches the exact class contract for all known flavors (regression)', () => { + const cases: Array<{ flavor: string; label: string; bg: string }> = [ + { flavor: 'claude', label: 'Cl', bg: 'bg-[#d97706]' }, + { flavor: 'codex', label: 'Cx', bg: 'bg-[#111827]' }, + { flavor: 'cursor', label: 'Cu', bg: 'bg-[#0f766e]' }, + { flavor: 'gemini', label: 'Gm', bg: 'bg-[#2563eb]' }, + { flavor: 'kimi', label: 'Km', bg: 'bg-[#7c3aed]' }, + { flavor: 'pi', label: 'Pi', bg: 'bg-[#5b21b6]' }, + { flavor: 'opencode', label: 'Op', bg: 'bg-[#15803d]' }, + ] + for (const { flavor, label, bg } of cases) { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe(label) + expect(badge.className).toContain(bg) + } + }) + + it('renders the "Un" badge with secondary-bg colors for null flavor', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe('Un') + expect(badge.className).toContain('bg-[var(--app-secondary-bg)]') + }) + + it('renders the "Un" badge for undefined flavor', () => { + const { container } = render() + expect(getBadge(container).textContent).toBe('Un') + }) + + it('renders the "Un" badge for empty string', () => { + const { container } = render() + expect(getBadge(container).textContent).toBe('Un') + }) + + it('renders the "Un" badge for unknown flavor strings', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.textContent).toBe('Un') + expect(badge.className).toContain('bg-[var(--app-secondary-bg)]') + }) + + it('normalizes flavor case and whitespace', () => { + // The component lowercases + trims internally so 'PI ', 'Pi', ' pi' + // all resolve to the Pi badge. + for (const flavor of ['PI', 'Pi', ' pi ', 'PI ']) { + const { container } = render() + expect(getBadge(container).textContent).toBe('Pi') + } + }) + + it('does NOT match a flavor when only whitespace is present', () => { + // ' '.trim() === '' so the unknown branch is the only valid one. + const { container } = render() + expect(getBadge(container).textContent).toBe('Un') + }) + + it('applies the default size classes when no className is provided', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.className).toContain('h-4') + expect(badge.className).toContain('w-4') + }) + + it('appends the provided className alongside the badge classes', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.className).toContain('h-6') + expect(badge.className).toContain('w-6') + // The default size classes must be replaced by the custom className + // (the implementation uses `${className ?? 'h-4 w-4'}`). + expect(badge.className).not.toContain('h-4 w-4') + }) + + it('marks the badge aria-hidden for screen readers (decorative only)', () => { + const { container } = render() + const badge = getBadge(container) + expect(badge.getAttribute('aria-hidden')).toBe('true') + }) +}) diff --git a/web/src/components/AgentFlavorIcon.tsx b/web/src/components/AgentFlavorIcon.tsx index b88f25181a..796bea956f 100644 --- a/web/src/components/AgentFlavorIcon.tsx +++ b/web/src/components/AgentFlavorIcon.tsx @@ -19,6 +19,10 @@ const FLAVOR_BADGES: Record = { label: 'Km', colors: 'bg-[#7c3aed] text-white', }, + pi: { + label: 'Pi', + colors: 'bg-[#5b21b6] text-white', + }, opencode: { label: 'Op', colors: 'bg-[#15803d] text-white', diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 5b0325a6f6..0bb8484b11 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -8,6 +8,10 @@ import { useFue } from '@/lib/use-fue' import { FueCallout, FueDot } from '@/components/Fue' import { useRef, useState } from 'react' +function ChevronIcon() { + return +} + function VoiceAssistantIcon() { return ( void + piThinkingLabel?: string + piThinkingDisabled?: boolean + piThinkingOpen?: boolean + onPiThinkingToggle?: () => void // Scratchlist drawer toggle. When `onScratchlistToggle` is provided, a // notepad icon appears next to the schedule-send icon. Click toggles // composer-send-routing between chat and scratchlist; SessionChat owns @@ -498,6 +511,42 @@ export function ComposerButtons(props: { ) : null} + {props.piModelLabel ? ( + + ) : null} + + {props.piThinkingLabel ? ( + + ) : null} + {props.showTerminalButton ? (
+ { + handleModelChange({ provider: piModel.provider, modelId: piModel.modelId }) + }} + onClose={closeAllPanels} + /> +
+ ) + } + + // Thinking level panel + if (showPiThinkingPanel && selectedPiModel?.reasoning !== false) { + panels.push( +
+ handleEffortChange(level)} + onClose={closeAllPanels} + /> +
+ ) + } + + if (panels.length > 0) return <>{panels} + } + + // Non-Pi flavors: original unified gear menu if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) { return (
@@ -765,81 +899,79 @@ export function HappyComposer(props: {
{t('misc.model')}
- {modelOptions.map((option) => { - const isSelected = selectedModelBase !== undefined - ? selectedModelBase === option.value - : model === option.value - return ( - + ))}
- - {option.label} - - - ) - })} - - ) : null} - - {showModelSettings && showModelEffortSettings ? ( -
- ) : null} - - {showModelEffortSettings ? ( -
-
- {agentFlavor === 'cursor' ? t('misc.variant') : t('misc.effort')} -
- {modelEffortOptions!.map((option) => ( - - ))} +
+ {isSelected && ( +
+ )} +
+ + {option.label} + + + ) + }) + )}
) : null} @@ -987,6 +1119,12 @@ export function HappyComposer(props: { return null }, [ showSettings, + showPiModelPanel, + showPiThinkingPanel, + agentFlavor, + piModels, + selectedPiModel, + closeAllPanels, showCollaborationSettings, showPermissionSettings, showModelSettings, @@ -1113,6 +1251,14 @@ export function HappyComposer(props: { onSchedule={setPendingSchedule} onClearSchedule={isControlled ? onClearScheduleProp : () => setPendingScheduleLocal(null)} hasAttachments={hasAttachments} + piModelLabel={piModelLabel} + piModelDisabled={controlsDisabled || !piHasModels} + piModelOpen={showPiModelPanel} + onPiModelToggle={handlePiModelToggle} + piThinkingLabel={piThinkingLabel} + piThinkingDisabled={controlsDisabled || !piHasModels || !selectedPiModel || selectedPiModel.reasoning === false} + piThinkingOpen={showPiThinkingPanel} + onPiThinkingToggle={handlePiThinkingToggle} scratchlistMode={props.scratchlistMode} scratchlistCount={props.scratchlistCount} onScratchlistToggle={props.onScratchlistToggle} diff --git a/web/src/components/AssistantChat/PiModelPanel.tsx b/web/src/components/AssistantChat/PiModelPanel.tsx new file mode 100644 index 0000000000..899b2086c5 --- /dev/null +++ b/web/src/components/AssistantChat/PiModelPanel.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from '@/lib/use-translation' +import type { PiModelSummary } from '@/types/api' +import { groupModelsByProvider } from './piModelGroups' +import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' + +export function PiModelPanel(props: { + models: PiModelSummary[] + currentModel: { provider: string; modelId: string } | null + controlsDisabled?: boolean + onSelect: (model: PiModelSummary) => void + onClose: () => void +}) { + const { t } = useTranslation() + const groups = groupModelsByProvider(props.models) + const disabled = props.controlsDisabled ?? false + + const isSelected = (piModel: PiModelSummary) => + props.currentModel?.provider === piModel.provider && + props.currentModel?.modelId === piModel.modelId + + return ( + +
+
+ {t('misc.model')} +
+ {groups.map((group) => ( +
+
+ {group.label} +
+ {group.models.map((piModel) => { + const selected = isSelected(piModel) + return ( + + ) + })} +
+ ))} +
+
+ ) +} diff --git a/web/src/components/AssistantChat/PiThinkingLevelPanel.tsx b/web/src/components/AssistantChat/PiThinkingLevelPanel.tsx new file mode 100644 index 0000000000..9402900180 --- /dev/null +++ b/web/src/components/AssistantChat/PiThinkingLevelPanel.tsx @@ -0,0 +1,76 @@ +import { PI_THINKING_LEVEL_LABELS } from '@hapi/protocol' +import type { PiThinkingLevelMap } from '@/types/api' +import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' +import { isThinkingLevelSupported } from './piThinkingLevelOptions' + +const ALL_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const + +/** + * Determine which thinking levels a model supports. + * - reasoning=false → no levels + * - reasoning=true (or unknown) + thinkingLevelMap → filter by map via isThinkingLevelSupported + * - reasoning=true (or unknown) + no map → all levels except xhigh + */ +function getSupportedLevels( + reasoning?: boolean, + thinkingLevelMap?: PiThinkingLevelMap, +): string[] { + if (reasoning === false) return [] + return ALL_LEVELS.filter((level) => isThinkingLevelSupported(level, thinkingLevelMap)) +} + +export function PiThinkingLevelPanel(props: { + currentLevel: string | null + reasoning?: boolean + thinkingLevelMap?: PiThinkingLevelMap + controlsDisabled?: boolean + onSelect: (level: string | null) => void + onClose: () => void +}) { + const supportedLevels = getSupportedLevels(props.reasoning, props.thinkingLevelMap) + const disabled = props.controlsDisabled ?? false + + if (supportedLevels.length === 0) return null + + return ( + +
+
+ Thinking Level +
+ {supportedLevels.map((level) => ( + + ))} +
+
+ ) +} diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index ef18983017..a7900cec3f 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -141,6 +141,19 @@ describe('getModelOptionsForFlavor', () => { { value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama EXAONE' } ]) }) + + it('returns just the auto/default option for pi flavor (no Claude fallback)', () => { + const options = getModelOptionsForFlavor('pi') + expect(options).toEqual([{ value: null, label: 'Default' }]) + }) + + it('keeps the current pi model in the options list when it is not auto', () => { + const options = getModelOptionsForFlavor('pi', 'claude-sonnet-4-5') + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4-5' } + ]) + }) }) describe('getNextModelForFlavor', () => { @@ -197,4 +210,82 @@ describe('getNextModelForFlavor', () => { const next = getNextModelForFlavor('cursor', 'composer-2.5') expect(next).toBe('composer-2.5') }) + + it('keeps the current pi model on cycle (no Claude fallback)', () => { + // Pi has no predefined model list — Ctrl/Cmd+M must not cycle + // through Claude presets, which would push sonnet/opus ids into + // a Pi session via set-session-config. + const next = getNextModelForFlavor('pi', 'claude-sonnet-4-5') + expect(next).toBe('claude-sonnet-4-5') + }) + + it('returns null for pi without a current model (no Claude fallback)', () => { + const next = getNextModelForFlavor('pi', null) + expect(next).toBeNull() + }) + + it('treats "auto" as null and returns null for pi (no Claude preset injection)', () => { + // normalizeCurrentModel maps 'auto' to null; a Pi session whose UI + // displays 'Auto' must not be switched to sonnet/opus by the + // cycler shortcut. + const next = getNextModelForFlavor('pi', 'auto') + expect(next).toBeNull() + }) + + it('treats "default" as null and returns null for pi', () => { + const next = getNextModelForFlavor('pi', 'default') + expect(next).toBeNull() + }) + + it('treats empty/whitespace strings as null for pi (no Claude preset injection)', () => { + expect(getNextModelForFlavor('pi', '')).toBeNull() + expect(getNextModelForFlavor('pi', ' ')).toBeNull() + }) + + it('trims surrounding whitespace from the current pi model', () => { + const next = getNextModelForFlavor('pi', ' claude-sonnet-4-5 ') + expect(next).toBe('claude-sonnet-4-5') + }) + + it('keeps a kimi current model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('kimi', 'kimi-k2-0711')).toBe('kimi-k2-0711') + expect(getNextModelForFlavor('kimi', null)).toBeNull() + }) + + it('keeps a cursor current model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('cursor', 'composer-2.5')).toBe('composer-2.5') + expect(getNextModelForFlavor('cursor', null)).toBeNull() + }) + + it('keeps an opencode current model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('opencode', 'ollama/legacy')).toBe('ollama/legacy') + expect(getNextModelForFlavor('opencode', null)).toBeNull() + }) +}) + +describe('getModelOptionsForFlavor — pi normalize filter', () => { + it('drops "auto" and renders just the default option for pi', () => { + // 'auto' should be normalized to null, which equals the auto entry; + // we must not produce a duplicate { value: null, label: 'auto' } row. + const options = getModelOptionsForFlavor('pi', 'auto') + expect(options).toEqual([{ value: null, label: 'Default' }]) + }) + + it('drops "default" and renders just the default option for pi', () => { + const options = getModelOptionsForFlavor('pi', 'default') + expect(options).toEqual([{ value: null, label: 'Default' }]) + }) + + it('drops empty/whitespace currentModel for pi', () => { + expect(getModelOptionsForFlavor('pi', '')).toEqual([{ value: null, label: 'Default' }]) + expect(getModelOptionsForFlavor('pi', ' ')).toEqual([{ value: null, label: 'Default' }]) + }) + + it('trims whitespace from a real current pi model', () => { + const options = getModelOptionsForFlavor('pi', ' custom-model ') + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'custom-model', label: 'custom-model' } + ]) + }) }) diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index 11a5fea41e..9f350ecd16 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -126,6 +126,14 @@ export function getModelOptionsForFlavor( if (flavor === 'kimi') { return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) } + // Pi model list is provided dynamically via piModels prop in SessionChat, + // not through this function. Show just the auto/default option here to + // prevent falling through to the Claude preset cycler (which would + // surface unrelated Claude models and let set-session-config push + // `sonnet`/`opus` ids into a Pi session). + if (flavor === 'pi') { + return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) + } return getClaudeModelOptions(currentModel) } @@ -167,5 +175,10 @@ export function getNextModelForFlavor( if (flavor === 'kimi') { return normalizeCurrentModel(currentModel) } + // Pi model list is provided dynamically via piModels prop — pressing + // Ctrl/Cmd+M must not fall through to the Claude preset cycler. + if (flavor === 'pi') { + return normalizeCurrentModel(currentModel) + } return getNextClaudeComposerModel(currentModel) } diff --git a/web/src/components/AssistantChat/piModelGroups.ts b/web/src/components/AssistantChat/piModelGroups.ts new file mode 100644 index 0000000000..df0d33c090 --- /dev/null +++ b/web/src/components/AssistantChat/piModelGroups.ts @@ -0,0 +1,35 @@ +import type { PiModelSummary } from '@/types/api' + +type ProviderGroup = { + provider: string + label: string + models: PiModelSummary[] +} + +/** Format provider name for display */ +function formatProviderLabel(provider: string): string { + if (provider === 'unknown') return 'Other' + // Capitalize first letter, keep rest as-is + return provider.charAt(0).toUpperCase() + provider.slice(1) +} + +/** Group Pi models by provider, preserving original order within each group */ +export function groupModelsByProvider(models: PiModelSummary[]): ProviderGroup[] { + const groupOrder: string[] = [] + const groups = new Map() + + for (const model of models) { + const provider = model.provider || 'unknown' + if (!groups.has(provider)) { + groupOrder.push(provider) + groups.set(provider, []) + } + groups.get(provider)!.push(model) + } + + return groupOrder.map((provider) => ({ + provider, + label: formatProviderLabel(provider), + models: groups.get(provider)!, + })) +} diff --git a/web/src/components/AssistantChat/piThinkingLevelOptions.ts b/web/src/components/AssistantChat/piThinkingLevelOptions.ts new file mode 100644 index 0000000000..8a9ad35806 --- /dev/null +++ b/web/src/components/AssistantChat/piThinkingLevelOptions.ts @@ -0,0 +1,82 @@ +import { PI_THINKING_LEVELS, PI_THINKING_LEVEL_LABELS, type PiThinkingLevel } from '@hapi/protocol' +import type { PiThinkingLevelMap } from '@/types/api' + +type PiThinkingLevelOption = { + value: string + label: string +} + +function normalizePiThinkingLevel(level?: string | null): string | null { + const trimmedLevel = level?.trim().toLowerCase() + if (!trimmedLevel || trimmedLevel === 'default' || trimmedLevel === 'auto') { + return null + } + + return trimmedLevel +} + +function formatPiThinkingLevelLabel(level: string): string { + return PI_THINKING_LEVEL_LABELS[level as PiThinkingLevel] + ?? `${level.charAt(0).toUpperCase()}${level.slice(1)}` +} + +/** + * Get thinking level options filtered by the model's thinkingLevelMap. + * Levels mapped to `null` in the map are unsupported and excluded. + * Levels not present in the map are included (treated as supported with default mapping). + */ +export function getPiThinkingLevelOptions( + currentLevel?: string | null, + thinkingLevelMap?: PiThinkingLevelMap +): PiThinkingLevelOption[] { + const normalizedCurrentLevel = normalizePiThinkingLevel(currentLevel) + const options: PiThinkingLevelOption[] = [] + + // Include current level if it's non-standard (custom) + if ( + normalizedCurrentLevel + && !(PI_THINKING_LEVELS as readonly string[]).includes(normalizedCurrentLevel) + && !isLevelExcluded(normalizedCurrentLevel, thinkingLevelMap) + ) { + options.push({ + value: normalizedCurrentLevel, + label: formatPiThinkingLevelLabel(normalizedCurrentLevel) + }) + } + + options.push(...PI_THINKING_LEVELS + .filter((level) => !isLevelExcluded(level, thinkingLevelMap)) + .map((level) => ({ + value: level, + label: PI_THINKING_LEVEL_LABELS[level] + })) + ) + + return options +} + +/** Check whether a thinking level is supported by the model's thinkingLevelMap */ +export function isThinkingLevelSupported(level: string, map?: PiThinkingLevelMap): boolean { + // xhigh requires explicit opt-in via the map + if (level === 'xhigh') { + if (!map || !(level in map)) return false + return map[level] !== null + } + if (!map || !(level in map)) return true + return map[level] !== null +} + +/** A level is excluded if it maps to `null` in the thinkingLevelMap, or xhigh without explicit opt-in */ +function isLevelExcluded(level: string, map?: PiThinkingLevelMap): boolean { + return !isThinkingLevelSupported(level, map) +} + +/** Return the highest supported thinking level, or null if none */ +export function getHighestThinkingLevel(map?: PiThinkingLevelMap): string | null { + for (let i = PI_THINKING_LEVELS.length - 1; i >= 0; i--) { + if (isThinkingLevelSupported(PI_THINKING_LEVELS[i]!, map)) { + return PI_THINKING_LEVELS[i]! + } + } + return null +} diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index eaa3c823df..0da14396ee 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -37,6 +37,7 @@ export const MODEL_OPTIONS: Record | null + const piCachedModels = piMetadata?.piAvailableModels as PiModelSummary[] | undefined ?? [] + // Provider-qualified selected model — disambiguates when two providers + // share a modelId (hub persists this alongside the legacy modelId string). + const piSelectedModel = piMetadata?.piSelectedModel as { provider: string; modelId: string } | null | undefined const cursorCatalogReadinessArgs = useMemo(() => ({ sessionLoading: cursorModelsState.isLoading, machineLoading: machineCursorModelsState.isLoading, @@ -552,7 +565,6 @@ function SessionChatInner(props: SessionChatProps) { ? resolveSessionCursorVariantSelectValue(props.session.model, cursorModelEffortOptions) : null ), [agentFlavor, cursorModelEffortOptions, props.session.model]) - const { abortSession, switchSession, @@ -792,7 +804,7 @@ function SessionChatInner(props: SessionChatProps) { }, [setCollaborationMode, props.onRefresh, haptic]) // Model mode change handler - const handleModelChange = useCallback(async (model: string | null) => { + const handleModelChange = useCallback(async (model: { provider: string; modelId: string } | string | null) => { try { await setModel(model) haptic.notification('success') @@ -1015,7 +1027,11 @@ function SessionChatInner(props: SessionChatProps) {
0 ? piModelsState.availableModels : piCachedModels) : undefined} + piSelectedModel={agentFlavor === 'pi' ? piSelectedModel : undefined} availableModelReasoningEffortOptions={ agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0 ? opencodeReasoningEffortState.options @@ -1153,9 +1177,11 @@ function SessionChatInner(props: SessionChatProps) { && !cursorModelsState.error && cursorPicker && cursorPicker.modelOptions.length > 0 - ? handleCursorBaseModelChange + ? ((model) => handleCursorBaseModelChange(typeof model === 'string' ? model : model?.modelId ?? null)) : undefined) - : handleModelChange + : agentFlavor === 'pi' + ? (props.session.active && !piModelsState.error ? handleModelChange : undefined) + : handleModelChange } onModelEffortChange={ agentFlavor === 'cursor' diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index bdbb59f3da..d9e8b30022 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -19,7 +19,7 @@ export function useSessionActions( switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise setCollaborationMode: (mode: CodexCollaborationMode) => Promise - setModel: (model: string | null) => Promise + setModel: (model: { provider: string; modelId: string } | string | null) => Promise setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise setEffort: (effort: string | null) => Promise setServiceTier: (serviceTier: string | null) => Promise @@ -111,7 +111,7 @@ export function useSessionActions( }) const modelMutation = useMutation({ - mutationFn: async (model: string | null) => { + mutationFn: async (model: { provider: string; modelId: string } | string | null) => { if (!api || !sessionId) { throw new Error('Session unavailable') } diff --git a/web/src/hooks/queries/usePiModels.ts b/web/src/hooks/queries/usePiModels.ts new file mode 100644 index 0000000000..61ffd96ddd --- /dev/null +++ b/web/src/hooks/queries/usePiModels.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { PiModelSummary, PiModelsResponse } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function usePiModels(args: { + api: ApiClient | null + sessionId?: string | null + enabled?: boolean +}): { + availableModels: PiModelSummary[] + currentModelId: string | null + isLoading: boolean + error: string | null +} { + const { api, sessionId } = args + const enabled = Boolean(args.enabled && api && sessionId) + + const query = useQuery({ + queryKey: sessionId + ? queryKeys.sessionPiModels(sessionId) + : ['session-pi-models', 'unknown'] as const, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + if (!sessionId) { + throw new Error('Pi models target unavailable') + } + return await api.callPiEndpoint(sessionId, 'models') + }, + enabled, + staleTime: 60_000, + retry: false, + }) + + return { + availableModels: query.data?.availableModels ?? [], + currentModelId: query.data?.currentModelId ?? null, + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load Pi models') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load Pi models' + : null, + } +} diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index a0664af7e2..e7adcfb309 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -17,6 +17,7 @@ export const queryKeys = { slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const, sessionCodexModels: (sessionId: string) => ['session-codex-models', sessionId] as const, sessionCursorModels: (sessionId: string) => ['session-cursor-models', sessionId] as const, + sessionPiModels: (sessionId: string) => ['session-pi-models', sessionId] as const, machineCursorModels: (machineId: string) => ['machine-cursor-models', machineId] as const, sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const, sessionOpencodeReasoningEffortOptions: (sessionId: string) => ['session-opencode-reasoning-effort-options', sessionId] as const, diff --git a/web/src/lib/sessionResume.test.ts b/web/src/lib/sessionResume.test.ts index 637ae965db..31c5209b7a 100644 --- a/web/src/lib/sessionResume.test.ts +++ b/web/src/lib/sessionResume.test.ts @@ -129,3 +129,132 @@ describe('sessionResume', () => { }), 3)).toBe(false) }) }) + +describe('sessionResume — pi flavor', () => { + it('resolveAgentSessionIdFromMetadata returns piSessionId when flavor is pi', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + piSessionId: 'pi-sess-123', + })).toBe('pi-sess-123') + }) + + it('resolveAgentSessionIdFromMetadata returns undefined when flavor is pi but no piSessionId', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + })).toBeUndefined() + }) + + it('resolveAgentSessionIdFromMetadata ignores stale cross-flavor ids when flavor is pi', () => { + // Stale ids from other flavors must not satisfy a Pi resume — hub + // will reject them and the web layer would otherwise claim the + // session is resumable. + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + claudeSessionId: 'claude-stale', + codexSessionId: 'codex-stale', + })).toBeUndefined() + }) + + it('resolveAgentSessionIdFromMetadata prefers piSessionId over other ids when flavor is pi', () => { + // Defensive: even if a stale id slipped in, the pi id should win. + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', + host: 'h', + flavor: 'pi', + piSessionId: 'pi-sess-real', + claudeSessionId: 'claude-stale', + })).toBe('pi-sess-real') + }) + + it('inactiveSessionCanResume allows resume of pi session when piSessionId is present', () => { + expect(inactiveSessionCanResume(makeSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'pi', + piSessionId: 'pi-sess-abc', + }, + }), 0)).toBe(true) + }) + + it('inactiveSessionCanResume allows fresh pi spawn when path is set and there are no messages', () => { + expect(inactiveSessionCanResume(makeSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' }, + }), 0)).toBe(true) + }) + + it('inactiveSessionCanResume rejects inactive pi session with messages but no piSessionId (no Pi recovery fallback)', () => { + // Pi does not have a recover-from-messages path the way Claude does. + // If the cli lost the session id, the user must start a new session + // (or click resume in the cli to re-establish the id). + expect(inactiveSessionCanResume(makeSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' }, + }), 3)).toBe(false) + }) + + it('inactiveSessionCanResume rejects pi session whose only id is a stale cross-flavor id', () => { + // Stale codexSessionId alone does NOT satisfy Pi resume. + expect(inactiveSessionCanResume(makeSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'pi', + codexSessionId: 'stale-codex', + }, + }), 3)).toBe(false) + }) + + it('inactiveSessionCanResume allows active pi session unconditionally', () => { + expect(inactiveSessionCanResume(makeSession({ + active: true, + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' }, + }), 3)).toBe(true) + }) +}) + +describe('sessionResume — regression for all other flavor ids', () => { + // Every flavor-specific id resolver must still work; the switch in + // sessionResume.ts grew a new 'pi' branch and the existing branches + // must not be regressed. + it('codex', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'codex', codexSessionId: 'cx-1', + })).toBe('cx-1') + }) + it('gemini', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'gemini', geminiSessionId: 'gm-1', + })).toBe('gm-1') + }) + it('opencode', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'opencode', opencodeSessionId: 'oc-1', + })).toBe('oc-1') + }) + it('cursor', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'cursor', cursorSessionId: 'cu-1', + })).toBe('cu-1') + }) + it('kimi', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'kimi', kimiSessionId: 'ki-1', + })).toBe('ki-1') + }) + it('claude (default branch)', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'claude', claudeSessionId: 'cl-1', + })).toBe('cl-1') + }) + it('unknown flavor falls back to claude branch', () => { + expect(resolveAgentSessionIdFromMetadata({ + path: '/p', host: 'h', flavor: 'mystery', claudeSessionId: 'cl-1', + })).toBe('cl-1') + }) +}) diff --git a/web/src/lib/sessionResume.ts b/web/src/lib/sessionResume.ts index 061e08e575..5cb9fd587c 100644 --- a/web/src/lib/sessionResume.ts +++ b/web/src/lib/sessionResume.ts @@ -3,7 +3,8 @@ import type { Session } from '@/types/api' /** Agent thread id used by hub `resolveAgentResumeId`, flavor-specific. * Mirrors hub: cross-flavor ids are ignored to avoid the web layer claiming a - * session is resumable when the hub will only honor the current flavor's id. */ + * session is resumable when the hub will only honor the current flavor's id. + */ export function resolveAgentSessionIdFromMetadata( metadata: Session['metadata'] | null | undefined, ): string | undefined { @@ -17,6 +18,7 @@ export function resolveAgentSessionIdFromMetadata( case 'opencode': return metadata.opencodeSessionId ?? undefined case 'cursor': return metadata.cursorSessionId ?? undefined case 'kimi': return metadata.kimiSessionId ?? undefined + case 'pi': return metadata.piSessionId ?? undefined default: return metadata.claudeSessionId ?? undefined } } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index b9f5d0f762..e3a78c81a7 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -28,6 +28,9 @@ export type { OpencodeModelsResponse, OpencodeModelSummary, PathExistsResponse, + PiModelSummary, + PiModelsResponse, + PiThinkingLevelMap, SlashCommand, SlashCommandsResponse, SessionResponse, From 0bf17b818ebcec5b41123fa5e81d91a349edc3d7 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:01:39 +0100 Subject: [PATCH 02/34] fix(web): use button CSS variables for Retry button on error screen (#889) --app-link resolves to #ffffff in dark mode, making the Retry button invisible (white text on white background). Switch to --app-button / --app-button-text which have correct contrast in both themes. Fixes #43 Co-authored-by: Cursor --- web/src/router.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/router.tsx b/web/src/router.tsx index 84e030a531..5d8263acf1 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -771,7 +771,7 @@ function SessionPage() { From 26d3c2eb346256db2e02ea74f98033b289f4efba Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:10:10 +0100 Subject: [PATCH 03/34] fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/load succeeds (closes #939) (#948) * fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/load succeeds Emit session-ready from the CLI after ACP load/newSession completes; hub resumeSession and cursor dedup wait for that signal before merging rows so a failed session/load no longer deletes the archived session the operator can retry. Refs #917. Closes #939. Co-authored-by: Cursor * fix(hub): gate session-ready wait on cursor ACP protocol only Legacy stream-json Cursor resumes use cursorLegacyRemoteLauncher, which does not emit session-ready; limiting the defer-merge and dedup gates to ACP avoids 60s resume_failed timeouts on those sessions. Co-authored-by: Cursor * fix(hub): block ACP dedup until session-ready, including on session-end Inactive ACP spawns that never emitted session-ready could still trigger deduplicateByAgentSessionId on session-end and delete the original row. Require session-ready for all ACP dedup paths and skip end-of-session dedup when load never succeeded. Co-authored-by: Cursor * fix(hub): restore session-end dedup for non-ACP cursor duplicates Only skip the session-end dedup retry for Cursor ACP rows that never emitted session-ready. Codex/Claude/legacy Cursor duplicates still merge when the live row ends. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/api/apiSession.ts | 8 + .../cursor/cursorAcpRemoteLauncher.test.ts | 68 +++-- cli/src/cursor/cursorAcpRemoteLauncher.ts | 32 ++- hub/README.md | 1 + hub/src/socket/handlers/cli/index.ts | 9 +- .../socket/handlers/cli/sessionHandlers.ts | 20 +- hub/src/socket/server.ts | 2 + hub/src/startHub.ts | 1 + hub/src/sync/sessionModel.test.ts | 270 ++++++++++++++++++ hub/src/sync/syncEngine.ts | 64 ++++- shared/src/socket.ts | 2 + 11 files changed, 455 insertions(+), 22 deletions(-) diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 370239b5e9..404e10a48e 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -586,6 +586,14 @@ export class ApiSessionClient extends EventEmitter { }) } + /** Hub waits for this before mergeSessions on Cursor ACP reopen (tiann/hapi#939). */ + emitSessionReady(): void { + this.socket.emit('session-ready', { + sid: this.sessionId, + time: Date.now() + }) + } + emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean }): void { if (localIds.length === 0) return // `clearQueuedThinkingGrace` is an opt-in signal for the hub to drop diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index 3aeb747389..164d2065b8 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -138,15 +138,7 @@ import { ApiSessionClient } from '@/api/apiSession'; function makeSession(sessionId: string | null): CursorSession { const queue = new MessageQueue2(() => 'mode'); - const client = { - rpcHandlerManager: { - registerHandler: vi.fn() - }, - updateMetadata: vi.fn(), - sendSessionEvent: vi.fn(), - sendAgentMessage: vi.fn(), - keepAlive: vi.fn() - } as unknown as ApiSessionClient; + const client = makeClient(); const session = new CursorSession({ api: {} as never, @@ -168,6 +160,19 @@ function makeSession(sessionId: string | null): CursorSession { return session; } +function makeClient() { + return { + rpcHandlerManager: { + registerHandler: vi.fn() + }, + updateMetadata: vi.fn(), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn(), + emitSessionReady: vi.fn() + } as unknown as ApiSessionClient; +} + describe('cursorAcpRemoteLauncher', () => { beforeEach(() => { harness.initializeError = null; @@ -265,6 +270,26 @@ describe('cursorAcpRemoteLauncher', () => { expect(harness.newSessionCalled).toBe(true); expect(harness.loadSessionCalled).toBe(false); expect(session.onSessionFoundWithProtocol).toHaveBeenCalledWith('new-acp-session', 'acp'); + expect(session.client.emitSessionReady).toHaveBeenCalledTimes(1); + }); + + it('emits session-ready after session/load succeeds', async () => { + const session = makeSession('resume-thread-ready'); + await cursorAcpRemoteLauncher(session); + + expect(harness.loadSessionCalled).toBe(true); + expect(session.client.emitSessionReady).toHaveBeenCalledTimes(1); + }); + + it('does not emit session-ready when session/load fails', async () => { + harness.loadSessionError = new Error('session not found'); + const session = makeSession('old-stream-json-id'); + + await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow( + /Legacy stream-json sessions cannot be loaded via ACP/ + ); + + expect(session.client.emitSessionReady).not.toHaveBeenCalled(); }); it('applies debug mode immediately when setPermissionMode is called', async () => { @@ -274,7 +299,8 @@ describe('cursorAcpRemoteLauncher', () => { updateMetadata: vi.fn(), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive: vi.fn() + keepAlive: vi.fn(), + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -318,7 +344,8 @@ describe('cursorAcpRemoteLauncher', () => { updateMetadata: vi.fn(), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive + keepAlive, + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -362,7 +389,8 @@ describe('cursorAcpRemoteLauncher', () => { updateMetadata: vi.fn(), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive + keepAlive, + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -409,7 +437,8 @@ describe('cursorAcpRemoteLauncher', () => { updateMetadata: vi.fn(), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive: vi.fn() + keepAlive: vi.fn(), + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -453,7 +482,8 @@ describe('cursorAcpRemoteLauncher', () => { updateMetadata: vi.fn(), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive: vi.fn() + keepAlive: vi.fn(), + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -509,7 +539,8 @@ describe('cursorAcpRemoteLauncher', () => { updateMetadata: vi.fn(), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive + keepAlive, + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -550,7 +581,8 @@ describe('cursorAcpRemoteLauncher', () => { updateMetadata: vi.fn(), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive: vi.fn() + keepAlive: vi.fn(), + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -595,7 +627,8 @@ describe('cursorAcpRemoteLauncher', () => { updateMetadata: vi.fn(), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), - keepAlive: vi.fn() + keepAlive: vi.fn(), + emitSessionReady: vi.fn() } as unknown as ApiSessionClient; const session = new CursorSession({ @@ -636,6 +669,7 @@ describe('cursorAcpRemoteLauncher', () => { sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive: vi.fn(), + emitSessionReady: vi.fn(), emitMessagesConsumed: vi.fn() } as unknown as ApiSessionClient; diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 58b611bee1..a3fe254552 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -105,7 +105,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { mcpServers: mcpServerList }); } catch (error) { - logger.warn('[cursor-acp] session/load failed', error); + logger.warn('[cursor-acp] session/load failed', formatAcpLoadError(error)); throw new Error( 'Failed to resume Cursor ACP session. Legacy stream-json sessions cannot be loaded via ACP.' ); @@ -125,6 +125,8 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { session.onSessionFoundWithProtocol(acpSessionId, 'acp'); } + session.client.emitSessionReady(); + syncCursorModelsFromAcp(backend, acpSessionId); const initialMetadata = backend.getSessionModelsMetadata(acpSessionId); @@ -436,6 +438,34 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } } +function formatAcpLoadError(error: unknown): Record { + if (error instanceof Error) { + const record: Record = { + name: error.name, + message: error.message + }; + const code = (error as Error & { code?: unknown }).code; + if (code !== undefined) { + record.code = code; + } + const data = (error as Error & { data?: unknown }).data; + if (data !== undefined) { + record.data = data; + } + const cause = error.cause; + if (cause !== undefined) { + record.cause = cause instanceof Error + ? { name: cause.name, message: cause.message } + : cause; + } + return record; + } + if (typeof error === 'object' && error !== null) { + return { ...(error as Record) }; + } + return { message: String(error) }; +} + function isSpawnDefaultModel(modelId: string): boolean { const normalized = modelId.trim().toLowerCase(); return normalized === 'auto' || normalized === 'default' || normalized === 'default[]'; diff --git a/hub/README.md b/hub/README.md index faed729730..b224b5df78 100644 --- a/hub/README.md +++ b/hub/README.md @@ -152,6 +152,7 @@ Namespace: `/cli` - `update-metadata` - Update session metadata. - `update-state` - Update agent state. - `session-alive` - Keep session active. +- `session-ready` - Cursor ACP `session/load` (or `newSession`) succeeded; hub defers merge/dedup until this arrives on reopen. - `session-end` - Mark session ended. - `machine-alive` - Keep machine online. - `rpc-register` - Register RPC handler. diff --git a/hub/src/socket/handlers/cli/index.ts b/hub/src/socket/handlers/cli/index.ts index 223af96306..f39b510c7d 100644 --- a/hub/src/socket/handlers/cli/index.ts +++ b/hub/src/socket/handlers/cli/index.ts @@ -27,6 +27,11 @@ type SessionEndPayload = { time: number } +type SessionReadyPayload = { + sid: string + time: number +} + type MachineAlivePayload = { machineId: string time: number @@ -38,6 +43,7 @@ export type CliHandlersDeps = { rpcRegistry: RpcRegistry terminalRegistry: TerminalRegistry onSessionAlive?: (payload: SessionAlivePayload) => void + onSessionReady?: (payload: SessionReadyPayload) => void onSessionEnd?: (payload: SessionEndPayload) => void onMachineAlive?: (payload: MachineAlivePayload) => void onWebappEvent?: (event: SyncEvent) => void @@ -48,7 +54,7 @@ export type CliHandlersDeps = { } export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlersDeps): void { - const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps + const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionReady, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps const terminalNamespace = io.of('/terminal') const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null @@ -106,6 +112,7 @@ export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlers resolveSessionAccess, emitAccessError, onSessionAlive, + onSessionReady, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 123af75a6d..7ffec6dbbe 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -32,6 +32,11 @@ type SessionEndPayload = { reason?: SessionEndReason } +type SessionReadyPayload = { + sid: string + time: number +} + type ResolveSessionAccess = (sessionId: string) => AccessResult type EmitAccessError = (scope: 'session' | 'machine', id: string, reason: AccessErrorReason) => void @@ -62,6 +67,7 @@ export type SessionHandlersDeps = { resolveSessionAccess: ResolveSessionAccess emitAccessError: EmitAccessError onSessionAlive?: (payload: SessionAlivePayload) => void + onSessionReady?: (payload: SessionReadyPayload) => void onSessionEnd?: (payload: SessionEndPayload) => void onWebappEvent?: (event: SyncEvent) => void onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void @@ -74,7 +80,7 @@ export type SessionHandlersDeps = { } export function registerSessionHandlers(socket: CliSocketWithData, deps: SessionHandlersDeps): void { - const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps + const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionReady, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps socket.on('message', (data: unknown) => { const parsed = messageSchema.safeParse(data) @@ -279,6 +285,18 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session onSessionAlive?.(data) }) + socket.on('session-ready', (data: SessionReadyPayload) => { + if (!data || typeof data.sid !== 'string' || typeof data.time !== 'number') { + return + } + const sessionAccess = resolveSessionAccess(data.sid) + if (!sessionAccess.ok) { + emitAccessError('session', data.sid, sessionAccess.reason) + return + } + onSessionReady?.(data) + }) + socket.on('messages-consumed', (data: { sid: string; localIds: string[]; clearQueuedThinkingGrace?: boolean }) => { if (!data || typeof data.sid !== 'string' || !Array.isArray(data.localIds)) { return diff --git a/hub/src/socket/server.ts b/hub/src/socket/server.ts index af7533e5c8..8f804e37bd 100644 --- a/hub/src/socket/server.ts +++ b/hub/src/socket/server.ts @@ -37,6 +37,7 @@ export type SocketServerDeps = { getSession?: (sessionId: string) => { active: boolean; namespace: string } | null onWebappEvent?: (event: SyncEvent) => void onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void + onSessionReady?: (payload: { sid: string; time: number }) => void onSessionEnd?: (payload: { sid: string; time: number }) => void onMachineAlive?: (payload: { machineId: string; time: number }) => void onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void @@ -116,6 +117,7 @@ export function createSocketServer(deps: SocketServerDeps): { rpcRegistry, terminalRegistry, onSessionAlive: deps.onSessionAlive, + onSessionReady: deps.onSessionReady, onSessionEnd: deps.onSessionEnd, onMachineAlive: deps.onMachineAlive, onWebappEvent: deps.onWebappEvent, diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 2a07ad70f0..58a4734477 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -185,6 +185,7 @@ export async function startHub(options: StartHubOptions = {}): Promise syncEngine?.handleRealtimeEvent(event), onSessionAlive: (payload) => syncEngine?.handleSessionAlive(payload), + onSessionReady: (payload) => syncEngine?.handleSessionReady(payload), onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload), onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload), onBackgroundTaskDelta: (sessionId, delta) => syncEngine?.handleBackgroundTaskDelta(sessionId, delta), diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 8489ade921..7ea9950e14 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1128,6 +1128,276 @@ describe('session model', () => { } }) + it('defers mergeSessions for cursor reopen until session-ready (load failure leaves old row)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const oldSession = engine.getOrCreateSession( + 'cursor-reopen-old', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-load-fail', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + engine.handleSessionEnd({ sid: oldSession.id, time: Date.now() }) + + const spawnedSession = engine.getOrCreateSession( + 'cursor-reopen-spawned', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-load-fail', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + const spawnedSessionId = spawnedSession.id + + let mergeCalls = 0 + const sessionCache = (engine as any).sessionCache + const mergeSessions = sessionCache.mergeSessions.bind(sessionCache) + sessionCache.mergeSessions = async (oldSessionId: string, newSessionId: string, namespace: string) => { + mergeCalls += 1 + return mergeSessions(oldSessionId, newSessionId, namespace) + } + + ;(engine as any).rpcGateway.spawnSession = async () => { + engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() }) + return { type: 'success', sessionId: spawnedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionReady = async () => 'ended' + + const result = await engine.resumeSession(oldSession.id, 'default') + + expect(result).toEqual({ + type: 'error', + message: 'Session ended before Cursor ACP load completed', + code: 'resume_failed' + }) + expect(mergeCalls).toBe(0) + expect(store.sessions.getSession(oldSession.id)).not.toBeNull() + } finally { + engine.stop() + } + }) + + it('does not dedup-merge when ACP spawn ends without session-ready', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const oldSession = engine.getOrCreateSession( + 'cursor-acp-dedup-old', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-dedup-fail', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + const spawnedSession = engine.getOrCreateSession( + 'cursor-acp-dedup-spawned', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-dedup-fail', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + + let mergeCalls = 0 + const sessionCache = (engine as any).sessionCache + const mergeSessions = sessionCache.mergeSessions.bind(sessionCache) + sessionCache.mergeSessions = async (oldSessionId: string, newSessionId: string, namespace: string) => { + mergeCalls += 1 + return mergeSessions(oldSessionId, newSessionId, namespace) + } + + engine.handleSessionAlive({ sid: spawnedSession.id, time: Date.now() }) + engine.handleSessionEnd({ sid: spawnedSession.id, time: Date.now(), reason: 'error' }) + + expect(mergeCalls).toBe(0) + expect(store.sessions.getSession(oldSession.id)).not.toBeNull() + } finally { + engine.stop() + } + }) + + it('mergeSessions runs for cursor reopen after session-ready', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const oldSession = engine.getOrCreateSession( + 'cursor-reopen-old-ready', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-load-ok', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + engine.handleSessionEnd({ sid: oldSession.id, time: Date.now() }) + + const spawnedSession = engine.getOrCreateSession( + 'cursor-reopen-spawned-ready', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'cursor-csid-load-ok', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + const spawnedSessionId = spawnedSession.id + + let mergeCalls = 0 + const sessionCache = (engine as any).sessionCache + const mergeSessions = sessionCache.mergeSessions.bind(sessionCache) + sessionCache.mergeSessions = async (oldSessionId: string, newSessionId: string, namespace: string) => { + mergeCalls += 1 + return mergeSessions(oldSessionId, newSessionId, namespace) + } + + ;(engine as any).rpcGateway.spawnSession = async () => { + engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() }) + engine.handleSessionReady({ sid: spawnedSessionId, time: Date.now() }) + return { type: 'success', sessionId: spawnedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(oldSession.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: spawnedSessionId }) + expect(mergeCalls).toBe(1) + expect(store.sessions.getSession(oldSession.id)).toBeNull() + } finally { + engine.stop() + } + }) + + it('does not wait for session-ready on cursor stream-json reopen', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const oldSession = engine.getOrCreateSession( + 'cursor-legacy-reopen-old', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'legacy-csid', + cursorSessionProtocol: 'stream-json' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + engine.handleSessionEnd({ sid: oldSession.id, time: Date.now() }) + + const spawnedSession = engine.getOrCreateSession( + 'cursor-legacy-reopen-spawned', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'cursor', + cursorSessionId: 'legacy-csid', + cursorSessionProtocol: 'stream-json' + }, + null, + 'default' + ) + const spawnedSessionId = spawnedSession.id + + let waitForSessionReadyCalls = 0 + ;(engine as any).waitForSessionReady = async () => { + waitForSessionReadyCalls += 1 + return 'timeout' + } + ;(engine as any).rpcGateway.spawnSession = async () => { + engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() }) + return { type: 'success', sessionId: spawnedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(oldSession.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: spawnedSessionId }) + expect(waitForSessionReadyCalls).toBe(0) + } finally { + engine.stop() + } + }) + it('resolves a local resume target for a Codex session', () => { const store = new Store(':memory:') const engine = new SyncEngine( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 1d733cee5d..29c442f7f9 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -133,6 +133,8 @@ export class SyncEngine { private readonly messageService: MessageService private readonly rpcGateway: RpcGateway private inactivityTimer: NodeJS.Timeout | null = null + /** Sessions that emitted `session-ready` (Cursor ACP load/newSession complete). */ + private readonly sessionReadyIds = new Set() constructor( private readonly store: Store, @@ -269,6 +271,9 @@ export class SyncEngine { this.sessionCache.refreshSession(event.sessionId) const after = this.sessionCache.getSession(event.sessionId) if (after?.metadata && !this.hasSameAgentSessionIds(before?.metadata ?? null, after.metadata)) { + if (!this.canRunCursorDedup(after)) { + return + } void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => { // best-effort: dedup failure is harmless, web-side safety net hides remaining duplicates }) @@ -306,11 +311,21 @@ export class SyncEngine { this.triggerDedupIfNeeded(payload.sid) } + handleSessionReady(payload: { sid: string; time: number }): void { + this.sessionReadyIds.add(payload.sid) + this.triggerDedupIfNeeded(payload.sid) + } + clearQueuedThinkingGrace(sessionId: string): void { this.sessionCache.clearQueuedThinkingGrace(sessionId) } handleSessionEnd(payload: { sid: string; time: number; reason?: 'completed' | 'terminated' | 'error' }): void { + const before = this.sessionCache.getSession(payload.sid) + const isCursorAcp = before?.metadata?.flavor === 'cursor' + && before.metadata.cursorSessionProtocol === 'acp' + const shouldRetryDedup = !isCursorAcp || this.sessionReadyIds.has(payload.sid) + this.sessionCache.handleSessionEnd(payload) this.eventPublisher.emit({ type: 'session-ended', @@ -318,8 +333,12 @@ export class SyncEngine { reason: payload.reason }) // Retry dedup now that this session is inactive — a prior dedup may have - // skipped it because it was still active at the time. - this.triggerDedupIfNeeded(payload.sid) + // skipped it because it was still active at the time. Cursor ACP rows that + // never reached session-ready must not dedup-merge the original on failure. + if (shouldRetryDedup) { + this.triggerDedupIfNeeded(payload.sid) + } + this.sessionReadyIds.delete(payload.sid) } handleBackgroundTaskDelta(sessionId: string, delta: { started: number; completed: number }): void { @@ -1163,6 +1182,19 @@ export class SyncEngine { // permissionMode is passed to spawnSession above; do not call set-session-config here. // session-alive can arrive before the CLI registers that RPC handler, which caused resume_failed. + const needsReadyBeforeMerge = spawnResult.sessionId !== access.sessionId + && flavor === 'cursor' + && metadata.cursorSessionProtocol === 'acp' + if (needsReadyBeforeMerge) { + const readyResult = await this.waitForSessionReady(spawnResult.sessionId) + if (readyResult !== 'ready') { + const message = readyResult === 'ended' + ? 'Session ended before Cursor ACP load completed' + : 'Session failed to become ready' + return { type: 'error', message, code: 'resume_failed' } + } + } + if (spawnResult.sessionId !== access.sessionId) { // The old session may have already been merged by the automatic dedup path // (triggered when the spawned CLI sets its agent session ID in metadata). @@ -1410,9 +1442,22 @@ export class SyncEngine { && (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null) } + private canRunCursorDedup(session: Session): boolean { + if (session.metadata?.flavor !== 'cursor') { + return true + } + if (session.metadata?.cursorSessionProtocol !== 'acp') { + return true + } + return this.sessionReadyIds.has(session.id) + } + private triggerDedupIfNeeded(sessionId: string): void { const session = this.sessionCache.getSession(sessionId) if (session?.metadata) { + if (!this.canRunCursorDedup(session)) { + return + } void this.sessionCache.deduplicateByAgentSessionId(sessionId).catch(() => { // best-effort: web-side safety net hides remaining duplicates }) @@ -1431,6 +1476,21 @@ export class SyncEngine { return false } + async waitForSessionReady(sessionId: string, timeoutMs: number = 60_000): Promise<'ready' | 'ended' | 'timeout'> { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + if (this.sessionReadyIds.has(sessionId)) { + return 'ready' + } + const session = this.getSession(sessionId) + if (!session?.active) { + return 'ended' + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + return 'timeout' + } + async waitForSessionInactive(sessionId: string, timeoutMs: number = 15_000): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { diff --git a/shared/src/socket.ts b/shared/src/socket.ts index 9d6e2e087e..e050b0df98 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -213,6 +213,8 @@ export interface ClientToServerEvents { serviceTier?: string | null collaborationMode?: CodexCollaborationMode }) => void + /** CLI agent finished session/load (or equivalent) and can accept prompts. */ + 'session-ready': (data: { sid: string; time: number }) => void 'session-end': (data: { sid: string; time: number; reason?: SessionEndReason }) => void 'messages-consumed': (data: { sid: string; localIds: string[] }) => void 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: UpdateMetadataAck) => void) => void From b3add07ad79e36fe3dda4a206af3a36d25e84093 Mon Sep 17 00:00:00 2001 From: KorenKrita Date: Thu, 18 Jun 2026 10:11:02 +0800 Subject: [PATCH 04/34] fix: detect stale PID in runner state after abnormal shutdown (#931) * fix: verify PID belongs to hapi before treating runner as alive After OS upgrade, stale runner.state.json PID can be reused by unrelated processes. The old kill(pid, 0) check passes for any process, causing start-sync to loop with 'Runner already running' indefinitely. Now uses ps/wmic to confirm the process command line contains 'hapi' before considering the runner alive. Falls back to alive-only check if ps/wmic fails. * fix: precise runner process detection and wmic fallback * fix: add fallback for ps failure in non-Windows branch --- cli/src/runner/controlClient.ts | 8 ++++---- cli/src/utils/process.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/cli/src/runner/controlClient.ts b/cli/src/runner/controlClient.ts index b1d8de3c80..fa9daa1d28 100644 --- a/cli/src/runner/controlClient.ts +++ b/cli/src/runner/controlClient.ts @@ -10,7 +10,7 @@ import packageJson from '../../package.json'; import { existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { isBunCompiled, projectPath } from '@/projectPath'; -import { isProcessAlive, killProcess } from '@/utils/process'; +import { isProcessAlive, isHapiRunnerProcess, killProcess } from '@/utils/process'; import { configuration } from '@/configuration'; import { hashRunnerCliApiToken, isRunnerStateCompatibleWithIdentity } from './runnerIdentity'; @@ -143,12 +143,12 @@ export async function checkIfRunnerRunningAndCleanupStaleState(): Promise Date: Thu, 18 Jun 2026 03:11:18 +0100 Subject: [PATCH 05/34] fix(cli): stateful MCP HTTP transport for display_image (#944) * fix(cli): stateful MCP HTTP transport for display_image MCP SDK 1.29+ rejects stateless StreamableHTTP reuse across separate POSTs (initialize, notifications/initialized, tools/call), so display_image 500'd on the second request. Generate per-session IDs instead. Add hapi-display-image.mjs to call the live session CLI's MCP via hostPid so generated-image bytes stay in the owning process. Co-authored-by: Cursor * fix(cli): multi-session MCP transport + hapiMcpUrl metadata Route streamable HTTP by mcp-session-id so agent bridge and hapi-display-image can each initialize without "already initialized". Publish metadata.hapiMcpUrl at MCP start; helper uses that instead of guessing loopback ports (hook server collision). Co-authored-by: Cursor * fix(scripts): preserve namespaced CLI_API_TOKEN in display-image helper Do not append :default; namespace is already encoded in the stored token. Co-authored-by: Cursor * fix(scripts): read settings only when CLI_API_TOKEN unset Env-only auth must not require ~/.hapi/settings.json to exist. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/claude/utils/startHappyServer.ts | 111 +++++++++++++++-------- scripts/tooling/hapi-display-image.mjs | 76 ++++++++++++++++ shared/src/schemas.ts | 1 + 3 files changed, 152 insertions(+), 36 deletions(-) create mode 100644 scripts/tooling/hapi-display-image.mjs diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index df0b0fd47f..b2001a5703 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -4,7 +4,7 @@ */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { createServer } from "node:http"; +import { createServer, type IncomingMessage } from "node:http"; import { lstat, readFile } from "node:fs/promises"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { AddressInfo } from "node:net"; @@ -18,38 +18,29 @@ type StartHappyServerOptions = { emitTitleSummary?: boolean; }; -export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) { - const emitTitleSummary = options.emitTitleSummary ?? true; - - // Handler that sends title updates via the client +function createHapiMcpServer(client: ApiSessionClient, emitTitleSummary: boolean): McpServer { const handler = async (title: string) => { logger.debug('[hapiMCP] Changing title to:', title); try { if (emitTitleSummary) { - // Send title as a summary message, similar to title generator. client.sendClaudeSessionMessage({ type: 'summary', summary: title, leafUuid: randomUUID() }); } - + return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }; - // - // Create the MCP server - // - const mcp = new McpServer({ name: "HAPI MCP", version: "1.0.0", }); - // Avoid TS instantiation depth issues by widening the schema type. const changeTitleInputSchema: z.ZodTypeAny = z.object({ title: z.string().describe('The new title for the chat session'), }); @@ -66,7 +57,7 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH }, async (args: { title: string }) => { const response = await handler(args.title); logger.debug('[hapiMCP] Response:', response); - + if (response.success) { return { content: [ @@ -77,19 +68,18 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH ], isError: false, }; - } else { - return { - content: [ - { - type: 'text' as const, - text: `Failed to change chat title: ${response.error || 'Unknown error'}`, - }, - ], - isError: true, - }; } - }); + return { + content: [ + { + type: 'text' as const, + text: `Failed to change chat title: ${response.error || 'Unknown error'}`, + }, + ], + isError: true, + }; + }); mcp.registerTool('display_image', { description: 'Display a local image file inline in the current HAPI chat session', @@ -155,19 +145,58 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH } }); - const transport = new StreamableHTTPServerTransport({ - // NOTE: Returning session id here will result in claude - // sdk spawn to fail with `Invalid Request: Server already initialized` - sessionIdGenerator: undefined - }); - await mcp.connect(transport); + return mcp; +} + +function readMcpSessionId(req: IncomingMessage): string | undefined { + const raw = req.headers['mcp-session-id']; + if (typeof raw === 'string') { + return raw; + } + if (Array.isArray(raw)) { + return raw[0]; + } + return undefined; +} - // - // Create the HTTP server - // +export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) { + const emitTitleSummary = options.emitTitleSummary ?? true; + const transports = new Map(); + const mcps = new Map(); + + const createMcpTransport = () => { + const mcp = createHapiMcpServer(client, emitTitleSummary); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + transports.set(sessionId, transport); + mcps.set(sessionId, mcp); + }, + onsessionclosed: (sessionId) => { + transports.delete(sessionId); + const server = mcps.get(sessionId); + mcps.delete(sessionId); + void server?.close(); + }, + }); + void mcp.connect(transport); + return transport; + }; const server = createServer(async (req, res) => { try { + const sessionId = readMcpSessionId(req); + const transport = sessionId + ? transports.get(sessionId) + : createMcpTransport(); + + if (!transport) { + if (!res.headersSent) { + res.writeHead(404).end(); + } + return; + } + await transport.handleRequest(req, res); } catch (error) { logger.debug("Error handling request:", error); @@ -184,13 +213,23 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH }); }); + const mcpUrl = baseUrl.toString(); + client.updateMetadata((metadata) => ({ + ...metadata, + hapiMcpUrl: mcpUrl, + })); + return { - url: baseUrl.toString(), + url: mcpUrl, toolNames: ['change_title', 'display_image'], stop: () => { logger.debug('[hapiMCP] Stopping server'); - mcp.close(); + for (const mcp of mcps.values()) { + mcp.close(); + } + transports.clear(); + mcps.clear(); server.close(); } - } + }; } diff --git a/scripts/tooling/hapi-display-image.mjs b/scripts/tooling/hapi-display-image.mjs new file mode 100644 index 0000000000..c2670585d2 --- /dev/null +++ b/scripts/tooling/hapi-display-image.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env bun +/** + * Post a local image inline to a HAPI session via the session CLI's display_image MCP tool. + * + * Uses session.metadata.hapiMcpUrl (published at MCP server start) so we hit the MCP + * endpoint, not the session hook server on another loopback port in the same process. + * + * Usage: + * bun scripts/tooling/hapi-display-image.mjs [title] + */ + +import { readFileSync, lstatSync } from 'node:fs' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +const HAPI_HOST = process.env.HAPI_HOST ?? 'http://localhost:3006' +const SETTINGS = process.env.HAPI_SETTINGS ?? `${process.env.HOME}/.hapi/settings.json` + +const sessionArg = process.argv[2] +const imagePath = process.argv[3] +const title = process.argv[4] + +if (!sessionArg || !imagePath) { + console.error('usage: hapi-display-image.mjs [title]') + process.exit(2) +} + +if (!lstatSync(imagePath).isFile()) { + console.error(`not a file: ${imagePath}`) + process.exit(2) +} + +const token = process.env.CLI_API_TOKEN ?? JSON.parse(readFileSync(SETTINGS, 'utf8')).cliApiToken +if (!token) { + console.error('missing CLI_API_TOKEN env and no cliApiToken in settings') + process.exit(2) +} +const authRes = await fetch(`${HAPI_HOST}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accessToken: token }), +}) +if (!authRes.ok) { + console.error('auth failed', authRes.status) + process.exit(3) +} +const { token: jwt } = await authRes.json() + +const sessionsRes = await fetch(`${HAPI_HOST}/api/sessions?limit=500`, { + headers: { Authorization: `Bearer ${jwt}` }, +}) +const sessionsBody = await sessionsRes.json() +const sessions = sessionsBody.sessions ?? sessionsBody +const session = sessions.find((s) => s.id.startsWith(sessionArg)) +if (!session) { + console.error(`no session for prefix ${sessionArg}`) + process.exit(4) +} + +const mcpUrl = session.metadata?.hapiMcpUrl +if (!mcpUrl) { + console.error('session has no hapiMcpUrl metadata (restart session CLI after MCP fix lands)') + process.exit(5) +} + +console.error(`hapi-display-image: session=${session.id} mcp=${mcpUrl}`) + +const client = new Client({ name: 'hapi-display-image', version: '1.0.0' }, { capabilities: {} }) +const transport = new StreamableHTTPClientTransport(new URL(mcpUrl)) +await client.connect(transport) +const result = await client.callTool({ + name: 'display_image', + arguments: { path: imagePath, title: title ?? undefined }, +}) +await client.close() +console.log(JSON.stringify(result, null, 2)) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 1760337e00..1b891fbef5 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -56,6 +56,7 @@ export const MetadataSchema = z.object({ happyToolsDir: z.string().optional(), startedFromRunner: z.boolean().optional(), hostPid: z.number().optional(), + hapiMcpUrl: z.string().url().optional(), startedBy: z.enum(['runner', 'terminal']).optional(), lifecycleState: z.string().optional(), lifecycleStateSince: z.number().optional(), From fc8c32e07a5ee846367aef9f58d3bd261b69264e Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 18 Jun 2026 10:11:31 +0800 Subject: [PATCH 06/34] fix: reliable generated-image display + stop client remount storm (#927) (#934) * test: reproduce issue #927 * fix(hub): cache generated images + raise socket buffer cap (closes #927) Generated-image display was slow and could silently fail: - The /generated-images route sent `Cache-Control: no-store`, so every card remount (session switch, scroll, reload) re-ran the full HTTP -> socket.io RPC -> base64 round-trip. The bytes for an imageId are immutable, so serve them `private, max-age=31536000, immutable` + ETag. - socket.io / bun-engine `maxHttpBufferSize` was left at the 1 MB default, while the MCP tool accepts images up to 25 MB. The base64 CLI -> hub ack frame for anything above ~750 KB raw exceeded the cap and was dropped. Raise the buffer to comfortably carry the largest allowed image. via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(hub): short-circuit generated-image revalidation with a 304 (#927) The imageId is an immutable content fingerprint, so use it as the ETag and answer If-None-Match with 304 before issuing the readGeneratedImage RPC. This makes the ETag actually useful: revalidation now skips the CLI socket round-trip entirely, and still serves correctly even after the image was evicted from the CLI's in-memory store. via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(web): stabilize ApiClient identity across token refresh (#927) The ApiClient was rebuilt whenever the token value changed (useMemo dep), even though every request already reads the live token via getToken/tokenRef. On a flaky/remote connection, repeated 401s -> onUnauthorized -> forced refresh churned `api`'s identity, which remounts everything keyed on it: VoiceBackendSession ([props.api]) -> Voice re-register spam, and GeneratedImageCard ([ctx.api]) -> per-image refetch storm, feeding a render avalanche. Depend on auth presence (hasToken) instead. Reproduced with a useAuth hook test (red->green); full web suite stays green. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --------- Co-authored-by: HAPI --- hub/src/socket/server.ts | 5 +- hub/src/socket/socketLimits.test.ts | 14 +++++ hub/src/socket/socketLimits.ts | 10 ++++ hub/src/web/routes/git.test.ts | 61 ++++++++++++++++++++++ hub/src/web/routes/git.ts | 32 +++++++++++- web/src/hooks/useAuth.test.tsx | 79 +++++++++++++++++++++++++++++ web/src/hooks/useAuth.ts | 12 +++-- 7 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 hub/src/socket/socketLimits.test.ts create mode 100644 hub/src/socket/socketLimits.ts create mode 100644 hub/src/web/routes/git.test.ts create mode 100644 web/src/hooks/useAuth.test.tsx diff --git a/hub/src/socket/server.ts b/hub/src/socket/server.ts index 8f804e37bd..02728afaed 100644 --- a/hub/src/socket/server.ts +++ b/hub/src/socket/server.ts @@ -9,6 +9,7 @@ import { parseAccessToken } from '../utils/accessToken' import { registerCliHandlers } from './handlers/cli' import { registerTerminalHandlers } from './handlers/terminal' import { RpcRegistry } from './rpcRegistry' +import { SOCKET_MAX_HTTP_BUFFER_SIZE } from './socketLimits' import type { SyncEvent } from '../sync/syncEngine' import { TerminalRegistry } from './terminalRegistry' import type { CliSocketWithData, SocketData, SocketServer } from './socketTypes' @@ -62,12 +63,14 @@ export function createSocketServer(deps: SocketServerDeps): { } const io = new Server({ - cors: corsOptions + cors: corsOptions, + maxHttpBufferSize: SOCKET_MAX_HTTP_BUFFER_SIZE }) const engine = new Engine({ path: '/socket.io/', cors: corsOptions, + maxHttpBufferSize: SOCKET_MAX_HTTP_BUFFER_SIZE, allowRequest: async (req) => { const origin = req.headers.get('origin') if (!origin || allowAllOrigins || corsOrigins.includes(origin)) { diff --git a/hub/src/socket/socketLimits.test.ts b/hub/src/socket/socketLimits.test.ts new file mode 100644 index 0000000000..2a8d853de4 --- /dev/null +++ b/hub/src/socket/socketLimits.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'bun:test' +import { MAX_GENERATED_IMAGE_BYTES, SOCKET_MAX_HTTP_BUFFER_SIZE } from './socketLimits' + +describe('socket limits', () => { + it('buffer size can carry the largest generated image after base64 + JSON-RPC framing', () => { + // Generated images cross the /cli socket as a base64 string inside a JSON-RPC envelope. + // base64 inflates the payload by ~4/3; the engine default (1e6) silently drops anything + // above ~750 KB raw (issue #927). The buffer must exceed the largest allowed image. + const base64Bytes = Math.ceil(MAX_GENERATED_IMAGE_BYTES / 3) * 4 + expect(SOCKET_MAX_HTTP_BUFFER_SIZE).toBeGreaterThan(base64Bytes) + // and must be well above the 1 MB engine default that caused the regression + expect(SOCKET_MAX_HTTP_BUFFER_SIZE).toBeGreaterThan(1e6) + }) +}) diff --git a/hub/src/socket/socketLimits.ts b/hub/src/socket/socketLimits.ts new file mode 100644 index 0000000000..c578c8c4dd --- /dev/null +++ b/hub/src/socket/socketLimits.ts @@ -0,0 +1,10 @@ +// The largest generated image the CLI will serve inline. Must stay in sync with the CLI-side +// limits in cli/src/claude/utils/startHappyServer.ts and cli/src/modules/common/generatedImages.ts. +export const MAX_GENERATED_IMAGE_BYTES = 25 * 1024 * 1024 + +// Generated images (and other large RPC payloads) cross the /cli socket as a base64 string wrapped +// in a JSON-RPC envelope, which inflates the payload by ~4/3. The engine.io default of 1e6 bytes +// silently drops the CLI -> hub ack frame for any image above ~750 KB raw, so a 25 MB image that +// the MCP tool happily accepts can never reach the browser (issue #927). Size the buffer to carry +// the largest allowed image after base64 + framing, with headroom. +export const SOCKET_MAX_HTTP_BUFFER_SIZE = 48 * 1024 * 1024 diff --git a/hub/src/web/routes/git.test.ts b/hub/src/web/routes/git.test.ts new file mode 100644 index 0000000000..320f1eb993 --- /dev/null +++ b/hub/src/web/routes/git.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { Session, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createGitRoutes } from './git' + +function buildApp(engine: Partial): Hono { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createGitRoutes(() => engine as SyncEngine)) + return app +} + +describe('generated images route', () => { + it('serves generated images with an immutable cache header instead of no-store', async () => { + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const session = { id: 'session-1', namespace: 'default', active: true } as unknown as Session + const engine = { + resolveSessionAccess: () => ({ ok: true as const, sessionId: 'session-1', session }), + readGeneratedImage: async () => ({ + success: true, + content: pngBytes.toString('base64'), + mimeType: 'image/png', + fileName: 'shot.png' + }) + } as unknown as Partial + + const response = await buildApp(engine).request('/api/sessions/session-1/generated-images/img-1') + + expect(response.status).toBe(200) + const cacheControl = response.headers.get('cache-control') ?? '' + // Generated images are content-addressed by an immutable random id, so they must be + // cacheable; `no-store` forces a full RPC round-trip on every remount (issue #927). + expect(cacheControl).toContain('immutable') + expect(cacheControl).not.toContain('no-store') + expect(response.headers.get('etag')).toBe('"img-1"') + }) + + it('returns 304 without an RPC round-trip when If-None-Match matches', async () => { + const session = { id: 'session-1', namespace: 'default', active: true } as unknown as Session + let rpcCalls = 0 + const engine = { + resolveSessionAccess: () => ({ ok: true as const, sessionId: 'session-1', session }), + readGeneratedImage: async () => { + rpcCalls += 1 + return { success: true, content: '', mimeType: 'image/png', fileName: 'shot.png' } + } + } as unknown as Partial + + const response = await buildApp(engine).request('/api/sessions/session-1/generated-images/img-1', { + headers: { 'if-none-match': '"img-1"' } + }) + + expect(response.status).toBe(304) + // The whole point: a cache hit must not touch the CLI over the socket. + expect(rpcCalls).toBe(0) + }) +}) diff --git a/hub/src/web/routes/git.ts b/hub/src/web/routes/git.ts index 8cd27a1e01..08a889e313 100644 --- a/hub/src/web/routes/git.ts +++ b/hub/src/web/routes/git.ts @@ -35,6 +35,21 @@ async function runRpc(fn: () => Promise): Promise { + const trimmed = candidate.trim() + return trimmed === '*' || trimmed.replace(/^W\//, '') === normalized + }) +} + export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -150,16 +165,31 @@ export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono engine.readGeneratedImage(sessionResult.sessionId, parsed.data.imageId)) if (!result.success || !result.content) { return c.json({ success: false, error: result.error ?? 'Generated image not found' }, 404) } const bytes = Uint8Array.from(Buffer.from(result.content, 'base64')) + // Generated images are content-addressed by an immutable random id, so the bytes for a + // given id never change. Cache aggressively so remounts/scroll/session reopen don't + // re-run the full HTTP -> socket.io RPC -> base64 round-trip every time (issue #927). return c.body(bytes, 200, { 'Content-Type': result.mimeType ?? 'application/octet-stream', 'Content-Disposition': `inline; filename="${encodeURIComponent(result.fileName ?? 'generated-image')}"`, - 'Cache-Control': 'no-store' + 'Cache-Control': GENERATED_IMAGE_CACHE_CONTROL, + ETag: etag }) }) diff --git a/web/src/hooks/useAuth.test.tsx b/web/src/hooks/useAuth.test.tsx new file mode 100644 index 0000000000..a7d7dbc3e0 --- /dev/null +++ b/web/src/hooks/useAuth.test.tsx @@ -0,0 +1,79 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +// Mock the network layer so we can drive token refreshes deterministically. +// The real ApiClient reads the live token via `getToken`, so the mock records the +// constructor options (including onUnauthorized) and hands out incrementing tokens. +const h = vi.hoisted(() => { + let idSeq = 0 + let authCount = 0 + class MockApiClient { + token: string + options: { getToken?: () => string | null; onUnauthorized?: () => unknown; baseUrl?: string } | undefined + readonly id: number + constructor(token: string, options?: MockApiClient['options']) { + this.token = token + this.options = options + this.id = ++idSeq + } + async authenticate(): Promise<{ token: string; user: { id: string } }> { + authCount += 1 + return { token: `token-${authCount}`, user: { id: 'u1' } } + } + } + class MockApiError extends Error { + status: number + code?: string + constructor(message: string, status = 401, code?: string) { + super(message) + this.status = status + this.code = code + } + } + return { MockApiClient, MockApiError } +}) + +vi.mock('@/api/client', () => ({ ApiClient: h.MockApiClient, ApiError: h.MockApiError })) + +// Imported after the mock is registered (vi.mock is hoisted). +import { useAuth } from '@/hooks/useAuth' + +type ApiWithOptions = { + id: number + options?: { getToken?: () => string | null; onUnauthorized?: () => unknown } +} + +describe('useAuth — api identity stability across token refresh (issue #927)', () => { + it('keeps the same ApiClient instance when the token refreshes', async () => { + // Stable authSource reference, exactly like the real caller (useAuthSource holds it in + // useState). This isolates the bug under test: a *token* refresh, not a source change. + const authSource = { type: 'accessToken' as const, token: 'seed' } + const { result } = renderHook(() => useAuth(authSource, 'http://hub.test')) + + // Initial authenticate resolves and sets the first token. + await waitFor(() => expect(result.current.api).not.toBeNull()) + const api1 = result.current.api as unknown as ApiWithOptions + const token1 = result.current.token + expect(token1).toBe('token-1') + + // Drive the exact real-world trigger: a 401 invokes onUnauthorized, + // which force-refreshes the token (this is what the flaky remote network does). + await act(async () => { + await api1.options?.onUnauthorized?.() + }) + + // The token did advance... + expect(result.current.token).toBe('token-2') + expect(result.current.token).not.toBe(token1) + + // ...but recreating the client was unnecessary: the OLD instance already serves + // the fresh token via getToken, so nothing downstream needed a new `api` reference. + expect(api1.options?.getToken?.()).toBe(result.current.token) + + // DESIRED: `api` stays referentially stable across a refresh, so effects keyed on + // `api` (VoiceBackendSession `[props.api]`, GeneratedImageCard `[ctx.api, ...]`) do + // NOT re-run / remount. On current code `api` is rebuilt because `token` is a useMemo + // dep, which drives the Voice-remount spam + per-image refetch storm. This fails today. + expect(result.current.api).toBe(api1 as unknown as typeof result.current.api) + }) +}) diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index 054ced5a08..8a6c38b2b1 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -157,15 +157,21 @@ export function useAuth(authSource: AuthSource | null, baseUrl: string): { } }, [baseUrl]) + // Keep the ApiClient referentially stable across token *refreshes*: the client always reads + // the live token via getToken (tokenRef), so it never needs rebuilding when the token value + // changes — only when auth presence toggles (login/logout). Rebuilding on every refresh churns + // `api`'s identity, which remounts everything keyed on it (VoiceBackendSession `[props.api]`, + // GeneratedImageCard `[ctx.api, ...]`) and drives the remount/refetch storm. Issue #927. + const hasToken = token !== null const api = useMemo(() => ( - token - ? new ApiClient(token, { + hasToken + ? new ApiClient(tokenRef.current ?? '', { baseUrl, getToken: () => tokenRef.current, onUnauthorized: () => refreshAuth({ force: true }) }) : null - ), [baseUrl, refreshAuth, token]) + ), [baseUrl, refreshAuth, hasToken]) useEffect(() => { let isCancelled = false From a8582566207be6988eb22b5dae046852b5f9466a Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:11:40 +0100 Subject: [PATCH 07/34] fix(web,hub): surface inactive-session error on text-only send (closes #918) (#922) Sending text via the web composer to an archived/inactive session silently dropped on the floor: the hub returned 409 but the web client swallowed the failure with a console.error in the resolveSessionId catch branch, leaving the operator with no signal and no recovery path. Hub: add a machine-readable `code: 'session_inactive'` to the 409 body so the web client can discriminate this branch without string-matching the i18n-able human message. Web (router.tsx, useSendMessage.ts, HappyComposer.tsx): - useSendMessage now fires `onError` on resolveSessionId rejection, not just on POST /messages failure -- closes the visibility hole when the inactive session has no resume target or resume itself fails. - The route classifies the thrown error: a 409 + session_inactive code or a synthetic ApiError thrown from resolveSessionId attaches a Reopen action to the existing inline composer-error affordance. Plain 4xx / 5xx / network keep the legacy text-restore UX untouched. - Reopen calls api.reopenSession (the same path as SessionList's Reopen menu item), invalidates the session queries, and navigates to the resumed sessionId. Per the orchestrator brief's friction pass on #917 the affordance does NOT auto-replay the send; the operator re-clicks Send on the restored composer text. Tests: - hub messages.test.ts: 409 carries `code: 'session_inactive'`. - useSendMessage.test.tsx: ApiError(409, session_inactive) from POST flows through onError; resolveSessionId rejection flows through onError keyed by the original sessionId; 500 keeps the legacy fallback path with no code attached. AI disclosure: implemented by an AI agent (Claude Opus 4.7) acting on operator instructions; tests pass locally (bun typecheck + bun run test for hub and web). Co-authored-by: Cursor --- hub/src/web/routes/guards.ts | 6 +- hub/src/web/routes/messages.test.ts | 24 ++++ .../AssistantChat/HappyComposer.tsx | 24 +++- .../hooks/mutations/useSendMessage.test.tsx | 110 ++++++++++++++- web/src/hooks/mutations/useSendMessage.ts | 16 +++ web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/router.tsx | 127 ++++++++++++++++-- 8 files changed, 293 insertions(+), 18 deletions(-) diff --git a/hub/src/web/routes/guards.ts b/hub/src/web/routes/guards.ts index 0e17ec0770..9056111d6e 100644 --- a/hub/src/web/routes/guards.ts +++ b/hub/src/web/routes/guards.ts @@ -27,7 +27,11 @@ export function requireSession( return c.json({ error }, status) } if (options?.requireActive && !access.session.active) { - return c.json({ error: 'Session is inactive' }, 409) + // `code` lets the web client discriminate the inactive-session 409 from + // other 4xx without string-matching the human message (which is i18n'd + // by the consumer and may change). See web onError handler in + // router.tsx which surfaces a Reopen affordance on this code. + return c.json({ error: 'Session is inactive', code: 'session_inactive' }, 409) } return { sessionId: access.sessionId, session: access.session } } diff --git a/hub/src/web/routes/messages.test.ts b/hub/src/web/routes/messages.test.ts index fbf12f1e6e..64248810ff 100644 --- a/hub/src/web/routes/messages.test.ts +++ b/hub/src/web/routes/messages.test.ts @@ -217,3 +217,27 @@ describe('POST /api/sessions/:id/messages — scheduledAt + attachments rejected expect(sentMessages).toHaveLength(1) }) }) + +// --------------------------------------------------------------------------- +// #918: inactive session 409 carries a machine-readable code +// --------------------------------------------------------------------------- + +describe('POST /api/sessions/:id/messages — inactive session response shape', () => { + it('returns 409 with code "session_inactive" when sending to an inactive session', async () => { + const { app, sentMessages } = createApp({ active: false }) + + const response = await app.request('/api/sessions/session-1/messages', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'hello', localId: 'local-inactive' }) + }) + + expect(response.status).toBe(409) + const body = await response.json() as { error: string; code: string } + expect(body.error).toBe('Session is inactive') + // Web client discriminates this branch via `code` without string-matching + // the human message; see useSendMessage onError consumer in router.tsx. + expect(body.code).toBe('session_inactive') + expect(sentMessages).toHaveLength(0) + }) +}) diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 9ce87408b0..10287ae6e7 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -56,6 +56,10 @@ export interface TextInputState { * or null for an immediate send. When non-null, the composer also * restores the schedule via `onSchedule` so the operator can edit and * retry without silently downgrading a scheduled send to immediate. + * - `action` is an optional recovery affordance rendered as a button next + * to the message. Used by the inactive-session branch (#918) to expose + * a one-click Reopen. Other failure modes (5xx, network, generic 4xx) + * leave this null and only render the message. * * Owned by the route component (`router.tsx`); the composer is a pure * consumer that: @@ -68,6 +72,11 @@ export type ComposerSendError = { text: string message: string scheduledAt: number | null + action?: { + label: string + onClick: () => void + pending?: boolean + } | null } const defaultSuggestionHandler = async (): Promise => [] @@ -1190,9 +1199,20 @@ export function HappyComposer(props: {
- {sendError.message} + {sendError.message} + {sendError.action ? ( + + ) : null}
) : null} diff --git a/web/src/hooks/mutations/useSendMessage.test.tsx b/web/src/hooks/mutations/useSendMessage.test.tsx index 9187bce44c..084f06a283 100644 --- a/web/src/hooks/mutations/useSendMessage.test.tsx +++ b/web/src/hooks/mutations/useSendMessage.test.tsx @@ -3,7 +3,7 @@ import { renderHook, act, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { ReactNode } from 'react' import { useSendMessage } from './useSendMessage' -import type { ApiClient } from '@/api/client' +import { ApiError, type ApiClient } from '@/api/client' vi.mock('@/lib/message-window-store', () => ({ appendOptimisticMessage: vi.fn(), @@ -523,6 +523,114 @@ describe('useSendMessage', () => { await expect(acceptedPromise!).resolves.toBe(true) }) + // #918: the inactive-session 409 path + describe('inactive-session 409 (issue #918)', () => { + it('fires onError with the ApiError so the consumer can render a session_inactive affordance', async () => { + // Hub returns 409 with code: 'session_inactive' (guards.ts). + // The api client throws ApiError(status=409, code='session_inactive'). + const onError = vi.fn() + const api = createMockApi(async () => { + throw new ApiError( + 'HTTP 409 Conflict: {"error":"Session is inactive","code":"session_inactive"}', + 409, + 'session_inactive', + '{"error":"Session is inactive","code":"session_inactive"}' + ) + }) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('hello inactive') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { text: string; error: unknown; sessionId: string } + expect(info.text).toBe('hello inactive') + expect(info.sessionId).toBe('session-A') + expect(info.error).toBeInstanceOf(ApiError) + const apiErr = info.error as ApiError + expect(apiErr.status).toBe(409) + expect(apiErr.code).toBe('session_inactive') + }) + + it('fires onError when resolveSessionId rejects (pre-mutation inactive-session failure)', async () => { + // Pre-mutation: the route's resolveSessionId throws when + // inactiveSessionCanResume returns false OR api.resumeSession + // fails. Prior to #918 this dropped the typed text into the + // void with only a console.error; the operator saw nothing. + // The hook must surface this through onError too. + const onError = vi.fn() + const api = createMockApi() + const resumeError = new ApiError('Session is inactive', 409, 'session_inactive') + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { + onError, + resolveSessionId: async () => { throw resumeError }, + onSessionResolved: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('hello pre-mutation') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + const info = onError.mock.calls[0][0] as { text: string; error: unknown; sessionId: string } + expect(info.text).toBe('hello pre-mutation') + // Keyed by the ORIGINAL sessionId: pre-mutation never navigated. + expect(info.sessionId).toBe('session-A') + expect(info.error).toBe(resumeError) + }) + + it('5xx still uses the legacy text-restore path (#918 must not regress transient-failure UX)', async () => { + // Acceptance criterion: a real transient 500/network failure + // must keep the original behavior (remove optimistic row, + // onError fires with the plain message), not adopt the + // session_inactive affordance. + const onError = vi.fn() + const api = createMockApi(async () => { + throw new ApiError( + 'HTTP 500 Internal Server Error', + 500, + undefined, + undefined + ) + }) + + const { removeOptimisticMessage } = await import('@/lib/message-window-store') + const removeMock = vi.mocked(removeOptimisticMessage) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { onError }), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.sendMessage('hello transient') + }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledTimes(1) + }) + expect(removeMock).toHaveBeenCalledWith('session-A', 'local-id-1') + const info = onError.mock.calls[0][0] as { error: unknown } + // No session_inactive code -> consumer renders fallback + // message, no Reopen action attached. + expect((info.error as ApiError).code).toBeUndefined() + expect((info.error as ApiError).status).toBe(500) + }) + }) + it('preserves scheduledAt when retrying a failed scheduled message', async () => { const sendMock = vi.fn(async () => {}) const api = createMockApi(sendMock) diff --git a/web/src/hooks/mutations/useSendMessage.ts b/web/src/hooks/mutations/useSendMessage.ts index 2f6c77ff46..c37d38742b 100644 --- a/web/src/hooks/mutations/useSendMessage.ts +++ b/web/src/hooks/mutations/useSendMessage.ts @@ -235,6 +235,22 @@ export function useSendMessage( } catch (error) { haptic.notification('error') console.error('Failed to resolve session before send:', error) + // #918: surface the failure via onError so the route can render + // an inline affordance instead of silently swallowing the + // typed text. This covers the "no resume target" branch + // (inactiveSessionCanResume === false) and also any failure + // from api.resumeSession itself. The mutation never started + // (no optimistic row to clean up); onError is the only + // visibility hook the consumer has for this pre-mutation + // path. Key by the ORIGINAL sessionId because navigation + // hasn't happened yet -- the operator is still on the + // archived session's route. + options?.onError?.({ + sessionId, + text, + error, + scheduledAt: scheduledAt ?? null + }) return false } finally { resolveGuardRef.current = false diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d5f059c80c..5e4e92864c 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -240,6 +240,8 @@ export default { 'chat.terminal': 'Terminal', 'chat.switchRemote': 'Switch to remote mode', 'chat.sendError.fallback': "Couldn't send your message. Edit and try again.", + 'chat.sendError.sessionInactive': 'This session is archived. Reopen it to send your message.', + 'chat.sendError.sessionInactive.action': 'Reopen', // Codex review 'codexReview.title': 'Codex review', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 5e63692e94..301a3d72d2 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -244,6 +244,8 @@ export default { 'chat.terminal': '终端', 'chat.switchRemote': '切换到远程模式', 'chat.sendError.fallback': '消息未能发送。请修改后重试。', + 'chat.sendError.sessionInactive': '此会话已归档。请先重新打开再发送消息。', + 'chat.sendError.sessionInactive.action': '重新打开', // Codex review 'codexReview.title': 'Codex review', diff --git a/web/src/router.tsx b/web/src/router.tsx index 5d8263acf1..3b13d7c2c6 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -32,6 +32,7 @@ import { useSlashCommands } from '@/hooks/queries/useSlashCommands' import { useSkills } from '@/hooks/queries/useSkills' import { useSendMessage, type SendErrorInfo } from '@/hooks/mutations/useSendMessage' import type { ComposerSendError } from '@/components/AssistantChat/HappyComposer' +import { ApiError } from '@/api/client' import { queryKeys } from '@/lib/query-keys' import { useToast } from '@/lib/toast-context' import { useTranslation } from '@/lib/use-translation' @@ -574,20 +575,27 @@ function SessionsIndexPage() { } /** - * Extract a user-facing message from a thrown send error. - * `request` in the api client throws plain `Error` for !res.ok, with the - * format `"HTTP : "` -- we surface the message as - * a single line and fall back to a localized default when nothing usable is - * present (e.g. an aborted fetch that resolved with no message). + * Classify a thrown send error into a {message, code} pair the composer can + * render. `code` lets the consumer attach a recovery affordance (Reopen on + * `session_inactive`) without re-inspecting the raw error. + * + * `request` in the api client throws `ApiError` for !res.ok with `status` + * and `code` parsed from the JSON body. Older / non-JSON failures arrive as + * plain `Error`; we surface those by their message verbatim, falling back to + * a localized default when nothing usable is present (e.g. an aborted fetch + * that resolved with no message). */ -function deriveSendErrorMessage( +function classifySendError( error: unknown, t: (key: string) => string, -): string { +): { message: string; code: string | null } { + if (error instanceof ApiError && error.status === 409 && error.code === 'session_inactive') { + return { message: t('chat.sendError.sessionInactive'), code: 'session_inactive' } + } if (error instanceof Error && error.message) { - return error.message + return { message: error.message, code: null } } - return t('chat.sendError.fallback') + return { message: t('chat.sendError.fallback'), code: null } } function SessionPage() { @@ -629,9 +637,22 @@ function SessionPage() { // text into the OLD session's composer and the next render would clear // it. The bumped `id` still lets the composer dedupe restorations of // identical text. - const [sendErrors, setSendErrors] = useState>({}) + // + // We persist the classifier `code` (not the bound action) so the + // composer-visible action stays reactive to `reopeningSessionId` state + // changes -- the action is built fresh on each render from {raw error + // record} x {current reopen state}. See classifySendError + the + // Reopen affordance below. + type RawSendError = { + id: number + text: string + message: string + code: string | null + scheduledAt: number | null + } + const [sendErrors, setSendErrors] = useState>({}) + const [reopeningSessionId, setReopeningSessionId] = useState(null) const sendErrorIdRef = useRef(0) - const sendError = sendErrors[sessionId] ?? null const clearSendError = useCallback(() => { setSendErrors((prev) => { if (!(sessionId in prev)) return prev @@ -641,6 +662,67 @@ function SessionPage() { }) }, [sessionId]) + // Reopen recovery (#918): one-click affordance attached to the inline + // composer error when the rejected send was inactive-session. Mirrors + // SessionList's Reopen UX -- POST /sessions/:id/reopen via + // api.reopenSession -- so the operator's mental model is consistent + // across surfaces. We do NOT auto-replay the send: per #917 the reopen + // path has known fragility, so the operator re-clicks Send on the + // restored composer text once Reopen lands. + const reopenFromErrorAffordance = useCallback((errorSessionId: string) => { + if (!api) return + setReopeningSessionId((prev) => prev ?? errorSessionId) + void (async () => { + try { + const result = await api.reopenSession(errorSessionId) + // Clear the inline error -- the operator now has a live + // session to retry against. + setSendErrors((prev) => { + if (!(errorSessionId in prev)) return prev + const next = { ...prev } + delete next[errorSessionId] + return next + }) + await queryClient.invalidateQueries({ queryKey: queryKeys.session(result.sessionId) }) + await queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) + if (result.sessionId && result.sessionId !== errorSessionId) { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId: result.sessionId }, + replace: true + }) + } + } catch (err) { + const message = err instanceof Error ? err.message : t('dialog.error.default') + addToast({ + title: t('resume.failed.title'), + body: message, + sessionId: errorSessionId, + url: '' + }) + } finally { + setReopeningSessionId(null) + } + })() + }, [api, queryClient, navigate, addToast, t]) + + const rawSendError = sendErrors[sessionId] ?? null + const sendError: ComposerSendError | null = rawSendError + ? { + id: rawSendError.id, + text: rawSendError.text, + message: rawSendError.message, + scheduledAt: rawSendError.scheduledAt, + action: rawSendError.code === 'session_inactive' + ? { + label: t('chat.sendError.sessionInactive.action'), + onClick: () => reopenFromErrorAffordance(sessionId), + pending: reopeningSessionId === sessionId + } + : null + } + : null + const { sendMessage, retryMessage, @@ -662,12 +744,14 @@ function SessionPage() { }, onError: (info: SendErrorInfo) => { sendErrorIdRef.current += 1 + const { message, code } = classifySendError(info.error, t) setSendErrors((prev) => ({ ...prev, [info.sessionId]: { id: sendErrorIdRef.current, text: info.text, - message: deriveSendErrorMessage(info.error, t), + message, + code, scheduledAt: info.scheduledAt } })) @@ -677,7 +761,15 @@ function SessionPage() { return currentSessionId } if (!inactiveSessionCanResume(session, messages.length)) { - throw new Error(t('resume.unavailable.noTarget')) + // #918: surface as a session_inactive ApiError so the + // onError consumer's classifier renders the Reopen + // affordance. `status: 409` mirrors the hub guard for + // structural parity; no HTTP call was made. + throw new ApiError( + t('chat.sendError.sessionInactive'), + 409, + 'session_inactive', + ) } try { return await api.resumeSession(currentSessionId, { permissionMode: session.permissionMode ?? undefined }) @@ -689,7 +781,14 @@ function SessionPage() { sessionId: currentSessionId, url: '' }) - throw error + // Rebrand as a session_inactive ApiError so the inline + // affordance offers Reopen (a separate code path from the + // failed Resume) and the operator has a recovery click. + throw new ApiError( + t('chat.sendError.sessionInactive'), + 409, + 'session_inactive', + ) } }, onSessionResolved: (resolvedSessionId) => { From 4e4043d0e42408576f8c44c35bd4d90e8612e51b Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 18 Jun 2026 10:11:51 +0800 Subject: [PATCH 08/34] fix(claude): flush OutgoingMessageQueue before consuming next user turn (#909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutgoingMessageQueue.scheduleProcessing() defers socket.emit() via setTimeout(fn,0) — a macrotask. The Claude SDK's nextMessage() callback runs in a microtask chain, which executes before that macrotask fires. This means messages-consumed for turn N+1 can be sent to the hub before the queued agent messages from turn N have been emitted. The hub stamps invokedAt on the N+1 user message at receive time, and then stores the late-arriving agent messages with created_at > invokedAt_N+1. Since compareMessages sorts by invokedAt ?? createdAt ascending, those agent messages sort permanently below the N+1 user message. Fix: await messageQueue.flush() at the top of nextMessage() so all pending outgoing agent messages are sent through the socket before messages-consumed is dispatched. Closes #908 via [HAPI](https://hapi.run) Co-authored-by: HAPI --- cli/src/claude/claudeRemoteLauncher.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index c5f4a327f4..f3ca0eb702 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -306,6 +306,14 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { return permissionHandler.isAborted(toolCallId); }, nextMessage: async () => { + // Flush any pending outgoing messages before consuming the next user + // turn. Without this, scheduleProcessing()'s setTimeout(fn,0) fires + // after the microtask that sends messages-consumed, causing the hub + // to stamp invokedAt on the next user message before it stores the + // current turn's queued agent messages — making them sort permanently + // below the next user message. + await messageQueue.flush(); + if (pending) { let p = pending; pending = null; From 3dfbd61c7a9d8239e5b74f452422f36627952974 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:12:02 +0100 Subject: [PATCH 09/34] fix(runner): surface dangling-symlink errors instead of misleading EEXIST (#892) * fix(runner): surface dangling-symlink errors instead of misleading EEXIST When a session's workspace path is a symbolic link to a directory that no longer exists, the runner previously failed with a kernel-level error: Unable to create directory at '/path'. System error: EEXIST: file already exists, mkdir '/path'. The cause: `fs.access` follows symlinks and throws ENOENT when the target is missing, then the fallback `fs.mkdir(dir, { recursive: true })` cannot tolerate the existing non-directory entry (the dangling symlink itself) and surfaces EEXIST verbatim. Users had no way to tell that the symlink target was the actual problem. Replace the inline `fs.access` + `fs.mkdir` pair with a small `validateWorkspaceDirectory` helper that uses `fs.lstat` so symlinks are inspected without being followed, then explicitly handles: - missing path -> approval flow / mkdir as before - existing directory -> ok - regular file at the workspace path -> "non-directory file" error - symlink to existing directory -> ok - symlink to a non-directory -> "not a directory" error - dangling symlink -> diagnostic naming both the symlink path and the missing target, with recovery options (recreate the target, remove the symlink, archive the session) The mkdir error switch is preserved (EACCES, ENOTDIR, ENOSPC, EROFS) and extended with an EEXIST race-recheck that lstat's the path again so the kernel error code never leaks to the user. Adds focused unit tests for both the fs-touching paths (real tmpdir + symlinks) and the pure errno-to-message mapper. Closes #890 Co-authored-by: Cursor * fix(runner): drop copy-pasteable rm command from dangling-symlink hint The dangling-symlink recovery message embedded the user-controlled workspace path inside a literal `rm '...'` shell command shape. A path containing a single quote would break the quoting and turn the diagnostic into a shell-injection / accidental-delete vector when the user copy-pasted the suggested command. Describe the recovery action in prose instead ("remove the dangling symlink at ''") so the path is no longer presented as a copy-pasteable command. Adds a regression test that exercises a path containing a single quote and asserts the message never contains the literal `rm ...` shape. Codex review on PR #892. Co-authored-by: Cursor * fix(runner): preserve ENOTDIR diagnostic on lstat parent-path failure When the workspace path sits under a regular-file parent, fs.lstat throws ENOTDIR before mkdir runs. Route that through describeMkdirError so the user still sees the historic "file already exists at this path" message instead of the generic inspect-workspace-path text. Codex follow-up review on PR #892 (post-rebase). Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/runner/run.ts | 62 ++--- .../runner/validateWorkspaceDirectory.test.ts | 209 +++++++++++++++ cli/src/runner/validateWorkspaceDirectory.ts | 237 ++++++++++++++++++ 3 files changed, 468 insertions(+), 40 deletions(-) create mode 100644 cli/src/runner/validateWorkspaceDirectory.test.ts create mode 100644 cli/src/runner/validateWorkspaceDirectory.ts diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index ea827f878d..694ad7197b 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -21,6 +21,7 @@ import { isRetryableConnectionError } from '@/utils/errorUtils'; import { cleanupRunnerState, getInstalledCliMtimeMs, isRunnerRunningCurrentlyInstalledHappyVersion, stopRunner, waitForRunnerHandoff } from './controlClient'; import { startRunnerControlServer } from './controlServer'; import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree'; +import { validateWorkspaceDirectory } from './validateWorkspaceDirectory'; import { join } from 'path'; import { buildMachineMetadata } from '@/agent/sessionFactory'; import { resolveWorkspaceRoots } from '@/utils/workspaceRoot'; @@ -295,47 +296,28 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): let happyProcess: ReturnType | null = null; if (sessionType === 'simple') { - try { - await fs.access(directory); + const validation = await validateWorkspaceDirectory(directory, { + approvedNewDirectoryCreation + }); + if (validation.type === 'requestApproval') { + logger.debug(`[RUNNER RUN] Directory creation not approved for: ${directory}`); + return { + type: 'requestToApproveDirectoryCreation', + directory + }; + } + if (validation.type === 'error') { + logger.debug(`[RUNNER RUN] Workspace directory validation failed: ${validation.errorMessage}`); + return { + type: 'error', + errorMessage: validation.errorMessage + }; + } + directoryCreated = validation.created; + if (validation.created) { + logger.debug(`[RUNNER RUN] Successfully created directory: ${directory}`); + } else { logger.debug(`[RUNNER RUN] Directory exists: ${directory}`); - } catch (error) { - logger.debug(`[RUNNER RUN] Directory doesn't exist, creating: ${directory}`); - - // Check if directory creation is approved - if (!approvedNewDirectoryCreation) { - logger.debug(`[RUNNER RUN] Directory creation not approved for: ${directory}`); - return { - type: 'requestToApproveDirectoryCreation', - directory - }; - } - - try { - await fs.mkdir(directory, { recursive: true }); - logger.debug(`[RUNNER RUN] Successfully created directory: ${directory}`); - directoryCreated = true; - } catch (mkdirError: any) { - let errorMessage = `Unable to create directory at '${directory}'. `; - - // Provide more helpful error messages based on the error code - if (mkdirError.code === 'EACCES') { - errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`; - } else if (mkdirError.code === 'ENOTDIR') { - errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`; - } else if (mkdirError.code === 'ENOSPC') { - errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`; - } else if (mkdirError.code === 'EROFS') { - errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`; - } else { - errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`; - } - - logger.debug(`[RUNNER RUN] Directory creation failed: ${errorMessage}`); - return { - type: 'error', - errorMessage - }; - } } } else { try { diff --git a/cli/src/runner/validateWorkspaceDirectory.test.ts b/cli/src/runner/validateWorkspaceDirectory.test.ts new file mode 100644 index 0000000000..9798c97ac5 --- /dev/null +++ b/cli/src/runner/validateWorkspaceDirectory.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, writeFile, symlink, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + describeMkdirError, + validateWorkspaceDirectory, +} from './validateWorkspaceDirectory'; + +let workRoot: string; + +beforeEach(async () => { + workRoot = await mkdtemp(join(tmpdir(), 'hapi-validate-workspace-')); +}); + +afterEach(async () => { + await rm(workRoot, { recursive: true, force: true }); +}); + +describe('validateWorkspaceDirectory', () => { + it('creates a missing directory when approved', async () => { + const target = join(workRoot, 'new-workspace'); + const result = await validateWorkspaceDirectory(target, { + approvedNewDirectoryCreation: true, + }); + expect(result).toEqual({ type: 'ok', created: true }); + }); + + it('requests approval when path is missing and creation is not approved', async () => { + const target = join(workRoot, 'unapproved'); + const result = await validateWorkspaceDirectory(target, { + approvedNewDirectoryCreation: false, + }); + expect(result).toEqual({ type: 'requestApproval' }); + }); + + it('returns ok without creating when path is already a directory', async () => { + const target = join(workRoot, 'existing-dir'); + await mkdir(target); + const result = await validateWorkspaceDirectory(target, { + approvedNewDirectoryCreation: true, + }); + expect(result).toEqual({ type: 'ok', created: false }); + }); + + it('returns an error when path is a regular file', async () => { + const target = join(workRoot, 'collision-file'); + await writeFile(target, 'hello'); + const result = await validateWorkspaceDirectory(target, { + approvedNewDirectoryCreation: true, + }); + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errorMessage).toContain('non-directory file'); + expect(result.errorMessage).toContain(target); + } + }); + + it('preserves the ENOTDIR diagnostic when the parent path is a regular file', async () => { + const parentFile = join(workRoot, 'parent-file'); + await writeFile(parentFile, 'hello'); + const target = join(parentFile, 'child-dir'); + const result = await validateWorkspaceDirectory(target, { + approvedNewDirectoryCreation: true, + }); + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errorMessage).toContain(target); + expect(result.errorMessage).toMatch(/file already exists/i); + expect(result.errorMessage).not.toMatch(/Unable to inspect workspace path/); + } + }); + + it('returns ok when path is a symlink to an existing directory', async () => { + const realTarget = join(workRoot, 'real-target'); + await mkdir(realTarget); + const link = join(workRoot, 'good-symlink'); + await symlink(realTarget, link); + + const result = await validateWorkspaceDirectory(link, { + approvedNewDirectoryCreation: true, + }); + expect(result).toEqual({ type: 'ok', created: false }); + }); + + it('returns a diagnostic error when path is a dangling symlink', async () => { + const missingTarget = join(workRoot, 'gone-target'); + const link = join(workRoot, 'dangling-symlink'); + await symlink(missingTarget, link); + // missingTarget is never created, so the link is dangling. + + const result = await validateWorkspaceDirectory(link, { + approvedNewDirectoryCreation: true, + }); + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errorMessage).toContain(link); + expect(result.errorMessage).toContain(missingTarget); + expect(result.errorMessage).toMatch(/symbolic link/i); + expect(result.errorMessage).toMatch(/no longer exists/i); + expect(result.errorMessage).toMatch(/Recovery:/); + expect(result.errorMessage).not.toMatch(/EEXIST/); + // Regression: must not embed the user-controlled path inside a + // copy-pasteable shell command (`rm '...'`) - a path with a + // single quote would break the quoting and create an injection + // / accidental-delete vector. (Codex review on PR #892.) + expect(result.errorMessage).not.toMatch(/`rm /); + } + }); + + it('does not produce a copy-pasteable rm command when the path contains a single quote', async () => { + // Regression for the PR #892 Codex review Major: paths with + // single quotes used to break out of the literal `rm '...'` + // recovery hint and turn the diagnostic into a shell-injection + // / accidental-delete vector. + const trickyDir = join(workRoot, "weird'name"); + await mkdir(trickyDir); + const missingTarget = join(trickyDir, 'gone-target'); + const link = join(trickyDir, 'dangling-symlink'); + await symlink(missingTarget, link); + + const result = await validateWorkspaceDirectory(link, { + approvedNewDirectoryCreation: true, + }); + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errorMessage).toContain(link); + expect(result.errorMessage).toContain(missingTarget); + expect(result.errorMessage).not.toMatch(/`rm /); + } + }); + + it('returns an error when path is a symlink to a non-directory', async () => { + const targetFile = join(workRoot, 'target-file'); + await writeFile(targetFile, 'hello'); + const link = join(workRoot, 'symlink-to-file'); + await symlink(targetFile, link); + + const result = await validateWorkspaceDirectory(link, { + approvedNewDirectoryCreation: true, + }); + expect(result.type).toBe('error'); + if (result.type === 'error') { + expect(result.errorMessage).toContain(link); + expect(result.errorMessage).toContain(targetFile); + expect(result.errorMessage).toMatch(/not a directory/i); + } + }); +}); + +describe('describeMkdirError', () => { + const directory = '/tmp/hapi-test-target'; + + it('produces a Permission denied message for EACCES', () => { + const msg = describeMkdirError(directory, { + code: 'EACCES', + message: 'permission denied', + }); + expect(msg).toContain(directory); + expect(msg).toContain('Permission denied'); + }); + + it('produces an ENOTDIR message for ENOTDIR', () => { + const msg = describeMkdirError(directory, { + code: 'ENOTDIR', + message: 'not a directory', + }); + expect(msg).toContain(directory); + expect(msg).toMatch(/file already exists/i); + }); + + it('produces a No space left on device message for ENOSPC', () => { + const msg = describeMkdirError(directory, { + code: 'ENOSPC', + message: 'no space left on device', + }); + expect(msg).toContain(directory); + expect(msg).toMatch(/No space left on device/i); + }); + + it('produces a read-only file system message for EROFS', () => { + const msg = describeMkdirError(directory, { + code: 'EROFS', + message: 'read-only file system', + }); + expect(msg).toContain(directory); + expect(msg).toMatch(/read-only/i); + }); + + it('produces a non-directory race message for EEXIST', () => { + const msg = describeMkdirError(directory, { + code: 'EEXIST', + message: 'file already exists', + }); + expect(msg).toContain(directory); + expect(msg).toMatch(/non-directory file/i); + expect(msg).not.toMatch(/EEXIST/); + }); + + it('falls back to System error for unknown codes', () => { + const msg = describeMkdirError(directory, { + code: 'EWEIRD', + message: 'something strange', + }); + expect(msg).toContain(directory); + expect(msg).toContain('System error: something strange'); + }); +}); diff --git a/cli/src/runner/validateWorkspaceDirectory.ts b/cli/src/runner/validateWorkspaceDirectory.ts new file mode 100644 index 0000000000..d33fcf98d0 --- /dev/null +++ b/cli/src/runner/validateWorkspaceDirectory.ts @@ -0,0 +1,237 @@ +import fs from 'fs/promises'; + +/** + * Result of validating (and optionally creating) a workspace directory before + * a session is spawned at it. + * + * - `ok`: the directory exists (or was just created) and is usable as a cwd. + * `created` distinguishes the just-created case so the runner can surface a + * user-visible "we created this folder for you" message. + * - `requestApproval`: the path does not exist and the caller has not approved + * new-directory creation. Surfaces back to the web UI as the existing + * `requestToApproveDirectoryCreation` flow. + * - `error`: validation failed. `errorMessage` is the user-facing string and + * is preferred over leaking raw kernel errors (EEXIST etc.). + */ +export type ValidateWorkspaceDirectoryResult = + | { type: 'ok'; created: boolean } + | { type: 'requestApproval' } + | { type: 'error'; errorMessage: string }; + +export interface ValidateWorkspaceDirectoryOptions { + approvedNewDirectoryCreation: boolean; +} + +/** + * Resolve a workspace directory before spawning a session at it. + * + * Replaces the historic `fs.access` + `fs.mkdir({ recursive: true })` pair in + * `run.ts`, which produced a misleading EEXIST error on dangling symlinks + * (symlink points at a deleted target, `fs.access` follows the link and + * throws ENOENT, then `mkdir` cannot tolerate the existing non-directory + * entry and surfaces `EEXIST: file already exists, mkdir '...'` to the user). + * + * The replacement uses `fs.lstat` so symlinks are inspected without being + * followed, distinguishes dangling symlinks from genuinely missing paths and + * from regular files squatting at the workspace path, and only attempts + * `mkdir` when the path truly does not exist. + */ +export async function validateWorkspaceDirectory( + directory: string, + options: ValidateWorkspaceDirectoryOptions +): Promise { + const { approvedNewDirectoryCreation } = options; + + let lstat: Awaited> | null = null; + try { + lstat = await fs.lstat(directory); + } catch (err: any) { + if (err?.code === 'ENOENT') { + // path does not exist - fall through to mkdir / approval flow + } else if (err?.code === 'ENOTDIR') { + // Parent path contains a regular file; preserve the historic + // mkdir ENOTDIR diagnostic instead of the generic inspect text. + return { + type: 'error', + errorMessage: describeMkdirError(directory, err), + }; + } else { + return { + type: 'error', + errorMessage: + `Unable to inspect workspace path '${directory}'. ` + + `System error: ${err?.message || err}. ` + + `Please verify the path is valid and you have the necessary permissions.`, + }; + } + } + + if (lstat) { + if (lstat.isSymbolicLink()) { + return await handleSymlink(directory); + } + if (lstat.isDirectory()) { + return { type: 'ok', created: false }; + } + return { + type: 'error', + errorMessage: + `A non-directory file already exists at '${directory}'. ` + + `Cannot use it as a workspace. Please move or remove the file, or pick a different workspace path.`, + }; + } + + if (!approvedNewDirectoryCreation) { + return { type: 'requestApproval' }; + } + + try { + await fs.mkdir(directory, { recursive: true }); + return { type: 'ok', created: true }; + } catch (err: any) { + return await buildMkdirError(directory, err); + } +} + +async function handleSymlink(directory: string): Promise { + let linkTarget = ''; + try { + linkTarget = await fs.readlink(directory); + } catch { + // Best-effort: if we can't read the link, we still report a useful error below. + } + + let realPath: string; + try { + realPath = await fs.realpath(directory); + } catch (err: any) { + if (err?.code === 'ENOENT') { + const targetDescription = linkTarget + ? `'${linkTarget}'` + : 'a target that no longer exists'; + // Deliberately do NOT embed `directory` inside a copy-pasteable + // shell command (e.g. `rm '...'`): a path containing a single + // quote would break the quoting and turn this diagnostic into + // a shell-injection / accidental-delete vector. Describe the + // recovery action in prose instead. (Codex review on PR #892.) + return { + type: 'error', + errorMessage: + `Workspace path '${directory}' is a symbolic link to ${targetDescription}, ` + + `which no longer exists. This usually means the target was deleted ` + + `(e.g. via \`git worktree remove\`) without removing the symlink. ` + + `Recovery: recreate the directory at the target path, remove the dangling symlink at '${directory}', ` + + `or archive this session.`, + }; + } + return { + type: 'error', + errorMessage: + `Unable to resolve symbolic link at '${directory}'. ` + + `System error: ${err?.message || err}. ` + + `Please verify the symlink target is reachable and you have the necessary permissions.`, + }; + } + + let resolvedStat; + try { + resolvedStat = await fs.stat(realPath); + } catch (err: any) { + return { + type: 'error', + errorMessage: + `Unable to stat resolved path '${realPath}' (symlinked from '${directory}'). ` + + `System error: ${err?.message || err}.`, + }; + } + + if (resolvedStat.isDirectory()) { + return { type: 'ok', created: false }; + } + + return { + type: 'error', + errorMessage: + `Workspace path '${directory}' is a symbolic link to '${realPath}', which is not a directory. ` + + `Please update the symlink to point at a directory, or pick a different workspace path.`, + }; +} + +/** + * Pure mapping of `mkdir` errno codes to user-facing messages. Exported for + * unit tests; production callers go through `buildMkdirError` which adds + * `EEXIST` race-handling on top. + */ +export function describeMkdirError( + directory: string, + err: { code?: string; message?: string } | undefined | null +): string { + const prefix = `Unable to create directory at '${directory}'. `; + switch (err?.code) { + case 'EACCES': + return ( + prefix + + `Permission denied. You don't have write access to create a folder at this location. ` + + `Try using a different path or check your permissions.` + ); + case 'ENOTDIR': + return ( + prefix + + `A file already exists at this path or in the parent path. ` + + `Cannot create a directory here. Please choose a different location.` + ); + case 'ENOSPC': + return ( + prefix + + `No space left on device. Your disk is full. Please free up some space and try again.` + ); + case 'EROFS': + return ( + prefix + + `The file system is read-only. Cannot create directories here. Please choose a writable location.` + ); + case 'EEXIST': + return ( + prefix + + `A non-directory file appeared at this path between the existence check ` + + `and directory creation. Please move or remove it, or pick a different path.` + ); + default: + return ( + prefix + + `System error: ${err?.message || err}. ` + + `Please verify the path is valid and you have the necessary permissions.` + ); + } +} + +async function buildMkdirError( + directory: string, + err: any +): Promise { + if (err?.code === 'EEXIST') { + // Race with a parallel writer between the initial lstat and mkdir, OR + // a non-directory entry that mkdir({ recursive: true }) refused to + // tolerate. lstat the path again to produce a targeted message + // instead of leaking the kernel error verbatim. + try { + const raceStat = await fs.lstat(directory); + if (raceStat.isDirectory()) { + // mkdir({ recursive: true }) should not throw EEXIST on an + // existing directory; if it did, treat the directory as good + // enough rather than failing the user. + return { type: 'ok', created: false }; + } + if (raceStat.isSymbolicLink()) { + return handleSymlink(directory); + } + } catch { + // Fall through to the message-only path below if we can't even + // lstat the path again (very unusual race). + } + } + return { + type: 'error', + errorMessage: describeMkdirError(directory, err), + }; +} From a2862a3300d66a7dd9fa59b83a9898549dacd2f5 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:12:12 +0100 Subject: [PATCH 10/34] docs(installation): add KillMode=process to runner systemd unit (closes #915) (#928) The runner spawns child agent sessions with `detached: true` (`cli/src/runner/run.ts:454`) so they survive runner restart, and runner cleanup (`run.ts:1049`) does not iterate or kill tracked children on shutdown. The runner is already designed as a long-lived process whose exit leaves agent sessions intact. But Node's `detached: true` calls `setsid()` (new process session), which does NOT escape the parent's systemd cgroup. Without an explicit `KillMode`, systemd defaults to `control-group`, which SIGTERMs every PID in the runner's cgroup whenever the unit stops - forcibly archiving every running session and discarding the detach contract. Adds `KillMode=process` to the reference runner unit and a note explaining the contract. With this change, `systemctl restart hapi-runner.service` (and any cascade-stop from `Requires=`) only signals the main runner PID; the cleanup runs without killing descendants; agent sessions stay alive; the new runner reconnects via the existing socket.io reconnect path (`cli/src/api/apiMachine.ts:385`) and re-establishes control via the existing RPC layer. This is the smallest fix for #915. The complementary safety net - runner re-attaching to orphaned children on cold start when no running runner exists - will be tracked in a separate issue and PR. AI-disclosure (per CONTRIBUTING.md): drafted with claude-opus-4.7 as peer agent during a fork-side post-mortem of a 7-hour outage that this fix would have prevented. Co-authored-by: Cursor --- docs/guide/installation.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 612d92df6e..ae6e8ddcf9 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -536,6 +536,7 @@ After=network.target hapi-hub.service [Service] Type=simple +KillMode=process ExecStart=/usr/local/bin/hapi runner start-sync Restart=always RestartSec=5 @@ -544,6 +545,8 @@ RestartSec=5 WantedBy=default.target ``` +> **Why `KillMode=process`?** The runner spawns each agent session as a detached child process (`detached: true` in `cli/src/runner/run.ts`) so that sessions stay alive when the runner exits. Without `KillMode=process`, systemd's default `KillMode=control-group` sends SIGTERM to every PID in the runner's cgroup when the unit stops, defeating the detach and forcibly archiving every running session. `KillMode=process` preserves the contract: stopping or restarting the runner only signals the runner itself; agent sessions stay alive, and a fresh runner re-establishes control via the existing socket.io reconnect path. This applies to runner upgrades, manual restarts, and any reboot in which the runner unit is stopped before agents have finished. + Enable and start: ```bash From 78155a9d277b87f849fa9ad8ea9f7657ea9c4614 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:13:36 +0100 Subject: [PATCH 11/34] perf(web): add staleTime to useSession to suppress focus/mount refetches (#884) (#885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(web): suppress useSession refetch storm (closes #884) Two compounding behaviours in the React client were producing a sustained ~100 req/sec stream of GET /api/sessions/ to the hub on installs with a moderately-sized session fleet: 1. `useSession` had no `staleTime`, so window-focus and remount each triggered a fresh REST round-trip even though SSE was already pushing the same data via `patchSessionDetail`. 2. `useSSE`'s `session-added`/`session-updated` handler unconditionally queued a per-session `invalidateQueries({queryKey: ['session', id]})` whenever the incoming SSE payload was not a structured patch, fanning out a refetch to every still-observed `useSession` regardless of whether the user was currently viewing that session detail. Fixes: - `useSession` now sets `staleTime: 30_000` (exported as `SESSION_DETAIL_STALE_TIME_MS` for testability/tuning). SSE remains authoritative for freshness; the REST endpoint is a cold-start path. - `useSSE` only queues per-session detail invalidation when an active observer is mounted for that session. The new `hasActiveSessionDetailObserver` helper checks the TanStack query cache for `getObserversCount() > 0`. List-summary invalidation is unchanged (sidebar still updates). No behaviour change for the patch-path: structured `SessionPatch` events still flow through `patchSessionDetail` + `patchSessionSummary` and update in place. The fallback path is what we are taming. Measured on the reporter's box pre-fix: 31,944 GET /api/sessions/ hits over a 5-minute idle window across 132 distinct session UUIDs (~106 req/sec). Expected post-fix: ~0 for sessions whose detail page is not currently open, gated by `staleTime` for navigation thrash. Tests: - `useSession.test.ts` asserts `SESSION_DETAIL_STALE_TIME_MS` is set. - `useSSE.test.ts` adds 4 cases for `hasActiveSessionDetailObserver` covering no-cache, cache-without-observer, mounted-observer, and cross-session isolation. * fix(web): revert observer-gating in useSSE (address PR #885 review) The Codex review on #885 correctly flagged that `hasActiveSessionDetailObserver`-gating around the two `queueSessionDetailInvalidation` fallback paths broke an important correctness invariant: with `staleTime: 30_000` in place, skipping the invalidation entirely (instead of letting TanStack mark the cache stale) means a subsequent remount within 30s will serve the stale cached detail without a REST recovery fetch. This regressed real backend code paths. Hub emits `session-updated` events with no structured `data` field on todos / teamState / metadata / agentState changes (see `hub/src/socket/handlers/cli/sessionHandlers.ts:117,128,216,263`), which hit the gated `else` branch. Root cause of the over-correction was a misunderstanding of TanStack v5 semantics: `invalidateQueries` with the default `refetchType: 'active'` is *already* a network no-op for unobserved queries — it just marks them stale. The manual observer-count check was structurally redundant *and* incorrectly suppressed the stale marking. Revert: restore the original unconditional `queueSessionDetailInvalidation` calls on both fallback branches. Drop the `hasActiveSessionDetailObserver` helper export and its 4 unit-test cases. Keep Fix A (`staleTime: 30_000` on `useSession`) intact — that change is independently safe and addresses the focus-refetch / remount-refetch class of redundant requests. * docs(web): correct staleTime rationale in useSession (#884) Stand-in cold review on PR #885 caught that the comment overstated the fix's reach. `web/src/lib/query-client.ts:7` already sets the global default `refetchOnWindowFocus: false` and `staleTime: 5_000`, so the per-query `staleTime: 30_000` does NOT cut focus-refetches (there were none) and only extends the remount/reconnect-no-refetch window from 5s to 30s. Rewrite the comment to be accurate about scope: the change suppresses remount refetches within a 30s window, and explicit `invalidateQueries` (SSE fallback path, reconnect-recovery in `App.tsx`) still refetches active observers — so live updates and recovery flows are preserved. No code behaviour change; comment-only edit. * fix(web): invalidate all cached session details on SSE reconnect Codex review on PR #885 caught a real regression introduced by the `SESSION_DETAIL_STALE_TIME_MS = 30_000` change: the reconnect-recovery handler in `App.tsx` only invalidated the *currently-selected* session's detail. With per-query staleTime extended from 5s (global default) to 30s, a previously-viewed but non-selected session whose cache was still within the freshness window could serve stale data after the SSE channel missed updates during the disconnect. Scenario: 1. User views session A → cache populated, fresh. 2. User switches to session B → A's observer unmounts, cache lingers (gcTime: 5min). 3. SSE disconnects. Session A receives updates server-side that no patch event reaches the client. 4. SSE reconnects. Old `handleSseConnect` only invalidated `session(selectedSessionId=B)`, NOT A. 5. User navigates back to A within 30s → useSession remounts → cache is still considered fresh by staleTime → no REST recovery fetch → user sees stale A data. Fix: broaden the per-session invalidation in `handleSseConnect` from `['session', selectedSessionId]` to the prefix `['session']`, which matches every cached session-detail entry. Active observers refetch (same as before — only the selected session was active), inactive cached entries get marked stale so the next remount refetches. Performance impact: zero new fetches on reconnect (the selected session is still the only one with an active observer in practice). Marking inactive entries stale is metadata-only, free. This restores the pre-staleTime invariant where every cached session detail was either fresh (just fetched) or actively re-fetched on reconnect, and matches the documented contract that SSE is the authoritative freshness signal while REST is the cold-start / reconnect-recovery path. --------- Co-authored-by: heavygee --- web/src/App.tsx | 9 ++++++--- web/src/hooks/queries/useSession.test.ts | 12 +++++++++++- web/src/hooks/queries/useSession.ts | 12 ++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index fc23635acf..3a11e6c25e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -205,9 +205,12 @@ function AppInner() { } const invalidations = [ queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), - ...(selectedSessionId ? [ - queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) }) - ] : []) + // Invalidate ALL cached session-detail entries on reconnect, not just + // the selected one. With `SESSION_DETAIL_STALE_TIME_MS` extending the + // freshness window on `useSession`, a previously-viewed session that + // received updates during the SSE gap would otherwise serve stale + // cached data on remount. See tiann/hapi#884. + queryClient.invalidateQueries({ queryKey: ['session'] }) ] const refreshMessages = (selectedSessionId && api) ? fetchLatestMessages(api, selectedSessionId) diff --git a/web/src/hooks/queries/useSession.test.ts b/web/src/hooks/queries/useSession.test.ts index b5adcfdc31..7d374cbc0b 100644 --- a/web/src/hooks/queries/useSession.test.ts +++ b/web/src/hooks/queries/useSession.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isSessionNotFoundError } from './useSession' +import { isSessionNotFoundError, SESSION_DETAIL_STALE_TIME_MS } from './useSession' describe('isSessionNotFoundError', () => { it('matches hub 404 session responses', () => { @@ -11,3 +11,13 @@ describe('isSessionNotFoundError', () => { expect(isSessionNotFoundError(null)).toBe(false) }) }) + +describe('SESSION_DETAIL_STALE_TIME_MS', () => { + // SSE patches the cache directly on session-updated events, so the REST + // endpoint is just a cold-start / reconnect-recovery path. A long staleTime + // suppresses focus-refetch and remount-refetch storms — primary lever for + // the refetch-storm fix (tiann/hapi#884). + it('is set to a value that suppresses focus/mount refetches', () => { + expect(SESSION_DETAIL_STALE_TIME_MS).toBeGreaterThanOrEqual(10_000) + }) +}) diff --git a/web/src/hooks/queries/useSession.ts b/web/src/hooks/queries/useSession.ts index d9d6e5be07..adf086ea11 100644 --- a/web/src/hooks/queries/useSession.ts +++ b/web/src/hooks/queries/useSession.ts @@ -8,6 +8,17 @@ export function isSessionNotFoundError(error: unknown): boolean { && (error.message.includes('HTTP 404') || error.message.includes('Session not found')) } +// Session detail freshness is driven by SSE events (`useSSE` patches the cache +// directly on `session-updated`). The REST endpoint is only a cold-start / +// reconnect-recovery path, so a long per-query staleTime extends the global +// default (5s, see `web/src/lib/query-client.ts`) for `useSession` only — this +// suppresses remount-refetch when the user navigates back to a recently-viewed +// session within the window, without making the UI stale. Explicit +// `invalidateQueries` calls (SSE fallback path, reconnect-recovery in +// `App.tsx`) still refetch active observers regardless of staleTime, so live +// updates and recovery flows continue to work. See tiann/hapi#884. +export const SESSION_DETAIL_STALE_TIME_MS = 30_000 + export function useSession(api: ApiClient | null, sessionId: string | null): { session: Session | null isLoading: boolean @@ -25,6 +36,7 @@ export function useSession(api: ApiClient | null, sessionId: string | null): { return await api.getSession(sessionId) }, enabled: Boolean(api && sessionId), + staleTime: SESSION_DETAIL_STALE_TIME_MS, retry: (failureCount, error) => { if (isSessionNotFoundError(error)) { return false From 5f27abddd4e2d61283f74240c4e5a06de0b0e22b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:15:10 +0100 Subject: [PATCH 12/34] feat(web): in-app PWA update prompt when new service worker is available (#946) * feat(web): in-app PWA update prompt when new service worker is available (closes #938) User-controlled reload with a persistent banner, visibility-triggered SW checks, and an expandable rationale. Switches registerType to prompt. Co-authored-by: Cursor * fix(web): align vite.config with soup layers for clean driver merge Keeps registerType prompt while matching garden IWER stubs and PWA share_target shape expected by feat/pwa-share-target in the manifest. Co-authored-by: Cursor * Revert "fix(web): align vite.config with soup layers for clean driver merge" This reverts commit 6f0915b0884d029a2413d8819a4dfe81d7c4e595. * fix(web): make PWA reload apply waiting service worker updates Handle SKIP_WAITING in injectManifest sw.ts and reload via controllerchange with a timed fallback when vite-plugin-pwa prompt mode does not navigate. Co-authored-by: Cursor * fix(web): satisfy setTimeout mock typing in PWA reload tests Co-authored-by: Cursor * fix(web): register PWA service worker before auth gates Mount PwaUpdateProvider at app root and show the update banner on login and error screens so registerSW runs for logged-out users too. Co-authored-by: Cursor * fix(web): offset PWA update banner below top status banners Reserve top-12 when syncing or reconnecting so the reload prompt stays visible above SyncingBanner and ReconnectingBanner. Co-authored-by: Cursor * fix(web): offset PWA update banner below voice error banner Use PwaUpdateBannerWithStatusOffset inside VoiceProvider so voice errors share the same top-12 reservation as sync and reconnect banners. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- docs/guide/pwa.md | 11 +- web/src/App.tsx | 45 ++-- web/src/components/PwaUpdateBanner.test.tsx | 139 ++++++++++++ web/src/components/PwaUpdateBanner.tsx | 73 +++++++ web/src/hooks/usePwaUpdate.test.ts | 230 ++++++++++++++++++++ web/src/hooks/usePwaUpdate.ts | 123 +++++++++++ web/src/lib/locales/en.ts | 5 + web/src/lib/locales/zh-CN.ts | 5 + web/src/lib/pwa-update-context.tsx | 24 ++ web/src/main.tsx | 22 -- web/src/sw.ts | 10 + web/vite.config.ts | 3 +- 12 files changed, 649 insertions(+), 41 deletions(-) create mode 100644 web/src/components/PwaUpdateBanner.test.tsx create mode 100644 web/src/components/PwaUpdateBanner.tsx create mode 100644 web/src/hooks/usePwaUpdate.test.ts create mode 100644 web/src/hooks/usePwaUpdate.ts create mode 100644 web/src/lib/pwa-update-context.tsx diff --git a/docs/guide/pwa.md b/docs/guide/pwa.md index 5b6dfd5825..631795f046 100644 --- a/docs/guide/pwa.md +++ b/docs/guide/pwa.md @@ -56,11 +56,14 @@ An offline indicator appears when you lose connection. ### Auto-Update -HAPI automatically checks for updates: +HAPI checks for updates in the background and lets you choose when to reload: -- Updates are checked hourly in the background -- When a new version is available, you'll see a prompt -- Click "Reload" to get the latest version +- Updates are checked hourly and when you return to the tab +- When a new version is available, a persistent in-app banner appears at the top +- Tap **Reload** when you're ready to apply the update — the banner stays until you do +- Expand **"Why can't I dismiss this?"** on the banner for the rationale + +HAPI uses a user-controlled reload instead of forcing an automatic refresh, so you choose when to reload. The banner cannot be dismissed without upgrading, so you won't forget you're on an old build. ### Background Sync diff --git a/web/src/App.tsx b/web/src/App.tsx index 3a11e6c25e..2ff3e6c200 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram' @@ -23,11 +23,13 @@ import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from '@/lib import { LoginPrompt } from '@/components/LoginPrompt' import { InstallPrompt } from '@/components/InstallPrompt' import { OfflineBanner } from '@/components/OfflineBanner' +import { PwaUpdateBanner, PwaUpdateBannerWithStatusOffset } from '@/components/PwaUpdateBanner' import { SyncingBanner } from '@/components/SyncingBanner' import { ReconnectingBanner } from '@/components/ReconnectingBanner' import { VoiceErrorBanner } from '@/components/VoiceErrorBanner' import { LoadingState } from '@/components/LoadingState' import { ToastContainer } from '@/components/ToastContainer' +import { PwaUpdateProvider } from '@/lib/pwa-update-context' import { ToastProvider, useToast } from '@/lib/toast-context' import type { SyncEvent } from '@/types/api' @@ -35,10 +37,21 @@ type ToastEvent = Extract const REQUIRE_SERVER_URL = requireHubUrlForLogin() +function withPwaBanner(content: ReactNode) { + return ( + <> + + {content} + + ) +} + export function App() { return ( - + + + ) } @@ -341,16 +354,16 @@ function AppInner() { // Loading auth source if (isAuthSourceLoading) { - return ( + return withPwaBanner(
-
+
, ) } // No auth source (browser environment, not logged in) if (!authSource) { - return ( + return withPwaBanner( + />, ) } if (needsBinding) { - return ( + return withPwaBanner( + />, ) } // Authenticating (also covers the gap before useAuth effect starts) if (isAuthLoading || (authSource && !token && !authError)) { - return ( + return withPwaBanner(
-
+
, ) } @@ -390,7 +403,7 @@ function AppInner() { if (authError || !token || !api) { // If using access token and auth failed, show login again if (authSource.type === 'accessToken') { - return ( + return withPwaBanner( + />, ) } // Telegram auth failed - return ( + return withPwaBanner(
{t('login.title')}
@@ -413,13 +426,17 @@ function AppInner() {
Open this page from Telegram using the bot's "Open App" button (not "Open in browser").
-
+
, ) } return ( + ({ + usePwaUpdateContext: () => usePwaUpdateMock(), +})) + +vi.mock('@/lib/voice-context', () => ({ + useVoiceOptional: () => useVoiceOptionalMock(), +})) + +vi.mock('@/hooks/useOnlineStatus', () => ({ + useOnlineStatus: () => true, +})) + +vi.mock('@/hooks/usePlatform', () => ({ + usePlatform: () => ({ + haptic: { + impact: vi.fn(), + notification: vi.fn(), + }, + }), +})) + +function renderBanner() { + return render( + + + , + ) +} + +describe('PwaUpdateBanner', () => { + beforeEach(() => { + vi.clearAllMocks() + useVoiceOptionalMock.mockReturnValue(null) + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn(() => 'en'), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(() => null), + length: 0, + }, + configurable: true, + }) + }) + + afterEach(() => { + cleanup() + }) + + it('does not render when no update is available', () => { + usePwaUpdateMock.mockReturnValue({ + needRefresh: false, + reload: vi.fn(), + }) + + renderBanner() + + expect(screen.queryByTestId('pwa-update-banner')).not.toBeInTheDocument() + }) + + it('renders a reload-only banner with no dismiss action', () => { + const reload = vi.fn() + + usePwaUpdateMock.mockReturnValue({ + needRefresh: true, + reload, + }) + + renderBanner() + + expect(screen.getByTestId('pwa-update-banner')).toBeInTheDocument() + expect(screen.getByText('New version available')).toBeInTheDocument() + expect(screen.getByText('Reload to get the latest HAPI')).toBeInTheDocument() + expect(screen.getAllByRole('button')).toHaveLength(1) + + fireEvent.click(screen.getByRole('button', { name: 'Reload' })) + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('honors a custom top offset when provided', () => { + usePwaUpdateMock.mockReturnValue({ + needRefresh: true, + reload: vi.fn(), + }) + + render( + + + , + ) + + expect(screen.getByTestId('pwa-update-banner')).toHaveClass('top-12') + }) + + it('offsets below voice error banners when shown inside the voice provider', () => { + usePwaUpdateMock.mockReturnValue({ + needRefresh: true, + reload: vi.fn(), + }) + useVoiceOptionalMock.mockReturnValue({ + status: 'error', + errorMessage: 'Mic failed', + }) + + render( + + + , + ) + + expect(screen.getByTestId('pwa-update-banner')).toHaveClass('top-12') + }) + + it('expands the rationale section when the disclosure is opened', () => { + usePwaUpdateMock.mockReturnValue({ + needRefresh: true, + reload: vi.fn(), + }) + + renderBanner() + + const disclosure = screen.getByText("Why can't I dismiss this?") + expect(screen.queryByText(/agent running/i)).not.toBeVisible() + + fireEvent.click(disclosure) + + expect(screen.getByText(/agent running/i)).toBeVisible() + expect(screen.getByText(/finish what you are doing first/i)).toBeVisible() + }) +}) diff --git a/web/src/components/PwaUpdateBanner.tsx b/web/src/components/PwaUpdateBanner.tsx new file mode 100644 index 0000000000..1f78c8aac1 --- /dev/null +++ b/web/src/components/PwaUpdateBanner.tsx @@ -0,0 +1,73 @@ +import { useOnlineStatus } from '@/hooks/useOnlineStatus' +import { usePlatform } from '@/hooks/usePlatform' +import { usePwaUpdateContext } from '@/lib/pwa-update-context' +import { useTranslation } from '@/lib/use-translation' +import { useVoiceOptional } from '@/lib/voice-context' + +export function PwaUpdateBanner({ topClassName }: { topClassName?: string } = {}) { + const { t } = useTranslation() + const { needRefresh, reload } = usePwaUpdateContext() + const isOnline = useOnlineStatus() + const { haptic } = usePlatform() + + if (!needRefresh) { + return null + } + + const topClass = topClassName ?? (isOnline ? 'top-2' : 'top-10') + + return ( +
+
+
+

+ {t('pwa.update.title')} +

+

+ {t('pwa.update.body')} +

+
+ +
+ +
+ + {t('pwa.update.whyToggle')} + +

+ {t('pwa.update.whyBody')} +

+
+
+ ) +} + +export function PwaUpdateBannerWithStatusOffset({ + isSyncing, + isReconnecting, +}: { + isSyncing: boolean + isReconnecting: boolean +}) { + const voice = useVoiceOptional() + const hasTopStatusBanner = + isSyncing || + isReconnecting || + Boolean(voice && voice.status === 'error' && voice.errorMessage) + + return ( + + ) +} diff --git a/web/src/hooks/usePwaUpdate.test.ts b/web/src/hooks/usePwaUpdate.test.ts new file mode 100644 index 0000000000..d5733b693c --- /dev/null +++ b/web/src/hooks/usePwaUpdate.test.ts @@ -0,0 +1,230 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PWA_UPDATE_CHECK_INTERVAL_MS, + PWA_UPDATE_RELOAD_FALLBACK_MS, + requestPwaUpdateReload, + setupRegistrationUpdateChecks, + usePwaUpdate, +} from '@/hooks/usePwaUpdate' + +const registerSWMock = vi.fn() +const serviceWorkerListeners = new Map>() + +vi.mock('virtual:pwa-register', () => ({ + registerSW: (options: Parameters[0]) => registerSWMock(options), +})) + +beforeEach(() => { + serviceWorkerListeners.clear() + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + addEventListener: (type: string, listener: EventListener) => { + const bucket = serviceWorkerListeners.get(type) ?? new Set() + bucket.add(listener) + serviceWorkerListeners.set(type, bucket) + }, + removeEventListener: (type: string, listener: EventListener) => { + serviceWorkerListeners.get(type)?.delete(listener) + }, + }, + }) +}) + +describe('setupRegistrationUpdateChecks', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('checks for updates on an hourly interval', () => { + const registration = { + update: vi.fn().mockResolvedValue(undefined), + } as unknown as ServiceWorkerRegistration + + const cleanup = setupRegistrationUpdateChecks(registration) + + vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS) + expect(registration.update).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS) + expect(registration.update).toHaveBeenCalledTimes(2) + + cleanup() + }) + + it('checks for updates when the tab becomes visible', () => { + const registration = { + update: vi.fn().mockResolvedValue(undefined), + } as unknown as ServiceWorkerRegistration + + const cleanup = setupRegistrationUpdateChecks(registration) + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + document.dispatchEvent(new Event('visibilitychange')) + expect(registration.update).not.toHaveBeenCalled() + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + document.dispatchEvent(new Event('visibilitychange')) + expect(registration.update).toHaveBeenCalledTimes(1) + + cleanup() + }) + + it('removes listeners and clears the interval on cleanup', () => { + const registration = { + update: vi.fn().mockResolvedValue(undefined), + } as unknown as ServiceWorkerRegistration + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener') + const clearIntervalSpy = vi.spyOn(window, 'clearInterval') + + const cleanup = setupRegistrationUpdateChecks(registration) + cleanup() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) + expect(clearIntervalSpy).toHaveBeenCalled() + }) +}) + +describe('requestPwaUpdateReload', () => { + it('reloads immediately when updateSW is unavailable', async () => { + const reloadPage = vi.fn() + + await requestPwaUpdateReload(null, { reloadPage }) + + expect(reloadPage).toHaveBeenCalledTimes(1) + }) + + it('calls updateSW and reloads on controllerchange', async () => { + const updateSW = vi.fn().mockImplementation(async () => { + for (const listener of serviceWorkerListeners.get('controllerchange') ?? []) { + listener(new Event('controllerchange')) + } + }) + const reloadPage = vi.fn() + + await requestPwaUpdateReload(updateSW, { reloadPage }) + + expect(updateSW).toHaveBeenCalledWith(true) + expect(reloadPage).toHaveBeenCalledTimes(1) + }) + + it('falls back to reload when controllerchange never fires', async () => { + vi.useFakeTimers() + + const updateSW = vi.fn().mockResolvedValue(undefined) + const reloadPage = vi.fn() + + const pending = requestPwaUpdateReload(updateSW, { + reloadPage, + setTimeoutFn: vi.fn((callback, delay) => { + expect(delay).toBe(PWA_UPDATE_RELOAD_FALLBACK_MS) + return setTimeout(callback, delay) + }) as typeof setTimeout, + }) + + await pending + vi.runAllTimers() + + expect(updateSW).toHaveBeenCalledWith(true) + expect(reloadPage).toHaveBeenCalledTimes(1) + + vi.useRealTimers() + }) +}) + +describe('usePwaUpdate', () => { + let capturedOptions: { + onNeedRefresh?: () => void + onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void + } = {} + const updateSW = vi.fn().mockResolvedValue(undefined) + + beforeEach(() => { + capturedOptions = {} + updateSW.mockClear() + registerSWMock.mockImplementation((options) => { + capturedOptions = options + return updateSW + }) + }) + + it('registers the service worker and exposes refresh state', () => { + const { result } = renderHook(() => usePwaUpdate()) + + expect(registerSWMock).toHaveBeenCalledTimes(1) + expect(result.current.needRefresh).toBe(false) + + act(() => { + capturedOptions.onNeedRefresh?.() + }) + + expect(result.current.needRefresh).toBe(true) + }) + + it('reloads through updateSW when reload is called', async () => { + const updateSW = vi.fn().mockImplementation(async () => { + for (const listener of serviceWorkerListeners.get('controllerchange') ?? []) { + listener(new Event('controllerchange')) + } + }) + registerSWMock.mockImplementation((options) => { + capturedOptions = options + return updateSW + }) + + const { result } = renderHook(() => usePwaUpdate()) + + await act(async () => { + result.current.reload() + }) + + expect(updateSW).toHaveBeenCalledWith(true) + }) + + it('keeps needRefresh true until a successful reload clears the page', () => { + const { result } = renderHook(() => usePwaUpdate()) + + act(() => { + capturedOptions.onNeedRefresh?.() + }) + + expect(result.current.needRefresh).toBe(true) + + act(() => { + result.current.reload() + }) + + expect(updateSW).toHaveBeenCalledWith(true) + expect(result.current.needRefresh).toBe(true) + }) + + it('wires registration update checks from onRegistered', () => { + vi.useFakeTimers() + + const registration = { + update: vi.fn().mockResolvedValue(undefined), + } as unknown as ServiceWorkerRegistration + + renderHook(() => usePwaUpdate()) + + act(() => { + capturedOptions.onRegistered?.(registration) + }) + + vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS) + expect(registration.update).toHaveBeenCalledTimes(1) + + vi.useRealTimers() + }) +}) diff --git a/web/src/hooks/usePwaUpdate.ts b/web/src/hooks/usePwaUpdate.ts new file mode 100644 index 0000000000..5a4e18911b --- /dev/null +++ b/web/src/hooks/usePwaUpdate.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { registerSW } from 'virtual:pwa-register' + +export const PWA_UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000 +export const PWA_UPDATE_RELOAD_FALLBACK_MS = 2000 + +export async function requestPwaUpdateReload( + updateSW: ((reloadPage?: boolean) => Promise) | null | undefined, + options: { + reloadPage?: () => void + setTimeoutFn?: typeof setTimeout + clearTimeoutFn?: typeof clearTimeout + } = {}, +): Promise { + const reloadPage = options.reloadPage ?? (() => window.location.reload()) + const setTimeoutFn = options.setTimeoutFn ?? setTimeout + const clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout + + if (!updateSW) { + reloadPage() + return + } + + let reloaded = false + const doReload = () => { + if (reloaded) { + return + } + reloaded = true + reloadPage() + } + + const onControllerChange = () => { + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange) + doReload() + } + + navigator.serviceWorker.addEventListener('controllerchange', onControllerChange) + + let fallbackTimer: ReturnType | undefined + + try { + await updateSW(true) + } catch (error) { + console.error('PWA update failed', error) + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange) + if (fallbackTimer !== undefined) { + clearTimeoutFn(fallbackTimer) + } + doReload() + return + } + + fallbackTimer = setTimeoutFn(() => { + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange) + doReload() + }, PWA_UPDATE_RELOAD_FALLBACK_MS) +} + +export function setupRegistrationUpdateChecks( + registration: ServiceWorkerRegistration, +): () => void { + const intervalId = window.setInterval(() => { + void registration.update() + }, PWA_UPDATE_CHECK_INTERVAL_MS) + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + void registration.update() + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + window.clearInterval(intervalId) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } +} + +export function usePwaUpdate() { + const [needRefresh, setNeedRefresh] = useState(false) + const updateSWRef = useRef<((reloadPage?: boolean) => Promise) | null>(null) + const cleanupRef = useRef<(() => void) | null>(null) + + useEffect(() => { + const updateSW = registerSW({ + onNeedRefresh() { + setNeedRefresh(true) + }, + onOfflineReady() { + console.log('App ready for offline use') + }, + onRegistered(registration) { + cleanupRef.current?.() + cleanupRef.current = null + + if (!registration) { + return + } + + cleanupRef.current = setupRegistrationUpdateChecks(registration) + }, + onRegisterError(error) { + console.error('SW registration error:', error) + }, + }) + + updateSWRef.current = updateSW + + return () => { + cleanupRef.current?.() + cleanupRef.current = null + updateSWRef.current = null + } + }, []) + + const reload = useCallback(() => { + void requestPwaUpdateReload(updateSWRef.current) + }, []) + + return { needRefresh, reload } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 5e4e92864c..fc3c75e736 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -466,6 +466,11 @@ export default { 'reconnecting.reason.closed': 'stream closed', 'reconnecting.reason.heartbeatTimeout': 'heartbeat timeout', 'reconnecting.reason.visibilityRecovery': 'resuming after background', + 'pwa.update.title': 'New version available', + 'pwa.update.body': 'Reload to get the latest HAPI', + 'pwa.update.reload': 'Reload', + 'pwa.update.whyToggle': "Why can't I dismiss this?", + 'pwa.update.whyBody': 'HAPI will not reload your tab automatically while you may have an agent running, a permission waiting, or a message in progress. Running an old web build against the current server can cause sync bugs and failed actions. This banner stays visible until you reload so you are not stuck on a stale version by accident — but you choose when to tap Reload and finish what you are doing first.', // Send blocked 'send.blocked.title': 'Cannot send message', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 301a3d72d2..5fb4e08d09 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -470,6 +470,11 @@ export default { 'reconnecting.reason.closed': '流连接已关闭', 'reconnecting.reason.heartbeatTimeout': '心跳超时', 'reconnecting.reason.visibilityRecovery': '后台恢复中', + 'pwa.update.title': '新版本可用', + 'pwa.update.body': '重新加载以获取最新版 HAPI', + 'pwa.update.reload': '重新加载', + 'pwa.update.whyToggle': '为什么不能关闭此提示?', + 'pwa.update.whyBody': '当你可能有正在运行的智能体、待处理的权限请求或未发送的消息时,HAPI 不会自动重新加载标签页。旧版网页与当前服务器一起运行可能导致同步错误和操作失败。此横幅会一直保持显示,直到你重新加载,以免你在不知情的情况下停留在旧版本 — 但何时点击「重新加载」由你决定,可以先完成手头的工作。', // Send blocked 'send.blocked.title': '无法发送消息', diff --git a/web/src/lib/pwa-update-context.tsx b/web/src/lib/pwa-update-context.tsx new file mode 100644 index 0000000000..725cfdb9d3 --- /dev/null +++ b/web/src/lib/pwa-update-context.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext, type ReactNode } from 'react' +import { usePwaUpdate } from '@/hooks/usePwaUpdate' + +type PwaUpdateContextValue = ReturnType + +const PwaUpdateContext = createContext(null) + +export function PwaUpdateProvider({ children }: { children: ReactNode }) { + const value = usePwaUpdate() + + return ( + + {children} + + ) +} + +export function usePwaUpdateContext() { + const value = useContext(PwaUpdateContext) + if (!value) { + throw new Error('usePwaUpdateContext must be used within PwaUpdateProvider') + } + return value +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 569e77efd0..6c5aee2e02 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -4,7 +4,6 @@ import { QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { RouterProvider, createMemoryHistory } from '@tanstack/react-router' import './index.css' -import { registerSW } from 'virtual:pwa-register' import { initializeFontScale } from '@/hooks/useFontScale' import { getTelegramWebApp, isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram' import { queryClient } from './lib/query-client' @@ -52,27 +51,6 @@ async function bootstrap() { restoreSpaRedirect() } - const updateSW = registerSW({ - onNeedRefresh() { - if (confirm('New version available! Reload to update?')) { - updateSW(true) - } - }, - onOfflineReady() { - console.log('App ready for offline use') - }, - onRegistered(registration) { - if (registration) { - setInterval(() => { - registration.update() - }, 60 * 60 * 1000) - } - }, - onRegisterError(error) { - console.error('SW registration error:', error) - } - }) - const history = isTelegram ? createMemoryHistory({ initialEntries: [getInitialPath()] }) : undefined diff --git a/web/src/sw.ts b/web/src/sw.ts index ebe55dc0a7..021d4a7071 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -91,6 +91,16 @@ registerRoute( }) ) +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting() + } +}) + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()) +}) + self.addEventListener('push', (event) => { const payload = event.data?.json() as PushPayload | undefined if (!payload) { diff --git a/web/vite.config.ts b/web/vite.config.ts index 58ab142268..04a254fb5f 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -65,7 +65,8 @@ export default defineConfig({ plugins: [ react(), VitePWA({ - registerType: 'autoUpdate', + // User-controlled reload avoids mid-session surprise reloads (autoUpdate reloads all tabs). + registerType: 'prompt', includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'], strategies: 'injectManifest', srcDir: 'src', From ce67823fc3d5690e8afea9c914097a8540037b57 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:15:22 +0100 Subject: [PATCH 13/34] feat(web,hub): rich hover tooltips on session-list attention indicators (#941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): rich hover tooltips on session-list attention indicators The session-row attention dots and the future-scheduled clock icon used plain `title=""` attributes which gave only a one-word label ("Permission required"). Replace those with hover/focus-revealed tooltips that name *which* tools are blocking, count background tasks, surface the "updated Nm ago" timestamp, and explain the pending schedule. To make per-tool copy possible without an extra round trip, `SessionSummary` now carries a structured slice of the pending tool requests, capped at `PENDING_REQUEST_SUMMARY_CAP = 5` oldest-first: pendingRequests: Array<{ id; kind; tool; since }> `pendingRequestsCount` remains the authoritative total; `pendingRequestKinds` is still derived from the FULL request set so a single `'input'` request beyond the cap still surfaces its kind on the session row. The tooltip primitive (`HoverTooltip`) is a CSS-driven reveal — no portal, no positioning JS — so it composes cheaply inside the existing session-row ` + )} +
+
{props.t('settings.display.themeColors.description')}
+
+ {keys.map((key) => { + const value = getPickerValue(key.id) + const customized = isCustomized(key.id) + return ( +
+ {props.t(key.labelKey)} +
+ {customized && ( + + )} + +
+
+ ) + })} +
+ + ) +} + export default function SettingsPage() { const { t, locale, setLocale } = useTranslation() const { api } = useAppContext() @@ -733,6 +793,7 @@ export default function SettingsPage() { )} +
+
+ ) + } + + const { payload } = load + + return ( +
+
+
+
+
{t('share.title')}
+ +
+
+ {t('share.subtitle')} +
+
+
+ +
+
+ + +
+
+ {t('share.recentSessions')} +
+ {pickerSessions === null ? ( + + ) : pickerSessions.length === 0 ? ( +
+ {t('share.noActiveSessions')} +
+ ) : ( +
    + {pickerSessions.map((session) => ( +
  • + +
  • + ))} +
+ )} +
+ + +
+
+
+ ) +} diff --git a/web/src/sw.ts b/web/src/sw.ts index 021d4a7071..4ed4847914 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -3,6 +3,14 @@ import { precacheAndRoute } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' import { CacheFirst, NetworkFirst } from 'workbox-strategies' import { ExpirationPlugin } from 'workbox-expiration' +import { + cleanupExpiredShareTransfers, + ingestShareRequest, + putShareTransfer, +} from './lib/shareTransfer' +import { shareTargetPathname } from './lib/sharePath' + +const sharePath = shareTargetPathname() declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array @@ -131,3 +139,41 @@ self.addEventListener('notificationclick', (event) => { const url = data?.url ?? '/' event.waitUntil(self.clients.openWindow(url)) }) + +// Web Share Target — manifest declares POST /share, Android Chrome posts a +// multipart form with title/text/url/files. Stash in IDB so the SPA route +// can read it after the 303 redirect (which converts POST -> GET). +self.addEventListener('fetch', (event) => { + const request = event.request + if (request.method !== 'POST') return + const url = new URL(request.url) + if (url.pathname !== sharePath) return + + event.respondWith(handleShareTarget(request)) +}) + +async function handleShareTarget(request: Request): Promise { + // Resolve to absolute URLs because Response.redirect throws on relative + // input per the Fetch spec; Chrome currently tolerates relative paths + // but the SW spec is explicit and the cost of resolving is one line. + const origin = self.location.origin + try { + const { redirectTo } = await ingestShareRequest(request, { put: putShareTransfer }) + return Response.redirect(new URL(redirectTo, origin).toString(), 303) + } catch (error) { + // Surface a minimal page if IDB write fails — don't 5xx silently or + // the user gets a Chrome error sheet instead of useful UI. + console.error('share-target ingest failed', error) + return Response.redirect(new URL(`${sharePath}?error=ingest`, origin).toString(), 303) + } +} + +// Best-effort GC for stale share transfers (TTL-only — never blocks +// anything else). 1h TTL is set in shareTransfer.ts. +self.addEventListener('activate', (event) => { + event.waitUntil( + cleanupExpiredShareTransfers().catch((error) => { + console.warn('share-transfer cleanup failed', error) + }) + ) +}) diff --git a/web/vite.config.ts b/web/vite.config.ts index 04a254fb5f..2f2e29b64d 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,8 +3,10 @@ import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { shareTargetPathnameFromBase } from './src/lib/sharePath' const base = process.env.VITE_BASE_URL || '/' +const shareAction = shareTargetPathnameFromBase(base) const hubTarget = process.env.VITE_HUB_PROXY || 'http://127.0.0.1:3006' const appVersion = readAppVersion() @@ -100,7 +102,38 @@ export default defineConfig({ type: 'image/png', purpose: 'any' } - ] + ], + // Web Share Target — Android Chrome routes POSTs to /share + // when the user picks HAPI in the system share sheet. The + // service worker (`web/src/sw.ts`) intercepts POST /share, + // stashes the multipart payload in IndexedDB, and 303- + // redirects to /share?id= for the SPA picker. + // `*/*` is the broad fallback; explicit MIME prefixes stay + // first because some Chrome versions only honor declared + // prefixes when surfacing in the share sheet. + share_target: { + action: shareAction, + method: 'POST', + enctype: 'multipart/form-data', + params: { + title: 'title', + text: 'text', + url: 'url', + files: [ + { + name: 'files', + accept: [ + 'image/*', + 'application/pdf', + 'text/*', + 'application/json', + 'application/zip', + '*/*' + ] + } + ] + } + } }, injectManifest: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'] From a8e08e80144ad7d74e614b5c041ae60846221533 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 18 Jun 2026 10:16:17 +0800 Subject: [PATCH 16/34] feat(web): add download button to file viewer (#926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a download icon button to the file viewer toolbar (next to copy-path). Clicking it decodes the existing base64 file content into a Blob and triggers a browser download — no new backend endpoint required. Works for text, binary, and image files. Button is hidden until the file has loaded successfully. Closes #924 via [HAPI](https://hapi.run) Co-authored-by: HAPI --- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/routes/sessions/file.tsx | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index f33b394faa..3630d35088 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -310,6 +310,7 @@ export default { 'file.page.unknownPath': 'Unknown path', 'file.page.copyPath': 'Copy path', 'file.page.copyContent': 'Copy file content', + 'file.page.download': 'Download file', 'file.page.tab.diff': 'Diff', 'file.page.tab.file': 'File', 'file.page.missingPath': 'No file path provided.', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index b5e6c07bff..ef48bc819b 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -314,6 +314,7 @@ export default { 'file.page.unknownPath': '未知路径', 'file.page.copyPath': '复制路径', 'file.page.copyContent': '复制文件内容', + 'file.page.download': '下载文件', 'file.page.tab.diff': 'Diff', 'file.page.tab.file': '文件', 'file.page.missingPath': '未提供文件路径。', diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index a232740491..ab2b89d961 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -36,6 +36,44 @@ function decodePath(value: string): string { return decoded.ok ? decoded.text : value } +function DownloadIcon(props: { className?: string }) { + return ( + + + + + + ) +} + +function triggerDownload(fileName: string, base64Content: string, mimeType: string | null) { + const byteChars = atob(base64Content) + const byteArray = new Uint8Array(byteChars.length) + for (let i = 0; i < byteChars.length; i++) { + byteArray[i] = byteChars.charCodeAt(i) + } + const blob = new Blob([byteArray], { type: mimeType ?? 'application/octet-stream' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + function BackIcon(props: { className?: string }) { return ( 0 && contentSizeBytes <= MAX_COPYABLE_FILE_BYTES + const canDownload = fileContentResult?.success === true && Boolean(fileContentResult.content) + const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff') useEffect(() => { @@ -260,6 +300,16 @@ export default function FilePage() { > {pathCopied ? : } + {canDownload ? ( + + ) : null} From 22bf7e04d2aec73aac8cb2df252bc0c01a0cafb3 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 18 Jun 2026 10:16:32 +0800 Subject: [PATCH 17/34] fix(web): persist file explorer expanded tree and scroll position across navigation (#911) * fix(web): persist file explorer expanded tree and scroll position across navigation Expanded folder state and scroll position in the Directories tab were stored only in local React state, so navigating to a file and back would reset the tree to the root and scroll to top. Now both are saved to sessionStorage (keyed by sessionId) on every change and restored on remount, so the explorer resumes exactly where the user left off. Closes #910 via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(web): key DirectoryTree by sessionId to prevent stale expanded state across sessions When navigating between sessions, React can reuse the same DirectoryTree instance. The useState lazy initializer only runs on first mount, so the tree would hydrate with the wrong session's expanded set and then overwrite the new session's storage key. Adding key={sessionId} forces a fresh mount per session. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --------- Co-authored-by: HAPI --- .../components/SessionFiles/DirectoryTree.tsx | 31 +++++++++++++++++-- web/src/routes/sessions/files.tsx | 28 +++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/web/src/components/SessionFiles/DirectoryTree.tsx b/web/src/components/SessionFiles/DirectoryTree.tsx index 80b1e532a2..4f282cb33d 100644 --- a/web/src/components/SessionFiles/DirectoryTree.tsx +++ b/web/src/components/SessionFiles/DirectoryTree.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import type { ApiClient } from '@/api/client' import { FileIcon } from '@/components/FileIcon' import { useSessionDirectory } from '@/hooks/queries/useSessionDirectory' @@ -171,13 +171,40 @@ function DirectoryNode(props: { ) } +const STORAGE_KEY_PREFIX = 'hapi-dir-expanded-' + +function readExpanded(sessionId: string): Set { + try { + const raw = sessionStorage.getItem(STORAGE_KEY_PREFIX + sessionId) + if (raw) { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return new Set(parsed as string[]) + } + } catch { + // ignore + } + return new Set(['']) +} + +function writeExpanded(sessionId: string, expanded: Set) { + try { + sessionStorage.setItem(STORAGE_KEY_PREFIX + sessionId, JSON.stringify([...expanded])) + } catch { + // ignore + } +} + export function DirectoryTree(props: { api: ApiClient | null sessionId: string rootLabel: string onOpenFile: (path: string) => void }) { - const [expanded, setExpanded] = useState>(() => new Set([''])) + const [expanded, setExpanded] = useState>(() => readExpanded(props.sessionId)) + + useEffect(() => { + writeExpanded(props.sessionId, expanded) + }, [props.sessionId, expanded]) const handleToggle = useCallback((path: string) => { setExpanded((prev) => { diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx index f8c698d76d..a77e05b7a3 100644 --- a/web/src/routes/sessions/files.tsx +++ b/web/src/routes/sessions/files.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams, useSearch } from '@tanstack/react-router' import type { FileSearchItem, GitFileStatus } from '@/types/api' import { FileIcon } from '@/components/FileIcon' @@ -236,6 +236,8 @@ function FileListSkeleton(props: { label: string; rows?: number }) { ) } +const SCROLL_KEY_PREFIX = 'hapi-dir-scroll-' + export default function FilesPage() { const { api } = useAppContext() const { t } = useTranslation() @@ -246,10 +248,31 @@ export default function FilesPage() { const search = useSearch({ from: '/sessions/$sessionId/files' }) const { session } = useSession(api, sessionId) const [searchQuery, setSearchQuery] = useState('') + const scrollRef = useRef(null) const initialTab = search.tab === 'directories' ? 'directories' : 'changes' const [activeTab, setActiveTab] = useState<'changes' | 'directories'>(initialTab) + useEffect(() => { + const el = scrollRef.current + if (!el) return + const key = SCROLL_KEY_PREFIX + sessionId + try { + const saved = sessionStorage.getItem(key) + if (saved !== null) el.scrollTop = Number(saved) + } catch { + // ignore + } + return () => { + try { + sessionStorage.setItem(key, String(el.scrollTop)) + } catch { + // ignore + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]) + const { status: gitStatus, error: gitError, @@ -411,7 +434,7 @@ export default function FilesPage() { ) : null} -
+
{showGitErrorBanner && activeTab === 'changes' ? (
@@ -441,6 +464,7 @@ export default function FilesPage() { ) ) : activeTab === 'directories' ? ( Date: Thu, 18 Jun 2026 10:16:42 +0800 Subject: [PATCH 18/34] fix: active-only session filter + paginated "Show N more" (closes #901) (#903) * test: reproduce issue #901 (active-only filter + paginated show more) * fix: active-only session filter + paginated 'Show N more' (closes #901) Add a persisted 'Active sessions only' toggle in Settings -> Display that hides inactive sessions in the sidebar while keeping the selected session visible. Change 'Show N more' to reveal one batch (preview-limit size) per click instead of expanding every hidden session at once, with 'Show less' to collapse back to the initial preview. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --------- Co-authored-by: HAPI --- web/src/components/SessionList.test.ts | 47 +++++++++++ web/src/components/SessionList.tsx | 91 +++++++++++++++++----- web/src/hooks/useShowActiveSessionsOnly.ts | 90 +++++++++++++++++++++ web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/routes/settings/index.tsx | 19 +++++ 6 files changed, 230 insertions(+), 21 deletions(-) create mode 100644 web/src/hooks/useShowActiveSessionsOnly.ts diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index ad2598693d..3cfe546065 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -3,6 +3,8 @@ import type { SessionSummary } from '@/types/api' import { deduplicateSessionsByAgentId, expandSelectedSessionCollapseOverrides, + filterActiveSessionsOnly, + getNextSessionVisibleCount, getSessionDedupKey, getVisibleSessionPreview, isSidebarEmptySessionStub, @@ -298,6 +300,51 @@ describe('getVisibleSessionPreview', () => { }) +describe('filterActiveSessionsOnly', () => { + it('keeps only active sessions when no selection', () => { + const sessions = [ + makeSession({ id: 'live', active: true, metadata: { path: '/p' } }), + makeSession({ id: 'dead', metadata: { path: '/p' } }) + ] + expect(filterActiveSessionsOnly(sessions).map(s => s.id)).toEqual(['live']) + }) + + it('keeps the selected inactive session visible', () => { + const sessions = [ + makeSession({ id: 'live', active: true, metadata: { path: '/p' } }), + makeSession({ id: 'dead', metadata: { path: '/p' } }), + makeSession({ id: 'selected-dead', metadata: { path: '/p' } }) + ] + expect(filterActiveSessionsOnly(sessions, 'selected-dead').map(s => s.id).sort()) + .toEqual(['live', 'selected-dead']) + }) + + it('preserves input order', () => { + const sessions = [ + makeSession({ id: 'a', active: true, metadata: { path: '/p' } }), + makeSession({ id: 'b', metadata: { path: '/p' } }), + makeSession({ id: 'c', active: true, metadata: { path: '/p' } }) + ] + expect(filterActiveSessionsOnly(sessions).map(s => s.id)).toEqual(['a', 'c']) + }) +}) + +describe('getNextSessionVisibleCount', () => { + it('reveals one batch of step size per call', () => { + expect(getNextSessionVisibleCount(8, 8, 20)).toBe(16) + expect(getNextSessionVisibleCount(16, 8, 20)).toBe(20) + }) + + it('never exceeds the total session count', () => { + expect(getNextSessionVisibleCount(18, 8, 20)).toBe(20) + expect(getNextSessionVisibleCount(20, 8, 20)).toBe(20) + }) + + it('always advances by at least one even with a zero step', () => { + expect(getNextSessionVisibleCount(5, 0, 20)).toBe(6) + }) +}) + describe('expandSelectedSessionCollapseOverrides', () => { it('expands collapsed project and machine, but preserves session preview folding', () => { const overrides = new Map([ diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 7ccf699f6a..8443b88279 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -13,6 +13,7 @@ import { useTranslation } from '@/lib/use-translation' import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' +import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' import { classifySessionAttention } from '@/lib/sessionAttention' import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' @@ -173,6 +174,20 @@ export function prepareSidebarSessions(sessions: SessionSummary[], selectedSessi .filter(session => shouldShowSessionInSidebar(session, selectedSessionId)) } +// "Active sessions only" view: hide inactive sessions, but never hide the one the +// operator currently has open — otherwise toggling the filter would yank the +// selected session out from under them. +export function filterActiveSessionsOnly(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] { + return sessions.filter(session => session.active || session.id === selectedSessionId) +} + +// Paginated "Show N more": reveal one batch (step) at a time instead of expanding +// every hidden session at once. Always advances by at least one and never exceeds +// the total so the button reliably reaches a fully-expanded state. +export function getNextSessionVisibleCount(current: number, step: number, total: number): number { + return Math.min(current + Math.max(1, step), total) +} + function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] { const groups = new Map() @@ -793,6 +808,7 @@ export function SessionList(props: { const { renderHeader = true, api, selectedSessionId, machineLabelsById = {}, onNewSessionInDirectory } = props const { sessionPreviewLimit } = useSessionPreviewLimit() const { sessionListStatusMode } = useSessionListStatusMode() + const { showActiveSessionsOnly } = useShowActiveSessionsOnly() const showDetailedStatus = sessionListStatusMode === 'detailed' const [searchQuery, setSearchQuery] = useState('') const [, setCodexImportedSessionsVersion] = useState(0) @@ -817,8 +833,11 @@ export function SessionList(props: { } const allSessions = useMemo( - () => prepareSidebarSessions(props.sessions, selectedSessionId), - [props.sessions, selectedSessionId] + () => { + const prepared = prepareSidebarSessions(props.sessions, selectedSessionId) + return showActiveSessionsOnly ? filterActiveSessionsOnly(prepared, selectedSessionId) : prepared + }, + [props.sessions, selectedSessionId, showActiveSessionsOnly] ) const visibleSessions = useMemo( () => isSearching @@ -860,20 +879,30 @@ export function SessionList(props: { }) } - const isSessionGroupExpanded = (group: SessionGroup): boolean => { - if (isSearching || group.sessions.length <= sessionPreviewLimit) return true - const key = `sessions::${group.key}` - const override = collapseOverrides.get(key) - if (override !== undefined) return !override - return false + // Per-group reveal cap for paginated "Show N more". Absent = collapsed to the + // preview limit; each "Show more" bumps it by one batch (step = preview limit). + const [sessionVisibleCounts, setSessionVisibleCounts] = useState>( + () => new Map() + ) + + const getGroupVisibleCount = (group: SessionGroup): number => { + return sessionVisibleCounts.get(group.key) ?? sessionPreviewLimit } - const toggleSessionGroup = (group: SessionGroup) => { - const key = `sessions::${group.key}` - const expanded = isSessionGroupExpanded(group) - setCollapseOverrides(prev => { + const showMoreSessions = (group: SessionGroup) => { + setSessionVisibleCounts(prev => { + const next = new Map(prev) + const current = prev.get(group.key) ?? sessionPreviewLimit + next.set(group.key, getNextSessionVisibleCount(current, sessionPreviewLimit, group.sessions.length)) + return next + }) + } + + const collapseSessionGroup = (group: SessionGroup) => { + setSessionVisibleCounts(prev => { + if (!prev.has(group.key)) return prev const next = new Map(prev) - next.set(key, expanded) + next.delete(group.key) return next }) } @@ -882,9 +911,9 @@ export function SessionList(props: { return getVisibleSessionPreview( group.sessions, { - expanded: isSessionGroupExpanded(group), + expanded: isSearching, selectedSessionId, - limit: sessionPreviewLimit + limit: getGroupVisibleCount(group) } ) } @@ -959,6 +988,23 @@ export function SessionList(props: { }) }, [allGroups]) + // Clean up reveal caps for groups that no longer exist. + useEffect(() => { + setSessionVisibleCounts(prev => { + if (prev.size === 0) return prev + const knownKeys = new Set(allGroups.map(g => g.key)) + const next = new Map(prev) + let changed = false + for (const key of next.keys()) { + if (!knownKeys.has(key)) { + next.delete(key) + changed = true + } + } + return changed ? next : prev + }) + }, [allGroups]) + return (
{renderHeader ? ( @@ -1021,7 +1067,8 @@ export function SessionList(props: { const isCollapsed = isGroupCollapsed(group) const visibleGroupSessions = getVisibleGroupSessions(group) const hiddenSessionCount = group.sessions.length - visibleGroupSessions.length - const sessionGroupExpanded = isSessionGroupExpanded(group) + const canCollapseSessions = getGroupVisibleCount(group) > sessionPreviewLimit + const showMoreCount = Math.min(sessionPreviewLimit, hiddenSessionCount) const canStartInGroupDirectory = group.directory !== 'Other' return (
@@ -1072,18 +1119,20 @@ export function SessionList(props: { showDetailedStatus={showDetailedStatus} /> ))} - {!isSearching && group.sessions.length > sessionPreviewLimit && (sessionGroupExpanded || hiddenSessionCount > 0) ? ( + {!isSearching && group.sessions.length > sessionPreviewLimit && (hiddenSessionCount > 0 || canCollapseSessions) ? ( ) : null}
diff --git a/web/src/hooks/useShowActiveSessionsOnly.ts b/web/src/hooks/useShowActiveSessionsOnly.ts new file mode 100644 index 0000000000..9e5be10bba --- /dev/null +++ b/web/src/hooks/useShowActiveSessionsOnly.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useState } from 'react' + +export const DEFAULT_SHOW_ACTIVE_SESSIONS_ONLY = false + +function getShowActiveSessionsOnlyStorageKey(): string { + return 'hapi-show-active-sessions-only' +} + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined' +} + +function safeGetItem(key: string): string | null { + if (!isBrowser()) { + return null + } + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeSetItem(key: string, value: string): void { + if (!isBrowser()) { + return + } + try { + localStorage.setItem(key, value) + } catch { + // Ignore storage errors + } +} + +function safeRemoveItem(key: string): void { + if (!isBrowser()) { + return + } + try { + localStorage.removeItem(key) + } catch { + // Ignore storage errors + } +} + +function parseShowActiveSessionsOnly(raw: string | null): boolean { + if (raw === 'true') { + return true + } + return DEFAULT_SHOW_ACTIVE_SESSIONS_ONLY +} + +export function getInitialShowActiveSessionsOnly(): boolean { + return parseShowActiveSessionsOnly(safeGetItem(getShowActiveSessionsOnlyStorageKey())) +} + +export function useShowActiveSessionsOnly(): { + showActiveSessionsOnly: boolean + setShowActiveSessionsOnly: (value: boolean) => void +} { + const [showActiveSessionsOnly, setShowActiveSessionsOnlyState] = useState(getInitialShowActiveSessionsOnly) + + useEffect(() => { + if (!isBrowser()) { + return + } + + const onStorage = (event: StorageEvent) => { + if (event.key !== getShowActiveSessionsOnlyStorageKey()) { + return + } + setShowActiveSessionsOnlyState(parseShowActiveSessionsOnly(event.newValue)) + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const setShowActiveSessionsOnly = useCallback((value: boolean) => { + setShowActiveSessionsOnlyState(value) + + if (value === DEFAULT_SHOW_ACTIVE_SESSIONS_ONLY) { + safeRemoveItem(getShowActiveSessionsOnlyStorageKey()) + } else { + safeSetItem(getShowActiveSessionsOnlyStorageKey(), String(value)) + } + }, []) + + return { showActiveSessionsOnly, setShowActiveSessionsOnly } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 3630d35088..d174da5a0d 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -532,6 +532,8 @@ export default { 'settings.display.sessionPreviewLimit': 'Sessions Before Folding', 'settings.display.sessionPreviewLimit.decrease': 'Show fewer sessions before folding', 'settings.display.sessionPreviewLimit.increase': 'Show more sessions before folding', + 'settings.display.activeSessionsOnly': 'Active sessions only', + 'settings.display.activeSessionsOnly.desc': 'Hide inactive sessions in the sidebar. The session you have open stays visible.', 'settings.display.sessionListStatus': 'Session list status', 'settings.display.sessionListStatus.standard': 'Standard', 'settings.display.sessionListStatus.detailed': 'Detailed', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ef48bc819b..a093dda4ed 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -536,6 +536,8 @@ export default { 'settings.display.sessionPreviewLimit': '会话折叠阈值', 'settings.display.sessionPreviewLimit.decrease': '减少折叠前显示的会话数', 'settings.display.sessionPreviewLimit.increase': '增加折叠前显示的会话数', + 'settings.display.activeSessionsOnly': '仅显示活跃会话', + 'settings.display.activeSessionsOnly.desc': '在侧边栏隐藏非活跃会话;当前打开的会话仍会保留显示。', 'settings.display.sessionListStatus': '会话列表状态', 'settings.display.sessionListStatus.standard': '标准', 'settings.display.sessionListStatus.detailed': '详细', diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index e9946b3150..256033628c 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -19,6 +19,7 @@ import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize import { getComposerEnterBehaviorOptions, useComposerEnterBehavior, type ComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior' import { getTerminalToolDisplayModeOptions, useTerminalToolDisplayMode, type TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' import { getSessionListStatusModeOptions, useSessionListStatusMode, type SessionListStatusMode } from '@/hooks/useSessionListStatusMode' +import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' import { MAX_SESSION_PREVIEW_LIMIT, MIN_SESSION_PREVIEW_LIMIT, @@ -398,6 +399,7 @@ export default function SettingsPage() { const { composerEnterBehavior, setComposerEnterBehavior } = useComposerEnterBehavior() const { terminalToolDisplayMode, setTerminalToolDisplayMode } = useTerminalToolDisplayMode() const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode() + const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly() const { toolGroupBackground, userMessageBackground, @@ -897,6 +899,23 @@ export default function SettingsPage() { decreaseLabel={t('settings.display.sessionPreviewLimit.decrease')} increaseLabel={t('settings.display.sessionPreviewLimit.increase')} /> +
+
+ {t('settings.display.activeSessionsOnly')} + {t('settings.display.activeSessionsOnly.desc')} +
+ +
From 02a0aa67330f5cb808544534e38714a178a134e3 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 18 Jun 2026 10:17:57 +0800 Subject: [PATCH 22/34] fix(cursor): surface agent errors with warning styling in web UI (#871) * test: reproduce issue #864 Assert Cursor error paths emit agent error payloads and web UI renders them as warning-styled events instead of neutral session messages. Co-authored-by: Cursor * fix(cursor): surface agent errors with warning styling in web UI (closes #864) Route Cursor stderr, init, prompt, and legacy exit failures through sendAgentMessage({ type: 'error' }) and teach the web chat layer to render error events with a warning icon instead of neutral info text. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/agent/messageConverter.test.ts | 12 +++++++++ .../cursor/cursorAcpRemoteLauncher.test.ts | 5 ++++ cli/src/cursor/cursorAcpRemoteLauncher.ts | 27 ++++++++++++++----- .../cursor/cursorLegacyRemoteLauncher.test.ts | 27 +++++++++++-------- cli/src/cursor/cursorLegacyRemoteLauncher.ts | 23 ++++++++++++---- cli/src/cursor/cursorLocalLauncher.ts | 6 ++++- web/src/chat/normalize.test.ts | 21 +++++++++++++++ web/src/chat/normalizeAgent.ts | 15 +++++++++++ web/src/chat/presentation.test.ts | 12 +++++++++ web/src/chat/presentation.ts | 3 +++ web/src/chat/reducerEvents.ts | 12 +++++++++ web/src/chat/reducerTimeline.ts | 9 +++++++ web/src/chat/types.ts | 1 + 13 files changed, 149 insertions(+), 24 deletions(-) diff --git a/cli/src/agent/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index 0aeb31f3de..86ca29cc28 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -50,6 +50,18 @@ describe('convertAgentMessage', () => { }); }); + it('converts agent errors into error wire payloads', () => { + const converted = convertAgentMessage({ + type: 'error', + message: 'Cursor Agent failed: authentication required' + }); + + expect(converted).toEqual({ + type: 'error', + message: 'Cursor Agent failed: authentication required' + }); + }); + it('converts usage messages into token_count payloads', () => { const converted = convertAgentMessage({ type: 'usage', diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index 164d2065b8..1aeca09b3b 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -207,11 +207,16 @@ describe('cursorAcpRemoteLauncher', () => { it('throws on initialize failure without invoking legacy launcher', async () => { harness.initializeError = new Error('agent acp not found'); const session = makeSession(null); + const client = session.client as unknown as { sendAgentMessage: ReturnType }; await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow( /Cursor ACP mode is required for new Cursor remote sessions/ ); + expect(client.sendAgentMessage).toHaveBeenCalledWith({ + type: 'error', + message: expect.stringContaining('agent acp not found') + }); expect(legacyLauncher).not.toHaveBeenCalled(); expect(harness.newSessionCalled).toBe(false); }); diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index a3fe254552..4382974e6d 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -64,7 +64,10 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { backend.onStderrError((error) => { logger.debug('[cursor-acp] stderr error', error); - session.sendSessionEvent({ type: 'message', message: error.message }); + const converted = convertAgentMessage({ type: 'error', message: error.message }); + if (converted) { + session.sendAgentMessage(converted); + } messageBuffer.addMessage(error.message, 'status'); }); @@ -72,7 +75,13 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { await backend.initialize(); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - throw new Error(`${CURSOR_ACP_REQUIRED_MESSAGE} (${errMsg})`); + const fullMsg = `${CURSOR_ACP_REQUIRED_MESSAGE} (${errMsg})`; + const converted = convertAgentMessage({ type: 'error', message: fullMsg }); + if (converted) { + session.sendAgentMessage(converted); + } + messageBuffer.addMessage(fullMsg, 'status'); + throw new Error(fullMsg); } await backend.authenticateIfAvailable('cursor_login'); @@ -204,11 +213,12 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } catch (error) { logger.warn('[cursor-acp] prompt failed', error); const errMsg = error instanceof Error ? error.message : String(error); - session.sendSessionEvent({ - type: 'message', - message: `Cursor Agent failed: ${errMsg}` - }); - messageBuffer.addMessage(`Cursor Agent failed: ${errMsg}`, 'status'); + const message = `Cursor Agent failed: ${errMsg}`; + const converted = convertAgentMessage({ type: 'error', message }); + if (converted) { + session.sendAgentMessage(converted); + } + messageBuffer.addMessage(message, 'status'); } finally { session.onThinkingChange(false); await this.permissionAdapter?.cancelAll('Prompt finished'); @@ -271,6 +281,9 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { case 'plan': this.messageBuffer.addMessage('Plan updated', 'status'); break; + case 'error': + this.messageBuffer.addMessage(message.message, 'status'); + break; case 'turn_complete': break; default: diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts index 32d8116265..9b18c6c622 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts @@ -12,7 +12,12 @@ vi.mock('@/ui/logger', () => ({ })); vi.mock('@/agent/messageConverter', () => ({ - convertAgentMessage: () => null + convertAgentMessage: (message: { type: string; message?: string }) => { + if (message.type === 'error' && typeof message.message === 'string') { + return { type: 'error', message: message.message }; + } + return null; + } })); vi.mock('@/ui/ink/OpencodeDisplay', () => ({ @@ -145,9 +150,9 @@ describe('cursorLegacyRemoteLauncher', () => { await cursorLegacyRemoteLauncher(session); expect(spawnMock).toHaveBeenCalledTimes(2); - const messages = client.sendSessionEvent.mock.calls + const messages = client.sendAgentMessage.mock.calls .map((c) => c[0]) - .filter((e: any) => e.type === 'message'); + .filter((e: any) => e.type === 'error'); expect(messages).toHaveLength(1); expect(messages[0].message).toContain('Cursor authentication expired'); expect(messages[0].message).toContain("'agent login'"); @@ -184,9 +189,9 @@ describe('cursorLegacyRemoteLauncher', () => { const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); await cursorLegacyRemoteLauncher(session); - const messages = client.sendSessionEvent.mock.calls + const messages = client.sendAgentMessage.mock.calls .map((c) => c[0]) - .filter((e: any) => e.type === 'message'); + .filter((e: any) => e.type === 'error'); expect(messages).toHaveLength(1); expect(messages[0].message).toContain('rate limit'); expect(messages[0].message).toContain('queued and will retry'); @@ -209,9 +214,9 @@ describe('cursorLegacyRemoteLauncher', () => { await cursorLegacyRemoteLauncher(session); expect(spawnMock).toHaveBeenCalledTimes(1); - const messageEvents = client.sendSessionEvent.mock.calls + const messageEvents = client.sendAgentMessage.mock.calls .map((c) => c[0]) - .filter((e: any) => e.type === 'message'); + .filter((e: any) => e.type === 'error'); expect(messageEvents).toHaveLength(1); expect(messageEvents[0].message).toContain('Agent exited (134)'); expect(messageEvents[0].message).toContain('Segmentation fault'); @@ -242,9 +247,9 @@ describe('cursorLegacyRemoteLauncher', () => { await cursorLegacyRemoteLauncher(session); expect(spawnMock).toHaveBeenCalledTimes(1); - const messageEvents = client.sendSessionEvent.mock.calls + const messageEvents = client.sendAgentMessage.mock.calls .map((c) => c[0]) - .filter((e: any) => e.type === 'message'); + .filter((e: any) => e.type === 'error'); expect(messageEvents).toHaveLength(1); expect(messageEvents[0].message).toContain('Agent exited (143)'); expect(messageEvents[0].message).not.toContain('queued and will retry'); @@ -307,9 +312,9 @@ describe('cursorLegacyRemoteLauncher', () => { expect(spawnMock).toHaveBeenCalledTimes(5); - const messageEvents = client.sendSessionEvent.mock.calls + const messageEvents = client.sendAgentMessage.mock.calls .map((c) => c[0]) - .filter((e: any) => e.type === 'message'); + .filter((e: any) => e.type === 'error'); // 4 transient retry banners + 1 drop banner = 5 expect(messageEvents).toHaveLength(5); const banners = messageEvents.map((e: any) => e.message); diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index b8f80e856a..7e222886db 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -220,15 +220,22 @@ class CursorRemoteLauncher extends RemoteLauncherBase { this.consecutiveTransientFailures = 0; const errMsg = `Agent exited (${exitCode}): ${truncateStderrForDisplay(stderr)}`; logger.warn(`[cursor-remote] ${errMsg}`); - session.sendSessionEvent({ type: 'message', message: errMsg }); + const converted = convertAgentMessage({ type: 'error', message: errMsg }); + if (converted) { + session.sendAgentMessage(converted); + } messageBuffer.addMessage(errMsg, 'status'); } } catch (error) { this.consecutiveTransientFailures = 0; logger.warn('[cursor-remote] Agent run failed', error); const errMsg = error instanceof Error ? error.message : String(error); - session.sendSessionEvent({ type: 'message', message: `Cursor Agent failed: ${errMsg}` }); - messageBuffer.addMessage(`Cursor Agent failed: ${errMsg}`, 'status'); + const message = `Cursor Agent failed: ${errMsg}`; + const converted = convertAgentMessage({ type: 'error', message }); + if (converted) { + session.sendAgentMessage(converted); + } + messageBuffer.addMessage(message, 'status'); } finally { session.onThinkingChange(false); if (session.queue.size() === 0 && !this.shouldExit) { @@ -317,7 +324,10 @@ class CursorRemoteLauncher extends RemoteLauncherBase { `[cursor-remote] transient agent failures hit cap (${MAX_CONSECUTIVE_TRANSIENT_FAILURES}); dropping message`, { exitCode, stderr: stderr.slice(0, STDERR_DISPLAY_LIMIT) } ); - session.sendSessionEvent({ type: 'message', message: dropMsg }); + const converted = convertAgentMessage({ type: 'error', message: dropMsg }); + if (converted) { + session.sendAgentMessage(converted); + } messageBuffer.addMessage(dropMsg, 'status'); this.consecutiveTransientFailures = 0; return; @@ -343,7 +353,10 @@ class CursorRemoteLauncher extends RemoteLauncherBase { session.queue.unshift(message, mode); } const friendly = friendlyTransientMessage(exitCode, stderr); - session.sendSessionEvent({ type: 'message', message: friendly }); + const converted = convertAgentMessage({ type: 'error', message: friendly }); + if (converted) { + session.sendAgentMessage(converted); + } messageBuffer.addMessage(friendly, 'status'); await this.transientBackoff(getTransientBackoffMs()); } diff --git a/cli/src/cursor/cursorLocalLauncher.ts b/cli/src/cursor/cursorLocalLauncher.ts index 2e26a24b61..b08ce2aa37 100644 --- a/cli/src/cursor/cursorLocalLauncher.ts +++ b/cli/src/cursor/cursorLocalLauncher.ts @@ -2,6 +2,7 @@ import { logger } from '@/ui/logger'; import { cursorLocal } from './cursorLocal'; import { CursorSession } from './session'; import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; +import { convertAgentMessage } from '@/agent/messageConverter'; function permissionModeToCursorArgs(mode?: string): { mode?: 'plan' | 'ask' | 'debug'; yolo?: boolean } { if (mode === 'plan') { @@ -46,7 +47,10 @@ export async function cursorLocalLauncher(session: CursorSession): Promise<'swit }); }, sendFailureMessage: (message) => { - session.sendSessionEvent({ type: 'message', message }); + const converted = convertAgentMessage({ type: 'error', message }); + if (converted) { + session.sendAgentMessage(converted); + } }, recordLocalLaunchFailure: (message, exitReason) => { session.recordLocalLaunchFailure(message, exitReason); diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 3a3db02e6f..6b9b0a4e5a 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -178,6 +178,27 @@ describe('normalizeDecryptedMessage', () => { }) }) + it('normalizes agent error payloads as error events', () => { + const normalized = normalizeDecryptedMessage(makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'error', + message: 'Cursor Agent failed: authentication required' + } + } + })) + + expect(normalized).toMatchObject({ + role: 'event', + content: { + type: 'error', + message: 'Cursor Agent failed: authentication required' + } + }) + }) + it('treats non-sidechain string user output as sidechain', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 39de43c655..3f2bf8814d 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -577,6 +577,21 @@ export function normalizeAgentRecord( } } + if (data.type === 'error' && typeof data.message === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: { + type: 'error', + message: data.message + }, + isSidechain: false, + meta + } + } + if (data.type === 'message' && typeof data.message === 'string') { const review = parseCodexReviewMessage(data.message) if (review) { diff --git a/web/src/chat/presentation.test.ts b/web/src/chat/presentation.test.ts index 6918fde4d0..af4497fa62 100644 --- a/web/src/chat/presentation.test.ts +++ b/web/src/chat/presentation.test.ts @@ -1,6 +1,18 @@ import { describe, expect, it } from 'vitest' import { getEventPresentation, formatMessageTimestamp, formatResetTime } from './presentation' +describe('getEventPresentation — agent errors', () => { + it('formats error events with warning icon and message text', () => { + const result = getEventPresentation({ + type: 'error', + message: 'Cursor Agent failed: authentication required' + }) + + expect(result.icon).toBe('⚠️') + expect(result.text).toBe('Cursor Agent failed: authentication required') + }) +}) + describe('getEventPresentation — limit-warning', () => { it('formats five_hour warning', () => { const result = getEventPresentation({ diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 676c6bcc22..168693dc5a 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -182,6 +182,9 @@ export function getEventPresentation(event: AgentEvent): EventPresentation { const suffix = typeLabel ? ` (${typeLabel})` : '' return { icon: '⏳', text: endsAt ? `Usage limit reached${suffix} until ${formatUnixTimestamp(endsAt)}` : `Usage limit reached${suffix}` } } + if (event.type === 'error') { + return { icon: '⚠️', text: typeof event.message === 'string' ? event.message : 'Error' } + } if (event.type === 'message') { return { icon: null, text: typeof event.message === 'string' ? event.message : 'Message' } } diff --git a/web/src/chat/reducerEvents.ts b/web/src/chat/reducerEvents.ts index f2302caba9..23ac761b7f 100644 --- a/web/src/chat/reducerEvents.ts +++ b/web/src/chat/reducerEvents.ts @@ -79,6 +79,18 @@ export function dedupeAgentEvents(blocks: ChatBlock[]): ChatBlock[] { continue } + if (event.type === 'error' && typeof event.message === 'string') { + const message = event.message.trim() + const key = `error:${message}` + if (key === prevEventKey) { + continue + } + result.push(block) + prevEventKey = key + prevTitleChangedTo = null + continue + } + let key: string try { key = `event:${JSON.stringify(event)}` diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index d13cd34a9a..6b95d193b4 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -180,6 +180,15 @@ function normalizeTraceMessage( meta: source.meta } + if (data.type === 'error' && typeof data.message === 'string') { + return [{ + ...base, + id: traceId, + role: 'event', + content: { type: 'error', message: data.message } + } as TracedMessage] + } + if (data.type === 'message' && typeof data.message === 'string') { return [{ ...base, diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 4641e91387..f42d0540a6 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -16,6 +16,7 @@ export type UsageData = { export type AgentEvent = | { type: 'switch'; mode: 'local' | 'remote' } | { type: 'message'; message: string } + | { type: 'error'; message: string } | { type: 'title-changed'; title: string } | { type: 'limit-reached'; endsAt: number; limitType: string } | { type: 'limit-warning'; /** 0–1 ratio (e.g. 0.9 = 90%), integer-precision via CLI pipe format */ utilization: number; endsAt: number; limitType: string } From b1910b6b2e0f133045d603358a2553046018a619 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:36:36 +0100 Subject: [PATCH 23/34] feat(web): session header files and outline view toggles (#952) * feat(web): session header files and outline view toggles Files and outline icons in SessionHeader act as depressed toggles; files view shares the session header and places refresh beside the search box. Co-authored-by: Cursor * chore(web): add Playwright handoff script for session view toggles Supports new-feature-intake visual gate: files toggle pressed + refresh beside search. Co-authored-by: Cursor * fix(dev): handoff script avoid networkidle on live hub SSE HAPI keeps connections open on :3006; domcontentloaded is the correct wait. Co-authored-by: Cursor * fix(web): place filesystem refresh outside search field Refresh is a sibling of the search pill, not inside it, so the control is visually and structurally separate from file search. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- scripts/dev/session-view-toggles-handoff.mjs | 90 ++++++++++++++++ web/src/components/SessionChat.tsx | 25 ++++- web/src/components/SessionHeader.tsx | 35 +++++-- web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/router.tsx | 16 +++ web/src/routes/sessions/files.tsx | 102 ++++++++++--------- 7 files changed, 210 insertions(+), 62 deletions(-) create mode 100644 scripts/dev/session-view-toggles-handoff.mjs diff --git a/scripts/dev/session-view-toggles-handoff.mjs b/scripts/dev/session-view-toggles-handoff.mjs new file mode 100644 index 0000000000..e9a418560e --- /dev/null +++ b/scripts/dev/session-view-toggles-handoff.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * Playwright handoff for session header view toggles (files + outline). + * Usage: node scripts/dev/session-view-toggles-handoff.mjs [screenshotPath] + */ +import { chromium } from 'playwright' +import { mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +const sessionId = process.argv[2] +const cliToken = process.argv[3] +const screenshotPath = resolve(process.argv[4] ?? 'localdocs/playwright-runs/session-view-toggles-handoff.png') + +if (!sessionId || !cliToken) { + console.error('usage: session-view-toggles-handoff.mjs [screenshotPath]') + process.exit(2) +} + +function launchOptions() { + const chromePath = process.env.PLAYWRIGHT_CHROME_PATH?.trim() + if (chromePath) return { headless: true, executablePath: chromePath } + if (process.platform === 'linux' && !process.env.PLAYWRIGHT_BUNDLED_CHROMIUM) { + return { headless: true, channel: 'chrome' } + } + return { headless: true } +} + +const baseUrl = 'http://127.0.0.1:3006' +const storageKey = `hapi_access_token::${baseUrl}` +const url = `${baseUrl}/sessions/${sessionId}` +const browser = await chromium.launch(launchOptions()) +const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + serviceWorkers: 'block', +}) +const page = await context.newPage() +await page.addInitScript(({ key, token }) => { + localStorage.setItem(key, token) +}, { key: storageKey, token: cliToken }) +const consoleMessages = [] +page.on('console', (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`)) +page.on('pageerror', (err) => consoleMessages.push(`pageerror: ${err.message}`)) + +try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) + + const login = page.getByPlaceholder('Access token') + if (await login.isVisible({ timeout: 3000 }).catch(() => false)) { + await login.fill(cliToken) + await page.getByRole('button', { name: /sign in|login|connect/i }).click() + await page.waitForLoadState('domcontentloaded', { timeout: 60000 }) + } + + await page.getByRole('button', { name: 'Files' }).first().waitFor({ state: 'visible', timeout: 60000 }) + + // Toggle into files mode — button should become pressed. + await page.getByRole('button', { name: 'Files' }).first().click() + await page.getByPlaceholder('Search files').waitFor({ timeout: 30000 }) + await page.getByRole('button', { name: 'Refresh filesystem view' }).waitFor({ timeout: 10000 }) + + const filesBtn = page.getByRole('button', { name: 'Return to conversation' }) + await filesBtn.waitFor({ timeout: 5000 }) + const pressed = await filesBtn.getAttribute('aria-pressed') + if (pressed !== 'true') { + throw new Error(`Expected files toggle aria-pressed=true, got ${pressed}`) + } + + mkdirSync(dirname(screenshotPath), { recursive: true }) + await page.screenshot({ path: screenshotPath, fullPage: false }) + + console.log(JSON.stringify({ + ok: true, + screenshot: screenshotPath, + url: page.url().replace(/token=[^&]+/, 'token='), + filesTogglePressed: pressed, + }, null, 2)) +} catch (error) { + mkdirSync(dirname(screenshotPath), { recursive: true }) + await page.screenshot({ path: screenshotPath, fullPage: false }).catch(() => {}) + console.error(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + screenshot: screenshotPath, + bodyText: (await page.locator('body').innerText().catch(() => '')).slice(0, 500), + consoleMessages, + }, null, 2)) + process.exitCode = 1 +} finally { + await browser.close() +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index af612bce5e..8edc884ddf 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -374,6 +374,8 @@ type SessionChatProps = { // user dismisses or starts editing. sendError?: ComposerSendError | null onClearSendError?: () => void + initialOutlineOpen?: boolean + onInitialOutlineConsumed?: () => void } /** @@ -407,7 +409,15 @@ function SessionChatInner(props: SessionChatProps) { const blocksByIdRef = useRef>(new Map()) const visibleGroupsRef = useRef([]) const [forceScrollToken, setForceScrollToken] = useState(0) - const [outlineOpen, setOutlineOpen] = useState(false) + const [outlineOpen, setOutlineOpen] = useState(props.initialOutlineOpen ?? false) + useEffect(() => { + if (!props.initialOutlineOpen) { + return + } + setOutlineOpen(true) + props.onInitialOutlineConsumed?.() + }, [props.initialOutlineOpen, props.onInitialOutlineConsumed]) + const [cursorSelectedBase, setCursorSelectedBase] = useState('auto') const lastSyncedCursorModelRef = useRef(undefined) const scratchlist = useScratchlist(props.session.id) @@ -995,13 +1005,18 @@ function SessionChatInner(props: SessionChatProps) { props.onRefresh() }, [switchSession, props.onRefresh]) - const handleViewFiles = useCallback(() => { + const handleToggleFiles = useCallback(() => { + setOutlineOpen(false) navigate({ to: '/sessions/$sessionId/files', params: { sessionId: props.session.id } }) }, [navigate, props.session.id]) + const handleToggleOutline = useCallback(() => { + setOutlineOpen((open) => !open) + }, []) + const handleViewTerminal = useCallback(() => { navigate({ to: '/sessions/$sessionId/terminal', @@ -1089,8 +1104,10 @@ function SessionChatInner(props: SessionChatProps) { setOutlineOpen(true)} + onToggleFiles={props.session.metadata?.path ? handleToggleFiles : undefined} + filesActive={false} + onToggleOutline={handleToggleOutline} + outlineActive={outlineOpen} api={props.api} onSessionDeleted={props.onBack} onSessionReopened={(newSessionId) => { diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index a65f84d0fe..c80ac68a4e 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -70,6 +70,14 @@ function OutlineIcon(props: { className?: string }) { ) } +function headerToggleClass(active: boolean): string { + return `flex h-8 w-8 items-center justify-center rounded-full transition-colors ${ + active + ? 'bg-[var(--app-button)] text-[var(--app-button-text)] hover:opacity-90' + : 'text-[var(--app-hint)] hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]' + }` +} + function MoreVerticalIcon(props: { className?: string }) { return ( void - onViewFiles?: () => void - onOpenOutline?: () => void + onToggleFiles?: () => void + filesActive?: boolean + onToggleOutline?: () => void + outlineActive?: boolean api: ApiClient | null onSessionDeleted?: () => void onSessionReopened?: (newSessionId: string) => void @@ -194,24 +204,27 @@ export function SessionHeader(props: {
- {props.onViewFiles ? ( + {props.onToggleFiles ? ( ) : null} - {props.onOpenOutline ? ( + {props.onToggleOutline ? ( diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d174da5a0d..60b119bc27 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -142,6 +142,7 @@ export default { // Session header 'session.title': 'Files', + 'session.view.returnToChat': 'Return to conversation', 'session.more': 'More actions', 'session.outline.open': 'Conversation outline', 'session.outline.close': 'Close outline', @@ -284,6 +285,7 @@ export default { // Files page 'files.page.title': 'Files', 'files.page.refresh': 'Refresh', + 'files.page.refreshFilesystem': 'Refresh filesystem view', 'files.page.searchPlaceholder': 'Search files', 'files.projectRoot': 'project root', 'files.branch.detached': 'detached HEAD', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index a093dda4ed..a44efebdb8 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -142,6 +142,7 @@ export default { // Session header 'session.title': '文件', + 'session.view.returnToChat': '返回会话', 'session.more': '更多操作', 'session.outline.open': '会话大纲', 'session.outline.close': '关闭大纲', @@ -288,6 +289,7 @@ export default { // Files page 'files.page.title': '文件', 'files.page.refresh': '刷新', + 'files.page.refreshFilesystem': '刷新文件系统视图', 'files.page.searchPlaceholder': '搜索文件', 'files.projectRoot': '项目根目录', 'files.branch.detached': '游离 HEAD', diff --git a/web/src/router.tsx b/web/src/router.tsx index 40847b86b0..84222fef70 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -10,6 +10,7 @@ import { useMatchRoute, useNavigate, useParams, + useSearch, } from '@tanstack/react-router' import { getScrollRestorationKey } from '@/lib/scrollRestorationKey' import { App } from '@/App' @@ -609,6 +610,7 @@ function SessionPage() { const queryClient = useQueryClient() const { addToast } = useToast() const { sessionId } = useParams({ from: '/sessions/$sessionId' }) + const { outline } = useSearch({ from: '/sessions/$sessionId' }) const { session, error: sessionError, @@ -856,6 +858,14 @@ function SessionPage() { void refetchMessages() }, [refetchMessages, refetchSession]) + const handleInitialOutlineConsumed = useCallback(() => { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId }, + replace: true, + }) + }, [navigate, sessionId]) + if (!session) { if (sessionError) { return ( @@ -912,6 +922,8 @@ function SessionPage() { availableSlashCommands={slashCommands} sendError={sendError} onClearSendError={clearSendError} + initialOutlineOpen={outline} + onInitialOutlineConsumed={handleInitialOutlineConsumed} /> ) } @@ -1097,6 +1109,10 @@ const sessionsIndexRoute = createRoute({ const sessionDetailRoute = createRoute({ getParentRoute: () => sessionsRoute, path: '$sessionId', + validateSearch: (search: Record): { outline?: boolean } => { + const outline = search.outline === true || search.outline === 'true' + return outline ? { outline: true } : {} + }, component: SessionDetailRoute, }) diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx index a77e05b7a3..0d253239d7 100644 --- a/web/src/routes/sessions/files.tsx +++ b/web/src/routes/sessions/files.tsx @@ -3,6 +3,8 @@ import { useNavigate, useParams, useSearch } from '@tanstack/react-router' import type { FileSearchItem, GitFileStatus } from '@/types/api' import { FileIcon } from '@/components/FileIcon' import { DirectoryTree } from '@/components/SessionFiles/DirectoryTree' +import { SessionHeader } from '@/components/SessionHeader' +import { LoadingState } from '@/components/LoadingState' import { useAppContext } from '@/lib/app-context' import { useAppGoBack } from '@/hooks/useAppGoBack' import { useGitStatusFiles } from '@/hooks/queries/useGitStatusFiles' @@ -19,25 +21,6 @@ import { queryKeys } from '@/lib/query-keys' import { useQueryClient } from '@tanstack/react-query' import { useTranslation } from '@/lib/use-translation' -function BackIcon(props: { className?: string }) { - return ( - - - - ) -} - function RefreshIcon(props: { className?: string }) { return ( (gitError ? formatGitStatusError(gitError, t) : null), @@ -346,45 +328,71 @@ export default function FilesPage() { }) }, [navigate, sessionId]) + const handleToggleFiles = useCallback(() => { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId }, + }) + }, [navigate, sessionId]) + + const handleToggleOutline = useCallback(() => { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId }, + search: { outline: true }, + }) + }, [navigate, sessionId]) + + if (!session) { + return ( +
+ +
+ ) + } + return (
-
-
- -
-
{t('files.page.title')}
-
{subtitle}
-
- -
-
+ { + navigate({ + to: '/sessions/$sessionId/files', + params: { sessionId: newSessionId }, + replace: true, + }) + }} + />
-
-
- +
+
+ setSearchQuery(event.target.value)} placeholder={t('files.page.searchPlaceholder')} - className="w-full bg-transparent text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none" + className="min-w-0 flex-1 bg-transparent text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none" autoCapitalize="none" autoCorrect="off" />
+
From a0259b531e5fae11774c83a47a165a4c628d3578 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Fri, 19 Jun 2026 17:37:04 +0800 Subject: [PATCH 24/34] feat(web): drag-and-drop files onto chat panel to add as attachments (#936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): drag-and-drop files onto chat panel to add as attachments Closes #935 Adds a `useDragOver` hook that detects when a file is being dragged over the browser window and suppresses the browser's default file-open behaviour for drops outside the accept zone. A new `DragDropZone` component wraps the inner `AssistantRuntimeProvider` content in `SessionChat`. It shows a semi-transparent overlay (dashed border + "Drop to attach" label) on the right-side chat panel as soon as any file drag is detected — regardless of where the pointer is on the page. Dropping on the right panel adds the files as composer attachments via the existing `api.composer().addAttachment()` path. Drops on the left sidebar are suppressed (no navigation, no attachment). via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(web): disable drag-drop zone when pendingSchedule is active The backend rejects requests with both scheduledAt and attachments. DragDropZone now respects pendingSchedule the same way paste and the attach button do — disabled=true suppresses the overlay, sets dropEffect='none', and skips addAttachment on drop. via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(web): harden drag-drop default-action handling Address HAPI Bot review on #936: - useDragOver: cancel the browser's default file-open/navigation on the document-level `drop` event for file payloads, not only on `dragover`. Preventing default on `dragover` alone still lets the browser open a file dropped outside any zone (e.g. the sidebar), which could unload the app. - DragDropZone: only preventDefault when the drop payload actually contains files, so non-file drops (e.g. dragging selected text into the composer) keep their default browser behaviour. Add regression tests for both. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 * fix(web): use Simplified Chinese for composer.dropToAttach in zh-CN Address HAPI Bot review on #936: the new zh-CN string used Traditional Chinese forms (放開以附加檔案) in the Simplified Chinese locale, which is inconsistent with neighbouring keys (e.g. composer.attach = 添加文件). Use 松开以添加文件 to match. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: HAPI Co-authored-by: Claude Opus 4.8 --- .../AssistantChat/DragDropZone.test.tsx | 79 +++++++++++++++++++ .../components/AssistantChat/DragDropZone.tsx | 60 ++++++++++++++ web/src/components/SessionChat.tsx | 6 +- web/src/hooks/useDragOver.test.ts | 62 +++++++++++++++ web/src/hooks/useDragOver.ts | 59 ++++++++++++++ web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + 7 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 web/src/components/AssistantChat/DragDropZone.test.tsx create mode 100644 web/src/components/AssistantChat/DragDropZone.tsx create mode 100644 web/src/hooks/useDragOver.test.ts create mode 100644 web/src/hooks/useDragOver.ts diff --git a/web/src/components/AssistantChat/DragDropZone.test.tsx b/web/src/components/AssistantChat/DragDropZone.test.tsx new file mode 100644 index 0000000000..6f3737d71e --- /dev/null +++ b/web/src/components/AssistantChat/DragDropZone.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, fireEvent } from '@testing-library/react' + +const addAttachment = vi.fn() + +vi.mock('@assistant-ui/react', () => ({ + useAssistantApi: () => ({ + composer: () => ({ addAttachment }), + }), +})) + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +import { DragDropZone } from './DragDropZone' + +function createDropEvent(types: string[], files: File[]): Event { + const event = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { + value: { types, files }, + configurable: true, + }) + return event +} + +describe('DragDropZone drop handling', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('adds dropped files as attachments and cancels the browser default', () => { + const { container } = render( + +
+ + ) + const zone = container.firstChild as HTMLElement + const file = new File(['x'], 'a.txt', { type: 'text/plain' }) + const event = createDropEvent(['Files'], [file]) + + fireEvent(zone, event) + + expect(event.defaultPrevented).toBe(true) + expect(addAttachment).toHaveBeenCalledTimes(1) + expect(addAttachment).toHaveBeenCalledWith(file) + }) + + it('ignores non-file drops so the browser keeps its default (e.g. text into composer)', () => { + const { container } = render( + +
+ + ) + const zone = container.firstChild as HTMLElement + const event = createDropEvent(['text/plain'], []) + + fireEvent(zone, event) + + expect(event.defaultPrevented).toBe(false) + expect(addAttachment).not.toHaveBeenCalled() + }) + + it('does not attach when disabled but still cancels the file default', () => { + const { container } = render( + +
+ + ) + const zone = container.firstChild as HTMLElement + const file = new File(['x'], 'a.txt', { type: 'text/plain' }) + const event = createDropEvent(['Files'], [file]) + + fireEvent(zone, event) + + expect(event.defaultPrevented).toBe(true) + expect(addAttachment).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/components/AssistantChat/DragDropZone.tsx b/web/src/components/AssistantChat/DragDropZone.tsx new file mode 100644 index 0000000000..102ac5d19f --- /dev/null +++ b/web/src/components/AssistantChat/DragDropZone.tsx @@ -0,0 +1,60 @@ +import { useCallback } from 'react' +import { useAssistantApi } from '@assistant-ui/react' +import { useDragOver } from '@/hooks/useDragOver' +import { useTranslation } from '@/lib/use-translation' + +export function DragDropZone({ + children, + disabled, +}: { + children: React.ReactNode + disabled?: boolean +}) { + const api = useAssistantApi() + const isDragging = useDragOver() + const { t } = useTranslation() + + const onDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault() + e.dataTransfer.dropEffect = disabled ? 'none' : 'copy' + } + }, [disabled]) + + const onDrop = useCallback( + async (e: React.DragEvent) => { + // Let non-file drops (e.g. selected text into the composer) keep + // their default browser behaviour instead of being cancelled. + if (!e.dataTransfer.types.includes('Files')) return + e.preventDefault() + if (disabled) return + const files = Array.from(e.dataTransfer.files) + if (files.length === 0) return + try { + for (const file of files) { + await api.composer().addAttachment(file) + } + } catch (error) { + console.error('Error adding dragged file:', error) + } + }, + [api, disabled] + ) + + return ( +
+ {children} + {isDragging && !disabled && ( +
+
+ {t('composer.dropToAttach')} +
+
+ )} +
+ ) +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 8edc884ddf..57eedc3b0e 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { AssistantRuntimeProvider, useAssistantApi, useAssistantState } from '@assistant-ui/react' +import { DragDropZone } from '@/components/AssistantChat/DragDropZone' import type { ApiClient } from '@/api/client' import type { AttachmentMetadata, @@ -1137,7 +1138,8 @@ function SessionChatInner(props: SessionChatProps) { -
+ + -
+
{/* Voice session component - renders nothing but initializes voice backend */} diff --git a/web/src/hooks/useDragOver.test.ts b/web/src/hooks/useDragOver.test.ts new file mode 100644 index 0000000000..6a98a47b01 --- /dev/null +++ b/web/src/hooks/useDragOver.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useDragOver } from './useDragOver' + +function makeDragEvent(type: string, types: string[]): Event { + const event = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { + value: { types }, + configurable: true, + }) + return event +} + +describe('useDragOver', () => { + it('prevents the browser default when a file is dropped outside a zone', () => { + // Regression: a file dropped on the document (e.g. the sidebar) must not + // trigger the browser's file-open/navigation behaviour. + const { unmount } = renderHook(() => useDragOver()) + const event = makeDragEvent('drop', ['Files']) + act(() => { + document.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(true) + unmount() + }) + + it('does not prevent default for a non-file drop', () => { + const { unmount } = renderHook(() => useDragOver()) + const event = makeDragEvent('drop', ['text/plain']) + act(() => { + document.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(false) + unmount() + }) + + it('also prevents default on dragover for files so the drop can be cancelled', () => { + const { unmount } = renderHook(() => useDragOver()) + const event = makeDragEvent('dragover', ['Files']) + act(() => { + document.dispatchEvent(event) + }) + expect(event.defaultPrevented).toBe(true) + unmount() + }) + + it('tracks file-drag state and clears it on drop', () => { + const { result, unmount } = renderHook(() => useDragOver()) + expect(result.current).toBe(false) + + act(() => { + document.dispatchEvent(makeDragEvent('dragenter', ['Files'])) + }) + expect(result.current).toBe(true) + + act(() => { + document.dispatchEvent(makeDragEvent('drop', ['Files'])) + }) + expect(result.current).toBe(false) + unmount() + }) +}) diff --git a/web/src/hooks/useDragOver.ts b/web/src/hooks/useDragOver.ts new file mode 100644 index 0000000000..6a53e90eda --- /dev/null +++ b/web/src/hooks/useDragOver.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' + +/** + * Returns true while the user is dragging files over the browser window. + * Also suppresses the browser's default file-open behaviour for drags that + * land outside an explicit drop zone. + */ +export function useDragOver(): boolean { + const [isDraggingFiles, setIsDraggingFiles] = useState(false) + + useEffect(() => { + const onDragEnter = (e: DragEvent) => { + if (e.dataTransfer?.types.includes('Files')) { + setIsDraggingFiles(true) + } + } + + // Only clear when the drag leaves the browser window entirely + // (relatedTarget === null means the pointer moved outside the document) + const onDragLeave = (e: DragEvent) => { + if (e.relatedTarget === null) { + setIsDraggingFiles(false) + } + } + + const clearDrag = () => setIsDraggingFiles(false) + + // Prevent the browser from opening/navigating to a file dropped outside + // an explicit drop zone (e.g. the sidebar). This must run on BOTH + // `dragover` and `drop`: preventing only `dragover` still lets the + // browser perform its default file-open action on the `drop` event. + const preventFileDefault = (e: DragEvent) => { + if (e.dataTransfer?.types.includes('Files')) { + e.preventDefault() + } + } + + const onDrop = (e: DragEvent) => { + preventFileDefault(e) + clearDrag() + } + + document.addEventListener('dragenter', onDragEnter) + document.addEventListener('dragleave', onDragLeave) + document.addEventListener('dragend', clearDrag) + document.addEventListener('drop', onDrop) + document.addEventListener('dragover', preventFileDefault) + + return () => { + document.removeEventListener('dragenter', onDragEnter) + document.removeEventListener('dragleave', onDragLeave) + document.removeEventListener('dragend', clearDrag) + document.removeEventListener('drop', onDrop) + document.removeEventListener('dragover', preventFileDefault) + } + }, []) + + return isDraggingFiles +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 60b119bc27..dcaeeb20d3 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -412,6 +412,7 @@ export default { 'composer.abort': 'Abort', 'composer.switchRemote': 'Switch to remote mode', 'composer.attach': 'Attach file', + 'composer.dropToAttach': 'Drop to attach', 'composer.send': 'Send', 'composer.stop': 'Stop', 'composer.voice': 'Voice assistant', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index a44efebdb8..7508af1039 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -416,6 +416,7 @@ export default { 'composer.abort': '中止', 'composer.switchRemote': '切换到远程模式', 'composer.attach': '添加文件', + 'composer.dropToAttach': '松开以添加文件', 'composer.send': '发送', 'composer.stop': '停止', 'composer.voice': '语音助手', From 2ab3b398875a05298acd140754facf2db819260d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:37:32 +0100 Subject: [PATCH 25/34] fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #919) (#923) * fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #919) These four contained bugs were uncovered by a 2026-06-15 hub-restart incident where `hapi-restart-hub` SIGTERMed 23 cursor ACP sessions. Each fix lands independently of the architectural #915 (hub-restart cascade-archive) and the hypothesis-pending #917 (reopen creates dead session); audit-trail correctness and idempotency wins stand on their own. Fresh ACP sessions could be SIGTERMed during the async `update-metadata` ACK round-trip, stranding the on-disk ACP store with no DB handle. Add `ApiSessionClient.flushMetadata()` and await it after `onSessionFoundWithProtocol` on the fresh-session branch. Resume-path pre-registration (PR #834) is unchanged. Hub-restart-cascade SIGTERMs went through the same path as web-UI Archive clicks, both writing archiveReason='User terminated'. New default is 'Hub restart'; the KillSession RPC handler (the authoritative user-archive signal) now explicitly stamps 'User terminated' before cleanupAndExit. SIGINT (local-terminal Ctrl-C) keeps the 'User terminated' label too. `rpcGateway.killSession` threw a generic Error when no target socket was registered, and the archive route surfaced that as 500. Add typed `RpcTargetMissingError`, narrow on it in `syncEngine.archiveSession`, fall back to a hub-side `markSessionArchivedFromHub` write so lifecycleState still flips to 'archived'. Drop the requireActive guard on the route and 2xx-noop for already-archived rows. without refresh, producing forever-409 on rename/reopen until an unrelated event triggered a cache refresh. `renameSession`, `clearSessionArchiveMetadata`, `restoreSessionArchiveMetadata` now retry-with-refresh (5 attempts, then throw) mirroring the existing good pattern in `mergeSessions`. Refs tiann/hapi#913 Refs tiann/hapi#914 Refs tiann/hapi#916 Refs tiann/hapi#919 AI disclosure: implementation by Claude Sonnet 4.5 (Cursor agent peer) under operator supervision. Issue triage by a sibling discovery agent. Per CONTRIBUTING.md AI-assisted contributions policy. Co-authored-by: Cursor * fix(cli): runner-spawned children use 'Stopped by runner' as default archive reason Addresses bot review of #923: with the #914 default-archiveReason flip to 'Hub restart', runner-driven SIGTERM paths (`hapi runner stop-session`, webhook-timeout cleanup at run.ts:587, orphan-cleanup at run.ts:267) all mislabel as 'Hub restart' which is also inaccurate audit-trail noise. Smallest defensible change: parameterise the lifecycle default via HAPI_DEFAULT_ARCHIVE_REASON env, and have the runner set 'Stopped by runner' on spawn. Terminal-launched sessions (no runner parent, no env var) still default to 'Hub restart' since hub-restart cascade documented at #915 is the most plausible SIGTERM source for those. Explicit overrides via setArchiveReason (KillSession RPC, SIGINT Ctrl-C, markCrash uncaught exception) still win. Two new unit tests cover the env-var default and the override precedence. Refs tiann/hapi#914. Co-authored-by: Cursor * fix(hub): markSessionArchivedFromHub surfaces persistence failures as 5xx Addresses second-round bot review of #923 (Major): `markSessionArchivedFromHub` silently returned on DB write errors and on exhausted version-retry attempts, which would let `/archive` claim 200 OK while the row stayed unarchived. That regresses the #916 acceptance criterion that non-RPC errors during archive must still propagate as 5xx. Both fall-through paths now throw, matching the contract of the sibling writers in this file (renameSession, mergeSessions). The sessionModel test suite gains two cases that spy on `store.sessions.updateSessionMetadata` to force `error` and `version-mismatch` shapes and asserts the helper throws. The existing route test at `hub/src/web/routes/sessions.test.ts:1015` already covers the route-level 500 propagation for any error thrown out of `archiveSession`, so no new route test is needed. Imports `spyOn` from `bun:test` to match this test file's runtime (the rest of the hub package uses bun:test, not vitest). Refs tiann/hapi#916. Co-authored-by: Cursor * revert(cli): drop HAPI_DEFAULT_ARCHIVE_REASON env override Reverts `1c8972a3`. Bot review round 3 surfaced that the env-on-spawn approach (the bot's own round-1 suggestion shape) mislabels hub-restart-cascade SIGTERMs against runner-spawned children: systemd killcgroup on `hapi-runner.service` stop sends SIGTERM to all runner-children directly, and those would archive as 'Stopped by runner' instead of 'Hub restart'. The two suggestions are mutually incompatible without adding an IPC channel (stdio: 'ipc' on spawn) so the runner can stamp setArchiveReason via childProcess.send() before SIGTERMing. That is a refactor, not a smallest-defensible change. Going back to the simple shape: SIGTERM default is 'Hub restart' for everyone, runner-internal stop paths share that label. The audit-trail-correctness criterion from the #914 issue is met (SIGTERM no longer falsely labels as 'User terminated'). Finer attribution between cascade vs runner-stop is deferred as a follow-up. Refs tiann/hapi#914. Co-authored-by: Cursor * fix(cli): clean completions get 'Session completed', not 'Hub restart' Addresses bot review round 4 of #923 (Major): every agent runner (runClaude, runCodex, runCursor, runGemini, runKimi, runOpencode) calls setSessionEndReason('completed') on the natural exit path without touching archiveReason. With the SIGTERM default flipped to 'Hub restart', clean completions were now archived as restart cascades. Fix: setSessionEndReason flips archiveReason to 'Session completed' when it transitions to 'completed' AND no caller has already overridden the archive reason. This covers all six agent runners with a single setter change (no per-runner edits). Two new tests cover the natural-completion default and the override precedence (explicit setArchiveReason still wins). Refs tiann/hapi#914. Co-authored-by: Cursor * fix(hub): restore inactive-session guard on /archive except split-brain Addresses post-rebase bot review Major on #923: dropping requireActive entirely let normal inactive non-archived rows (completed stubs, UI Delete/Reopen targets) fall through to archiveSession, which could stamp archivedBy=hub on sessions that were never active. Restore the 409 for inactive rows unless metadata.lifecycleState is still 'running' (hub-restart split-brain cleanup case from #916). Two route tests cover the guard and the exception. Refs tiann/hapi#916. Co-authored-by: Cursor * fix(cli): merge runnerLifecycle tests after upstream rebase Post-rebase fix: Session completed tests referenced makeFakeSession which was renamed to createMockApiSessionWithMetadataCapture when merging upstream hasExplicitSessionEndReason tests with #914 archive reason coverage. Co-authored-by: Cursor * fix(cli): pass lifecycle object to KillSession handler in Pi runner Upstream #862 (Pi agent) landed after this branch was cut. runPi.ts still registered the legacy bare cleanupAndExit callback, so web Archive for Pi sessions would persist archiveReason: Hub restart instead of User terminated. One-line fix matching the other six agent runners. Refs tiann/hapi#914. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/agent/runnerLifecycle.test.ts | 109 +++++++ cli/src/agent/runnerLifecycle.ts | 42 ++- cli/src/api/apiSession.ts | 15 + .../claude/registerKillSessionHandler.test.ts | 65 ++++ cli/src/claude/registerKillSessionHandler.ts | 29 +- cli/src/claude/runClaude.ts | 2 +- cli/src/codex/runCodex.ts | 2 +- .../cursor/cursorAcpRemoteLauncher.test.ts | 66 ++++ cli/src/cursor/cursorAcpRemoteLauncher.ts | 12 + cli/src/cursor/runCursor.ts | 2 +- cli/src/gemini/runGemini.ts | 2 +- cli/src/kimi/runKimi.ts | 2 +- cli/src/opencode/runOpencode.ts | 2 +- cli/src/pi/runPi.ts | 2 +- hub/src/sync/rpcGateway.test.ts | 47 ++- hub/src/sync/rpcGateway.ts | 25 +- hub/src/sync/sessionCache.ts | 286 ++++++++++++------ hub/src/sync/sessionModel.test.ts | 263 +++++++++++++++- hub/src/sync/syncEngine.ts | 20 +- hub/src/web/routes/sessions.test.ts | 123 ++++++++ hub/src/web/routes/sessions.ts | 17 +- 21 files changed, 1020 insertions(+), 113 deletions(-) create mode 100644 cli/src/claude/registerKillSessionHandler.test.ts diff --git a/cli/src/agent/runnerLifecycle.test.ts b/cli/src/agent/runnerLifecycle.test.ts index ec9c3d99c6..172dca0aef 100644 --- a/cli/src/agent/runnerLifecycle.test.ts +++ b/cli/src/agent/runnerLifecycle.test.ts @@ -23,6 +23,23 @@ function createMockApiSession() { } as unknown as Parameters[0]['session']; } +function createMockApiSessionWithMetadataCapture() { + const metadataWrites: Array> = [] + return { + updateMetadata: vi.fn((handler: (m: Record) => Record) => { + const next = handler({}) + metadataWrites.push(next) + return next + }), + sendSessionDeath: vi.fn(), + flush: vi.fn(async () => {}), + close: vi.fn(async () => {}), + metadataWrites + } as unknown as Parameters[0]['session'] & { + metadataWrites: Array> + } +} + describe('createRunnerLifecycle', () => { let lifecycle: RunnerLifecycle; @@ -85,3 +102,95 @@ describe('createRunnerLifecycle', () => { }); }); }); + +// tiann/hapi#914: the runnerLifecycle's default archiveReason is now +// 'Hub restart' (was 'User terminated'). Out-of-band SIGTERM from the +// hub-restart cascade keeps that default. Explicit user actions +// (clicking Archive in the web UI, Ctrl-C in a local terminal, +// uncaught exception) reassign the reason before archive metadata is +// written. +describe('createRunnerLifecycle archiveReason defaults (tiann/hapi#914)', () => { + it('uses Hub restart as the default archiveReason when no override is applied', async () => { + const session = createMockApiSessionWithMetadataCapture() + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'test' + }) + + await lifecycle.cleanup() + + expect(session.metadataWrites).toHaveLength(1) + expect(session.metadataWrites[0]).toMatchObject({ + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'Hub restart' + }) + }) + + it('writes the operator-supplied reason when setArchiveReason is called (e.g. KillSession RPC)', async () => { + const session = createMockApiSessionWithMetadataCapture() + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'test' + }) + + lifecycle.setArchiveReason('User terminated') + await lifecycle.cleanup() + + expect(session.metadataWrites[0]).toMatchObject({ + archiveReason: 'User terminated' + }) + }) + + it('markCrash overrides the default reason to "Session crashed"', async () => { + const session = createMockApiSessionWithMetadataCapture() + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'test' + }) + + lifecycle.markCrash(new Error('boom')) + await lifecycle.cleanup() + + expect(session.metadataWrites[0]).toMatchObject({ + archiveReason: 'Session crashed' + }) + }) + + // tiann/hapi#914 review round 4: clean agent-loop completions + // (runClaude / runCodex / runCursor / runGemini / runKimi / + // runOpencode all call setSessionEndReason('completed') without + // touching archiveReason) must not be archived as 'Hub restart'. + // The setSessionEndReason setter flips the default when the runner + // transitions to 'completed'. + it('setSessionEndReason("completed") flips the default reason to "Session completed"', async () => { + const session = createMockApiSessionWithMetadataCapture() + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'test' + }) + + lifecycle.setSessionEndReason('completed') + await lifecycle.cleanup() + + expect(session.metadataWrites[0]).toMatchObject({ + archiveReason: 'Session completed' + }) + }) + + it('an explicit setArchiveReason before setSessionEndReason("completed") still wins', async () => { + const session = createMockApiSessionWithMetadataCapture() + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'test' + }) + + lifecycle.setArchiveReason('User terminated') + lifecycle.setSessionEndReason('completed') + await lifecycle.cleanup() + + expect(session.metadataWrites[0]).toMatchObject({ + archiveReason: 'User terminated' + }) + }) +}) diff --git a/cli/src/agent/runnerLifecycle.ts b/cli/src/agent/runnerLifecycle.ts index d632c17a02..16a0495120 100644 --- a/cli/src/agent/runnerLifecycle.ts +++ b/cli/src/agent/runnerLifecycle.ts @@ -24,7 +24,27 @@ export type RunnerLifecycle = { export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLifecycle { let exitCode = 0 - let archiveReason = 'User terminated' + // tiann/hapi#914: default reason is 'Hub restart' (parent-driven SIGTERM + // is the most common non-user cause). Genuine user actions (clicking + // Archive in the web UI, or Ctrl-C in a local terminal) explicitly + // reassign this via `setArchiveReason` BEFORE `cleanupAndExit` runs: + // - KillSession RPC handler → 'User terminated' (see registerKillSessionHandler) + // - SIGINT handler → 'User terminated' (Ctrl-C in local terminal) + // - uncaughtException/Reject → 'Session crashed' (via markCrash) + // + // Out-of-band SIGTERM (hub-restart cascade, systemd cgroup kill on + // hapi-runner.service stop, `kill ` from the operator) keeps the + // default and is correctly labelled 'Hub restart' on the audit trail. + // + // Runner-internal stop paths (`hapi runner stop-session`, webhook-timeout + // cleanup at run.ts:587, orphan cleanup at run.ts:267) also currently + // hit this default - that is technically inaccurate but follows the + // friction-mode "smallest defensible change" rule for this PR. Finer + // attribution would require an IPC channel (stdio: 'ipc' on spawn) so + // the runner can stamp `setArchiveReason` before SIGTERMing; tracked as + // a follow-up to keep this PR focussed on the user-action lie that + // motivated #914. + let archiveReason = 'Hub restart' let sessionEndReason: SessionEndReason = 'terminated' let sessionEndReasonExplicit = false let cleanupStarted = false @@ -98,6 +118,18 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi const setSessionEndReason = (reason: SessionEndReason) => { sessionEndReason = reason sessionEndReasonExplicit = true + // tiann/hapi#914 review round 4: every agent runner + // (runClaude / runCodex / runCursor / runGemini / runKimi / + // runOpencode) calls setSessionEndReason('completed') before + // cleanupAndExit() on the natural-exit path without setting an + // archive reason. With the SIGTERM-driven default of 'Hub restart', + // clean completions would otherwise be audit-trailed as restart + // cascades. Flip the default to 'Session completed' when the end + // reason transitions to 'completed' AND no caller has already + // overridden the archive reason. + if (reason === 'completed' && archiveReason === 'Hub restart') { + archiveReason = 'Session completed' + } } const hasExplicitSessionEndReason = () => sessionEndReasonExplicit @@ -110,11 +142,19 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi } const registerProcessHandlers = () => { + // tiann/hapi#914: SIGTERM is treated as the default reason ('Hub restart') + // because the runner is restarted by systemd as part of hub restart in + // production. If a future code path needs to distinguish "operator + // killed the host process" from "hub restart", it can call + // setArchiveReason() before the runner exits. process.on('SIGTERM', () => { void cleanupAndExit() }) + // Ctrl-C in a local terminal is genuine user intent — keep the + // pre-#914 label so the audit trail still shows it. process.on('SIGINT', () => { + archiveReason = 'User terminated' void cleanupAndExit() }) diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 404e10a48e..cd98d69a51 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -748,6 +748,21 @@ export class ApiSessionClient extends EventEmitter { }) } + /** + * tiann/hapi#913: wait until any pending `update-metadata` writes have + * been acked by the hub (or the timeout elapses). `updateMetadata` is + * fire-and-forget at the call site because it's invoked on the hot path + * for every turn; this helper lets the few callers who actually need + * durability — fresh ACP session-id pre-registration is the canonical + * case — synchronously gate on persistence without changing every + * caller's signature. + * + * Returns true when the lock drained, false when the timeout fired. + */ + async flushMetadata(timeoutMs: number = 5_000): Promise { + return await this.drainLock(this.metadataLock, timeoutMs) + } + async flush(options?: { timeoutMs?: number }): Promise { const deadlineMs = Date.now() + (options?.timeoutMs ?? 5_000) diff --git a/cli/src/claude/registerKillSessionHandler.test.ts b/cli/src/claude/registerKillSessionHandler.test.ts new file mode 100644 index 0000000000..b293172955 --- /dev/null +++ b/cli/src/claude/registerKillSessionHandler.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { registerKillSessionHandler } from './registerKillSessionHandler' + +// tiann/hapi#914: the KillSession RPC is the authoritative "user-terminated" +// signal because the hub only sends it when the operator clicks Archive in +// the web UI. Out-of-band SIGTERM (hub-restart cascade, host-level `kill`) +// hits the SIGTERM signal handler in runnerLifecycle, which now keeps the +// default reason 'Hub restart' so the audit trail stays correct. +describe('registerKillSessionHandler (tiann/hapi#914)', () => { + function makeRegistry() { + const handlers = new Map unknown>() + return { + registerHandler: (method: string, handler: (params: unknown) => unknown) => { + handlers.set(method, handler as (params?: unknown) => unknown) + }, + handlers + } + } + + it('stamps archiveReason=User terminated before triggering cleanupAndExit', async () => { + const registry = makeRegistry() + const lifecycle = { + setArchiveReason: vi.fn(), + cleanupAndExit: vi.fn(async () => {}) + } + + registerKillSessionHandler( + registry as unknown as Parameters[0], + lifecycle + ) + + const handler = registry.handlers.get(RPC_METHODS.KillSession) + expect(handler).toBeDefined() + + const result = await handler?.() + expect(result).toEqual({ success: true, message: 'Killing hapi CLI process' }) + + // setArchiveReason MUST be called BEFORE cleanupAndExit so the archive + // metadata write reads the correct reason. + const setReasonOrder = lifecycle.setArchiveReason.mock.invocationCallOrder[0] + const cleanupOrder = lifecycle.cleanupAndExit.mock.invocationCallOrder[0] + expect(setReasonOrder).toBeLessThan(cleanupOrder) + expect(lifecycle.setArchiveReason).toHaveBeenCalledWith('User terminated') + expect(lifecycle.cleanupAndExit).toHaveBeenCalled() + }) + + it('still works with the legacy `(cleanupAndExit: () => Promise)` call shape', async () => { + // Back-compat: runAgentSession.ts passes a bare closure as the second + // argument instead of a lifecycle object. The handler should not crash + // when setArchiveReason is absent. + const registry = makeRegistry() + const cleanupAndExit = vi.fn(async () => {}) + + registerKillSessionHandler( + registry as unknown as Parameters[0], + cleanupAndExit + ) + + const handler = registry.handlers.get(RPC_METHODS.KillSession) + await handler?.() + + expect(cleanupAndExit).toHaveBeenCalled() + }) +}) diff --git a/cli/src/claude/registerKillSessionHandler.ts b/cli/src/claude/registerKillSessionHandler.ts index 37936b79bc..b42b9b49fc 100644 --- a/cli/src/claude/registerKillSessionHandler.ts +++ b/cli/src/claude/registerKillSessionHandler.ts @@ -11,18 +11,41 @@ interface KillSessionResponse { message: string; } +/** + * tiann/hapi#914: callers can pass either a bare `cleanupAndExit` closure + * (legacy) or an options object that lets the kill-RPC stamp an explicit + * `archiveReason` before the lifecycle teardown runs. The hub only sends + * KillSession when the operator clicked Archive in the UI, so this RPC is + * the authoritative "user-terminated" signal; out-of-band SIGTERM from a + * hub-restart cascade no longer collides with the default archive reason. + */ +export interface KillSessionLifecycle { + cleanupAndExit: () => Promise; + setArchiveReason?: (reason: string) => void; +} export function registerKillSessionHandler( rpcHandlerManager: RpcHandlerManager, - killThisHappy: () => Promise + lifecycleOrCleanup: KillSessionLifecycle | (() => Promise) ) { + const lifecycle: KillSessionLifecycle = typeof lifecycleOrCleanup === 'function' + ? { cleanupAndExit: lifecycleOrCleanup } + : lifecycleOrCleanup; + rpcHandlerManager.registerHandler(RPC_METHODS.KillSession, async () => { logger.debug('Kill session request received'); + // tiann/hapi#914: stamp the archive reason from the RPC path so the + // default in `runnerLifecycle.ts` can be reassigned away from + // 'User terminated'. A hub-restart-cascade SIGTERM does NOT go + // through this handler — it hits the SIGTERM signal handler — so + // those archives now stay labelled `'Hub restart'` (the new default). + lifecycle.setArchiveReason?.('User terminated'); + // This will start the cleanup process - void killThisHappy(); + void lifecycle.cleanupAndExit(); - // We should still be able to respond the the client, though they + // We should still be able to respond to the client, though they // should optimistically assume the session is dead. return { success: true, diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 4472aee92d..1ebb1601e5 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -145,7 +145,7 @@ export async function runClaude(options: StartOptions = {}): Promise { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); // Set initial agent state diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 4958b6acfc..de907c2271 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -98,7 +98,7 @@ export async function runCodex(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => { diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index 1aeca09b3b..761e86c387 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -166,6 +166,7 @@ function makeClient() { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive: vi.fn(), @@ -297,11 +298,68 @@ describe('cursorAcpRemoteLauncher', () => { expect(session.client.emitSessionReady).not.toHaveBeenCalled(); }); + // tiann/hapi#913: fresh ACP sessions previously persisted `cursorSessionId` + // via fire-and-forget `updateMetadata`. A SIGTERM within ~1s of the first + // turn (hub-restart cascade) could strand the session because the ACK + // never arrived. The fix awaits `client.flushMetadata()` between + // `onSessionFoundWithProtocol` and the main loop, gating turn processing + // on a durable persist. + it('awaits flushMetadata after registering a fresh cursorSessionId so SIGTERM cannot strand the session', async () => { + const session = makeSession(null); + const flushSpy = vi.fn(async () => true); + // Replace the mock fixture's flushMetadata so we can observe ordering. + (session.client as unknown as { flushMetadata: typeof flushSpy }).flushMetadata = flushSpy; + + let flushCalled = false; + flushSpy.mockImplementation(async () => { + flushCalled = true; + return true; + }); + + const onSessionFoundSpy = session.onSessionFoundWithProtocol as ReturnType; + let onSessionFoundCalledBeforeFlush = false; + onSessionFoundSpy.mockImplementation(() => { + if (!flushCalled) { + onSessionFoundCalledBeforeFlush = true; + } + }); + + await cursorAcpRemoteLauncher(session); + + expect(onSessionFoundCalledBeforeFlush).toBe(true); + expect(flushSpy).toHaveBeenCalled(); + }); + + it('preserves the #834 resume-path pre-registration shape (registration before backend.loadSession)', async () => { + // PR #834 pre-registers `cursorSessionId` BEFORE `backend.loadSession` + // so a load-session failure on a legacy store does not strand the + // session. The #913 fix must not relocate or remove that + // pre-registration. We verify by observing call ordering on the spy. + const session = makeSession('resume-acp-session'); + const onSessionFoundSpy = session.onSessionFoundWithProtocol as ReturnType; + + let preRegisterCalledBeforeLoadSession = false; + let preRegisterArgs: unknown[] | null = null; + onSessionFoundSpy.mockImplementation((id: string, protocol: string) => { + if (!harness.loadSessionCalled) { + preRegisterCalledBeforeLoadSession = true; + preRegisterArgs = [id, protocol]; + } + }); + + await cursorAcpRemoteLauncher(session); + + expect(preRegisterCalledBeforeLoadSession).toBe(true); + expect(preRegisterArgs).toEqual(['resume-acp-session', 'acp']); + expect(harness.loadSessionCalled).toBe(true); + }); + it('applies debug mode immediately when setPermissionMode is called', async () => { const queue = new MessageQueue2((mode) => mode.permissionMode); const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive: vi.fn(), @@ -347,6 +405,7 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive, @@ -392,6 +451,7 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive, @@ -440,6 +500,7 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive: vi.fn(), @@ -485,6 +546,7 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive: vi.fn(), @@ -542,6 +604,7 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive, @@ -584,6 +647,7 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive: vi.fn(), @@ -630,6 +694,7 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive: vi.fn(), @@ -671,6 +736,7 @@ describe('cursorAcpRemoteLauncher', () => { const client = { rpcHandlerManager: { registerHandler: vi.fn() }, updateMetadata: vi.fn(), + flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), keepAlive: vi.fn(), diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 4382974e6d..5e3eaf75b5 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -132,6 +132,18 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { if (acpSessionId !== resumeSessionId) { session.onSessionFoundWithProtocol(acpSessionId, 'acp'); + // tiann/hapi#913: block until the metadata write that pins + // `cursorSessionId` reaches the hub DB before we drop into + // `runMainLoop`. If SIGTERM (hub-restart cascade) lands during + // the first turn without this gate, the only durable handle + // linking the session to its on-disk ACP store is lost and the + // session strands. The resume path at lines 98-100 already + // relies on the latency of `backend.loadSession()` to flush the + // same write; the fresh-session path has no such cover. + const flushed = await session.client.flushMetadata(); + if (!flushed) { + logger.warn(`[cursor-acp] cursorSessionId metadata write did not ACK within 5s; session may be unrecoverable if killed before the lock drains (acpSessionId=${acpSessionId})`); + } } session.client.emitSessionReady(); diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index f5508f3429..a855a071fa 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -81,7 +81,7 @@ export async function runCursor(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); const syncSessionMode = () => { diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 34b13026e2..c2e667225e 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -113,7 +113,7 @@ export async function runGemini(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); const syncSessionMode = () => { diff --git a/cli/src/kimi/runKimi.ts b/cli/src/kimi/runKimi.ts index 97cc3703bc..f148b880de 100644 --- a/cli/src/kimi/runKimi.ts +++ b/cli/src/kimi/runKimi.ts @@ -82,7 +82,7 @@ export async function runKimi(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); const syncSessionMode = () => { diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index a78f931195..3e89e02a80 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -107,7 +107,7 @@ export async function runOpencode(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); const syncSessionMode = () => { diff --git a/cli/src/pi/runPi.ts b/cli/src/pi/runPi.ts index 430a047902..f3417483df 100644 --- a/cli/src/pi/runPi.ts +++ b/cli/src/pi/runPi.ts @@ -88,7 +88,7 @@ export async function runPi(opts: { }); lifecycle.registerProcessHandlers(); - registerKillSessionHandler(apiSession.rpcHandlerManager, lifecycle.cleanupAndExit); + registerKillSessionHandler(apiSession.rpcHandlerManager, lifecycle); registerLocalHandoffHandler(apiSession.rpcHandlerManager, lifecycle); let cleanupInitiated = false; diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts index 8c98fad941..d0825c0d68 100644 --- a/hub/src/sync/rpcGateway.test.ts +++ b/hub/src/sync/rpcGateway.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test' import type { Server } from 'socket.io' import type { RpcRegistry } from '../socket/rpcRegistry' -import { RpcGateway } from './rpcGateway' +import { RpcGateway, RpcTargetMissingError } from './rpcGateway' function createGateway() { const timeouts: number[] = [] @@ -70,3 +70,48 @@ describe('RpcGateway RPC timeouts', () => { }) }) +// tiann/hapi#916: rpcCall throws a typed `RpcTargetMissingError` when the +// target CLI is unreachable, so syncEngine.archiveSession can narrow on it +// and treat the kill as a benign no-op. +describe('RpcGateway no-target diagnostics (tiann/hapi#916)', () => { + it('throws RpcTargetMissingError(handler-not-registered) when no socket is registered for the method', async () => { + const io = { + of() { + return { + sockets: { + get() { return undefined } + } + } + } + } as unknown as Server + const rpcRegistry = { + getSocketIdForMethod() { return undefined } + } as unknown as RpcRegistry + const gateway = new RpcGateway(io, rpcRegistry) + + const error = await gateway.killSession('session-1').catch((e: unknown) => e) + expect(error).toBeInstanceOf(RpcTargetMissingError) + expect((error as RpcTargetMissingError).code).toBe('handler-not-registered') + }) + + it('throws RpcTargetMissingError(socket-disconnected) when the socket id is registered but no socket exists', async () => { + const io = { + of() { + return { + sockets: { + get() { return undefined } + } + } + } + } as unknown as Server + const rpcRegistry = { + getSocketIdForMethod() { return 'socket-1' } + } as unknown as RpcRegistry + const gateway = new RpcGateway(io, rpcRegistry) + + const error = await gateway.killSession('session-1').catch((e: unknown) => e) + expect(error).toBeInstanceOf(RpcTargetMissingError) + expect((error as RpcTargetMissingError).code).toBe('socket-disconnected') + }) +}) + diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 193eedd0bf..5a63f4547c 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -24,6 +24,27 @@ import type { RpcRegistry } from '../socket/rpcRegistry' const DEFAULT_RPC_TIMEOUT_MS = 30_000 const MODEL_LIST_RPC_TIMEOUT_MS = 120_000 +/** + * tiann/hapi#916: thrown by {@link RpcGateway.rpcCall} when the target CLI is + * unreachable (handler not registered or socket disconnected). Callers can + * narrow on this to treat "CLI gone" as a benign condition (e.g. archive + * still succeeds at the hub level) without swallowing real RPC errors like + * timeouts or protocol failures. + */ +export class RpcTargetMissingError extends Error { + readonly code: 'handler-not-registered' | 'socket-disconnected' + readonly method: string + + constructor(method: string, reason: 'handler-not-registered' | 'socket-disconnected') { + super(reason === 'handler-not-registered' + ? `RPC handler not registered: ${method}` + : `RPC socket disconnected: ${method}`) + this.name = 'RpcTargetMissingError' + this.code = reason + this.method = method + } +} + export type RpcCommandResponse = CommandResponse export type RpcReadFileResponse = FileReadResponse export type RpcGeneratedImageResponse = GeneratedImageResponse @@ -292,12 +313,12 @@ export class RpcGateway { private async rpcCall(method: string, params: unknown, timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS): Promise { const socketId = this.rpcRegistry.getSocketIdForMethod(method) if (!socketId) { - throw new Error(`RPC handler not registered: ${method}`) + throw new RpcTargetMissingError(method, 'handler-not-registered') } const socket = this.io.of('/cli').sockets.get(socketId) if (!socket) { - throw new Error(`RPC socket disconnected: ${method}`) + throw new RpcTargetMissingError(method, 'socket-disconnected') } const response = await socket.timeout(timeoutMs).emitWithAck('rpc-request', { diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 86dd02a564..304b01ccae 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -7,6 +7,11 @@ import { extractTodoWriteTodosFromMessageContent, TodosSchema } from './todos' import { extractBackgroundTaskDelta } from './backgroundTasks' const QUEUED_MESSAGE_THINKING_GRACE_MS = 15_000 +// tiann/hapi#919: metadata writers (renameSession, clearSessionArchiveMetadata, +// restoreSessionArchiveMetadata) retry on version-mismatch with a fresh cache +// snapshot. Cap retries so genuine concurrent contention still surfaces to the +// HTTP caller as 409 instead of spinning forever. +const METADATA_RETRY_ATTEMPTS = 5 type RuntimeConfigKey = 'permissionMode' | 'model' | 'modelReasoningEffort' | 'effort' | 'serviceTier' | 'collaborationMode' export class SessionCache { @@ -522,32 +527,105 @@ export class SessionCache { return updatedAt !== undefined && payloadTime < updatedAt } - async renameSession(sessionId: string, name: string): Promise { - const session = this.sessions.get(sessionId) - if (!session) { - throw new Error('Session not found') - } + /** + * tiann/hapi#916: hub-side write of the archive-metadata fields normally + * authored by the CLI's `archiveAndClose`. Called by `syncEngine.archiveSession` + * when the kill-RPC fails because the CLI is unreachable (e.g. the + * hub-restart cascade already killed it). Without this, the route would + * either 500 (pre-fix) or silently return ok=true while leaving + * `lifecycleState=running` on disk — both confuse the operator. + * + * Idempotent: if `lifecycleState` is already `archived` we return without + * touching the row to avoid resetting `lifecycleStateSince`. Best-effort: + * if every retry hits `version-mismatch` (genuine contention) the original + * `archiveSession` flow still marks the session inactive in cache via + * `handleSessionEnd`, just without flipping the persisted lifecycle. + */ + markSessionArchivedFromHub(sessionId: string, reason: string): void { + for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) { + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (!session) return + const current = session.metadata + if (!current) return + if (current.lifecycleState === 'archived') { + return + } - const currentMetadata = session.metadata ?? { path: '', host: '' } - const newMetadata = { ...currentMetadata, name } + const next: Record = { + ...current, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'hub', + archiveReason: reason + } - const result = this.store.sessions.updateSessionMetadata( - sessionId, - newMetadata, - session.metadataVersion, - session.namespace, - { touchUpdatedAt: false } - ) + const result = this.store.sessions.updateSessionMetadata( + sessionId, + next, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) - if (result.result === 'error') { - throw new Error('Failed to update session metadata') + if (result.result === 'error') { + // tiann/hapi#916 review feedback: persistence failure must + // surface so the route returns 5xx. Silently returning here + // would let `/archive` claim success while the row stays + // unarchived in the DB. + throw new Error('Failed to archive session metadata from hub') + } + + if (result.result === 'success') { + this.refreshSession(sessionId) + return + } + + this.refreshSession(sessionId) } - if (result.result === 'version-mismatch') { - throw new Error('Session was modified concurrently. Please try again.') + // tiann/hapi#916 review feedback: exhausted retries means we never + // got a successful write. Match the renameSession / mergeSessions + // contract and surface this as an error so non-RPC failures stay + // 5xx per the issue's acceptance criteria. + throw new Error('Session was modified concurrently while archiving from hub') + } + + async renameSession(sessionId: string, name: string): Promise { + // tiann/hapi#919: retry-with-refresh on version-mismatch instead of + // throwing on the first contention. Mirrors the good pattern in + // mergeSessions (~L780) and in syncEngine's metadata helpers. Without + // this, a stale cache snapshot produces forever-409 on PATCH /sessions/:id + // until some unrelated event triggers a refresh. + for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) { + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (!session) { + throw new Error('Session not found') + } + + const currentMetadata = session.metadata ?? { path: '', host: '' } + const newMetadata = { ...currentMetadata, name } + + const result = this.store.sessions.updateSessionMetadata( + sessionId, + newMetadata, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'error') { + throw new Error('Failed to update session metadata') + } + + if (result.result === 'success') { + this.refreshSession(sessionId) + return + } + + this.refreshSession(sessionId) } - this.refreshSession(sessionId) + throw new Error('Session was modified concurrently. Please try again.') } /** @@ -563,52 +641,59 @@ export class SessionCache { * No-op when metadata is null (callers should pre-check). */ async clearSessionArchiveMetadata(sessionId: string): Promise<{ cursorSessionProtocol?: 'acp' | 'stream-json' }> { - const session = this.sessions.get(sessionId) - if (!session) { - throw new Error('Session not found') - } + // tiann/hapi#919: retry-with-refresh on version-mismatch. The reopen + // flow runs this on every archived-session resume — a stale snapshot + // here used to forever-409 the only reopen affordance. + for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) { + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (!session) { + throw new Error('Session not found') + } - const currentMetadata = session.metadata - if (!currentMetadata) { - throw new Error('Session metadata missing') - } - - const next: Record = { ...currentMetadata } - delete next.lifecycleState - delete next.archivedBy - delete next.archiveReason - next.lifecycleStateSince = Date.now() - - let cursorSessionProtocol: 'acp' | 'stream-json' | undefined - if (currentMetadata.flavor === 'cursor') { - const existing = currentMetadata.cursorSessionProtocol - if (existing === 'acp' || existing === 'stream-json') { - cursorSessionProtocol = existing - } else if (currentMetadata.cursorSessionId) { - // Pre-#799 default: presence of cursorSessionId without protocol means stream-json. - cursorSessionProtocol = 'stream-json' - next.cursorSessionProtocol = 'stream-json' + const currentMetadata = session.metadata + if (!currentMetadata) { + throw new Error('Session metadata missing') } - } - const result = this.store.sessions.updateSessionMetadata( - sessionId, - next, - session.metadataVersion, - session.namespace, - { touchUpdatedAt: false } - ) + const next: Record = { ...currentMetadata } + delete next.lifecycleState + delete next.archivedBy + delete next.archiveReason + next.lifecycleStateSince = Date.now() + + let cursorSessionProtocol: 'acp' | 'stream-json' | undefined + if (currentMetadata.flavor === 'cursor') { + const existing = currentMetadata.cursorSessionProtocol + if (existing === 'acp' || existing === 'stream-json') { + cursorSessionProtocol = existing + } else if (currentMetadata.cursorSessionId) { + // Pre-#799 default: presence of cursorSessionId without protocol means stream-json. + cursorSessionProtocol = 'stream-json' + next.cursorSessionProtocol = 'stream-json' + } + } - if (result.result === 'error') { - throw new Error('Failed to update session metadata') - } + const result = this.store.sessions.updateSessionMetadata( + sessionId, + next, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) - if (result.result === 'version-mismatch') { - throw new Error('Session was modified concurrently. Please try again.') + if (result.result === 'error') { + throw new Error('Failed to update session metadata') + } + + if (result.result === 'success') { + this.refreshSession(sessionId) + return cursorSessionProtocol ? { cursorSessionProtocol } : {} + } + + this.refreshSession(sessionId) } - this.refreshSession(sessionId) - return cursorSessionProtocol ? { cursorSessionProtocol } : {} + throw new Error('Session was modified concurrently. Please try again.') } /** @@ -632,50 +717,59 @@ export class SessionCache { lifecycleStateSince?: number } ): Promise { - const session = this.sessions.get(sessionId) - if (!session) return - const current = session.metadata - if (!current) return + // tiann/hapi#919: retry-with-refresh on version-mismatch. This is the + // /reopen rollback path — if it fails the session is left in a + // half-cleared archive state, so making it robust to a stale snapshot + // matters more here than for the other two. + for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) { + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (!session) return + const current = session.metadata + if (!current) return + + const next: Record = { ...current } + if (snapshot.lifecycleState !== undefined) { + next.lifecycleState = snapshot.lifecycleState + } else { + delete next.lifecycleState + } + if (snapshot.archivedBy !== undefined) { + next.archivedBy = snapshot.archivedBy + } else { + delete next.archivedBy + } + if (snapshot.archiveReason !== undefined) { + next.archiveReason = snapshot.archiveReason + } else { + delete next.archiveReason + } + if (snapshot.lifecycleStateSince !== undefined) { + next.lifecycleStateSince = snapshot.lifecycleStateSince + } else { + delete next.lifecycleStateSince + } - const next: Record = { ...current } - if (snapshot.lifecycleState !== undefined) { - next.lifecycleState = snapshot.lifecycleState - } else { - delete next.lifecycleState - } - if (snapshot.archivedBy !== undefined) { - next.archivedBy = snapshot.archivedBy - } else { - delete next.archivedBy - } - if (snapshot.archiveReason !== undefined) { - next.archiveReason = snapshot.archiveReason - } else { - delete next.archiveReason - } - if (snapshot.lifecycleStateSince !== undefined) { - next.lifecycleStateSince = snapshot.lifecycleStateSince - } else { - delete next.lifecycleStateSince - } + const result = this.store.sessions.updateSessionMetadata( + sessionId, + next, + session.metadataVersion, + session.namespace, + { touchUpdatedAt: false } + ) - const result = this.store.sessions.updateSessionMetadata( - sessionId, - next, - session.metadataVersion, - session.namespace, - { touchUpdatedAt: false } - ) + if (result.result === 'error') { + throw new Error('Failed to restore archive metadata') + } - if (result.result === 'error') { - throw new Error('Failed to restore archive metadata') - } + if (result.result === 'success') { + this.refreshSession(sessionId) + return + } - if (result.result === 'version-mismatch') { - throw new Error('Session was modified concurrently during reopen rollback') + this.refreshSession(sessionId) } - this.refreshSession(sessionId) + throw new Error('Session was modified concurrently during reopen rollback') } async deleteSession(sessionId: string): Promise { diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 7ea9950e14..8fa18c074a 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, spyOn } from 'bun:test' import { toSessionSummary } from '@hapi/protocol' import type { SyncEvent } from '@hapi/protocol/types' import { Store } from '../store' @@ -2466,4 +2466,265 @@ describe('session model', () => { })).resolves.toBeUndefined() }) }) + + // tiann/hapi#916: when the CLI is gone, the kill-RPC throws + // RpcTargetMissingError. markSessionArchivedFromHub writes the archive + // metadata directly so the row's lifecycleState still flips to 'archived'. + describe('markSessionArchivedFromHub (tiann/hapi#916)', () => { + it('flips lifecycleState to archived with archivedBy=hub and the supplied reason', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-hub-archive', + { path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-1' }, + null, + 'default' + ) + + cache.markSessionArchivedFromHub(session.id, 'Archived from hub (CLI unreachable)') + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBe('archived') + expect(meta?.archivedBy).toBe('hub') + expect(meta?.archiveReason).toBe('Archived from hub (CLI unreachable)') + expect(typeof meta?.lifecycleStateSince).toBe('number') + }) + + it('is idempotent for already-archived sessions (does not reset lifecycleStateSince)', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const initialSince = 1700000000000 + const session = cache.getOrCreateSession( + 'session-already-archived', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated', + lifecycleStateSince: initialSince + }, + null, + 'default' + ) + + cache.markSessionArchivedFromHub(session.id, 'Should not overwrite') + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBe('archived') + expect(meta?.archivedBy).toBe('cli') + expect(meta?.archiveReason).toBe('User terminated') + expect(meta?.lifecycleStateSince).toBe(initialSince) + }) + + it('self-heals on version-mismatch via refresh-and-retry', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-hub-archive-stale', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')! + const oobWrite = store.sessions.updateSessionMetadata( + session.id, + { ...dbSession.metadata!, name: 'oob' }, + dbSession.metadataVersion, + 'default', + { touchUpdatedAt: false } + ) + expect(oobWrite.result).toBe('success') + + cache.markSessionArchivedFromHub(session.id, 'CLI unreachable') + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBe('archived') + expect(meta?.archivedBy).toBe('hub') + expect(meta?.name).toBe('oob') + }) + + // tiann/hapi#916 review feedback: persistence failures must surface + // so the /archive route returns 5xx per the acceptance criteria + // "Non-RPC errors during archive still propagate as 5xx (DB write + // failure, etc.)" — silent return would let the route claim success + // while the row stays unarchived. + it('throws when the store reports a hard error on the metadata write', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-hub-archive-error', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + const updateSpy = spyOn(store.sessions, 'updateSessionMetadata').mockReturnValue({ + result: 'error', + error: new Error('simulated DB write failure') + } as ReturnType) + + try { + expect(() => cache.markSessionArchivedFromHub(session.id, 'CLI unreachable')).toThrow(/Failed to archive session metadata from hub/) + } finally { + updateSpy.mockRestore() + } + }) + + it('throws when retries are exhausted by sustained version-mismatch contention', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-hub-archive-exhausted', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + const updateSpy = spyOn(store.sessions, 'updateSessionMetadata').mockReturnValue({ + result: 'version-mismatch' + } as ReturnType) + + try { + expect(() => cache.markSessionArchivedFromHub(session.id, 'CLI unreachable')).toThrow(/Session was modified concurrently while archiving from hub/) + } finally { + updateSpy.mockRestore() + } + }) + }) + + // tiann/hapi#919: the three metadata writers must self-heal on + // version-mismatch instead of one-shot-throwing. The bug was that a + // stale cache snapshot produced forever-409 on the corresponding HTTP + // endpoints — the cache never refreshed, so the same retry hit the + // same mismatch. Pattern mirrors mergeSessions (line ~780). + describe('version-mismatch self-heal (tiann/hapi#919)', () => { + it('renameSession recovers after a stale cache snapshot is detected', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-rename-stale', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + // Simulate a concurrent writer bumping the DB version under our feet: + // write a metadata patch out-of-band via the store, leaving the cache + // snapshot stale. + const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')! + const oobWrite = store.sessions.updateSessionMetadata( + session.id, + { ...dbSession.metadata!, name: 'concurrent-rename' }, + dbSession.metadataVersion, + 'default', + { touchUpdatedAt: false } + ) + expect(oobWrite.result).toBe('success') + + // Cache still holds the pre-OOB snapshot. Pre-fix, this call threw + // 'Session was modified concurrently'. Post-fix, it refreshes and + // succeeds. + await expect(cache.renameSession(session.id, 'final-name')).resolves.toBeUndefined() + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.name).toBe('final-name') + }) + + it('clearSessionArchiveMetadata recovers after a stale cache snapshot', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-clear-stale', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'thread-stale', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + }, + null, + 'default' + ) + + // Concurrent rename via the store bumps the DB version. + const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')! + const oobWrite = store.sessions.updateSessionMetadata( + session.id, + { ...dbSession.metadata!, name: 'oob-name' }, + dbSession.metadataVersion, + 'default', + { touchUpdatedAt: false } + ) + expect(oobWrite.result).toBe('success') + + await expect(cache.clearSessionArchiveMetadata(session.id)).resolves.toBeDefined() + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBeUndefined() + expect(meta?.archivedBy).toBeUndefined() + expect(meta?.name).toBe('oob-name') + }) + + it('restoreSessionArchiveMetadata recovers after a stale cache snapshot', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-restore-stale', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'thread-restore-stale' + // Started without archive metadata - simulates the post-clear state. + }, + null, + 'default' + ) + + // Concurrent unrelated write bumps DB version. + const dbSession = store.sessions.getSessionByNamespace(session.id, 'default')! + const oobWrite = store.sessions.updateSessionMetadata( + session.id, + { ...dbSession.metadata!, name: 'parallel-rename' }, + dbSession.metadataVersion, + 'default', + { touchUpdatedAt: false } + ) + expect(oobWrite.result).toBe('success') + + await expect(cache.restoreSessionArchiveMetadata(session.id, { + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated', + lifecycleStateSince: 1234 + })).resolves.toBeUndefined() + + const meta = cache.getSession(session.id)?.metadata as Record | null | undefined + expect(meta?.lifecycleState).toBe('archived') + expect(meta?.archiveReason).toBe('User terminated') + expect(meta?.lifecycleStateSince).toBe(1234) + expect(meta?.name).toBe('parallel-rename') + }) + }) }) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 7fe09087c1..d59f58fd47 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -23,6 +23,7 @@ import { MachineCache, type Machine } from './machineCache' import { MessageService } from './messageService' import { RpcGateway, + RpcTargetMissingError, type RpcCodexModel, type RpcCommandResponse, type RpcDeleteUploadResponse, @@ -452,7 +453,24 @@ export class SyncEngine { } async archiveSession(sessionId: string): Promise { - await this.rpcGateway.killSession(sessionId) + // tiann/hapi#916: when the CLI is already gone (e.g. after a + // hub-restart cascade SIGTERMed the runner but the in-memory + // `active` flag has not been reconciled yet) the kill-RPC throws + // and the route used to surface that as HTTP 500. Treat the + // missing target as a benign condition: still flip the session's + // lifecycleState to `archived` in the hub-side metadata so the + // UI does not see a half-cleaned zombie, and continue to mark + // it inactive in the cache. Real RPC errors (timeout, protocol + // failure) still propagate as 5xx. + try { + await this.rpcGateway.killSession(sessionId) + } catch (error) { + if (error instanceof RpcTargetMissingError) { + this.sessionCache.markSessionArchivedFromHub(sessionId, 'Archived from hub (CLI unreachable)') + } else { + throw error + } + } this.handleSessionEnd({ sid: sessionId, time: Date.now() }) } diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index d1875c8800..01f6d859c9 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -62,6 +62,7 @@ function createApp(session: Session, opts?: { listSlashCommands?: SyncEngine['listSlashCommands'] getSessionExport?: (sessionId: string, session: Session) => unknown sessionExists?: boolean + archiveSession?: (sessionId: string) => Promise }) { const applySessionConfigCalls: Array<[string, Record]> = [] const applySessionConfig = async (sessionId: string, config: Record) => { @@ -104,6 +105,7 @@ function createApp(session: Session, opts?: { resumed: true })) const sessionExists = opts?.sessionExists !== false + const archiveSessionMock = opts?.archiveSession ?? (async () => {}) const engine = { resolveSessionAccess: () => sessionExists ? { ok: true, sessionId: session.id, session } @@ -115,6 +117,7 @@ function createApp(session: Session, opts?: { listOpencodeReasoningEffortOptionsForSession, resumeSession, reopenSession, + archiveSession: archiveSessionMock, getSessionExport: opts?.getSessionExport ?? (() => ({ type: 'success', payload: { @@ -1014,4 +1017,124 @@ describe('sessions routes', () => { }) }) + // tiann/hapi#916: archive endpoint must be idempotent for already-archived + // rows and for split-brain rows whose CLI is gone but the in-memory `active` + // flag has not been reconciled to false yet. + describe('POST /sessions/:id/archive (tiann/hapi#916)', () => { + it('returns 2xx and calls archiveSession for an active session', async () => { + const calls: string[] = [] + const session = createSession({ active: true }) + const { app } = createApp(session, { + archiveSession: async (sessionId: string) => { calls.push(sessionId) } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(calls).toEqual(['session-1']) + }) + + it('returns 2xx and skips archiveSession when the row is already archived (idempotent)', async () => { + let called = false + const session = createSession({ + active: false, + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + } + }) + const { app } = createApp(session, { + archiveSession: async () => { called = true } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true, alreadyArchived: true }) + expect(called).toBe(false) + }) + + it('returns 2xx when the active session\'s CLI is gone — engine.archiveSession swallows the missing-RPC error', async () => { + // Pre-fix this returned 500 because rpcGateway.killSession threw + // 'RPC handler not registered'. Post-fix the engine narrows on + // RpcTargetMissingError and still flips lifecycle to archived. + const session = createSession({ active: true }) + const { app } = createApp(session, { + archiveSession: async () => { + // Simulates the post-fix behavior: engine catches the + // RpcTargetMissingError, calls markSessionArchivedFromHub, + // and returns normally. + } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + }) + + it('still surfaces a 5xx for non-RPC errors (e.g. DB write failure)', async () => { + const session = createSession({ active: true }) + const { app } = createApp(session, { + archiveSession: async () => { + throw new Error('DB write failed') + } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + expect(response.status).toBe(500) + }) + + it('returns 404 when the session id is unknown', async () => { + const session = createSession() + const { app } = createApp(session, { sessionExists: false }) + + const response = await app.request('/api/sessions/missing-id/archive', { method: 'POST' }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Session not found' }) + }) + + it('returns 409 for an inactive non-archived row whose lifecycle is not running', async () => { + let called = false + const session = createSession({ active: false }) + const { app } = createApp(session, { + archiveSession: async () => { called = true } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ error: 'Session is inactive' }) + expect(called).toBe(false) + }) + + it('returns 2xx for an inactive split-brain row still marked lifecycleState=running', async () => { + const calls: string[] = [] + const session = createSession({ + active: false, + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + lifecycleState: 'running' + } + }) + const { app } = createApp(session, { + archiveSession: async (sessionId: string) => { calls.push(sessionId) } + }) + + const response = await app.request('/api/sessions/session-1/archive', { method: 'POST' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(calls).toEqual(['session-1']) + }) + }) + }) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 20cffad9dd..f199a2938c 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -295,16 +295,31 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho }) app.post('/sessions/:id/archive', async (c) => { + // tiann/hapi#916: relax the blanket `requireActive: true` guard so + // the endpoint is idempotent for already-archived rows AND can clean + // up split-brain rows after a hub-restart cascade (inactive in cache + // but metadata.lifecycleState still 'running'). Normal inactive rows + // that are not archived (completed stubs, UI Delete/Reopen targets) + // keep the old 409 contract. const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { return engine } - const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + const sessionResult = requireSessionFromParam(c, engine) if (sessionResult instanceof Response) { return sessionResult } + const lifecycleState = sessionResult.session.metadata?.lifecycleState + if (lifecycleState === 'archived') { + return c.json({ ok: true, alreadyArchived: true }) + } + + if (!sessionResult.session.active && lifecycleState !== 'running') { + return c.json({ error: 'Session is inactive' }, 409) + } + await engine.archiveSession(sessionResult.sessionId) return c.json({ ok: true }) }) From b0eb4d5570ed194998f882fed323be0294a8a019 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:54:31 +0100 Subject: [PATCH 26/34] feat(cli): cross-flavor inline image display via MCP and ACP Share display_image prompt across MCP-bridge flavors (Cursor, Gemini, Kimi, Codex, Claude, OpenCode), auto-approve the tool in buildHapiMcpBridge, handle ACP image content blocks, and harden generated-image registration with content sniffing. Closes tiann/hapi#956 Co-authored-by: Cursor --- .../backends/acp/AcpMessageHandler.test.ts | 29 ++++++ .../agent/backends/acp/AcpMessageHandler.ts | 29 +++++- cli/src/agent/messageConverter.test.ts | 17 ++++ cli/src/agent/messageConverter.ts | 17 +++- cli/src/agent/types.ts | 1 + cli/src/claude/utils/startHappyServer.ts | 4 +- cli/src/claude/utils/systemPrompt.ts | 3 +- cli/src/codex/codexRemoteLauncher.ts | 40 +++----- .../codex/utils/buildHapiMcpBridge.test.ts | 30 ++++++ cli/src/codex/utils/buildHapiMcpBridge.ts | 14 +-- cli/src/codex/utils/codexMcpConfig.test.ts | 4 + cli/src/codex/utils/systemPrompt.ts | 13 ++- cli/src/cursor/cursorAcpRemoteLauncher.ts | 13 ++- cli/src/gemini/geminiRemoteLauncher.ts | 13 ++- cli/src/kimi/kimiRemoteLauncher.ts | 13 ++- cli/src/modules/common/displayImagePrompt.ts | 17 ++++ .../modules/common/generatedImages.test.ts | 30 +++++- cli/src/modules/common/generatedImages.ts | 97 ++++++++++++++++++- cli/src/modules/common/hapiMcpBridgePrompt.ts | 15 +++ .../permission/BasePermissionHandler.ts | 4 +- cli/src/opencode/opencodeRemoteLauncher.ts | 3 + cli/src/opencode/utils/systemPrompt.ts | 12 +-- 22 files changed, 361 insertions(+), 57 deletions(-) create mode 100644 cli/src/codex/utils/buildHapiMcpBridge.test.ts create mode 100644 cli/src/modules/common/displayImagePrompt.ts create mode 100644 cli/src/modules/common/hapiMcpBridgePrompt.ts diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts index ed699f590a..e82799f874 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import type { AgentMessage } from '@/agent/types'; import { AcpMessageHandler } from './AcpMessageHandler'; import { ACP_SESSION_UPDATE_TYPES } from './constants'; +import { clearGeneratedImages } from '@/modules/common/generatedImages'; function getToolResult(messages: AgentMessage[], id: string): Extract { const result = messages.find((message): message is Extract => @@ -2093,4 +2094,32 @@ describe('AcpMessageHandler', () => { }); }); }); + + it('emits generated_image agent messages from ACP image content blocks', async () => { + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { + type: 'image', + mimeType: 'image/png', + data: pngHeader.toString('base64') + } + }); + + await vi.waitFor(() => { + expect(messages.some((message) => message.type === 'generated_image')).toBe(true); + }); + + const imageMessage = messages.find( + (message): message is Extract => + message.type === 'generated_image' + ); + expect(imageMessage?.mimeType).toBe('image/png'); + expect(imageMessage?.fileName).toBeTruthy(); + expect(imageMessage?.imageId).toBeTruthy(); + clearGeneratedImages(); + }); }); diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index 68a9ac00b1..ffc0c09a9f 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -1,5 +1,7 @@ -import type { AgentMessage, PlanItem } from '@/agent/types'; import { randomUUID } from 'node:crypto'; +import { logger } from '@/ui/logger'; +import type { AgentMessage, PlanItem } from '@/agent/types'; +import { registerGeneratedImageFromAcpBlock } from '@/modules/common/generatedImages'; import { asString, isObject } from '@hapi/protocol'; import { deriveToolNameWithSource, isPlaceholderToolName } from '@/agent/utils'; import { parseRateLimitText } from '@/agent/rateLimitParser'; @@ -554,6 +556,11 @@ export class AcpMessageHandler { if (updateType === ACP_SESSION_UPDATE_TYPES.agentMessageChunk) { const content = update.content; + if (isObject(content) && content.type === 'image') { + this.flushReasoning(); + void this.emitGeneratedImageFromAcpContent(content); + return; + } const text = extractTextContent(content); if (text) { // Check once whether the buffered text is a prefix of this @@ -629,6 +636,26 @@ export class AcpMessageHandler { } } + private async emitGeneratedImageFromAcpContent(content: Record): Promise { + try { + const image = await registerGeneratedImageFromAcpBlock(content); + if (!image) { + return; + } + this.onMessage({ + type: 'generated_image', + imageId: image.id, + fileName: image.fileName, + mimeType: image.mimeType + }); + } catch (error) { + logger.debug( + '[AcpMessageHandler] Failed to register ACP image block:', + error instanceof Error ? error.message : String(error) + ); + } + } + private handleToolCall(update: Record): void { const toolCallId = asString(update.toolCallId); if (!toolCallId) return; diff --git a/cli/src/agent/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index 86ca29cc28..d15f53fb0d 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -89,4 +89,21 @@ describe('convertAgentMessage', () => { } }); }); + + it('converts generated_image messages into generated-image wire payloads', () => { + const converted = convertAgentMessage({ + type: 'generated_image', + imageId: 'img-1', + fileName: 'inline.png', + mimeType: 'image/png' + }); + + expect(converted).toMatchObject({ + type: 'generated-image', + imageId: 'img-1', + fileName: 'inline.png', + mimeType: 'image/png' + }); + expect(converted && 'id' in converted && typeof converted.id === 'string').toBe(true); + }); }); diff --git a/cli/src/agent/messageConverter.ts b/cli/src/agent/messageConverter.ts index 4e1dbed50d..67e048505c 100644 --- a/cli/src/agent/messageConverter.ts +++ b/cli/src/agent/messageConverter.ts @@ -32,7 +32,14 @@ export type CodexMessage = is_error?: boolean; } | { type: 'plan'; entries: PlanItem[] } - | { type: 'error'; message: string }; + | { type: 'error'; message: string } + | { + type: 'generated-image'; + imageId: string; + fileName: string; + mimeType: string; + id: string; + }; export function convertAgentMessage(message: AgentMessage): CodexMessage | null { switch (message.type) { @@ -78,6 +85,14 @@ export function convertAgentMessage(message: AgentMessage): CodexMessage | null type: 'plan', entries: message.items }; + case 'generated_image': + return { + type: 'generated-image', + imageId: message.imageId, + fileName: message.fileName, + mimeType: message.mimeType, + id: randomUUID() + }; case 'error': return { type: 'error', message: message.message }; case 'turn_complete': diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts index 03d8543187..ce0bc1b066 100644 --- a/cli/src/agent/types.ts +++ b/cli/src/agent/types.ts @@ -44,6 +44,7 @@ export type AgentMessage = contextWindow?: number; } | { type: 'plan'; items: PlanItem[] } + | { type: 'generated_image'; imageId: string; fileName: string; mimeType: string } | { type: 'turn_complete'; stopReason: string } | { type: 'error'; message: string }; diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index b2001a5703..6a53867884 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -12,7 +12,7 @@ import { z } from "zod"; import { logger } from "@/ui/logger"; import { ApiSessionClient } from "@/api/apiSession"; import { randomUUID } from "node:crypto"; -import { detectImageMimeType, registerGeneratedImage } from "@/modules/common/generatedImages"; +import { detectImageMimeType, registerGeneratedImage, MAX_GENERATED_IMAGE_BYTES } from "@/modules/common/generatedImages"; type StartHappyServerOptions = { emitTitleSummary?: boolean; @@ -94,7 +94,7 @@ function createHapiMcpServer(client: ApiSessionClient, emitTitleSummary: boolean throw new Error('Path is not a regular file'); } - const maxImageBytes = 25 * 1024 * 1024; + const maxImageBytes = MAX_GENERATED_IMAGE_BYTES; if (info.size > maxImageBytes) { throw new Error('Image is too large to display inline'); } diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 9e90179d22..941648998e 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -1,12 +1,13 @@ import { trimIdent } from "@/utils/trimIdent"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; +import { DISPLAY_IMAGE_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; /** * Base system prompt shared across all configurations */ const BASE_SYSTEM_PROMPT = (() => trimIdent(` Use the title tool sparingly. For a new chat, call the tool "mcp__hapi__change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. - When you create or find a local image file that the user should see, call the tool "mcp__hapi__display_image" with the image path so HAPI can show it inline. + ${DISPLAY_IMAGE_PROMPT_CLAUDE} `))(); /** diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 2cb80fe17f..3684ab47f1 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -1,6 +1,5 @@ import React from 'react'; import { randomUUID } from 'node:crypto'; -import { lstat, readFile } from 'node:fs/promises'; import { CodexAppServerClient } from './codexAppServerClient'; import { CodexPermissionHandler } from './utils/permissionHandler'; @@ -14,7 +13,7 @@ import type { CodexSession } from './session'; import type { EnhancedMode } from './loop'; import { hasCodexCliOverrides } from './utils/codexCliOverrides'; import { AppServerEventConverter } from './utils/appServerEventConverter'; -import { detectImageMimeType, registerGeneratedImage } from '@/modules/common/generatedImages'; +import { registerGeneratedImageFromPath } from '@/modules/common/generatedImages'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; import type { ThreadGoal, ThreadGoalStatus } from './appServerTypes'; @@ -27,32 +26,17 @@ import { } from '@/modules/common/remote/RemoteLauncherBase'; -async function registerGeneratedImageFromPath(args: { id: string; path: string; fileName?: string | null }): Promise | null> { - try { - const info = await lstat(args.path); - if (!info.isFile()) { - throw new Error('Path is not a regular file'); - } - const maxImageBytes = 25 * 1024 * 1024; - if (info.size > maxImageBytes) { - throw new Error('Image is too large to display inline'); - } - const bytes = await readFile(args.path); - const mimeType = detectImageMimeType(bytes); - if (!mimeType) { - throw new Error('Unsupported image content'); - } - return registerGeneratedImage({ - id: args.id, - path: args.path, - fileName: args.fileName, - mimeType, - bytes - }); - } catch (error) { - logger.debug('[CodexRemoteLauncher] Failed to register generated image:', error instanceof Error ? error.message : String(error)); - return null; + +async function registerGeneratedImageFromPathWrapper(args: { id: string; path: string; fileName?: string | null }): Promise> | null> { + const image = await registerGeneratedImageFromPath({ + id: args.id, + path: args.path, + fileName: args.fileName + }); + if (!image) { + logger.debug('[CodexRemoteLauncher] Failed to register generated image from path'); } + return image; } type HappyServer = Awaited>['server']; @@ -2215,7 +2199,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const imageId = randomUUID(); const savedPath = asString(msg.saved_path ?? msg.savedPath); if (savedPath) { - const image = await registerGeneratedImageFromPath({ + const image = await registerGeneratedImageFromPathWrapper({ id: imageId, path: savedPath, fileName: asString(msg.file_name ?? msg.fileName) diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts new file mode 100644 index 0000000000..efd2314a31 --- /dev/null +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildHapiMcpBridge } from './buildHapiMcpBridge'; + +vi.mock('@/claude/utils/startHappyServer', () => ({ + startHappyServer: vi.fn(async () => ({ + url: 'http://127.0.0.1:63995/', + stop: vi.fn(), + toolNames: ['change_title', 'display_image'] + })) +})); + +vi.mock('@/utils/spawnHappyCLI', () => ({ + getHappyCliCommand: vi.fn(() => ({ + command: 'hapi', + args: ['mcp', '--url', 'http://127.0.0.1:63995/'] + })) +})); + +describe('buildHapiMcpBridge', () => { + it('auto-approves change_title and display_image MCP tools', async () => { + const client = {} as never; + const bridge = await buildHapiMcpBridge(client); + + expect(bridge.mcpServers.hapi.tools).toEqual({ + change_title: { approval_mode: 'approve' }, + display_image: { approval_mode: 'approve' } + }); + expect(bridge.server.url).toBe('http://127.0.0.1:63995/'); + }); +}); diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 72ef85eaa1..86efc9353c 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -1,8 +1,8 @@ /** - * Unified MCP bridge setup for Codex local and remote modes. + * Unified MCP bridge setup for all flavors that wire HAPI tools through Codex-style MCP config. * - * This module provides a single source of truth for starting the hapi MCP - * bridge server and generating the MCP server configuration that Codex needs. + * Starts the hapi MCP bridge server and returns MCP server configuration for + * Gemini, Kimi, Cursor, OpenCode, and Codex launchers. */ import { startHappyServer } from '@/claude/utils/startHappyServer'; @@ -48,10 +48,9 @@ export interface HapiMcpBridgeOptions { /** * Start the hapi MCP bridge server and return the configuration - * needed to connect Codex to it. + * needed to connect agent flavors to it. * - * This is the single source of truth for MCP bridge setup, - * used by both local and remote launchers. + * Single source of truth for MCP bridge setup across local and remote launchers. */ export async function buildHapiMcpBridge( client: ApiSessionClient, @@ -74,6 +73,9 @@ export async function buildHapiMcpBridge( tools: { change_title: { approval_mode: 'approve' + }, + display_image: { + approval_mode: 'approve' } } } diff --git a/cli/src/codex/utils/codexMcpConfig.test.ts b/cli/src/codex/utils/codexMcpConfig.test.ts index 298f2dbd0e..c0b4597ec0 100644 --- a/cli/src/codex/utils/codexMcpConfig.test.ts +++ b/cli/src/codex/utils/codexMcpConfig.test.ts @@ -31,6 +31,9 @@ describe('codexMcpConfig', () => { tools: { change_title: { approval_mode: 'approve' as const + }, + display_image: { + approval_mode: 'approve' as const } } } @@ -39,6 +42,7 @@ describe('codexMcpConfig', () => { const args = buildMcpServerConfigArgs(mcpServers); expect(args).toContain('mcp_servers.hapi.tools.change_title.approval_mode="approve"'); + expect(args).toContain('mcp_servers.hapi.tools.display_image.approval_mode="approve"'); }); it('builds config args for multiple MCP servers', () => { diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index a6057be03b..ed003fbb6f 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -1,24 +1,29 @@ /** * Codex-specific system prompt for local mode. * - * This prompt instructs Codex to call the hapi__change_title function - * to set appropriate chat session titles. + * This prompt instructs Codex to call the hapi MCP tools for session title + * and inline image display. */ import { trimIdent } from '@/utils/trimIdent'; +import { DISPLAY_IMAGE_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; /** * Title instruction for Codex to call the hapi MCP tool. * Note: Codex exposes MCP tools under the `functions.` namespace, * so the tool is called as `functions.hapi__change_title`. */ -export const TITLE_INSTRUCTION = trimIdent(` +const CODEX_TITLE_INSTRUCTION = trimIdent(` Use the title tool sparingly. For a new chat, call it once after the user's initial request is clear, and set a concise task title. Prefer calling functions.hapi__change_title. If that exact tool name is unavailable, call an equivalent alias such as hapi__change_title, mcp__hapi__change_title, or hapi_change_title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. - When you create or find a local image file that the user should see, call functions.hapi__display_image with the image path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_image, mcp__hapi__display_image, or hapi_display_image. +`); + +export const TITLE_INSTRUCTION = trimIdent(` + ${CODEX_TITLE_INSTRUCTION} + ${DISPLAY_IMAGE_PROMPT_CODEX} `); /** diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 5e3eaf75b5..add205fd8c 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -20,6 +20,7 @@ import { applyCursorAcpMode, applyCursorAcpModel, wireIdForCursorSessionState } import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels'; import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; import type { AcpSdkBackend } from '@/agent/backends/acp'; +import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; class CursorAcpRemoteLauncher extends RemoteLauncherBase { private readonly session: CursorSession; @@ -33,6 +34,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { private defaultBackendModel: string | null = null; private unregisterModelApplyHandler: (() => void) | null = null; private modelApplySeq = 0; + private instructionsSent = false; constructor(session: CursorSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -211,9 +213,15 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { this.applyDisplayMode(batch.mode.permissionMode as PermissionMode); messageBuffer.addMessage(batch.message, 'user'); + let messageText = batch.message; + if (!this.instructionsSent) { + messageText = `${HAPI_MCP_BRIDGE_PROMPT}\n\n${messageText}`; + this.instructionsSent = true; + } + const promptContent: PromptContent[] = [{ type: 'text', - text: batch.message + text: messageText }]; session.onThinkingChange(true); @@ -296,6 +304,9 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { case 'error': this.messageBuffer.addMessage(message.message, 'status'); break; + case 'generated_image': + this.messageBuffer.addMessage(`Generated image: ${message.fileName}`, 'assistant'); + break; case 'turn_complete': break; default: diff --git a/cli/src/gemini/geminiRemoteLauncher.ts b/cli/src/gemini/geminiRemoteLauncher.ts index fa6e3cb5c8..83c70c86fa 100644 --- a/cli/src/gemini/geminiRemoteLauncher.ts +++ b/cli/src/gemini/geminiRemoteLauncher.ts @@ -10,6 +10,7 @@ import type { PermissionMode } from './types'; import { createGeminiBackend } from './utils/geminiBackend'; import { GeminiPermissionHandler } from './utils/permissionHandler'; import { resolveGeminiRuntimeConfig } from './utils/config'; +import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; class GeminiRemoteLauncher extends RemoteLauncherBase { private readonly session: GeminiSession; @@ -23,6 +24,7 @@ class GeminiRemoteLauncher extends RemoteLauncherBase { private displayPermissionMode: PermissionMode | null = null; private currentBackendModel: string | null = null; private setModelSupported: boolean | undefined = undefined; + private instructionsSent = false; constructor(session: GeminiSession, opts: { model?: string; hookSettingsPath?: string }) { super(process.env.DEBUG ? session.logPath : undefined); @@ -162,9 +164,15 @@ class GeminiRemoteLauncher extends RemoteLauncherBase { this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model); messageBuffer.addMessage(batch.message, 'user'); + let messageText = batch.message; + if (!this.instructionsSent) { + messageText = `${HAPI_MCP_BRIDGE_PROMPT}\n\n${messageText}`; + this.instructionsSent = true; + } + const promptContent: PromptContent[] = [{ type: 'text', - text: batch.message + text: messageText }]; session.onThinkingChange(true); @@ -240,6 +248,9 @@ class GeminiRemoteLauncher extends RemoteLauncherBase { case 'error': this.messageBuffer.addMessage(message.message, 'status'); break; + case 'generated_image': + this.messageBuffer.addMessage(`Generated image: ${message.fileName}`, 'assistant'); + break; case 'turn_complete': this.messageBuffer.addMessage('Turn complete', 'status'); break; diff --git a/cli/src/kimi/kimiRemoteLauncher.ts b/cli/src/kimi/kimiRemoteLauncher.ts index 21fb438f8a..c5ca62d2bb 100644 --- a/cli/src/kimi/kimiRemoteLauncher.ts +++ b/cli/src/kimi/kimiRemoteLauncher.ts @@ -10,6 +10,7 @@ import type { PermissionMode } from './types'; import { createKimiBackend } from './utils/kimiBackend'; import { KimiPermissionHandler } from './utils/permissionHandler'; import { resolveKimiRuntimeConfig } from './utils/config'; +import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; class KimiRemoteLauncher extends RemoteLauncherBase { private readonly session: KimiSession; @@ -23,6 +24,7 @@ class KimiRemoteLauncher extends RemoteLauncherBase { private currentBackendModel: string | null = null; private setModelSupported: boolean | undefined = undefined; private lastDisplayedToolCall = new Map(); + private instructionsSent = false; constructor(session: KimiSession, opts: { model?: string }) { super(process.env.DEBUG ? session.logPath : undefined); @@ -156,9 +158,15 @@ class KimiRemoteLauncher extends RemoteLauncherBase { this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model); messageBuffer.addMessage(batch.message, 'user'); + let messageText = batch.message; + if (!this.instructionsSent) { + messageText = `${HAPI_MCP_BRIDGE_PROMPT}\n\n${messageText}`; + this.instructionsSent = true; + } + const promptContent: PromptContent[] = [{ type: 'text', - text: batch.message + text: messageText }]; session.onThinkingChange(true); @@ -236,6 +244,9 @@ class KimiRemoteLauncher extends RemoteLauncherBase { case 'error': this.messageBuffer.addMessage(message.message, 'status'); break; + case 'generated_image': + this.messageBuffer.addMessage(`Generated image: ${message.fileName}`, 'assistant'); + break; case 'turn_complete': this.messageBuffer.addMessage('Turn complete', 'status'); break; diff --git a/cli/src/modules/common/displayImagePrompt.ts b/cli/src/modules/common/displayImagePrompt.ts new file mode 100644 index 0000000000..507c0903aa --- /dev/null +++ b/cli/src/modules/common/displayImagePrompt.ts @@ -0,0 +1,17 @@ +import { trimIdent } from '@/utils/trimIdent'; + +/** + * Shared display_image MCP tool hints — one export per tool naming convention. + * Inject into flavor system prompts and first-prompt bridge instructions. + */ +export const DISPLAY_IMAGE_PROMPT_CLAUDE = trimIdent(` + When you create or find a local image file that the user should see, call the tool "mcp__hapi__display_image" with the image path so HAPI can show it inline. +`); + +export const DISPLAY_IMAGE_PROMPT_CODEX = trimIdent(` + When you create or find a local image file that the user should see, call functions.hapi__display_image with the image path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_image, mcp__hapi__display_image, or hapi_display_image. +`); + +export const DISPLAY_IMAGE_PROMPT_HAPI_MCP = trimIdent(` + When you create or find a local image file that the user should see, call the tool "hapi_display_image" with the image path so HAPI can show it inline. +`); diff --git a/cli/src/modules/common/generatedImages.test.ts b/cli/src/modules/common/generatedImages.test.ts index 471539e41e..839c4a44a8 100644 --- a/cli/src/modules/common/generatedImages.test.ts +++ b/cli/src/modules/common/generatedImages.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { clearGeneratedImages, detectImageMimeType, getGeneratedImage, registerGeneratedImage } from './generatedImages' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { clearGeneratedImages, detectImageMimeType, getGeneratedImage, registerGeneratedImage, registerGeneratedImageFromAcpBlock, registerGeneratedImageFromPath } from './generatedImages' describe('generatedImages', () => { it('detects supported image MIME types from file bytes', () => { @@ -67,4 +70,29 @@ describe('generatedImages', () => { clearGeneratedImages() }) + it('registers images from ACP base64 image blocks after MIME sniffing', async () => { + const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]) + const image = await registerGeneratedImageFromAcpBlock({ + type: 'image', + mimeType: 'image/png', + data: pngHeader.toString('base64') + }) + + expect(image?.mimeType).toBe('image/png') + expect(getGeneratedImage(image!.id)?.content.subarray(0, 8)).toEqual(pngHeader.subarray(0, 8)) + clearGeneratedImages() + }) + + it('registers images from local file paths in ACP uri blocks', async () => { + const dir = join(tmpdir(), `hapi-acp-image-${Date.now()}`) + mkdirSync(dir, { recursive: true }) + const path = join(dir, 'inline.png') + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) + writeFileSync(path, bytes) + + const image = await registerGeneratedImageFromPath({ path }) + expect(image?.mimeType).toBe('image/png') + clearGeneratedImages() + }) + }) diff --git a/cli/src/modules/common/generatedImages.ts b/cli/src/modules/common/generatedImages.ts index fdf6cb9441..dc3f53fb71 100644 --- a/cli/src/modules/common/generatedImages.ts +++ b/cli/src/modules/common/generatedImages.ts @@ -1,4 +1,8 @@ -import { basename } from 'path' +import { basename } from 'node:path' +import { fileURLToPath } from 'node:url' +import { lstat, readFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { asString, isObject } from '@hapi/protocol' export type GeneratedImageMetadata = { id: string @@ -8,7 +12,7 @@ export type GeneratedImageMetadata = { createdAt: number } -const MAX_GENERATED_IMAGE_BYTES = 25 * 1024 * 1024 +export const MAX_GENERATED_IMAGE_BYTES = 25 * 1024 * 1024 const MAX_GENERATED_IMAGE_TOTAL_BYTES = 100 * 1024 * 1024 const MAX_GENERATED_IMAGE_COUNT = 100 @@ -105,3 +109,92 @@ export function clearGeneratedImages(): void { generatedImages.clear() generatedImageBytes = 0 } + +export async function registerGeneratedImageFromPath(args: { + id?: string + path: string + fileName?: string | null +}): Promise { + try { + const info = await lstat(args.path) + if (!info.isFile()) { + throw new Error('Path is not a regular file') + } + if (info.size > MAX_GENERATED_IMAGE_BYTES) { + throw new Error('Image is too large to display inline') + } + const bytes = await readFile(args.path) + const mimeType = detectImageMimeType(bytes) + if (!mimeType) { + throw new Error('Unsupported image content') + } + return registerGeneratedImage({ + id: args.id ?? randomUUID(), + path: args.path, + fileName: args.fileName, + mimeType, + bytes + }) + } catch { + return null + } +} + +function parseAcpImageUri(uri: string): string | null { + if (uri.startsWith('file://')) { + try { + return fileURLToPath(uri) + } catch { + return null + } + } + if (/^https?:\/\//i.test(uri)) { + return null + } + return uri +} + +export async function registerGeneratedImageFromAcpBlock(block: unknown): Promise { + if (!isObject(block) || block.type !== 'image') { + return null + } + + const data = asString(block.data) + const declaredMimeType = asString(block.mimeType ?? block.mime_type) + const uri = asString(block.uri ?? block.url) + + if (data) { + const bytes = Buffer.from(data, 'base64') + if (bytes.byteLength > MAX_GENERATED_IMAGE_BYTES) { + return null + } + const sniffedMimeType = detectImageMimeType(bytes) + if (!sniffedMimeType) { + return null + } + if (declaredMimeType && declaredMimeType !== sniffedMimeType) { + return null + } + const path = uri ? parseAcpImageUri(uri) ?? uri : `${randomUUID()}.bin` + return registerGeneratedImage({ + id: randomUUID(), + path, + fileName: basename(path), + mimeType: sniffedMimeType, + bytes + }) + } + + if (uri) { + const path = parseAcpImageUri(uri) + if (!path) { + return null + } + return registerGeneratedImageFromPath({ + path, + fileName: basename(path) + }) + } + + return null +} diff --git a/cli/src/modules/common/hapiMcpBridgePrompt.ts b/cli/src/modules/common/hapiMcpBridgePrompt.ts new file mode 100644 index 0000000000..74bf4b029b --- /dev/null +++ b/cli/src/modules/common/hapiMcpBridgePrompt.ts @@ -0,0 +1,15 @@ +import { trimIdent } from '@/utils/trimIdent'; +import { DISPLAY_IMAGE_PROMPT_HAPI_MCP } from './displayImagePrompt'; + +/** + * Title + display_image instructions for ACP flavors wired through buildHapiMcpBridge + * (Gemini, Kimi, Cursor, OpenCode). Prepended on the first user prompt. + */ +export const HAPI_MCP_TITLE_INSTRUCTION = trimIdent(` + Use the title tool sparingly. For a new chat, call the tool "hapi_change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. +`); + +export const HAPI_MCP_BRIDGE_PROMPT = trimIdent(` + ${HAPI_MCP_TITLE_INSTRUCTION} + ${DISPLAY_IMAGE_PROMPT_HAPI_MCP} +`); diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index f70908f5eb..b68dc79235 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -19,14 +19,16 @@ export type AutoApprovalRuleSet = { const AUTO_APPROVE_TOOL_NAME_HINTS = [ 'change_title', + 'display_image', 'happy__change_title', 'hapi_change_title', // OpenCode MCP tool pattern + 'hapi_display_image', 'geminireasoning', 'codexreasoning', 'think', 'save_memory' ]; -const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory']; +const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'display_image', 'save_memory']; const AUTO_APPROVE_WRITE_TOOL_HINTS = ['write', 'edit', 'create', 'delete', 'patch', 'fs-edit']; export function resolveToolAutoApprovalDecision( diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index b911fbca07..0abca7e06b 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -360,6 +360,9 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { case 'error': this.messageBuffer.addMessage(message.message, 'status'); break; + case 'generated_image': + this.messageBuffer.addMessage(`Generated image: ${message.fileName}`, 'assistant'); + break; case 'turn_complete': this.messageBuffer.addMessage('Turn complete', 'status'); break; diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index e968759626..9a2e556b26 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -1,19 +1,17 @@ /** - * OpenCode-specific system prompt for change_title tool. + * OpenCode-specific system prompt for hapi MCP tools (change_title, display_image). * * OpenCode exposes MCP tools with the naming pattern: _ - * The hapi MCP server exposes `change_title`, so it's called as `hapi_change_title`. + * The hapi MCP server exposes `change_title` and `display_image`. */ import { trimIdent } from '@/utils/trimIdent'; +import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt'; /** - * Title instruction for OpenCode to call the hapi MCP tool. + * Title and display_image instructions for OpenCode to call the hapi MCP tools. */ -export const TITLE_INSTRUCTION = trimIdent(` - Use the title tool sparingly. For a new chat, call the tool "hapi_change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. - When you create or find a local image file that the user should see, call the tool "hapi_display_image" with the image path so HAPI can show it inline. -`); +export const TITLE_INSTRUCTION = HAPI_MCP_BRIDGE_PROMPT; /** * The system prompt to inject for OpenCode sessions. From 8b6afb9c85640804ac3df00fec098cd73c26d79d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:07:34 +0100 Subject: [PATCH 27/34] fix(web): render generated-image cards reliably in chat Keep object URLs stable across refetch, upscale tiny inline images, fetch generated-image bytes with cache no-store (avoid empty 304 bodies), and load hapiMcpUrl from per-session API in hapi-display-image tooling. Co-authored-by: Cursor --- scripts/tooling/hapi-display-image.mjs | 15 ++++- web/src/api/client.ts | 5 +- .../AssistantChat/messages/ToolMessage.tsx | 59 ++++++++++++++----- web/src/components/ImagePreview.tsx | 4 +- 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/scripts/tooling/hapi-display-image.mjs b/scripts/tooling/hapi-display-image.mjs index c2670585d2..c490cc467d 100644 --- a/scripts/tooling/hapi-display-image.mjs +++ b/scripts/tooling/hapi-display-image.mjs @@ -57,7 +57,20 @@ if (!session) { process.exit(4) } -const mcpUrl = session.metadata?.hapiMcpUrl +// List endpoint omits metadata; per-session GET includes hapiMcpUrl. +let mcpUrl = session.metadata?.hapiMcpUrl +if (!mcpUrl) { + const detailRes = await fetch(`${HAPI_HOST}/api/sessions/${encodeURIComponent(session.id)}`, { + headers: { Authorization: `Bearer ${jwt}` }, + }) + if (!detailRes.ok) { + console.error('session detail fetch failed', detailRes.status) + process.exit(5) + } + const detailBody = await detailRes.json() + const detail = detailBody.session ?? detailBody + mcpUrl = detail.metadata?.hapiMcpUrl +} if (!mcpUrl) { console.error('session has no hapiMcpUrl metadata (restart session CLI after MCP fix lands)') process.exit(5) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d37e0828bc..febc565b55 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -327,7 +327,10 @@ export class ApiClient { headers.set('authorization', `Bearer ${authToken}`) } const res = await fetch(this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/generated-images/${encodeURIComponent(imageId)}`), { - headers + headers, + // Hub answers with ETag + Cache-Control; browser 304 responses have no body and + // fail res.ok — use no-store so GeneratedImageCard always gets bytes (issue #927). + cache: 'no-store' }) if (res.status === 401 && attempt === 0 && this.onUnauthorized) { const refreshed = await this.onUnauthorized() diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 871b463bcb..919707e10c 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState, type CSSProperties } from 'react' import type { ToolCallMessagePartProps } from '@assistant-ui/react' import type { ChatBlock } from '@/chat/types' import type { GeneratedImageBlock, ToolCallBlock } from '@/chat/types' @@ -49,22 +49,53 @@ function isGeneratedImageBlock(value: unknown): value is GeneratedImageBlock { return true } +const MIN_INLINE_IMAGE_DIMENSION = 64 + +function computeTinyImageScale(width: number, height: number): number { + const minDim = Math.min(width, height) + if (minDim <= 0 || minDim >= MIN_INLINE_IMAGE_DIMENSION) { + return 1 + } + return Math.min(MIN_INLINE_IMAGE_DIMENSION / minDim, 16) +} + function GeneratedImageCard(props: { block: GeneratedImageBlock }) { const ctx = useHappyChatContext() const [objectUrl, setObjectUrl] = useState(null) const [error, setError] = useState(null) + const [imageStyle, setImageStyle] = useState(undefined) + const objectUrlRef = useRef(null) + + useEffect(() => { + return () => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current) + objectUrlRef.current = null + } + } + }, []) useEffect(() => { let disposed = false - let nextObjectUrl: string | null = null - setObjectUrl(null) setError(null) void ctx.api.getGeneratedImageBlob(ctx.sessionId, props.block.imageId) .then((blob) => { if (disposed) return - nextObjectUrl = URL.createObjectURL(blob) + const nextObjectUrl = URL.createObjectURL(blob) + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current) + } + objectUrlRef.current = nextObjectUrl setObjectUrl(nextObjectUrl) + setImageStyle(undefined) + const probe = new Image() + probe.onload = () => { + if (disposed) return + const scale = computeTinyImageScale(probe.naturalWidth, probe.naturalHeight) + setImageStyle(scale === 1 ? undefined : { transform: `scale(${scale})` }) + } + probe.src = nextObjectUrl }) .catch((err: unknown) => { if (disposed) return @@ -73,9 +104,6 @@ function GeneratedImageCard(props: { block: GeneratedImageBlock }) { return () => { disposed = true - if (nextObjectUrl) { - URL.revokeObjectURL(nextObjectUrl) - } } }, [ctx.api, ctx.sessionId, props.block.imageId]) @@ -85,13 +113,16 @@ function GeneratedImageCard(props: { block: GeneratedImageBlock }) { Generated image · {props.block.fileName}
{objectUrl ? ( - +
+ +
) : error ? (
Generated image is unavailable. {error} diff --git a/web/src/components/ImagePreview.tsx b/web/src/components/ImagePreview.tsx index 49db13b66f..04187b524e 100644 --- a/web/src/components/ImagePreview.tsx +++ b/web/src/components/ImagePreview.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode, type SyntheticEvent, type WheelEvent } from 'react' +import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type SyntheticEvent, type WheelEvent } from 'react' import { CloseIcon } from '@/components/icons' const MIN_IMAGE_SCALE = 0.25 @@ -29,6 +29,7 @@ export function ImagePreview(props: { label: string buttonClassName?: string imageClassName?: string + imageStyle?: CSSProperties caption?: ReactNode }) { const [viewerOpen, setViewerOpen] = useState(false) @@ -227,6 +228,7 @@ export function ImagePreview(props: { src={props.src} alt={props.label} className={props.imageClassName ?? 'max-h-[calc(100vh-14rem)] max-w-full object-contain transition-transform group-hover:scale-[1.01]'} + style={props.imageStyle} draggable={false} /> {props.caption} From cb340cca0be4b9973423c2a4edb39b9450181f47 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:01:11 +0100 Subject: [PATCH 28/34] feat(cli+web): display_video MCP for inline mp4/webm (#956) Add display_video alongside display_image, video MIME sniffing with avif guard, web GeneratedImageCard video player, and hapi-display-image auto-routing. Co-authored-by: Cursor --- cli/src/claude/utils/startHappyServer.ts | 114 +++++++++++++----- cli/src/claude/utils/systemPrompt.ts | 4 +- cli/src/codex/happyMcpStdioBridge.ts | 28 +++++ cli/src/codex/utils/buildHapiMcpBridge.ts | 14 ++- cli/src/codex/utils/systemPrompt.ts | 14 +-- .../modules/common/generatedImages.test.ts | 10 +- cli/src/modules/common/generatedImages.ts | 30 ++++- scripts/tooling/hapi-display-image.mjs | 36 ++++-- .../AssistantChat/messages/ToolMessage.tsx | 70 ++++------- 9 files changed, 215 insertions(+), 105 deletions(-) diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 6a53867884..91eab8741a 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -12,7 +12,7 @@ import { z } from "zod"; import { logger } from "@/ui/logger"; import { ApiSessionClient } from "@/api/apiSession"; import { randomUUID } from "node:crypto"; -import { detectImageMimeType, registerGeneratedImage, MAX_GENERATED_IMAGE_BYTES } from "@/modules/common/generatedImages"; +import { detectImageMimeType, detectVideoMimeType, registerGeneratedImage } from "@/modules/common/generatedImages"; type StartHappyServerOptions = { emitTitleSummary?: boolean; @@ -50,6 +50,50 @@ function createHapiMcpServer(client: ApiSessionClient, emitTitleSummary: boolean title: z.string().optional().describe('Optional display title or filename for the image'), }); + const displayVideoInputSchema: z.ZodTypeAny = z.object({ + path: z.string().describe('Local filesystem path of the video to display inline (mp4 or webm)'), + title: z.string().optional().describe('Optional display title or filename for the video'), + }); + + const maxInlineMediaBytes = 25 * 1024 * 1024; + + async function displayInlineMedia(args: { path: string; title?: string }, mediaKind: 'image' | 'video') { + const info = await lstat(args.path); + if (!info.isFile()) { + throw new Error('Path is not a regular file'); + } + + if (info.size > maxInlineMediaBytes) { + throw new Error('File is too large to display inline'); + } + + const bytes = await readFile(args.path); + const mimeType = mediaKind === 'video' + ? detectVideoMimeType(bytes) + : detectImageMimeType(bytes); + if (!mimeType) { + throw new Error(mediaKind === 'video' ? 'Unsupported video content' : 'Unsupported image content'); + } + + const media = registerGeneratedImage({ + id: randomUUID(), + path: args.path, + fileName: args.title, + mimeType, + bytes + }); + + client.sendAgentMessage({ + type: 'generated-image', + imageId: media.id, + fileName: media.fileName, + mimeType: media.mimeType, + id: randomUUID() + }); + + return media; + } + mcp.registerTool('change_title', { description: 'Change the title of the current chat session', title: 'Change Chat Title', @@ -89,37 +133,7 @@ function createHapiMcpServer(client: ApiSessionClient, emitTitleSummary: boolean logger.debug('[hapiMCP] Display image:', args.path); try { - const info = await lstat(args.path); - if (!info.isFile()) { - throw new Error('Path is not a regular file'); - } - - const maxImageBytes = MAX_GENERATED_IMAGE_BYTES; - if (info.size > maxImageBytes) { - throw new Error('Image is too large to display inline'); - } - - const bytes = await readFile(args.path); - const mimeType = detectImageMimeType(bytes); - if (!mimeType) { - throw new Error('Unsupported image content'); - } - - const image = registerGeneratedImage({ - id: randomUUID(), - path: args.path, - fileName: args.title, - mimeType, - bytes - }); - - client.sendAgentMessage({ - type: 'generated-image', - imageId: image.id, - fileName: image.fileName, - mimeType: image.mimeType, - id: randomUUID() - }); + const image = await displayInlineMedia(args, 'image'); return { content: [ @@ -145,6 +159,40 @@ function createHapiMcpServer(client: ApiSessionClient, emitTitleSummary: boolean } }); + mcp.registerTool('display_video', { + description: 'Display a local mp4 or webm file inline in the current HAPI chat session', + title: 'Display Video', + inputSchema: displayVideoInputSchema, + }, async (args: { path: string; title?: string }) => { + logger.debug('[hapiMCP] Display video:', args.path); + + try { + const video = await displayInlineMedia(args, 'video'); + + return { + content: [ + { + type: 'text' as const, + text: `Displayed video: ${video.fileName}`, + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.debug('[hapiMCP] Failed to display video:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to display video: ${message}`, + }, + ], + isError: true, + }; + } + }); + return mcp; } @@ -221,7 +269,7 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH return { url: mcpUrl, - toolNames: ['change_title', 'display_image'], + toolNames: ['change_title', 'display_image', 'display_video'], stop: () => { logger.debug('[hapiMCP] Stopping server'); for (const mcp of mcps.values()) { diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 941648998e..804001d975 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -1,13 +1,13 @@ import { trimIdent } from "@/utils/trimIdent"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; -import { DISPLAY_IMAGE_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; /** * Base system prompt shared across all configurations */ const BASE_SYSTEM_PROMPT = (() => trimIdent(` Use the title tool sparingly. For a new chat, call the tool "mcp__hapi__change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. - ${DISPLAY_IMAGE_PROMPT_CLAUDE} + When you create or find a local image file that the user should see, call the tool "mcp__hapi__display_image" with the image path so HAPI can show it inline. + When you create or find a local mp4 or webm recording the user should see, call the tool "mcp__hapi__display_video" with the file path so HAPI can show it inline. `))(); /** diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index 7c30617c2b..518eeffb9b 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -123,6 +123,34 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { } ); + const displayVideoInputSchema: z.ZodTypeAny = z.object({ + path: z.string().describe('Local filesystem path of the video to display inline (mp4 or webm)'), + title: z.string().optional().describe('Optional display title or filename for the video'), + }); + + server.registerTool( + 'display_video', + { + description: 'Display a local mp4 or webm file inline in the current HAPI chat session', + title: 'Display Video', + inputSchema: displayVideoInputSchema, + }, + async (args: Record) => { + try { + const client = await ensureHttpClient(); + const response = await client.callTool({ name: 'display_video', arguments: args }); + return response as any; + } catch (error) { + return { + content: [ + { type: 'text' as const, text: `Failed to display video: ${error instanceof Error ? error.message : String(error)}` }, + ], + isError: true, + }; + } + } + ); + // Start STDIO transport const stdio = new StdioServerTransport(); await server.connect(stdio); diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 86efc9353c..7fd7cf329c 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -1,8 +1,8 @@ /** - * Unified MCP bridge setup for all flavors that wire HAPI tools through Codex-style MCP config. + * Unified MCP bridge setup for Codex local and remote modes. * - * Starts the hapi MCP bridge server and returns MCP server configuration for - * Gemini, Kimi, Cursor, OpenCode, and Codex launchers. + * This module provides a single source of truth for starting the hapi MCP + * bridge server and generating the MCP server configuration that Codex needs. */ import { startHappyServer } from '@/claude/utils/startHappyServer'; @@ -48,9 +48,10 @@ export interface HapiMcpBridgeOptions { /** * Start the hapi MCP bridge server and return the configuration - * needed to connect agent flavors to it. + * needed to connect Codex to it. * - * Single source of truth for MCP bridge setup across local and remote launchers. + * This is the single source of truth for MCP bridge setup, + * used by both local and remote launchers. */ export async function buildHapiMcpBridge( client: ApiSessionClient, @@ -76,6 +77,9 @@ export async function buildHapiMcpBridge( }, display_image: { approval_mode: 'approve' + }, + display_video: { + approval_mode: 'approve' } } } diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index ed003fbb6f..3f58b216a7 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -1,29 +1,25 @@ /** * Codex-specific system prompt for local mode. * - * This prompt instructs Codex to call the hapi MCP tools for session title - * and inline image display. + * This prompt instructs Codex to call the hapi__change_title function + * to set appropriate chat session titles. */ import { trimIdent } from '@/utils/trimIdent'; -import { DISPLAY_IMAGE_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; /** * Title instruction for Codex to call the hapi MCP tool. * Note: Codex exposes MCP tools under the `functions.` namespace, * so the tool is called as `functions.hapi__change_title`. */ -const CODEX_TITLE_INSTRUCTION = trimIdent(` +export const TITLE_INSTRUCTION = trimIdent(` Use the title tool sparingly. For a new chat, call it once after the user's initial request is clear, and set a concise task title. Prefer calling functions.hapi__change_title. If that exact tool name is unavailable, call an equivalent alias such as hapi__change_title, mcp__hapi__change_title, or hapi_change_title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. -`); - -export const TITLE_INSTRUCTION = trimIdent(` - ${CODEX_TITLE_INSTRUCTION} - ${DISPLAY_IMAGE_PROMPT_CODEX} + When you create or find a local image file that the user should see, call functions.hapi__display_image with the image path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_image, mcp__hapi__display_image, or hapi_display_image. + When you create or find a local mp4 or webm file the user should see, call functions.hapi__display_video with the file path. If unavailable, use hapi__display_video, mcp__hapi__display_video, or hapi_display_video. `); /** diff --git a/cli/src/modules/common/generatedImages.test.ts b/cli/src/modules/common/generatedImages.test.ts index 839c4a44a8..3da6492e0f 100644 --- a/cli/src/modules/common/generatedImages.test.ts +++ b/cli/src/modules/common/generatedImages.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { clearGeneratedImages, detectImageMimeType, getGeneratedImage, registerGeneratedImage, registerGeneratedImageFromAcpBlock, registerGeneratedImageFromPath } from './generatedImages' +import { clearGeneratedImages, detectImageMimeType, detectVideoMimeType, getGeneratedImage, registerGeneratedImage, registerGeneratedImageFromAcpBlock, registerGeneratedImageFromPath } from './generatedImages' describe('generatedImages', () => { it('detects supported image MIME types from file bytes', () => { @@ -13,6 +13,12 @@ describe('generatedImages', () => { expect(detectImageMimeType(Buffer.from([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66]))).toBe('image/avif') }) + it('detects supported video MIME types from file bytes', () => { + expect(detectVideoMimeType(Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]))).toBe('video/mp4') + expect(detectVideoMimeType(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))).toBe('video/webm') + expect(detectVideoMimeType(Buffer.from([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66]))).toBeNull() + }) + it('rejects non-image bytes even if the path has an image extension', () => { expect(detectImageMimeType(Buffer.from('not really a png'))).toBeNull() }) @@ -50,7 +56,7 @@ describe('generatedImages', () => { path: '/tmp/large.png', mimeType: 'image/png', bytes: new Uint8Array(25 * 1024 * 1024 + 1) - })).toThrow('Image is too large to display inline') + })).toThrow('File is too large to display inline') clearGeneratedImages() }) diff --git a/cli/src/modules/common/generatedImages.ts b/cli/src/modules/common/generatedImages.ts index dc3f53fb71..f1462a8f0e 100644 --- a/cli/src/modules/common/generatedImages.ts +++ b/cli/src/modules/common/generatedImages.ts @@ -59,6 +59,30 @@ export function detectImageMimeType(bytes: Uint8Array): string | null { return null } +export function detectVideoMimeType(bytes: Uint8Array): string | null { + if (bytes.length >= 12 && ascii(bytes, 4, 8) === 'ftyp') { + const brand = ascii(bytes, 8, 12) + if (brand === 'avif' || brand === 'avis') { + return null + } + return 'video/mp4' + } + + if (bytes.length >= 4 + && bytes[0] === 0x1a + && bytes[1] === 0x45 + && bytes[2] === 0xdf + && bytes[3] === 0xa3) { + return 'video/webm' + } + + return null +} + +export function isInlineMediaMimeType(mimeType: string): boolean { + return mimeType.startsWith('image/') || mimeType.startsWith('video/') +} + function ascii(bytes: Uint8Array, start: number, end: number): string { return String.fromCharCode(...bytes.subarray(start, end)) } @@ -66,7 +90,11 @@ function ascii(bytes: Uint8Array, start: number, end: number): string { export function registerGeneratedImage(args: { id: string; path: string; mimeType: string; bytes: Uint8Array; fileName?: string | null }): GeneratedImageMetadata { const content = Buffer.from(args.bytes) if (content.byteLength > MAX_GENERATED_IMAGE_BYTES) { - throw new Error('Image is too large to display inline') + throw new Error('File is too large to display inline') + } + + if (!isInlineMediaMimeType(args.mimeType)) { + throw new Error('Unsupported inline media MIME type') } const previous = generatedImages.get(args.id) diff --git a/scripts/tooling/hapi-display-image.mjs b/scripts/tooling/hapi-display-image.mjs index c490cc467d..454eddb139 100644 --- a/scripts/tooling/hapi-display-image.mjs +++ b/scripts/tooling/hapi-display-image.mjs @@ -1,17 +1,25 @@ #!/usr/bin/env bun /** - * Post a local image inline to a HAPI session via the session CLI's display_image MCP tool. + * Post a local image or video inline to a HAPI session via display_image / display_video MCP. * * Uses session.metadata.hapiMcpUrl (published at MCP server start) so we hit the MCP * endpoint, not the session hook server on another loopback port in the same process. * * Usage: - * bun scripts/tooling/hapi-display-image.mjs [title] + * bun scripts/tooling/hapi-display-image.mjs [title] + * + * Picks display_video for mp4/webm (ftyp / webm magic), else display_image. */ import { readFileSync, lstatSync } from 'node:fs' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +// MCP SDK is a cli workspace dep; Bun resolves imports from this script's dir, not cli/. +const cliRoot = join(dirname(fileURLToPath(import.meta.url)), '../../cli') +const sdkRoot = join(cliRoot, 'node_modules/@modelcontextprotocol/sdk/dist/esm') +const { Client } = await import(join(sdkRoot, 'client/index.js')) +const { StreamableHTTPClientTransport } = await import(join(sdkRoot, 'client/streamableHttp.js')) const HAPI_HOST = process.env.HAPI_HOST ?? 'http://localhost:3006' const SETTINGS = process.env.HAPI_SETTINGS ?? `${process.env.HOME}/.hapi/settings.json` @@ -21,10 +29,21 @@ const imagePath = process.argv[3] const title = process.argv[4] if (!sessionArg || !imagePath) { - console.error('usage: hapi-display-image.mjs [title]') + console.error('usage: hapi-display-image.mjs [title]') process.exit(2) } +function detectMediaTool(path) { + const head = readFileSync(path).subarray(0, 16) + if (head.length >= 12 && head.subarray(4, 8).toString('ascii') === 'ftyp') { + return 'display_video' + } + if (head.length >= 4 && head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) { + return 'display_video' + } + return 'display_image' +} + if (!lstatSync(imagePath).isFile()) { console.error(`not a file: ${imagePath}`) process.exit(2) @@ -57,7 +76,7 @@ if (!session) { process.exit(4) } -// List endpoint omits metadata; per-session GET includes hapiMcpUrl. +// List endpoint omits metadata; per-session GET includes hapiMcpUrl (#956 / PR #958). let mcpUrl = session.metadata?.hapiMcpUrl if (!mcpUrl) { const detailRes = await fetch(`${HAPI_HOST}/api/sessions/${encodeURIComponent(session.id)}`, { @@ -72,17 +91,18 @@ if (!mcpUrl) { mcpUrl = detail.metadata?.hapiMcpUrl } if (!mcpUrl) { - console.error('session has no hapiMcpUrl metadata (restart session CLI after MCP fix lands)') + console.error('session has no hapiMcpUrl metadata (happy MCP not running in that session CLI — check GET /api/sessions/:id)') process.exit(5) } console.error(`hapi-display-image: session=${session.id} mcp=${mcpUrl}`) +const mediaTool = detectMediaTool(imagePath) const client = new Client({ name: 'hapi-display-image', version: '1.0.0' }, { capabilities: {} }) const transport = new StreamableHTTPClientTransport(new URL(mcpUrl)) await client.connect(transport) const result = await client.callTool({ - name: 'display_image', + name: mediaTool, arguments: { path: imagePath, title: title ?? undefined }, }) await client.close() diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 919707e10c..efef78ab6d 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { useEffect, useState } from 'react' import type { ToolCallMessagePartProps } from '@assistant-ui/react' import type { ChatBlock } from '@/chat/types' import type { GeneratedImageBlock, ToolCallBlock } from '@/chat/types' @@ -49,53 +49,24 @@ function isGeneratedImageBlock(value: unknown): value is GeneratedImageBlock { return true } -const MIN_INLINE_IMAGE_DIMENSION = 64 - -function computeTinyImageScale(width: number, height: number): number { - const minDim = Math.min(width, height) - if (minDim <= 0 || minDim >= MIN_INLINE_IMAGE_DIMENSION) { - return 1 - } - return Math.min(MIN_INLINE_IMAGE_DIMENSION / minDim, 16) -} - function GeneratedImageCard(props: { block: GeneratedImageBlock }) { const ctx = useHappyChatContext() const [objectUrl, setObjectUrl] = useState(null) const [error, setError] = useState(null) - const [imageStyle, setImageStyle] = useState(undefined) - const objectUrlRef = useRef(null) - - useEffect(() => { - return () => { - if (objectUrlRef.current) { - URL.revokeObjectURL(objectUrlRef.current) - objectUrlRef.current = null - } - } - }, []) + const isVideo = props.block.mimeType?.startsWith('video/') ?? false + const mediaLabel = isVideo ? 'Generated video' : 'Generated image' useEffect(() => { let disposed = false + let nextObjectUrl: string | null = null + setObjectUrl(null) setError(null) void ctx.api.getGeneratedImageBlob(ctx.sessionId, props.block.imageId) .then((blob) => { if (disposed) return - const nextObjectUrl = URL.createObjectURL(blob) - if (objectUrlRef.current) { - URL.revokeObjectURL(objectUrlRef.current) - } - objectUrlRef.current = nextObjectUrl + nextObjectUrl = URL.createObjectURL(blob) setObjectUrl(nextObjectUrl) - setImageStyle(undefined) - const probe = new Image() - probe.onload = () => { - if (disposed) return - const scale = computeTinyImageScale(probe.naturalWidth, probe.naturalHeight) - setImageStyle(scale === 1 ? undefined : { transform: `scale(${scale})` }) - } - probe.src = nextObjectUrl }) .catch((err: unknown) => { if (disposed) return @@ -104,28 +75,37 @@ function GeneratedImageCard(props: { block: GeneratedImageBlock }) { return () => { disposed = true + if (nextObjectUrl) { + URL.revokeObjectURL(nextObjectUrl) + } } }, [ctx.api, ctx.sessionId, props.block.imageId]) return (
- Generated image · {props.block.fileName} + {mediaLabel} · {props.block.fileName}
{objectUrl ? ( -
- -
+ ) : ( + + ) ) : error ? (
- Generated image is unavailable. {error} + {mediaLabel} is unavailable. {error}
) : (
From 75ed60765c9f13002d7095dece90e863c92ebcc9 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:05:03 +0100 Subject: [PATCH 29/34] feat(cli+web): cross-flavor display_video parity with images (#956) Share display_video prompts across MCP-bridge flavors, auto-approve the tool, register mp4/webm via path sniffing, render inline video in web on the existing generated-image RPC path, and restore robust media card fetch. Co-authored-by: Cursor --- cli/src/claude/utils/systemPrompt.ts | 5 +- .../codex/utils/buildHapiMcpBridge.test.ts | 7 +- cli/src/codex/utils/codexMcpConfig.test.ts | 4 + cli/src/codex/utils/systemPrompt.ts | 5 +- cli/src/modules/common/displayImagePrompt.ts | 12 +++ .../modules/common/generatedImages.test.ts | 12 +++ cli/src/modules/common/generatedImages.ts | 4 +- cli/src/modules/common/hapiMcpBridgePrompt.ts | 5 +- .../permission/BasePermissionHandler.ts | 4 +- cli/src/opencode/utils/systemPrompt.ts | 4 +- .../AssistantChat/messages/ToolMessage.tsx | 84 +++++++++++++------ web/src/lib/generatedInlineMedia.test.ts | 16 ++++ web/src/lib/generatedInlineMedia.ts | 7 ++ 13 files changed, 131 insertions(+), 38 deletions(-) create mode 100644 web/src/lib/generatedInlineMedia.test.ts create mode 100644 web/src/lib/generatedInlineMedia.ts diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 804001d975..eb0e07aef7 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -1,13 +1,14 @@ import { trimIdent } from "@/utils/trimIdent"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; +import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; /** * Base system prompt shared across all configurations */ const BASE_SYSTEM_PROMPT = (() => trimIdent(` Use the title tool sparingly. For a new chat, call the tool "mcp__hapi__change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. - When you create or find a local image file that the user should see, call the tool "mcp__hapi__display_image" with the image path so HAPI can show it inline. - When you create or find a local mp4 or webm recording the user should see, call the tool "mcp__hapi__display_video" with the file path so HAPI can show it inline. + ${DISPLAY_IMAGE_PROMPT_CLAUDE} + ${DISPLAY_VIDEO_PROMPT_CLAUDE} `))(); /** diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts index efd2314a31..164cb88d53 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.test.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -5,7 +5,7 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ startHappyServer: vi.fn(async () => ({ url: 'http://127.0.0.1:63995/', stop: vi.fn(), - toolNames: ['change_title', 'display_image'] + toolNames: ['change_title', 'display_image', 'display_video'] })) })); @@ -17,13 +17,14 @@ vi.mock('@/utils/spawnHappyCLI', () => ({ })); describe('buildHapiMcpBridge', () => { - it('auto-approves change_title and display_image MCP tools', async () => { + it('auto-approves change_title, display_image, and display_video MCP tools', async () => { const client = {} as never; const bridge = await buildHapiMcpBridge(client); expect(bridge.mcpServers.hapi.tools).toEqual({ change_title: { approval_mode: 'approve' }, - display_image: { approval_mode: 'approve' } + display_image: { approval_mode: 'approve' }, + display_video: { approval_mode: 'approve' } }); expect(bridge.server.url).toBe('http://127.0.0.1:63995/'); }); diff --git a/cli/src/codex/utils/codexMcpConfig.test.ts b/cli/src/codex/utils/codexMcpConfig.test.ts index c0b4597ec0..6ced105183 100644 --- a/cli/src/codex/utils/codexMcpConfig.test.ts +++ b/cli/src/codex/utils/codexMcpConfig.test.ts @@ -34,6 +34,9 @@ describe('codexMcpConfig', () => { }, display_image: { approval_mode: 'approve' as const + }, + display_video: { + approval_mode: 'approve' as const } } } @@ -43,6 +46,7 @@ describe('codexMcpConfig', () => { expect(args).toContain('mcp_servers.hapi.tools.change_title.approval_mode="approve"'); expect(args).toContain('mcp_servers.hapi.tools.display_image.approval_mode="approve"'); + expect(args).toContain('mcp_servers.hapi.tools.display_video.approval_mode="approve"'); }); it('builds config args for multiple MCP servers', () => { diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index 3f58b216a7..82e3da28f3 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -6,6 +6,7 @@ */ import { trimIdent } from '@/utils/trimIdent'; +import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; /** * Title instruction for Codex to call the hapi MCP tool. @@ -18,8 +19,8 @@ export const TITLE_INSTRUCTION = trimIdent(` If that exact tool name is unavailable, call an equivalent alias such as hapi__change_title, mcp__hapi__change_title, or hapi_change_title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading. - When you create or find a local image file that the user should see, call functions.hapi__display_image with the image path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_image, mcp__hapi__display_image, or hapi_display_image. - When you create or find a local mp4 or webm file the user should see, call functions.hapi__display_video with the file path. If unavailable, use hapi__display_video, mcp__hapi__display_video, or hapi_display_video. + ${DISPLAY_IMAGE_PROMPT_CODEX} + ${DISPLAY_VIDEO_PROMPT_CODEX} `); /** diff --git a/cli/src/modules/common/displayImagePrompt.ts b/cli/src/modules/common/displayImagePrompt.ts index 507c0903aa..d3953eff1b 100644 --- a/cli/src/modules/common/displayImagePrompt.ts +++ b/cli/src/modules/common/displayImagePrompt.ts @@ -15,3 +15,15 @@ export const DISPLAY_IMAGE_PROMPT_CODEX = trimIdent(` export const DISPLAY_IMAGE_PROMPT_HAPI_MCP = trimIdent(` When you create or find a local image file that the user should see, call the tool "hapi_display_image" with the image path so HAPI can show it inline. `); + +export const DISPLAY_VIDEO_PROMPT_CLAUDE = trimIdent(` + When you create or find a local mp4 or webm recording the user should see, call the tool "mcp__hapi__display_video" with the file path so HAPI can show it inline. +`); + +export const DISPLAY_VIDEO_PROMPT_CODEX = trimIdent(` + When you create or find a local mp4 or webm file the user should see, call functions.hapi__display_video with the file path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_video, mcp__hapi__display_video, or hapi_display_video. +`); + +export const DISPLAY_VIDEO_PROMPT_HAPI_MCP = trimIdent(` + When you create or find a local mp4 or webm recording the user should see, call the tool "hapi_display_video" with the file path so HAPI can show it inline. +`); diff --git a/cli/src/modules/common/generatedImages.test.ts b/cli/src/modules/common/generatedImages.test.ts index 3da6492e0f..427fb32af8 100644 --- a/cli/src/modules/common/generatedImages.test.ts +++ b/cli/src/modules/common/generatedImages.test.ts @@ -101,4 +101,16 @@ describe('generatedImages', () => { clearGeneratedImages() }) + it('registers mp4 from local file paths after MIME sniffing', async () => { + const dir = join(tmpdir(), `hapi-inline-mp4-${Date.now()}`) + mkdirSync(dir, { recursive: true }) + const path = join(dir, 'inline.mp4') + const bytes = Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]) + writeFileSync(path, bytes) + + const video = await registerGeneratedImageFromPath({ path }) + expect(video?.mimeType).toBe('video/mp4') + clearGeneratedImages() + }) + }) diff --git a/cli/src/modules/common/generatedImages.ts b/cli/src/modules/common/generatedImages.ts index f1462a8f0e..247f4608bd 100644 --- a/cli/src/modules/common/generatedImages.ts +++ b/cli/src/modules/common/generatedImages.ts @@ -152,9 +152,9 @@ export async function registerGeneratedImageFromPath(args: { throw new Error('Image is too large to display inline') } const bytes = await readFile(args.path) - const mimeType = detectImageMimeType(bytes) + const mimeType = detectImageMimeType(bytes) ?? detectVideoMimeType(bytes) if (!mimeType) { - throw new Error('Unsupported image content') + throw new Error('Unsupported inline media content') } return registerGeneratedImage({ id: args.id ?? randomUUID(), diff --git a/cli/src/modules/common/hapiMcpBridgePrompt.ts b/cli/src/modules/common/hapiMcpBridgePrompt.ts index 74bf4b029b..92f2af8697 100644 --- a/cli/src/modules/common/hapiMcpBridgePrompt.ts +++ b/cli/src/modules/common/hapiMcpBridgePrompt.ts @@ -1,8 +1,8 @@ import { trimIdent } from '@/utils/trimIdent'; -import { DISPLAY_IMAGE_PROMPT_HAPI_MCP } from './displayImagePrompt'; +import { DISPLAY_IMAGE_PROMPT_HAPI_MCP, DISPLAY_VIDEO_PROMPT_HAPI_MCP } from './displayImagePrompt'; /** - * Title + display_image instructions for ACP flavors wired through buildHapiMcpBridge + * Title + display_image / display_video instructions for ACP flavors wired through buildHapiMcpBridge * (Gemini, Kimi, Cursor, OpenCode). Prepended on the first user prompt. */ export const HAPI_MCP_TITLE_INSTRUCTION = trimIdent(` @@ -12,4 +12,5 @@ export const HAPI_MCP_TITLE_INSTRUCTION = trimIdent(` export const HAPI_MCP_BRIDGE_PROMPT = trimIdent(` ${HAPI_MCP_TITLE_INSTRUCTION} ${DISPLAY_IMAGE_PROMPT_HAPI_MCP} + ${DISPLAY_VIDEO_PROMPT_HAPI_MCP} `); diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index b68dc79235..e03908405e 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -20,15 +20,17 @@ export type AutoApprovalRuleSet = { const AUTO_APPROVE_TOOL_NAME_HINTS = [ 'change_title', 'display_image', + 'display_video', 'happy__change_title', 'hapi_change_title', // OpenCode MCP tool pattern 'hapi_display_image', + 'hapi_display_video', 'geminireasoning', 'codexreasoning', 'think', 'save_memory' ]; -const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'display_image', 'save_memory']; +const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'display_image', 'display_video', 'save_memory']; const AUTO_APPROVE_WRITE_TOOL_HINTS = ['write', 'edit', 'create', 'delete', 'patch', 'fs-edit']; export function resolveToolAutoApprovalDecision( diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index 9a2e556b26..feb6d02d29 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -1,8 +1,8 @@ /** - * OpenCode-specific system prompt for hapi MCP tools (change_title, display_image). + * OpenCode-specific system prompt for hapi MCP tools (change_title, display_image, display_video). * * OpenCode exposes MCP tools with the naming pattern: _ - * The hapi MCP server exposes `change_title` and `display_image`. + * The hapi MCP server exposes `change_title`, `display_image`, and `display_video`. */ import { trimIdent } from '@/utils/trimIdent'; diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index efef78ab6d..bc0d7b5489 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState, type CSSProperties } from 'react' import type { ToolCallMessagePartProps } from '@assistant-ui/react' import type { ChatBlock } from '@/chat/types' import type { GeneratedImageBlock, ToolCallBlock } from '@/chat/types' @@ -15,6 +15,7 @@ import { useHappyChatContext } from '@/components/AssistantChat/context' import { CliOutputBlock } from '@/components/CliOutputBlock' import { UserBubbleContent, getUserBubbleClassName, shouldShowMessageStatus } from '@/components/AssistantChat/messages/user-bubble' import { ImagePreview } from '@/components/ImagePreview' +import { generatedInlineMediaLabel, isInlineVideoMimeType } from '@/lib/generatedInlineMedia' function isToolCallBlock(value: unknown): value is ToolCallBlock { if (!isObject(value)) return false @@ -49,37 +50,67 @@ function isGeneratedImageBlock(value: unknown): value is GeneratedImageBlock { return true } +const MIN_INLINE_IMAGE_DIMENSION = 64 + +function computeTinyImageScale(width: number, height: number): number { + const minDim = Math.min(width, height) + if (minDim <= 0 || minDim >= MIN_INLINE_IMAGE_DIMENSION) { + return 1 + } + return Math.min(MIN_INLINE_IMAGE_DIMENSION / minDim, 16) +} + function GeneratedImageCard(props: { block: GeneratedImageBlock }) { const ctx = useHappyChatContext() const [objectUrl, setObjectUrl] = useState(null) const [error, setError] = useState(null) - const isVideo = props.block.mimeType?.startsWith('video/') ?? false - const mediaLabel = isVideo ? 'Generated video' : 'Generated image' + const [imageStyle, setImageStyle] = useState(undefined) + const objectUrlRef = useRef(null) + const isVideo = isInlineVideoMimeType(props.block.mimeType) + const mediaLabel = generatedInlineMediaLabel(props.block.mimeType) + + useEffect(() => { + return () => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current) + objectUrlRef.current = null + } + } + }, []) useEffect(() => { let disposed = false - let nextObjectUrl: string | null = null - setObjectUrl(null) setError(null) void ctx.api.getGeneratedImageBlob(ctx.sessionId, props.block.imageId) .then((blob) => { if (disposed) return - nextObjectUrl = URL.createObjectURL(blob) + const nextObjectUrl = URL.createObjectURL(blob) + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current) + } + objectUrlRef.current = nextObjectUrl setObjectUrl(nextObjectUrl) + if (!isVideo) { + setImageStyle(undefined) + const probe = new Image() + probe.onload = () => { + if (disposed) return + const scale = computeTinyImageScale(probe.naturalWidth, probe.naturalHeight) + setImageStyle(scale === 1 ? undefined : { transform: `scale(${scale})` }) + } + probe.src = nextObjectUrl + } }) .catch((err: unknown) => { if (disposed) return - setError(err instanceof Error ? err.message : 'Failed to load generated image') + setError(err instanceof Error ? err.message : 'Failed to load inline media') }) return () => { disposed = true - if (nextObjectUrl) { - URL.revokeObjectURL(nextObjectUrl) - } } - }, [ctx.api, ctx.sessionId, props.block.imageId]) + }, [ctx.api, ctx.sessionId, props.block.imageId, isVideo]) return (
@@ -88,20 +119,25 @@ function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
{objectUrl ? ( isVideo ? ( -