Skip to content

[cold-review] feat(web): markdown Source | Preview toggle in session file pane#53

Draft
heavygee wants to merge 29 commits into
mainfrom
feat/file-markdown-preview
Draft

[cold-review] feat(web): markdown Source | Preview toggle in session file pane#53
heavygee wants to merge 29 commits into
mainfrom
feat/file-markdown-preview

Conversation

@heavygee

Copy link
Copy Markdown
Owner

Fork-side cold-review stage PR. Not for merge — staging branch for Codex review before upstream PR tiann#957.

Upstream target: tiann/hapiheavygee:feat/file-markdown-preview

Summary

Same diff as upstream tiann#957: markdown Source | Preview toggle in session file pane (closes tiann#954).

Test plan

  • bun typecheck
  • cd web && bun run test

Issues

Ref tiann#954

zhushanwen321 and others added 28 commits June 18, 2026 10:01
* 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<T>
syncEngine: 12 passthroughs → callPiRpc<T> delegate
web client: 12 methods → callPiEndpoint<T>
routes: use engine.callPiRpc with RPC_METHODS constants
hooks: use callPiEndpoint, add missing type imports

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <pi@local>
Co-authored-by: Claude <noreply@anthropic.com>
…iann#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 <cursoragent@cursor.com>
…load succeeds (closes tiann#939) (tiann#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 tiann#917. Closes tiann#939.

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
)

* 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
* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
…ann#927) (tiann#934)

* test: reproduce issue tiann#927

* fix(hub): cache generated images + raise socket buffer cap (closes tiann#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 <noreply@hapi.run>

* fix(hub): short-circuit generated-image revalidation with a 304 (tiann#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 <noreply@hapi.run>

* fix(web): stabilize ApiClient identity across token refresh (tiann#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 <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
…tiann#918) (tiann#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 tiann#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 <cursoragent@cursor.com>
…rn (tiann#909)

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 tiann#908

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
…XIST (tiann#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 tiann#890

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 '<path>'") 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 tiann#892.

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 tiann#892 (post-rebase).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
tiann#915) (tiann#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 tiann#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 <cursoragent@cursor.com>
…hes (tiann#884) (tiann#885)

* perf(web): suppress useSession refetch storm (closes tiann#884)

Two compounding behaviours in the React client were producing a sustained
~100 req/sec stream of GET /api/sessions/<uuid> 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/<uuid>
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 tiann#885 review)

The Codex review on tiann#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 (tiann#884)

Stand-in cold review on PR tiann#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 tiann#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 <heavygee@users.noreply.github.com>
…ble (tiann#946)

* feat(web): in-app PWA update prompt when new service worker is available (closes tiann#938)

User-controlled reload with a persistent banner, visibility-triggered SW
checks, and an expandable rationale. Switches registerType to prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* Revert "fix(web): align vite.config with soup layers for clean driver merge"

This reverts commit 6f0915b.

* 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 <cursoragent@cursor.com>

* fix(web): satisfy setTimeout mock typing in PWA reload tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
…rs (tiann#941)

* 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 `<button>` and stays out of the way on touch devices, which
keep getting the same `aria-label` the old `title=""` attribute provided
to screen readers.

Test coverage: shared derivation + cap + tie-break + full-set kind
behaviour; web tooltip render across all four attention kinds plus
mixed-kind overflow suppression and aria-label exposure.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): opaque tooltip surface; drop redundant 'updated Nm ago' body

Two operator-feedback fixes on the new session-list HoverTooltip:

1. Tooltip background was bg-[var(--app-bg)] - the same variable as the
   session row underneath - so the tooltip looked translucent and the row
   text bled through. Switch to bg-[var(--app-secondary-bg)] (#2C2C2E
   dark / #f3f4f6 light, both opaque) and bump shadow-md -> shadow-lg.
   Telegram-themed clients still pick up tg-theme-secondary-bg-color so
   the tooltip stays on-theme.

2. The 'unread' attention dot tooltip rendered 'New activity / Updated 5m
   ago', but the relative-time pill ('5m ago') is already on the right
   edge of the same session row. The tooltip body just duplicated info.
   Render only the title for the unread case; drop the
   session.tooltip.unread.body i18n key from en + zh-CN.

The other tooltip kinds (permission/input list tools, background lists
task count) keep their bodies - those facts are not visible elsewhere on
the row.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web,hub): show scheduled fire time in session-list clock tooltip

The schedule clock tooltip previously said only "Will fire when due."
while the row already showed a relative updated-at pill. Extend the
session-list API with nextScheduledAt (MIN future scheduled_at per
session, same filter as futureScheduledMessageCount) and render:

- single scheduled: "Fires in 5m · Jun 16, 1:45 PM"
- multiple: "Next in 5m · Jun 16, 1:45 PM · +2 more"

Extract formatScheduledTime from QueuedMessagesBar into web/lib/
scheduledTime.ts alongside formatFutureRelativeTime and the tooltip
composer. SSE upsert preserves nextScheduledAt until the list refetch
that already runs on schedule-related events.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): wire session-row keyboard focus to HoverTooltip a11y

Address PR tiann#941 Major review: aria-describedby and tooltip visibility
were on a non-focusable inner span, so keyboard users tabbing the session
row button never received the rich tooltip description and
group-focus-within never matched.

- Session row button owns aria-describedby (attention + schedule ids)
- Add group/session-row + SESSION_ROW_TOOLTIP_FOCUS_CLASS reveal on
  :focus-visible
- HoverTooltip takes required id; drop inner aria-label/describedby
- useSessionRowTooltipIds helper composes stable row tooltip ids
- Tests for id wiring and parent-focus reveal classes

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* test: reproduce issue tiann#866

* feat(web): OLED Black theme + per-appearance custom colors (closes tiann#866)

Add an explicit OLED Black appearance (true #000 canvas, border-based
elevation) alongside system/dark/light, and a curated "key color"
customizer. Each key color (background, surface, text, hint, accent,
border, user bubble) cascades to its --app-* tokens and is stored per
appearance so a color tuned for light never leaks onto pure black.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
…tiann#933)

* feat(web): Web Share Target -> composer attachment preload

PWA manifest now declares a `share_target` so Android Chrome surfaces
HAPI in the system share sheet for any app (Photos, Files, browser).

Pipeline on share:
  1. Service worker intercepts POST /share, parses the multipart payload
     (title/text/url + N files), persists it in IndexedDB under a
     transfer id, and 303-redirects to /share?id=<id>. The 303 forces
     Chrome to convert the POST into a GET so the SPA route mounts.
  2. New /share route loads the transfer, previews the content, and
     lets the user pick a recent active session (top 5 by activeAt) or
     a "+ New session". Tapping a session stashes the transfer id in
     sessionStorage and navigates to /sessions/:id.
  3. SessionChat mounts a ShareSeedConsumer once the AssistantRuntime
     is up; it consumes the pending transfer once per mount, seeds
     composer text + per-file attachments via the existing
     attachmentAdapter, then deletes the IDB row so a refresh of the
     session page does not replay the upload.

The whole feature reuses the existing /sessions/:id/upload endpoint;
no hub or shared changes.

Limitations (also disclosed in the PR body):
  - PWA must be installed; Android Chrome only registers share_target
    on install. iOS Safari ignores the manifest field entirely.
  - File MIME accept list is broad (`*/*` fallback); some Chrome
    versions still filter despite this.

Tests:
  - shareTransfer.test.ts (8) covers payload parse, multi-file order,
    type fallback, ingest redirect shape and error propagation.
  - sharePendingState.test.ts (3) covers atomic consume + overwrite.

Closes: pending upstream issue (filed before PR per intake doc).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): freeze picker session list at mount, sort by updatedAt, drop top-5 cap

The /share picker was visually re-shuffling under the operator's finger
as SSE events rolled in: every session metadata patch refreshed the
React Query cache, the useMemo recomputed, and items reordered (often
within a second of opening the share sheet). Sort key was activeAt,
which heartbeats every few seconds while a session is connected,
making the noise floor even higher.

Three changes:
  - Snapshot the active-session list once when sessions finish loading
    via useState + a deferred useEffect. The picker is a one-shot
    interaction; closing the share sheet and re-sharing produces a
    fresh snapshot, so freezing for the duration of the picker view is
    the right trade.
  - Sort by updatedAt desc to match SessionList's canonical "most
    recent interaction first" order. updatedAt only moves on
    user-meaningful events, not heartbeats.
  - Drop the TOP_SESSIONS=5 cap. The picker is already inside an
    app-scroll-y container, so showing all active sessions and letting
    the operator scroll matches the operator's mental model better
    than an arbitrary truncation.

Per operator dogfood report: "list of recent sessions is constantly
updating; should be just a scrollable list, from most recent
interaction to not."

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): base-aware share_target paths for subpath PWA deploys

Manifest share_target.action, SW POST matching, and ingest 303 redirects
were hard-coded to /share. Standalone builds with --base /<repo>/ put
scope/start_url under the subpath but left the share action at origin
root, so Chrome posted outside the SW scope and the handler never ran.

Extract shareTargetPathnameFromBase() (used at build time in
vite.config.ts and at runtime via import.meta.env.BASE_URL in sw.ts and
shareTransfer.ts). Normalizes base to a trailing slash before URL
resolution so /repo and /repo/ both resolve to /repo/share.

Addresses upstream PR tiann#933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): defer sessionStorage arm until new session spawn succeeds

The "+ New session" picker path called setSharePendingTransfer before a
session existed. Cancel, spawn failure, or backing out left a stale id in
sessionStorage that the next unrelated SessionChat mount would consume.

Pass shareTransferId via /sessions/new search params instead; arm the
consumer only in handleSuccess after spawn, and delete the IDB row on
cancel.

Addresses upstream PR tiann#933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): append share text to existing composer draft

ShareSeedConsumer called setText(seedText) unconditionally, clobbering
per-session drafts restored by useComposerDraft from sessionStorage.

Merge share title/text/url after any in-composer text or saved draft,
joined with a blank line. Pass sessionId into ShareSeedConsumer so
getDraft() can be consulted when the composer is still empty.

Addresses upstream PR tiann#933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): preserve shareTransferId through /browse detour

New-session spawn from the share picker could lose shareTransferId when
the operator opened /browse to pick a folder: handleChooseFolder and
BrowsePage handleStartSession dropped the search param, so handleSuccess
never armed the composer consumer.

Thread shareTransferId through browseRoute search validation and both
navigation hops.

Addresses upstream PR tiann#933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): consume pending transfer in effect for StrictMode

ShareSeedConsumer called consumeSharePendingTransfer during render.
React.StrictMode double-invokes render in dev; the discarded pass
deleted the sessionStorage key before the committed render seeded.

Move consume into a mount-only useEffect and gate the seed effect on
transferReady.

Addresses upstream PR tiann#933 review (Minor).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
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 tiann#924

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
…oss navigation (tiann#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 tiann#910

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* 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 <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
…nn#901) (tiann#903)

* test: reproduce issue tiann#901 (active-only filter + paginated show more)

* fix: active-only session filter + paginated 'Show N more' (closes tiann#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 <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
…ows (tiann#902)

* feat(web): mechanical repair of GFM tables with off-by-one separator rows

Adds a remark plugin (remarkRepairTables) that runs after remark-gfm and
silently fixes the dominant broken-table pattern seen in agent output:
the separator row has fewer pipe-delimited cells than the header row.

remark-gfm follows the GFM spec and silently truncates the table to the
separator column count, dropping header and data cells. This plugin reads
the original source via file.value position data, detects the mismatch,
pads the separator row, and re-parses the corrected block so all columns
are preserved.

Analysis of 7 days of session data: 975 apparent table blocks, 879 flagged
broken. Of those, 744 (84.6%) were false positives (inline pipes in prose
and shell commands). The separator off-by-one pattern accounted for the
majority of genuine failures (~94 of 135 real broken tables).

The plugin is wired into MARKDOWN_PLUGINS and MARKDOWN_PLUGINS_WITH_BREAKS,
immediately after remarkGfm where position data is available.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test(web): strengthen remarkRepairTables test suite

- Remove unused parseTableCols helper
- Assert alignment markers (:-- / --:) are preserved in repaired separator
- Add header-only table test (header + broken separator, no data rows)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): remove dead repairCount + add structural column-count assertion

- Drop repairCount from visitTables — increment was never read at call site
- Add per-row cell count assertion to the 3-column repair test to catch
  structural regressions that content-presence checks would miss

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test(web): add structural column-count assertions to remaining repair tests

Off-by-N (4-column), alignment-hints, and header-only tests now verify
each output row has the correct number of cells, not just content presence.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): skip escaped pipes in countSourceCells to prevent false repairs

\| inside a GFM table cell is a literal pipe character, not a cell
delimiter. The previous split('|') approach miscounted cells in headers
like | A \| B | C |, treating a valid 2-column table as 3-column and
padding the separator unnecessarily.

Replaces the split with a character-scan that tracks escape state.
Adds a test asserting the separator column count stays at 2 for tables
with escaped pipes in the header.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): re-parse repaired table with main processor to preserve inline extensions

parseTableBlock() previously created a bare remarkParse+remarkGfm processor,
so inline math (or other pipeline extensions) inside a repaired table cell was
parsed as plain text and lost after repair.

Fix: use this (the Processor instance unified passes to the plugin factory) to
re-parse the repaired block, so all registered extensions apply. Removes the
now-unused remarkParse/remarkGfm/unified imports.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): rewrite repair plugin as string preprocessor

The previous implementation visited `table` AST nodes after remark-gfm
parsed the source. But remark-gfm 4.x degrades a mismatched-separator
table (separator row has fewer cells than the header row) to a paragraph
node entirely — no `table` node is ever produced, so the visitor never
triggered and the repair was a no-op.

New approach: scan `file.value` for broken separator rows BEFORE the
AST is built, pad them in-place, then re-parse the corrected source so
remark-gfm produces proper table nodes. Export `repairMarkdownTables`
as a named function for direct testing.

Update the unit tests to actually discriminate between a repaired table
(stringified lines start with `|`) and the old broken paragraph output
(stringified lines start with `\|`, escaped by remark-stringify).

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): skip fenced code blocks and preserve indentation in repair scan

The string-level scanner was modifying table-like lines inside fenced code
blocks (``` / ~~~) — a bug reported in PR review (Major). Also preserves
original leading whitespace when replacing a separator line so indented
tables are not affected.

Add tests for fenced-code skip, ~~~ variant, and correct repair after a
fence closes.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): harden remark-repair-tables against code-span pipes and mixed fences

- countSourceCells: strip backtick code spans before counting column
  boundaries — a header like | `a | b` | c | is 2 columns, not 3
- repairMarkdownTables: track fenceChar ('`'|'~'|null) instead of a
  boolean toggle so ``` inside ~~~ no longer incorrectly flips fence state
- add 2 tests: code-span-with-pipe in header, backtick inside tilde fence
- fix stale comment in markdown-text.tsx (plugin reads file.value, not AST nodes)
- drop no-op .trimStart() (padSeparatorLine already returns a trimmed string)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): handle double-backtick code spans and preserve tree root on re-parse

- countSourceCells: use /`+[^`]*?`+/g so double-backtick spans like
  `` `a | b` `` are also stripped before counting column boundaries
- remarkRepairTables: Object.assign(tree, newTree) instead of only
  copying children, so position/data from the root node are preserved

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): require closing fence to match opener length (GFM fence rule)

A ```` fence must not be closed by ``` — GFM specifies the closer must use
the same marker character AND be at least as long as the opener sequence.
Track fenceLength alongside fenceChar so longer-backtick fences stay open
until a closer of equal or greater length arrives.

Also tighten the fence-match regex from /^\s*/ to /^ {0,3}/ to match the
GFM spec (fences are valid with up to 3 spaces of indentation, not arbitrary
whitespace). Adds a regression test for the ```` / ``` case.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): closing fence must have only whitespace after the marker (GFM rule)

GFM §4.5: a fence closing sequence may only be followed by optional spaces.
A content line like \`\`\`ts inside a code block is not a valid closer, so we
must not clear fenceChar when the remainder of the line is non-whitespace.

Captures rest after the marker and guards the close branch with /^\s*$/.
Opening fences are unaffected (info strings on openers remain valid).
Adds a regression test: ``` opener, ```ts content line, ``` closer.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
…ant no-op (tiann#887)

Without an explicit -fast suffix, inferSkuParamHints returned no fast hint, so
matchCliSkuToAcpWireId tied between fast=true and fast=false wires and kept
the first one. For composer-2.5 that meant the picker's "non-fast" sku silently
resolved to composer-2.5[fast=true] — the same wire the fast sku resolves to —
producing the "selected but no response" symptom in tiann#883.

Treat absence of -fast as fast=false so base-only skus pick the slow variant
and round-trip back to the matching radio.

Fixes tiann#883

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
…nn#871)

* test: reproduce issue tiann#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 <cursoragent@cursor.com>

* fix(cursor): surface agent errors with warning styling in web UI (closes tiann#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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
…iann#936)

* feat(web): drag-and-drop files onto chat panel to add as attachments

Closes tiann#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 <noreply@hapi.run>

* 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 <noreply@hapi.run>

* fix(web): harden drag-drop default-action handling

Address HAPI Bot review on tiann#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 <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(web): use Simplified Chinese for composer.dropToAttach in zh-CN

Address HAPI Bot review on tiann#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 <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
 tiann#916 tiann#919) (tiann#923)

* fix(hub,cli): four hub-restart-cascade cleanup bugs (tiann#913 tiann#914 tiann#916 tiann#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 tiann#915 (hub-restart
cascade-archive) and the hypothesis-pending tiann#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 tiann#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#913
Refs tiann#914
Refs tiann#916
Refs tiann#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 <cursoragent@cursor.com>

* fix(cli): runner-spawned children use 'Stopped by runner' as default archive reason

Addresses bot review of tiann#923: with the tiann#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 tiann#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#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): markSessionArchivedFromHub surfaces persistence failures as 5xx

Addresses second-round bot review of tiann#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 tiann#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#916.

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 tiann#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#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): clean completions get 'Session completed', not 'Hub restart'

Addresses bot review round 4 of tiann#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#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): restore inactive-session guard on /archive except split-brain

Addresses post-rebase bot review Major on tiann#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 tiann#916).
Two route tests cover the guard and the exception.

Refs tiann#916.

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 tiann#914 archive
reason coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): pass lifecycle object to KillSession handler in Pi runner

Upstream tiann#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#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Add Source | Preview toggle for .md/.mdx files in the session file route,
defaulting to preview with localStorage persistence. Reuse chat markdown
pipeline via MarkdownRenderer standalone mode (no assistant-ui thread).

Includes unit tests, Playwright smoke, and e2e fixture.

Closes tiann#954

Co-authored-by: Cursor <cursoragent@cursor.com>
Soup verify gate: defaultComponents merge type is wider than
react-markdown Components; standalone file-pane path needs explicit cast.
Standalone file preview now mirrors chat code-block rendering: fenced
blocks use SyntaxHighlighter and MARKDOWN_COMPONENTS_BY_LANGUAGE (mermaid
included) without requiring ThreadPrimitive context.

Addresses HAPI Bot Major on tiann#957.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee heavygee added the cold-review-clean Fork-side bot review is satisfactory; safe to promote to upstream PR label Jun 20, 2026
Move block detection to the pre override (react-markdown v10 does not pass
inline to custom code components). Add inline-code regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cold-review-clean Fork-side bot review is satisfactory; safe to promote to upstream PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(web): markdown Source | Preview toggle in session file pane

5 participants