Commit Graph
123 Commits
Author SHA1 Message Date
78155a9d27 perf(web): add staleTime to useSession to suppress focus/mount refetches (#884) (#885)
* perf(web): suppress useSession refetch storm (closes #884)

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

The Codex review on #885 correctly flagged that
`hasActiveSessionDetailObserver`-gating around the two
`queueSessionDetailInvalidation` fallback paths broke an important
correctness invariant: with `staleTime: 30_000` in place, skipping
the invalidation entirely (instead of letting TanStack mark the
cache stale) means a subsequent remount within 30s will serve the
stale cached detail without a REST recovery fetch.

This regressed real backend code paths.  Hub emits
`session-updated` events with no structured `data` field on todos /
teamState / metadata / agentState changes (see
`hub/src/socket/handlers/cli/sessionHandlers.ts:117,128,216,263`),
which hit the gated `else` branch.

Root cause of the over-correction was a misunderstanding of TanStack
v5 semantics: `invalidateQueries` with the default
`refetchType: 'active'` is *already* a network no-op for unobserved
queries — it just marks them stale.  The manual observer-count check
was structurally redundant *and* incorrectly suppressed the stale
marking.

Revert: restore the original unconditional
`queueSessionDetailInvalidation` calls on both fallback branches.
Drop the `hasActiveSessionDetailObserver` helper export and its 4
unit-test cases.  Keep Fix A (`staleTime: 30_000` on `useSession`)
intact — that change is independently safe and addresses the
focus-refetch / remount-refetch class of redundant requests.

* docs(web): correct staleTime rationale in useSession (#884)

Stand-in cold review on PR #885 caught that the comment overstated the
fix's reach.  `web/src/lib/query-client.ts:7` already sets the global
default `refetchOnWindowFocus: false` and `staleTime: 5_000`, so the
per-query `staleTime: 30_000` does NOT cut focus-refetches (there were
none) and only extends the remount/reconnect-no-refetch window from
5s to 30s.

Rewrite the comment to be accurate about scope: the change suppresses
remount refetches within a 30s window, and explicit `invalidateQueries`
(SSE fallback path, reconnect-recovery in `App.tsx`) still refetches
active observers — so live updates and recovery flows are preserved.

No code behaviour change; comment-only edit.

* fix(web): invalidate all cached session details on SSE reconnect

Codex review on PR #885 caught a real regression introduced by the
`SESSION_DETAIL_STALE_TIME_MS = 30_000` change: the reconnect-recovery
handler in `App.tsx` only invalidated the *currently-selected* session's
detail.  With per-query staleTime extended from 5s (global default) to
30s, a previously-viewed but non-selected session whose cache was still
within the freshness window could serve stale data after the SSE channel
missed updates during the disconnect.

Scenario:
1. User views session A → cache populated, fresh.
2. User switches to session B → A's observer unmounts, cache lingers
   (gcTime: 5min).
3. SSE disconnects.  Session A receives updates server-side that no
   patch event reaches the client.
4. SSE reconnects.  Old `handleSseConnect` only invalidated
   `session(selectedSessionId=B)`, NOT A.
5. User navigates back to A within 30s → useSession remounts → cache
   is still considered fresh by staleTime → no REST recovery fetch →
   user sees stale A data.

Fix: broaden the per-session invalidation in `handleSseConnect` from
`['session', selectedSessionId]` to the prefix `['session']`, which
matches every cached session-detail entry.  Active observers refetch
(same as before — only the selected session was active), inactive
cached entries get marked stale so the next remount refetches.

Performance impact: zero new fetches on reconnect (the selected
session is still the only one with an active observer in practice).
Marking inactive entries stale is metadata-only, free.

This restores the pre-staleTime invariant where every cached session
detail was either fresh (just fetched) or actively re-fetched on
reconnect, and matches the documented contract that SSE is the
authoritative freshness signal while REST is the cold-start /
reconnect-recovery path.

---------

Co-authored-by: heavygee <heavygee@users.noreply.github.com>
2026-06-18 10:13:36 +08:00
a858256620 fix(web,hub): surface inactive-session error on text-only send (closes #918) (#922)
Sending text via the web composer to an archived/inactive session
silently dropped on the floor: the hub returned 409 but the web client
swallowed the failure with a console.error in the resolveSessionId catch
branch, leaving the operator with no signal and no recovery path.

Hub: add a machine-readable `code: 'session_inactive'` to the 409 body
so the web client can discriminate this branch without string-matching
the i18n-able human message.

Web (router.tsx, useSendMessage.ts, HappyComposer.tsx):

  - useSendMessage now fires `onError` on resolveSessionId rejection,
    not just on POST /messages failure -- closes the visibility hole
    when the inactive session has no resume target or resume itself
    fails.

  - The route classifies the thrown error: a 409 + session_inactive
    code or a synthetic ApiError thrown from resolveSessionId attaches
    a Reopen action to the existing inline composer-error affordance.
    Plain 4xx / 5xx / network keep the legacy text-restore UX
    untouched.

  - Reopen calls api.reopenSession (the same path as SessionList's
    Reopen menu item), invalidates the session queries, and navigates
    to the resumed sessionId.  Per the orchestrator brief's friction
    pass on #917 the affordance does NOT auto-replay the send; the
    operator re-clicks Send on the restored composer text.

Tests:

  - hub messages.test.ts: 409 carries `code: 'session_inactive'`.
  - useSendMessage.test.tsx: ApiError(409, session_inactive) from POST
    flows through onError; resolveSessionId rejection flows through
    onError keyed by the original sessionId; 500 keeps the legacy
    fallback path with no code attached.

AI disclosure: implemented by an AI agent (Claude Opus 4.7) acting on
operator instructions; tests pass locally (bun typecheck + bun run
test for hub and web).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:11:40 +08:00
fc8c32e07a fix: reliable generated-image display + stop client remount storm (#927) (#934)
* test: reproduce issue #927

* fix(hub): cache generated images + raise socket buffer cap (closes #927)

Generated-image display was slow and could silently fail:

- The /generated-images route sent `Cache-Control: no-store`, so every
  card remount (session switch, scroll, reload) re-ran the full HTTP ->
  socket.io RPC -> base64 round-trip. The bytes for an imageId are
  immutable, so serve them `private, max-age=31536000, immutable` + ETag.
- socket.io / bun-engine `maxHttpBufferSize` was left at the 1 MB default,
  while the MCP tool accepts images up to 25 MB. The base64 CLI -> hub ack
  frame for anything above ~750 KB raw exceeded the cap and was dropped.
  Raise the buffer to comfortably carry the largest allowed image.

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

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

* fix(hub): short-circuit generated-image revalidation with a 304 (#927)

The imageId is an immutable content fingerprint, so use it as the ETag and
answer If-None-Match with 304 before issuing the readGeneratedImage RPC.
This makes the ETag actually useful: revalidation now skips the CLI socket
round-trip entirely, and still serves correctly even after the image was
evicted from the CLI's in-memory store.

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

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

* fix(web): stabilize ApiClient identity across token refresh (#927)

The ApiClient was rebuilt whenever the token value changed (useMemo dep),
even though every request already reads the live token via getToken/tokenRef.
On a flaky/remote connection, repeated 401s -> onUnauthorized -> forced
refresh churned `api`'s identity, which remounts everything keyed on it:
VoiceBackendSession ([props.api]) -> Voice re-register spam, and
GeneratedImageCard ([ctx.api]) -> per-image refetch storm, feeding a
render avalanche. Depend on auth presence (hasToken) instead.

Reproduced with a useAuth hook test (red->green); full web suite stays green.

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-18 10:11:31 +08:00
e23ae1b265 feat: add Pi Coding Agent support (#862)
* 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>
2026-06-18 10:01:10 +08:00
SSU-WEI HUANGandGitHub c311afddca fix(codex): Fast mode (service tier) toggle + /fast command (closes #898) (#904)
* test: reproduce issue #898 (Codex fast mode service tier)

* fix(codex): add Fast mode (service tier) toggle and /fast command (closes #898)

* feat(codex+web): Fast mode UI toggle with full persistence

Wires the Codex Fast mode (service tier) end-to-end so it can be toggled
from the web composer and survives reload/handoff:

- shared: serviceTier on Session/SessionPatch, session-alive payload,
  resume target, and a SessionServiceTierRequest schema
- cli: AgentSessionBase carries serviceTier through keepAlive; runCodex
  syncs it to the session instance
- hub: service_tier column (schema v10 + migration), store setter,
  sessionCache + syncEngine plumbing, POST /sessions/:id/service-tier
- web: api.setServiceTier + mutation, a Fast/Standard toggle in the
  composer settings (gated to Codex GPT-5.5/5.4), and StatusBar now
  reflects the real tier instead of the effort heuristic

Refs #898

* fix(codex): preserve unset/persisted service tier on startup keepalive

Addresses HAPI Bot [Major] on PR #904: applyCurrentConfigToSession ran
setServiceTier(currentServiceTier ?? null) on wrapper-ready, collapsing the
untouched `undefined` state into explicit Standard. The immediate
setCollaborationMode keepalive then persisted serviceTier: null, silently
downgrading resumed Fast sessions and disabling account-default Fast.

- Seed currentServiceTier from the persisted session (sessionInfo.serviceTier),
  so a resumed Fast thread keeps running Fast.
- Only call setServiceTier when the tier is explicit (!== undefined), preserving
  the three-state omit semantics at the keepalive boundary.
- Add regression tests: persisted Fast is re-asserted; untouched omits the tier.

* feat(codex+web): gate Fast toggle on catalog-advertised service tier

The Fast toggle was gated on a model-name regex (gpt-5.5/5.4), which still
showed a no-op control to API-key users — Fast credits only apply with
ChatGPT login. Codex's model/list catalog advertises the service tiers
actually available for each model in the current auth/plan context, so gate
on that instead:

- cli: capture serviceTiers (ids) per model in ModelListItem + normalizeModel
- shared: CodexModelSummary.serviceTiers (flows through the existing
  getSessionCodexModels pass-through; no hub change needed)
- web: codexModelAdvertisesFastTier(sessionModel, models) replaces the regex;
  SessionChat gates the toggle on it (hidden while the catalog is
  loading/errored). The toggle now only appears when toggling it will
  actually take effect.

Refs #898

* fix(codex): make explicit Standard service tier sticky across resume

Addresses HAPI Bot [Major] (round 2): a single persisted null conflated
"untouched" with "explicit Standard". A user who turned Fast off persisted
null, but startup mapped null -> undefined (untouched) and omitted serviceTier,
so an account/thread-default Fast could silently return after restart/resume.

Introduce a distinct stored representation:
- 'fast' / 'standard' are explicit user choices; null/undefined = untouched.
- Translate 'standard' -> Codex app-server serviceTier: null ONLY when building
  thread/turn params (toAppServerServiceTier); untouched omits the field.
- /fast off now stores 'standard'; the web Standard option sends 'standard'.
- Tighten SessionServiceTierRequest to enum(['fast','standard']) so stray tier
  strings are never forwarded.

Tests: sticky-Standard-on-resume regression; turn/thread params translate
'standard'->null and omit on untouched; hub route applies fast/standard and
rejects unsupported values + local sessions.

Refs #898

* fix(codex): recognize real Fast tier (id 'priority', name 'Fast') in catalog gate

Live E2E against an authed Codex session revealed the model catalog advertises
the Fast tier with id 'priority' and display name 'Fast' (not id 'fast'), so the
/fast/i gate — which only saw tier ids — wrongly hid the toggle for valid
ChatGPT users on gpt-5.5/gpt-5.4. Capture both the tier id and name as
lowercased tokens so the existing name-based match recognizes 'Fast'. The sent
value stays 'fast' (the documented service_tier value / raw additionalSpeedTiers
request tier). Verified end-to-end: gpt-5.5/gpt-5.4 gate on, gpt-5.4-mini off.

Refs #898

* fix(codex): preserve service tier across session resume

Resuming a Codex session spawns a fresh session (serviceTier null) and merges
the old one in. Unlike model/effort/permissionMode, serviceTier was neither
threaded through the resume spawn nor preserved in mergeSessionData, so a
resumed Fast (or explicit Standard) session silently reverted to the account
default.

Thread serviceTier through the spawn path like its siblings:
- hub: resumeSession passes session.serviceTier to spawnSession; rpcGateway +
  syncEngine carry it in the spawn RPC payload; mergeSessionData preserves it
  old->new (safety net).
- cli: SpawnSessionOptions.serviceTier; apiMachine forwards it; buildCliArgs
  emits --service-tier for codex; the codex command parses it; runCodex seeds
  currentServiceTier from the spawn override first (opts.serviceTier ??
  sessionInfo.serviceTier), so a resumed thread immediately runs the right tier.

Verified end-to-end: set Fast -> kill process -> reopen -> resumed session (new
id) still runs Fast. Tests: buildCliArgs --service-tier (codex only), runCodex
spawn-override seed, mergeSessionData service-tier preservation.

Refs #898

* fix(codex): send advertised 'priority' tier id for Fast, not 'fast'

The model catalog advertises the Fast tier with request id 'priority' (display
name 'Fast'), and OpenAI docs confirm service_tier='fast' maps to the request
value 'priority'. The app-server serviceTier override is a raw request value
that does not validate unknown strings (a live probe accepted 'bogus-xyz'), so
sending 'fast' risks being silently ignored — no Fast applied.

Translate the stored 'fast' state to app-server 'priority' at the thread/turn
param boundary (toAppServerServiceTier); the stored/UI/command representation
stays 'fast'/'standard'. Verified live: a turn with serviceTier='priority' runs
and consumes the Fast-tier rate budget.

Addresses HAPI Bot [Major]. Refs #898

* fix(codex): validate --service-tier CLI value (fast|standard)

Addresses HAPI Bot [Minor]: the internal --service-tier spawn arg accepted any
non-empty string, unlike the web /service-tier enum, so a malformed value could
be seeded into currentServiceTier and persisted via keepalive. Parse it to
'fast'|'standard' and reject anything else, matching the web endpoint.

Refs #898
2026-06-17 10:27:33 +08:00
cad58cfa0b fix(opencode): use ACP-reported reasoning effort options (#853)
* fix(opencode): use ACP-reported reasoning effort options

Expose thought_level options from OpenCode ACP to the web UI via RPC/API
instead of hardcoded presets, and validate effort values before setConfigOption.

Fixes #852

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

* fix(opencode): sync hub effort after coerced setConfigOption

When resolveThoughtLevelEffort falls back to a different supported value,
roll back session state after a successful ACP update so keepalive and the
web UI do not keep advertising the rejected effort.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:57:03 +08:00
393cd7bfbb feat(web): scratchlist v1.1 — composer-toggle drawer + reusable FUE primitive (#798)
* feat(web): scratchlist v1.1 — composer-toggle drawer + reusable FUE primitive

The v1 always-visible amber band proved too heavy for what's a 20% feature
in a typical session. v1.1 dials it back to a composer-toggle that opens
an on-demand drawer, paired with a reusable FUE (First-User Experience)
primitive so existing operators get a subtle pulsing dot + on-click
explainer the first time they see the toggle.

UX changes:
- Notepad icon in the composer toolbar (next to schedule-send) toggles
  scratchlist mode. Drawer renders only while mode is on.
- Composer's send button repaints amber and reads "Send to scratchlist"
  while the mode is sticky; submit routes adds into the scratchlist
  instead of the chat. Click the icon again to leave.
- Small entry-counter badge appears on the toggle when entries exist;
  empty-state shows just the icon (no zero-state guilt UI).

New reusable FUE primitive:
- web/src/lib/use-fue.ts: state machine (unseen → engaging → acknowledged)
  with localStorage persistence, namespaced under hapi.fue.v1.<featureId>
  so it can't collide with any future upstream onboarding flow.
- web/src/components/Fue.tsx: <FueDot> (small pulsing badge) and
  <FueCallout> (portal-rendered popover with title/body + "Got it"
  affirmative-action dismiss). No auto-timeout — reading speed varies
  and silent disappearance undercuts user trust.
- AGENTS.md adds a "Adding new web features — consider an FUE" section
  so future contributors discover the primitive.

Refactors:
- ScratchlistPanel.tsx: split rendering into <ScratchlistInventory>
  (presentational list) and <ScratchlistDrawer> (composer-controlled
  drawer with hint copy). Original <ScratchlistPanel> kept exported
  for the existing fixture-based tests.
- SessionChat.tsx: scratchlist state lifted into useScratchlist hook
  so the composer-toolbar counter and the drawer share one source of
  truth. onSend wrapped to route through scratchlist.add when mode
  is on.

Tests:
- 9 useFue hook tests (initial state, engage idempotency, no
  auto-acknowledge, dismiss, featureId switching, post-acknowledged
  engage no-op, resetFue helper).
- 5 placement helper tests (above/below switching, viewport edge
  clamping, visualViewport offset support).
- All 21 existing scratchlist lib tests + 14 ScratchlistPanel tests
  continue to pass.

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

* fix(scratchlist): prevent cross-session leak in useScratchlist hook

Per upstream review on PR #798 (github-actions[bot] [Major]):

  > useScratchlist persists current `entries` whenever `sessionId`
  > changes. On A -> B navigation, React first commits with B's id
  > and A's entries; after paint, this persist effect can write A's
  > entries to hapi.scratchlist.v1.B before the rehydrate effect
  > loads B. The previous keyed panel existed specifically to avoid
  > this race.

Lifting state out of the v1 panel (which sidestepped the race via
key={props.session.id} forced remount) re-introduced this same data-
loss window. The composer-controlled drawer in v1.1 cannot remount
on session change because its parent SessionChat doesn't either.

Fix: keep the loaded sessionId in state alongside the entries so they
swap atomically, and persist against the LOADED sessionId rather than
the prop. After A->B, the loaded sessionId is still A until rehydrate
runs, so a spurious persist re-writes A's storage with A's entries -
a no-op instead of a corruption.

Tests:
- New use-scratchlist.test.ts with 6 tests:
  - hydrates from localStorage on mount
  - add() persists to current session's storage only
  - rerender to a new session preserves the new session's existing entries
  - after switching, add() targets the new session
  - regression test that spies on Storage.prototype.setItem and asserts
    the rerender lifecycle never produces a (B-key, A-entries) write
  - remove()/move() target the loaded sessionId
- The setItem-spy test correctly fails against the buggy code (verified
  by temporarily reverting the fix) and passes with the fix in place.
- Full web suite: 88 files, 756 tests, all green. Typecheck clean.

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

* fix(scratchlist): route attachment/scheduled submits to chat instead of dropping them

Per upstream review on PR #798 (github-actions[bot] [Major]):

  > Prevent scratchlist mode from dropping attachments — in scratchlist
  > mode the wrapper returns success after adding only `text`, while
  > HappyComposer still treats composer attachments as sendable input.
  > A text+attachment submit therefore routes through this branch,
  > stores only the text, and silently discards the attachment instead
  > of sending or preserving it.

Same hazard applies to scheduledAt: scratchlist entries are pure-text
notes - they can't represent attachments or schedule metadata - so any
submit carrying either MUST fall through to props.onSend (chat) even
when the scratchlist toggle is on. Otherwise the wrapper short-circuits
to scratchlist.add(text), reports success to the composer, and the
composer dutifully clears attachments + schedule that the user just
queued.

Fix: extracted the routing rule into shouldRouteToScratchlist(mode,
attachments, scheduledAt) - returns true only when mode is on AND the
payload is pure text. onSendForComposer uses it.

Tests:
- 5 new shouldRouteToScratchlist unit tests (mode off, mode on +
  text-only, mode on + attachments, mode on + schedule, mode on + both)
- All in web/src/components/SessionChat.test.ts (13 tests total now)
- Full web suite: 88 files, 761 tests, all green. Typecheck clean.

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

* fix(scratchlist): clear pendingSchedule when scratchlist-mode submission falls back to chat

Per upstream review on PR #798 (github-actions[bot] [Major]):

  > The follow-up change correctly falls through to props.onSend when
  > scratchlist mode is on but scheduledAt is present, yet the
  > accepted-send cleanup still checks only !scratchlistMode. That
  > means a scheduled chat send made while the amber scratchlist UI is
  > active is accepted, but pendingSchedule stays set, so the next
  > normal send can accidentally reuse the same schedule.

Fix: handleSend now gates the cleanup branch on the actual route taken
(routedToScratchlist) rather than the scratchlist UI state. Reuses the
same shouldRouteToScratchlist helper so route + cleanup share a single
source of truth.

Tests:
- 2 new tests in SessionChat.test.ts that pin the decision matrix
  handleSend depends on:
  - 'cleanup gate: scheduled chat send while scratchlist toggle is on
     still clears schedule'
  - 'cleanup gate: pure-text scratchlist add does NOT clear schedule'
- Full web suite: 88 files, 763 tests, all green. Typecheck clean.

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

* fix(scratchlist): UnifiedButton must reflect actual routing, not raw scratchlist toggle

Per upstream review on PR #798 (github-actions[bot] [Major]):

  > Send button advertises scratchlist routing even when the submit
  > will go to chat — shouldRouteToScratchlist correctly falls back
  > to normal chat for attachments or scheduledAt, but UnifiedButton
  > still turns amber and labels the action as "Send to scratchlist"
  > whenever scratchlistMode is true. A scheduled send or attachment
  > send made in that state will be submitted to chat while the UI
  > says it is being stashed, which can send content to the agent
  > unexpectedly.

Fix:
- UnifiedButton's prop renamed `scratchlistMode` -> `routesToScratchlist`
  to make the contract explicit: "this submit really will go to the
  scratchlist", not "the scratchlist toggle is on".
- The call site computes `routesToScratchlist` from
  `scratchlistMode && !hasAttachments && pendingSchedule == null`,
  mirroring SessionChat's shouldRouteToScratchlist exactly. The button
  is now amber + "Send to scratchlist" only when the actual send path
  will hit scratchlist; attachments / pending schedule force a chat-
  style render that matches the real routing.
- UnifiedButton exported so it can be unit-tested directly.

Tests:
- 3 new render tests in ComposerButtons.test.tsx covering:
  - routesToScratchlist=true → amber + "Send to scratchlist"
  - routesToScratchlist=false → black + "Send" (the regression case)
  - omitted prop → defaults to chat-style render
- Full web suite: 89 files, 766 tests, all green. Typecheck clean.

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

* fix(scratchlist): exit scratchlist mode when promoting an entry to the composer

Per upstream review on PR #798 (HAPI Bot, follow-up after b256fe5):

  > Found one major issue: promoting a scratchlist item to the composer
  > keeps scratchlist mode enabled, so the next send re-adds it to the
  > scratchlist instead of sending to chat.

Promoting an entry to the composer means "I want to send this for real
now". With scratchlist mode still on, the next composer submit routes
back to scratchlist (per the v1.1 modal-mode contract), so the user's
click loop becomes promote -> send -> re-add -> nothing-actually-sent.

Fix: ScratchlistDrawerHost now calls onExitScratchlistMode whenever it
promotes an entry to the composer. Promote-to-queue does NOT exit the
mode (queue path bypasses the wrapper anyway, and the operator may
still be capturing related notes).

Tests:
- Exported ScratchlistDrawerHost so its host-level callbacks can be
  unit-tested in isolation (previously only ScratchlistDrawer was
  testable; the wiring was untested).
- New SessionChat.exit-mode.test.tsx with 2 tests:
  - promote-to-composer fires setText AND onExitScratchlistMode
  - promote-to-queue fires onSend but does NOT exit mode
- Full web suite: 90 files, 768 tests, all green. Typecheck clean.

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

* feat(scratchlist): Ctrl/Cmd+Shift+S toggles scratchlist mode (v1.1 hotkey)

The v1 always-visible panel had Ctrl/Cmd+Shift+S to expand the panel and
focus the input. v1.1 mounts the drawer only when scratchlistMode is on,
so the v1 listener (inside the panel) is dead code: it can't fire while
the drawer is unmounted, and the user has no way to open the drawer
without clicking the toolbar icon. Re-bind the shortcut at SessionChat
scope so it's always alive and toggles the mode.

Convention matches sibling globals (Ctrl/Cmd-m cycles agent model).
Ctrl/Cmd-Shift-S is unreserved by Chrome / Firefox / Safari (browser
Save As is Ctrl-S / Cmd-S, no Shift), so the user's save-page muscle
memory keeps working. Modifier requirement (Ctrl/Cmd+Shift) means it
can't collide with literal-character typing in any input - no focus
suppression needed.

The matcher is extracted to a pure helper isScratchlistToggleHotkey
so it's unit testable without mounting SessionChat. 6 new tests pin
the modifier matrix:
  - Ctrl+Shift+S (Linux/Windows) -> match
  - Cmd+Shift+S (macOS)          -> match
  - Cmd/Ctrl+S without Shift     -> reject (browser Save reservation)
  - bare S / Shift+S             -> reject (literal typing)
  - Ctrl+Shift+Alt+S             -> reject (avoid OS clashes)
  - other modifier+key combos    -> reject

Tooltip + FUE body now mention the hotkey so it's discoverable from
the same UI surface that introduces the feature (en + zh-CN).

Web suite 90 files / 774 tests, all green. Typecheck clean.

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

* fix(scratchlist): hotkey skips dialogs / inputs / contentEditable

Bot finding on PR #798 (PRRT_kwDOQuQOSc6HGtLn): the window-level
Ctrl/Cmd+Shift+S listener fires for every focus target, so the
shortcut can toggle scratchlist mode "behind" an open modal (rename
session, schedule picker, FUE callout, image preview), making the
next composer send route to scratchlist instead of chat. UX bug.

Add isScratchlistHotkeyBlockedTarget(target) and gate the listener
on it. Block targets:

  - any descendant of an open [role="dialog"] (Radix UI's
    DialogContent renders role="dialog"; FueCallout, ScheduleTimePicker,
    ImagePreview also use role="dialog")
  - HTMLInputElement (single-line inputs)
  - HTMLSelectElement
  - any contentEditable host (with attribute-based fallback for jsdom,
    which doesn't implement isContentEditable)

NOT blocked:
  - HTMLTextAreaElement (the composer textarea is the expected focus
    target when the operator presses the hotkey - blocking would
    defeat the shortcut)
  - the document body / unfocused targets

8 new unit tests pin the matrix. Function is exported / pure so
callers can reuse the same blocked-target rule for future global
shortcuts. Suggested fix from the bot applied modulo:

  - Use !== null on closest() result (explicit boolean for return type)
  - Add attribute-based contentEditable fallback for jsdom test env

* feat(scratchlist): copy-to-clipboard action on each entry

Add a per-entry "Copy to clipboard" button between the send-to-queue and
delete actions. On click, write the entry text via the shared
safeCopyToClipboard helper (which already handles the navigator.clipboard
primary path + the execCommand fallback for Safari / non-secure-context
edges); on success, briefly flip the icon to a check and the
aria-label/title to "Copied!" for 1500ms so the operator gets visual +
screen-reader confirmation. Failures (clipboard denied AND execCommand
fallback unavailable) silently no-op rather than throw at the click
handler.

Mirrored across both surfaces:
  - ScratchlistInventory (used by the v1.1 composer-toggle drawer)
  - ScratchlistPanel inline list (the v1 always-visible panel)

A small useCopiedFeedback() hook owns the "which entry just got copied"
state + the 1.5s auto-clear timeout. Pure state machine; the caller
wires safeCopyToClipboard separately so the hook itself stays free of
jsdom clipboard quirks. Cleared on unmount via the standard ref-tracked
timeout pattern, so promote-and-navigate-away can't leak.

Locale keys: scratchlist.action.copy / scratchlist.action.copied (en + zh-CN).

Three new tests:
  - v1 panel happy path: writeText called with the entry text, button
    flips to the "Copied!" label, entry is preserved (copy is non-destructive).
  - v1 panel failure path: writeText rejects AND execCommand returns
    false; button stays in "Copy to clipboard" state — no false success.
  - v1.1 drawer happy path: writeText called, label flips, and crucially
    no other entry handlers (onSend, onDelete, setText, onExitScratchlistMode)
    fire — copy is independent of all the other actions.

Web suite 90 files / 785 tests, all green. Typecheck clean.

* fix(scratchlist): reset all per-session state via keyed wrapper

Bot finding on PR #798 (PRRT_kwDOQuQOSc6HHOsa): when the operator
navigates between sessions on the same route (/sessions/A ->
/sessions/B), React reuses the SessionChat component instance.
Effects run AFTER the first paint, so for a single render window the
new session is rendered with the previous session's scratchlist
entries (useScratchlist's rehydrate-effect) AND drawer-open state
(scratchlistMode reset effect). Visual leak; drawer actions targeting
stale state.

Apply the bot's suggested fix verbatim modulo the type extraction:

    export function SessionChat(props) {
        return <SessionChatInner key={props.session.id} {...props} />
    }

Canonical React idiom for "fully reset state on prop change": the
keyed wrapper unmounts and re-mounts the inner component when
session.id changes, so every hook (useScratchlist's initial-state
factory, useState, useHappyRuntime, ...) starts fresh. This
supersedes the now-redundant effect-based reset:

  - useEffect(() => { setScratchlistMode(false) }, [session.id])  REMOVED

useScratchlist's atomic-loaded-sessionId persistence (added on the
prior PR round) stays as defense-in-depth for any caller that uses
the hook without the keyed-wrapper pattern.

Web suite 90 files / 785 tests, all green. Typecheck clean.

* fix(web): retain composer text on send failure (closes #776)

When the message composer submits and the hub responds with a 4xx/5xx
or the fetch fails outright, assistant-ui clears the composer
synchronously the moment send is invoked. Without intervention the
operator's typed text is destroyed at exactly the moment they most
need it preserved. SessionChat additionally clears any pending
schedule on accept, so a failed scheduled send was also silently
downgrading to immediate on the next attempt.

Behaviour:

- useSendMessage exposes onError({ sessionId, text, scheduledAt, error })
  so the route can hand the input back to the composer. sessionId is
  the resolved target (post-resolveSessionId), so an inactive-session
  resume that resolves a new id, kicks off async navigation, then
  fails the POST restores into the resumed session's composer rather
  than the old one.

- router.tsx stores sendErrors keyed by sessionId. Per-session lookup
  replaces the clear-on-session-change effect, so errors do not bleed
  between sessions and a session-scoped failure persists across
  navigation.

- HappyComposer accepts ComposerSendError, restores text via
  api.composer().setText() once per failure id, and re-establishes any
  pending schedule via onSchedule({ type: 'absolute', ms: scheduledAt }).
  It renders a red ring on the composer wrapper and a role="alert"
  inline message; both clear the moment the operator types or sends.

- onError forks on input.attachments. Text-only sends use the
  composer-restore path (removeOptimisticMessage drops the row so the
  failed bubble does not duplicate the restored text). Attachment
  sends keep the legacy failed-bubble UX (status='failed' + in-thread
  retry button) because the composer-restore path can't reinstate
  uploaded attachment metadata. retryMessage extracts attachments
  from the stored optimistic message via getMessageAttachments so
  failed-bubble retry of an attachment send re-fires with its files.

Acceptance (issue #776):

- Submit -> 500/502/503/network error -> composer text not cleared
- Submit -> 400/401/403 -> composer text not cleared, error inline
- Submit -> 2xx -> composer clears as today
- Operator can edit retained text and retry without re-typing
- Failed scheduled sends restore as scheduled, not as immediate

Tests in web/src/hooks/mutations/useSendMessage.test.tsx cover
text-only 4xx/5xx/network retention, scheduled-send carry-through,
optimistic-row removal on text-only failure, sessionId carry-through
under resolveSessionId, attachment failure fallback, and attachment
retry preservation. Full web suite passes (705 tests). bun typecheck
clean. No SCHEMA_VERSION bump (frontend-only).

* fix(test): correct AttachmentMetadata fixture shape + JSX namespace import

Two pre-existing test-only typecheck failures surfaced once scratchlist v1.1
was stacked into the driver soup.

* SessionChat.test.ts - the attachment() fixture used the legacy schema
  (kind, sizeBytes) instead of the current AttachmentMetadataSchema
  (filename, size, path). Updated to match the live shape so the cast
  is honest.

* ComposerButtons.test.tsx - JSX namespace is no longer global under
  the current TS lib config; switched the helper signature from
  JSX.Element to React's ReactElement (same runtime, named import).

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

* fix(scratchlist): soften panel chrome - drop strong amber fill, keep subtle accent border (#812)

Per #812 (and PR 827 from @swear01) the always-visible amber fill
on the scratchlist panel was too loud as a scroll element. This
swaps the warning *fill* for the chat-user-surface tone and uses
neutral text/pills/focus, but keeps the warning *border* as a soft
accent so the panel still reads as a different destination from a
normal user message.

The strong destination signal continues to live on the composer
Send button (it goes amber-500 only while scratchlist mode is
routing) and the active toggle button - those carry the
moment-of-action signal the user actually presses, and ComposerButtons
tests + the FUE copy already depend on that behavior, so they're
unchanged.

Credit to @swear01 (PR 827) for the styling note; this branch
absorbs that restyle and supersedes the Settings-toggle approach
because v1.1 hides the panel by default behind the composer drawer
toggle (no Settings entry needed).

Adds a regression-guard test asserting the panel uses the
chat-user-surface bg + warning-border (not the warning fill).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:49:42 +08:00
cb72703649 feat(hub+web): add POST /sessions/:id/reopen + Reopen button on inactive rows (#826)
* feat(hub+web): add POST /sessions/:id/reopen + Reopen button on inactive rows

Archived sessions retain their full transcript and metadata in the DB, but
today there is no path back to them from the web UI; the only way to revive
one is shell access plus sqlite metadata patching plus a manual /resume call.

This change adds a single one-click affordance:

- Hub: new POST /api/sessions/:id/reopen route on the existing sessions
  router. The route delegates to a new engine method `reopenSession` that:
  - is idempotent (active session -> 200 with `resumed:false`),
  - validates Cursor sessions still have a `cursorSessionId` once they have
    any messages (otherwise we cannot resume the agent thread),
  - clears `lifecycleState='archived'`, `archivedBy`, `archiveReason` via a
    versioned metadata update, and stamps `lifecycleStateSince`,
  - defaults `cursorSessionProtocol='stream-json'` for pre-#799 Cursor
    sessions (sessions that have a `cursorSessionId` but no protocol set),
    so routing still reaches the legacy launcher; ACP sessions keep their
    explicit protocol,
  - forwards to the same `resumeSession` path the existing /resume route
    uses, including the `canFreshSpawnNeverStartedSession` fallback.

  422 is returned with `{ missing: [...] }` when the agent metadata needed
  to resume is gone; other engine errors map to 404/409/503/500 with the
  existing shape (mirrors /resume).

- Web: a "Reopen" entry in the SessionActionMenu that appears next to
  "Delete" on inactive sessions only. Wired into both the SessionList rows
  and the SessionHeader more-menu, with a small dismissable error dialog
  for the 422 missing-metadata case.

- Tests: route-level coverage for the four response shapes (200 reopen,
  200 idempotent, 404, 422) plus 409/503 error mappings; sessionCache
  tests for the archive-metadata clear (including the legacy Cursor
  protocol default); React component test for the menu item rendering on
  inactive vs active sessions; mutation hook test for the api wiring and
  the ApiError surface needed by the UI.

Closes #819

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

* fix(reopen): address codex review findings on fork PR #33

Four P2 findings from the cold-review bot, three fixed and one explained:

1. Mutation now returns the reopen response so the UI can route to a possibly
   different sessionId. SyncEngine.resumeSession may merge the row into a
   freshly-spawned session id (matching the send-message resume flow); the
   chat view now navigates there, the row list calls onSelect on the new id.
2. reopenSession on the client now goes through `request()` instead of a
   hand-rolled fetch, so 401 + onUnauthorized refresh works the same as
   every other session action. `request()` now throws `ApiError` (with
   status/code/body) on non-401 errors - backward compatible because
   ApiError extends Error.
3. (Reply only) Pre-#799 Cursor protocol propagates correctly without the
   extra plumbing the bot suggested: `clearSessionArchiveMetadata` writes
   `cursorSessionProtocol='stream-json'` to the DB; the CLI's
   `bootstrapExistingSession` preserves it via `pickExistingSessionMetadata`;
   if it's still absent at the launcher, `isLegacyCursorSession` defaults
   to stream-json whenever `cursorSessionId` is present.
4. Archive metadata is now restored when resume fails. `reopenSession`
   captures a snapshot of `lifecycleState`/`archivedBy`/`archiveReason`/
   `lifecycleStateSince` before the clear; if `resumeSession` returns an
   error (no machine online, spawn timeout, etc.), the snapshot is put
   back via the new `SessionCache.restoreSessionArchiveMetadata`. Engine
   test covers both the rollback and the no-rollback-on-success cases.

Error rendering helper moved to `web/src/lib/reopenError.ts` so the chat
header and the session row share one implementation, and gained a unit test.

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

* fix(web): preserve engine error codes in ApiError.code on /reopen

`/sessions/:id/reopen` returns `{ error, code }` where `code` is the stable
taxonomy (`no_machine_online`, `resume_unavailable`, etc.) and `error` is the
human-readable message. The generic `request()` error path was reading only
`parsed.error`, so `ApiError.code` ended up being a message like
"No machine online" rather than `no_machine_online`, breaking taxonomy-based
branching in web callers.

`parseErrorCode` now prefers `parsed.code` and falls back to `parsed.error`
for legacy routes that only set `error`. Added api/client.test.ts covering
the three response shapes /reopen actually emits (503 with code, 500 without
code, 422 with missing[]).

Addresses upstream codex-action review on tiann/hapi#826.

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

* fix(reopen): restore archive metadata exactly on rollback (drop fresh lifecycleStateSince)

For an archived session that predates `lifecycleStateSince` (the field is
absent from its metadata), `clearSessionArchiveMetadata` stamps a fresh
timestamp. If `resumeSession` then fails, the rollback was leaving that
fresh timestamp in place, making the rolled-back row look like it was
just archived rather than preserving the original lifecycle age.

`restoreSessionArchiveMetadata` now does an EXACT restore: when a snapshot
field is undefined the corresponding key on the metadata is deleted, not
left alone. Applies symmetrically to lifecycleState / archivedBy /
archiveReason / lifecycleStateSince. Test updated to assert the deletion
of the fresh timestamp.

Addresses upstream codex-action review on tiann/hapi#826.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:29:31 +08:00
3a8693f380 feat(cursor): migrate remote sessions to ACP with model/variant pickers (#799)
* feat(cli,web,hub): migrate Cursor remote sessions to ACP with model/effort pickers

Move stream-json remote launcher to legacy path and add ACP launcher with
set_config_option model/mode sync, optimistic keepalive on config changes, and
shared catalog caching. Web gets dual base/effort Cursor pickers for session and
new-session flows; hide composer status bar when Cursor sends no usage_update.

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

* fix(cli,web,shared): Cursor model picker — ACP wires + CLI sku variants

Enrich the web/mobile picker with agent --list-models SKUs grouped under
ACP wire bases, fix session-open base highlight, and keep catalog discovery
safe while the ACP transport holds the CLI lock.

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

* fix(cursor-acp): apply ACP default model when web resets to Default

Web sends model: null for Default; push session/set_config_option with the
ACP default[] wire so Cursor backend matches hub state. Regression tests
for setModel(null) and applyModelConfig(null).

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

* fix(acp): clear stale agent-acp lock when owning process is gone

Check lock pid with signal 0; remove orphaned lock dirs after SIGKILL or
crash so listCursorModels can run cold probes again. Regression tests for
guard and catalog discovery.

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

* test(cursor): use live pid for ACP lock handler tests

Stale-lock cleanup clears dead pids; handler tests must simulate an
active lock with the current process pid to avoid cold probes/timeouts.

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

* fix(acp): scope agent CLI lock guard to Cursor agent command only

Gemini/OpenCode/Kimi ACP sessions must not register agent-acp-active;
that blocked listCursorModels while unrelated backends were running.

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

* fix(hub,web): reject Cursor model changes for local sessions

Hub returns 409 when controlledByUser is set, matching Codex. Web hides
model and variant pickers for local Cursor sessions so users do not hit
a dead RPC path. Document pre-push-review in AGENTS.md.

Verified: bun typecheck; bun run test (919 cli + 243 hub + 768 web + 46 shared).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): send stable ids for Cursor ask_question replies

Parse and submit question.id and option.id so ACP receives keys like
{ approach: ['a'] } instead of index/label. Verified: bun typecheck && bun run test.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:51:35 +08:00
a812a51dd7 feat(voice): backend voice picker + advanced controls behind disclosure (#742) (#743)
* feat(voice): voice personality, picker catalog, and prompt layer foundation

- voicePickerCatalog.ts: per-backend voice lists for Gemini and Qwen with
  resolve helpers (resolveGeminiLiveVoice, resolveQwenRealtimeVoice)
- voicePersonality.ts: VoicePersonalityPreferences schema, presets, composed
  system prompt with identity/character/response-length layers
- voicePromptLayers.ts: buildResolvedVoiceSystemPrompt, preset delivery snippets
- voiceSystemPromptParam.ts: hub-side base64url decode for ?systemPrompt=
- voicePickerPreferences.ts, voicePersonalitySession.ts: browser-side encode,
  decode, and storage helpers
- useVoicePersonality: React hook for preferences persistence

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

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

* fix(voice): preset delivery included when non-balanced preset selected; restore test typecheck

- isDefaultVoicePersonality: add preset check so warm/calm/direct presets
  trigger the delivery snippet instead of being treated as default
- web/tsconfig.json: remove test file exclusion from typecheck (restoring
  strict coverage of test code); fix resulting type error in mock declaration

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

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

* fix(voice): include use_speaker_boost in ElevenLabs TTS override payload

The checkbox persisted the pref but ttsDiffersFromDefault and
buildElevenLabsTtsOverride both omitted it, so the setting was never
sent to the agent.

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

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

* test(voice): update speaker_boost test to assert it IS included in override

The previous test asserted use_speaker_boost was omitted; now it's
correctly included in the TTS payload.

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

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

* fix(voice): authorize use_speaker_boost in ElevenLabs override schema

Add use_speaker_boost to both the VoiceAgentConfig tts override type
and the buildVoiceAgentConfig() platform_settings so the field is
accepted by the ElevenLabs agent runtime.

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

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

* fix(voice): propagate full language code through composed prompt, not just zh

getDefaultVoiceSystemPrompt and resolveComposedVoiceSystemPrompt were
filtering language to zh-only before passing to composeVoiceAgentPrompt.
Now append buildVoiceLanguageBlock(language) after composition so French,
Spanish, Japanese etc. reach Gemini/Qwen sessions correctly.

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

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

* fix(voice): only append language block when language explicitly set

Building language block unconditionally when no language is given
caused getDefaultVoiceSystemPrompt() to diverge from VOICE_SYSTEM_PROMPT.
Only append the block when a code is explicitly provided.

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

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

* fix(voice): always include language block for Gemini/Qwen in composed prompt

When auto-detect is on (language=undefined), the composed prompt sent
via hub proxy was losing the language auto-detect instruction because
the block was only added when language was explicitly set.

Now: ElevenLabs skips the block (has its own language field); Gemini/Qwen
always include it — undefined produces the auto-detect block, an explicit
code produces the appropriate language instruction.

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-05 21:43:04 +08:00
cc4025abdb fix(web,hub): queued bar SSE + never-started inactive resume (#761)
* fix(web): apply messages-consumed on global SSE connection

The global all-sessions SSE subscription returned early on message-stream
events without updating the message-window store. When session-scoped SSE
was reconnecting or the user had another session selected, messages-consumed
never cleared the queued bar even though the hub had stamped invoked_at.

Also harden mergeMessages so a stale invokedAt:null snapshot cannot clobber
an existing ack timestamp.

Fixes tiann/hapi#758

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

* fix(web,hub): resume never-started inactive sessions on first send

Hub fresh-spawns when inactive session has path but no agent thread id and
zero messages. Web guards resume, updates inactive banner copy, and surfaces
resume_unavailable before POST /resume when resume is impossible.

Fixes tiann/hapi#759

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

* fix(web): scope sessionResume guard to current flavor only

Hub `resolveAgentResumeId` only honors the metadata.flavor's id; the web
guard was falling back across all flavors so a cursor session with a stale
codexSessionId still tried to resume and 409'd. Mirror the hub switch and
default to claude when flavor is unknown.

Addresses HAPI Bot review on tiann/hapi#761.

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

* fix(web): allow claude session resume via hub message-id recovery

Hub `resolveAgentResumeId` falls back to `recoverClaudeSessionIdFromMessages`
on the claude branch when `metadata.claudeSessionId` is absent, so the web
guard must not block inactive claude sessions that have stored messages but
no metadata id. Other flavors have no such recovery path and stay rejected.

Addresses second HAPI Bot review thread on tiann/hapi#761
(`web/src/lib/sessionResume.ts:41`).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 12:07:13 +08:00
HeavyGeeandGitHub ec3722aba9 feat(web): session list status indicators (attention + scheduled) (#699) 2026-05-29 15:43:21 +08:00
6f2bb7d32b feat(opencode): add plan mode, reasoning effort, and status telemetry (#688)
* feat(opencode): support plan mode

* feat(opencode): support reasoning effort

* feat(opencode): surface context usage in web

Bridge OpenCode ACP usage updates into the existing token-count pipeline so the web status bar can show live context and cache information without a separate UI path.

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

* fix(opencode): restrict plan mode to remote, rollback reasoning effort on failure

- Block local OpenCode plan startup (tools not enforced in local path)
- Allow remote OpenCode plan only (ACP permission handler denies tools)
- Guard web /permission-mode endpoint for local OpenCode plan sessions
- Rollback session reasoning effort when OpenCode rejects set_config_option
- Wire rollback callback through opencodeLoop to runOpencode closure
- Add tests: local plan rejected, remote plan allowed, web guard, effort rollback

* fix(web): auto-retry OpenCode models query to populate model selector without refresh

- Retry early failures (RPC may still be registering on new sessions)
- Poll briefly until availableModels is non-empty
- Stop polling once model options are discovered
- Add tests for retry/poll/stop policy

* fix(opencode): cap model discovery polling

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 11:17:24 +08:00
SSU-WEI HUANGandGitHub 1d03f186d6 feat(cursor): support model selection (#684) 2026-05-26 16:13:38 +08:00
9af6696a68 fix(web): keep global SSE alive for session list status updates (#694)
When a session is open, the web app now keeps an always-on all:true SSE
connection for sidebar session-updated events while using a second
session-scoped stream for message delivery. Also bump session activity on
hub sendMessage so web-originated sends refresh list timestamps.

Fixes tiann/hapi#693

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 14:45:43 +08:00
b06251bd53 fix: stream opencode reasoning updates (#661)
Emit throttled ACP reasoning snapshots with stable stream ids so OpenCode reasoning updates render live without one row per token.

Collapse matching reasoning snapshots in the web timeline and avoid stale session 404 redirects during refetch.

Tests: targeted Vitest suite and bun typecheck.

Co-authored-by: twshe <twshe@outlook.com>
2026-05-22 08:57:50 +08:00
weishu 41f6b37d69 Structure SSE update patches 2026-05-21 10:44:46 +08:00
weishu 9698aa9f4c Unify agent flavor definitions 2026-05-21 10:23:57 +08:00
weishu 6759cf4657 Remove old protocol compatibility layers 2026-05-20 17:45:28 +08:00
weishu 9324598cd5 chore: remove dead UI and agent entrypoints 2026-05-20 17:32:08 +08:00
SmallSpiderandGitHub 74e40b8a1a fix(codex): stabilize goal status UI events (#652) 2026-05-20 17:26:10 +08:00
Junmo KimandGitHub b2a30c2e39 feat(hub,web): support scheduling messages for future delivery (#590) 2026-05-18 09:09:17 +08:00
NightWatcher314andGitHub 6a81597724 fix(web): sync PWA theme color with app theme (#628) 2026-05-17 11:52:48 +08:00
NightWatcher314andGitHub 7bb7c7d91a feat(web): configure session preview limit (#629) 2026-05-17 11:52:30 +08:00
junesandGitHub 60af9835b4 feat(web): 优化聚合 tool use 展示与聊天背景设置 (#619) 2026-05-13 13:04:31 +08:00
junesandGitHub 3a6574f2b9 fix(web): compact terminal tool cards by default (#601) 2026-05-09 09:00:18 +08:00
junesandGitHub 991b01cd10 fix(web): stabilize session history recovery (#593)
* fix(web): stabilize session history recovery

* fix(web): split latest and older history generations

Separate latest and older async guards in the message window store.
Prevent refreshes from wedging load-more state and add a regression test for the overlap.
2026-05-07 21:07:50 +08:00
junesandGitHub 08d3d9e111 feat(web): add composer enter behavior setting (#586) 2026-05-07 08:27:13 +08:00
Junmo KimandGitHub 8185f0287e feat(web,hub): cancel queued messages (#568) 2026-05-06 13:32:45 +08:00
Junmo KimandGitHub 9ee014098a feat(opencode): support model selection and mid-session model change (#558)
* refactor(opencode): declare ModelChange capability and add model field to OpencodeMode

Mark opencode flavor as supporting model change by adding Capabilities.ModelChange
to FLAVOR_CAPS.opencode. Add optional `model` field to OpencodeMode so the
set-session-config handler can carry a model alongside the existing permissionMode.

Pure structural change: no behavior change yet. The mid-session model change RPC
and UI wiring follow in subsequent commits, gated by this capability.

* feat(acp): branch setModel by flavor, capture session models metadata, expose getSessionModelsMetadata on AgentBackend interface

Adds an optional `flavor` argument to `AcpSdkBackend.setModel` so it can
dispatch the right `session/*` RPC for each agent flavor without changing
the call site Gemini already uses. Both Gemini and OpenCode wire to
`session/set_model`; the OpenCode response only carries `_meta.opencode`,
so the backend updates the cached `currentModelId` optimistically while
preserving the previously captured `availableModels`.

Captures `availableModels` and `currentModelId` from `session/new` /
`session/load` / `session/set_model` responses into per-session metadata,
exposed as `getSessionModelsMetadata(sessionId)` on the `AgentBackend`
interface so the hub can forward the snapshot to the web client.

* feat(opencode): accept model in set-session-config RPC and forward to launcher

Mirror the Gemini set-session-config handler so the web UI can change the
OpenCode model mid-session. Validates incoming model strings, persists null
("Default") for keepalive metadata, and pushes a keepAlive immediately so
the hub UI reflects the change without waiting for the next 2s tick.

Forward the model through opencodeLoop and into queued OpencodeMode entries
so the launcher can detect a per-batch model change. Add a setModel helper
on OpencodeSession to store the chosen model on the shared session base.

Wire --model up the runner path: parse --model <value> in commands/opencode.ts
and stop excluding opencode in buildCliArgs so the runner spawns OpenCode
with the user-selected initial model.

* feat(opencode): switch model mid-session via ACP RPC

Mirror the Gemini pattern from PR #543: when a user picks a different model
between turns, call backend.setModel with flavor='opencode' so the ACP backend
sends session/set_session_config_option (configId='model') to the running
OpenCode CLI. The next turn then runs against the new model.

The first batch on a fresh session seeds currentBackendModel without firing the
RPC — the OpenCode CLI was launched with that model via --model and there is
nothing to switch yet. If the running build does not implement the RPC we learn
that from the first method-not-found response, latch inline switching off, and
notify the user once. Other errors fall back to the previous model and surface
a one-line failure message.

* feat(hub): expose model selection and discovery for OpenCode sessions

Generalize the /sessions/:id/model guard via supportsModelChange so any flavor
that advertises the ModelChange capability becomes accepted automatically. This
piggybacks on the capability SSOT introduced in PR #400 and turns OpenCode on
without listing flavors inline.

Add a /sessions/:id/opencode-models endpoint that mirrors the existing
codex-models pattern. The endpoint forwards a per-session listOpencodeModels
RPC to the running OpenCode launcher, which returns the availableModels and
currentModelId metadata captured from the ACP session/new and
session/set_session_config_option responses. The web UI consumes this to
render the model dropdown without round-tripping ACP itself.

* feat(web): render OpenCode model dropdown in the chat composer

Mirror the Codex pattern in SessionChat: query /sessions/:id/opencode-models
via a new useOpencodeModels hook and feed the result into the composer's
availableModelOptions. The AssistantChat model dropdown now lists the user's
ollama / mlx / OpenCode Zen models with the same provider/model label that the
ACP server reports, so the picker matches the OpenCode TUI.

Stop falling back to the Claude composer model list when the flavor is
opencode and no custom options are supplied — that fallback briefly surfaced
unrelated Claude models in OpenCode sessions before the RPC response landed.
The NewSession flow keeps an empty MODEL_OPTIONS.opencode for now: model
discovery requires an active OpenCode ACP session, so the dropdown becomes
available once the session boots and stays empty (and hidden) at creation
time.

* feat(cli,hub): add cwd-based OpenCode model discovery RPC

Adds a short-lived `opencode acp` probe that runs `initialize` +
`session/new` against a target cwd to read the `availableModels` /
`currentModelId` snapshot, then tears the subprocess down. Results are
cached for 60s per cwd and concurrent probes coalesce into a single
spawn.

Exposes the probe through:
- `listOpencodeModelsForCwd` JSON-RPC handler on the CLI
- `RpcGateway.listOpencodeModelsForCwd` / `SyncEngine.listOpencodeModelsForCwd`
- `GET /api/machines/:id/opencode-models?cwd=...` on the hub

This lets the web NewSession form discover OpenCode models for a chosen
directory before any session is spawned.

* feat(web): add OpenCode model selector to NewSession with loading and default highlight

Adds a `OpencodeModelSelector` panel that the NewSession form swaps in
when the OpenCode flavor is selected. The panel:

- queries the new `GET /api/machines/:id/opencode-models?cwd=...` endpoint
  via `useOpencodeModelsForCwd` (TanStack Query, 60s staleTime, no retry),
- shows a labelled spinner + skeleton rows while discovering,
- renders an inline error with a Retry button when probing fails,
- renders an empty-state message when the directory yields no models,
- highlights the OpenCode-reported `currentModelId` with a "Default" badge
  and auto-selects it (or the first option) so the form has a sensible
  value if the user hits Enter without scrolling.

Selection is reset whenever the agent / machine / directory changes so a
new probe can establish a fresh default. Directory input is wrapped in
`useDeferredValue` so per-keystroke edits do not spawn a fresh
`opencode acp` probe. The chosen model is forwarded on session spawn
via the existing OpenCode `model` parameter.

Adds en + zh-CN locale strings for the loading / failure / empty / retry
/ default-badge labels.

* fix(cli): guard /machines/:id/opencode-models handler with workspace root check

The machine-scoped `listOpencodeModelsForCwd` RPC handler was registered by
`registerCommonHandlers` without any workspace-root check, so a web client
could pass an arbitrary `cwd` and have the runner spawn an `opencode acp`
subprocess plus a `session/new` against that path. That broke runner
isolation, since peer machine-scoped handlers (`list-directory`,
`spawn-happy-session`) already enforce the configured workspace root.

Re-register the handler in `ApiMachineClient` so it reuses the existing
`resolveForWorkspaceCheck` (realpath-based, with missing-tail walking) and
`isWithinWorkspaceRoot` helpers before delegating to the lower-level probe.
The resolved cwd is forwarded down so symlinked-but-contained paths still
work, while traversal attempts are rejected with the same error shape the
peer handlers use. Added unit tests around the new dispatch path. Addresses
HAPI Bot review on PR #558.

* fix(web): gate opencode model discovery on cwd existence

The new-session form previously enabled `useOpencodeModelsForCwd` as
soon as the OpenCode agent, machine, and any non-empty directory string
were present. Because that hook calls `/machines/:id/opencode-models`
and the CLI handler starts an `opencode acp` probe for that cwd, normal
typing through partial paths could launch expensive 30s OpenCode
subprocesses for non-existent directories before the path-existence
result had validated the final cwd.

Reorder NewSession so `useMachinePathsExists` runs before
`useOpencodeModelsForCwd`, then gate the discovery hook on the
directory having been positively confirmed to exist
(`pathExistence[deferredDirectory] === true`). The decision is
factored into a small pure helper `shouldEnableOpencodeModelDiscovery`
so the contract can be unit-tested without provider scaffolding.

* fix(web): keep current opencode model on shortcut without dynamic options

`getNextModelForFlavor` is invoked by the global Ctrl/Cmd+M shortcut in
`HappyComposer`, which is now active for OpenCode sessions because the
agent backend declares the `ModelChange` capability. When the dynamic
OpenCode model list has not yet been loaded — e.g. the user presses the
shortcut before `/opencode-models` returns — the function received an
`undefined`/empty `customOptions` and fell through to the Claude preset
cycler, which would emit `sonnet`/`opus` for an OpenCode session. The
following turn then attempted `session/set_model` with a Claude model id
that no OpenCode provider can serve.

Add an `opencode` branch that returns the (normalized) current model
unchanged when no dynamic options are available, mirroring the existing
empty-list policy of `getModelOptionsForFlavor`. Unit tests cover the
undefined / empty / null-current-model variants and lock the
no-Claude-fallback contract. Addresses HAPI Bot review on PR #558.
2026-05-03 12:50:22 +08:00
Junmo KimandGitHub 7d55bc1456 feat(web): float queued messages above composer until invocation (#542)
* refactor: add invoked_at column and propagate via messages-consumed

- Bump hub schema to V8: add `invoked_at INTEGER` to messages table
- Add `migrateFromV7ToV8` (idempotent ALTER TABLE ADD COLUMN)
- Add migration chain entries for V4/V5/V6/V7 → V8
- Expose `StoredMessage.invokedAt: number | null` and `markMessagesInvoked`
- Record server-side `Date.now()` in hub on `messages-consumed` socket event
- Propagate `invokedAt` through SSE (`messages-consumed` payload)
- Update `markMessagesConsumed` in web store to accept and store `invokedAt`
- Preserve optimistic `invokedAt` in `mergeMessages` (server echo path)
- Add migration unit tests (fresh V8, V7→V8 ALTER, markMessagesInvoked)

* feat(web): float queued messages above composer until invocation

Show queued (uninvoked) user messages in a dedicated floating bar above
the composer instead of inline in the thread timeline. Once the CLI acks
the batch via messages-consumed, the bar disappears and the messages
appear in the thread at their invocation position (invokedAt ordering).

- Add QueuedMessagesBar component: subscribes to message-window-store,
  filters user messages with invokedAt==null, shows clock icon + text
  preview; disappears when all messages are invoked
- Filter queued messages from thread (visibleMessages), sort by
  invokedAt ?? createdAt so invoked messages land at the right position
- Extend markMessagesConsumed to update server-loaded messages (status
  undefined) in addition to optimistic (status 'queued'), enabling
  multi-device and post-refresh scenarios
- Remove opacity-60 from UserMessage: queued messages no longer appear
  in the thread so the dimming branch is unreachable
- Include invokedAt in getMessagesPage/getMessagesAfter API responses
  so the web client can restore floating-bar state after page refresh
- Add invokedAt field to DecryptedMessageSchema for shared protocol type

* fix(hub,web): make sort use invokedAt and V8 backfill idempotent

- compareMessages: prioritize invokedAt/createdAt over seq so invoked
  messages land at their invocation position rather than their
  send-time seq position
- migrateFromV7ToV8: move backfill outside the ALTER guard so it
  re-runs if a previous attempt crashed between ALTER and UPDATE
  before the user_version bump (idempotent WHERE invoked_at IS NULL)

* fix(hub,web): cover localId-less messages and live-ack invokedAt

- addMessage: messages without a localId have no ack path
  (markMessagesInvoked matches by localId). Treat them as
  already-invoked at insert time so they land in the thread instead of
  sitting in the queued floating bar forever.
- markMessagesConsumed: apply the ack even when the message is already
  'sent' optimistically, so the live window receives invokedAt instead
  of waiting until a full refetch.

* fix(hub): propagate invokedAt in live message-received SSE payload

The SSE `message-received` event omitted `invokedAt` while REST
pagination included it, so localId-less CLI/local user messages arrived
on the live wire as queued (`invokedAt == null`) and stayed in the
floating bar until a full refetch replaced them with the stored row.

* fix(hub): propagate invokedAt in CLI socket message-received handler

The CLI socket 'message' handler fans out to web via a separate
`onWebappEvent` publisher; the previous fix only touched the
`MessageService` publisher. Aligns the live SSE payload shape with
the REST/page-load shape so localId-less CLI/local user messages with
`invokedAt = createdAt` (set in addMessage) reach web filters with the
field already populated, instead of being misclassified as queued
until a full refetch.

* fix(hub,web): add byPosition pagination to fix long-session queued message loss

Pagination used seq-based windows, so queued messages with low seq but late
invokedAt fell outside the visible window on refresh. Fix by adding a V8
byPosition mode that orders by COALESCE(invoked_at, created_at) DESC, seq DESC
with a composite cursor, while keeping the V7 seq path fully intact for
backward compatibility.

- hub/store/index: add idx_messages_session_position (createSchema + V7→V8 migration)
- hub/store/messages: add getMessagesByPosition with composite cursor SQL
- hub/store/messageStore: delegate getMessagesByPosition
- hub/sync/messageService: add getMessagesPageByPosition with nextBeforeAt response
- hub/sync/syncEngine: expose getMessagesPageByPosition
- hub/web/routes/messages: byPosition=1 query param dispatches to V8 path
- web/types/api: MessagesResponse.page gains optional nextBeforeAt
- web/api/client: getMessages gains byPosition + beforeAt options
- web/lib/message-window-store: fetchLatestMessages/fetchOlderMessages use V8
  composite cursor; fallback to seq cursor when hub returns no nextBeforeAt
- hub/store/migration-v8.test: 7 new tests covering position sort, composite
  cursor pagination, long-session scenario, V7 compat, and index existence

* fix(hub,web): re-sort on consume and use position cursor for next fetch

- markMessagesConsumed: re-merge with empty list to re-sort by position
  key after invokedAt is set. A queued user message becomes visible
  with the consume event; without re-sort it stays at its send-time
  array slot until the next fetch overwrites it.
- getMessagesPageByPosition: pick the cursor from stored[0] (oldest in
  position order) instead of scanning for minimum seq. With the page
  already in ascending position order, scanning for min seq could land
  on a low-seq, late-invoked row that is actually the newest in the
  page, causing the next older fetch to overlap.

* fix(web): trust invokedAt as the only invocation signal and pin cursor pair

- visibleMessages predicate (SessionChat + QueuedMessagesBar): drop the
  status === 'sent' check. status='sent' only means the REST write
  returned, not that the CLI consumed the message; an optimistic 'sent'
  with no invokedAt is still queued. invokedAt is the single source of
  truth for invocation.
- byPosition cursor: track oldestPositionSeq alongside oldestPositionAt
  so the server's cursor pair travels through the next older fetch
  unchanged. Recomputing beforeSeq from the local window's minimum seq
  could combine it with a server beforeAt that referred to a different
  row, causing the SQL cursor to skip or overlap.

* fix(hub): include uninvoked local messages in latest page

Long sessions can push a queued user message (invokedAt = null, sort key
= createdAt) outside the latest position-ordered page once the agent
emits more than `limit` later rows. A refresh or secondary client then
never receives the row, the floating bar stays empty, and the later
`messages-consumed` event only carries localIds — there is no way to
materialize the missing row at invocation time.

Pin uninvoked local user messages to every latest-page response
out-of-band. The pagination cursor still anchors to the position-ordered
page rows, so older-page fetches are unaffected.

* fix(web): preserve queued messages across trimVisible

The visible-window trim drops the oldest entries beyond
VISIBLE_WINDOW_SIZE, but a queued user message (invokedAt = null) sorts
by send time and is the oldest item. Once a long agent stream pushes
it past the window the row is gone from the client store, and the
`messages-consumed` SSE carries only localIds — there is no way to
restore or reposition the dropped row without a full refetch.

Pull queued rows out before slicing the regular budget, then merge
them back in. Queued rows are bounded by composer/CLI queue depth and
do not meaningfully grow the window.

* fix(web): use strict null for queued check and fall back invokedAt

- Optimistic message sets invokedAt: null explicitly so the strict-null
  queued check matches the local opt-in. Pre-V8 hub responses that
  omit the field (`undefined`) are treated as already-invoked and
  stay in the thread instead of being misclassified as queued.
- markMessagesConsumed: when the consume SyncEvent omits invokedAt
  (older hub) fall back to client time, otherwise a message that
  receives an ack with no server timestamp stays queued forever under
  the new strict-null filter. The persisted server value is still
  authoritative on next fetch.

* fix: comprehensive invokedAt propagation hardening (review feedback batch)

Bot review surfaced 11 propagation bugs incrementally; this batch fixes
9 additional adjacent issues found by hostile-review to break the
incremental discovery cycle:

- legacy DB (user_version=0 with HAPI tables): step ladder runs V1→V8
  before createSchema so pre-existing tables get all later columns/indexes
- step ladder includes V1/V2/V3 entries; previously V1-V3 DBs threw
- mergeSessionMessages collision branch forces invoked_at = created_at
  so unmergeable rows can't strand in the floating bar
- session-end auto-invokes still-queued user messages and broadcasts
  messages-consumed; the floating bar no longer pins ghost rows after
  the CLI is gone
- trimPending preserves queued rows symmetrically with trimVisible
- markMessagesInvoked is first-write-wins; duplicate acks are no-ops
  rather than re-stamping invoked_at and reordering the thread
- markMessagesConsumed migrates just-acked pending entries into the
  visible thread so non-at-bottom users see their own messages without
  scrolling
- mergeMessages dedup window compares by position key (invokedAt ?? createdAt)
  instead of createdAt only, so late-invoked optimistic copies don't
  duplicate the server echo
- isQueuedForInvocation centralized in lib/messages.ts (single
  predicate used by SessionChat, QueuedMessagesBar, and the store)

* fix(web): mirror hub's first-write-wins on markMessagesConsumed

The hub's markMessagesInvoked is first-write-wins, but the web store
was still overwriting any non-null invokedAt with the latest
messages-consumed timestamp. A duplicate ack (CLI re-emit) would leave
the SQLite row at the original timestamp while live clients moved
the message to the duplicate ack time, diverging until refetch.
Mirror the guard: only set invokedAt when it is null.

* fix: in-scope hostile-review polish

Web:
- fetchLatestMessages: persist the V8 composite cursor pair on the
  non-at-bottom branch too. Without this, a refresh while scrolled
  up dropped the cursor and the next loadMore fell back to V7 seq
  mode against a V8 hub — same asymmetric class of bug commit
  30df6b2 fixed for the at-bottom path.
- markMessagesConsumed: tighten the loose-null check on invokedAt
  to strict null, consistent with isQueuedForInvocation and the
  rest of the file. The idSet filter already shields V7-stamped
  rows from this path, but the strict-null contract should not
  vary by call site.
- messages: drop the upsertMessagesInCache export. It has no
  callers (verified with grep) and is the only user of the
  InfiniteData / MessagesResponse imports, so the imports go
  with it.

Hub tests:
- migration-v8.test.ts: add a session-end auto-invoke test
  (getUninvokedLocalMessages + markMessagesInvoked clears every
  queued row and stamps them all with the same invokedAt) and
  two byPosition union tests covering (1) a low-position queued
  row pushed out of the latest page is still surfaced via the
  uninvoked set, and (2) pageRows[0] is the oldest row in the
  page so the web client can safely anchor the next-older
  cursor on it.

* fix(hub,web): bot-13 polish — atomic SSE on DB success and attachment chip text

- sessionHandlers messages-consumed: emit messages-consumed only after
  markMessagesInvoked succeeds. Otherwise a transient SQLite failure
  would broadcast an invokedAt that was never persisted; live clients
  would hide the queued rows while a refresh / secondary client would
  see them as queued again, diverging the state.
- QueuedMessagesBar: fall back to attachment filenames when the
  message text is empty. The composer / POST /messages allow
  attachment-only sends; without the fallback those queued messages
  rendered as blank chips until invocation.
2026-04-29 17:23:01 +08:00
NightWatcher314andGitHub a612be50d6 Add Codex clear and compact slash commands (#541)
* Add Codex clear and compact slash commands

* Stabilize queued thinking event test

* Interrupt active Codex turn before slash commands
2026-04-29 14:15:44 +08:00
CoColateandGitHub 9d2dec137b feat(codex): support slash controls and skill discovery (#545)
* feat(codex): resolve slash controls before sending to Codex

* feat(codex): discover commands and skills

* fix(codex): handle slash commands before attachments

* fix(codex): block remaining unsupported built-ins
2026-04-29 09:20:11 +08:00
weishu 97be34e21c Add Codex model selection 2026-04-25 10:48:12 +08:00
1cb353b7b3 fix(web): use global pointer listeners for sidebar resize handle (#497)
* fix(web): use global pointer listeners for sidebar resize handle

The current implementation attaches pointermove/pointerup to the
resize handle element via setPointerCapture. When the cursor moves
fast enough to leave the narrow 4px handle, the browser may not
deliver subsequent pointer events to the element, causing:

- Cursor stuck as col-resize even after releasing the mouse
- Sidebar stops tracking the pointer, requiring a page reload

Switch to document-level pointermove/pointerup/pointercancel listeners
that are added on drag start and cleaned up on drag end. This
guarantees events are captured regardless of cursor position.

Also removes onPointerMove and onPointerUp from the hook's return
value (and the JSX props in router.tsx) since they are no longer
needed — the hook manages everything internally via useEffect.

* ci: retrigger CI (flaky AcpSdkBackend test)

* fix: scope drag listeners to the initiating pointer ID

Address review feedback: filter pointermove/pointerup/pointercancel
by the pointer that started the drag, so a second finger or stylus
cannot interfere with the resize.

---------

Co-authored-by: huchenxi <huchenxi@lattebank.com>
2026-04-21 13:55:49 +08:00
Junmo KimandGitHub 32755f9056 feat(web): show queued status for messages pending inference (#492) 2026-04-20 19:49:26 +08:00
fa8cf53c56 fix(web): prevent iOS PWA keyboard from pushing header behind status bar (#454) (#457)
On iOS PWA (black-translucent + viewport-fit=cover), opening the virtual
keyboard causes iOS to scroll the page upward, pushing the session header
behind the system status bar. Fix by resetting window.scrollTo(0, 0) when
the keyboard is detected open, and listening to visualViewport scroll
events in addition to resize to catch any deferred scrolling.

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

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-14 17:40:25 +08:00
Junmo KimandGitHub c32378b3ba feat(web): persist composer draft across session switches (#438)
* feat(web): persist composer draft across session switches

Switching between sessions now preserves the text typed in the
composer. Drafts are stored per-session in sessionStorage and
restored when the user navigates back.

- Add composer-drafts utility (sessionStorage, in-memory cache)
- Restore draft on HappyComposer mount, save on unmount
- Clear draft on message send
- Evict oldest drafts when exceeding 50 entries
- Add unit tests for composer-drafts

Fixes #231

* fix(web): add key prop to HappyComposer for explicit remount on session switch

* fix(web): move clearDraft to SessionChat after send validation

Prevents draft loss when Codex rejects an unsupported slash command.

* fix(web): remove explicit clearDraft, rely on unmount save

Successful sends clear the composer text, so the unmount save
naturally persists an empty string which deletes the draft entry.
This avoids clearing the draft when the send is blocked or fails.

* fix(web): clear draft on successful send via onSuccess callback

Move draft clearing to the send-success path so drafts are only
removed after the message is actually accepted by the server.

* fix(web): pass session ID to onSuccess to clear correct draft

The previous version used the current route's sessionId, which could
clear the wrong draft if the user switched sessions before the send
completed.

* test(web): add useSendMessage onSuccess callback tests

Verify that onSuccess receives the correct session ID (including
resolved IDs), and is not called on send failure or block.

* refactor(web): extract useComposerDraft hook with unit tests

Extract the draft save/restore logic from HappyComposer into a
dedicated useComposerDraft hook. Adds 6 unit tests covering:
- mount: restores saved draft via rAF
- mount: skips restore if composer already has text
- mount: skips restore if no saved draft
- unmount: saves current text after rAF has fired
- unmount: skips save before rAF (draftReady guard)
- no-op when sessionId is undefined

* fix(web): clear both route and resolved session drafts after send

When resolveSessionId swaps the session (e.g. inactive → resumed),
the sent ID differs from the route's session ID. Extract
clearDraftsAfterSend so both are cleared and unit-testable.

* fix(web): refresh eviction order when updating an existing draft

Delete the key before re-inserting so Object.keys() reflects the
most recent write, preventing a recently edited draft from being
evicted first.
2026-04-12 11:04:52 +08:00
Haoqing WangandGitHub 3b92268a40 fix(web): restore conditional TTL guard on visibility refresh (#443)
The forced refresh added in #442 triggers a full /api/auth round-trip
on every tab focus/visibility event. Since the JWT lifetime is now
4 hours, the original minTtlMs guard (refresh only when <60s remains)
is sufficient and avoids unnecessary auth traffic.
2026-04-11 22:12:20 +08:00
Haoqing WangandGitHub 9a48d5af3a fix(hub,web): extend JWT expiration and harden visibility refresh (#442)
- Extend JWT expiration from 15 minutes to 4 hours in both auth and
  bind endpoints. 15 minutes was too short — browser timer throttling
  in background tabs prevented the scheduled refresh from firing
  before expiration, causing unexpected logouts.

- Change the visibility/focus refresh from conditional (minTtlMs) to
  forced, so returning to a backgrounded tab always re-authenticates
  regardless of remaining token TTL. This eliminates the race between
  timer throttling and token expiration.

HAPI is a self-hosted tool, so the longer token lifetime is an
acceptable security tradeoff. The auth source (Telegram initData or
CLI access token) is still validated on every refresh.

Closes #412
2026-04-11 22:03:50 +08:00
Haoqing WangandGitHub f04a6fa226 fix(web): use explicit Manager+socket for terminal namespace connection (#433)
The convenience `io()` function misparses the `/terminal` path
component as part of the Engine.IO endpoint in some browser
environments, producing requests to `/terminal/socket.io/` instead
of `/socket.io/`. Using `new Manager(baseUrl)` + `manager.socket('/terminal')`
separates the transport URL from the namespace unambiguously.

Closes #251
2026-04-11 16:50:39 +08:00
FemoonandGitHub ef87e30727 feat(web): redesign sidebar with resizable width and 3-level hierarchy (#427) 2026-04-11 09:52:56 +08:00
weishu 79a13d26c6 Fix Codex reasoning effort resume and updates 2026-04-10 11:50:03 +08:00
Haoqing WangandGitHub 1c8cb5c90d fix(web): prevent mobile keyboard from covering chat input (#403) 2026-04-06 05:39:40 +08:00
450b4f8aa7 fix(web): reconnect SSE immediately when tab becomes visible (#398)
* fix(web): reconnect SSE immediately when tab becomes visible

The SSE watchdog skips heartbeat checks while the tab is hidden. If the
connection dies in the background, the user sees stale messages after
switching back and has to wait up to 10 s for the next watchdog tick.

Add a visibilitychange listener that checks heartbeat staleness
immediately when the tab becomes visible and reconnects if stale.

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

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

* fix(web): map visibility-recovery reason in reconnecting banner

The new 'visibility-recovery' reconnect reason was not mapped in
getReasonLabel(), so the raw string would appear in the UI banner.
Add localized labels for both en and zh-CN.

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-05 13:21:19 +08:00
QihanandGitHub 92885ddef0 fix(web): hide unsupported Codex slash commands in remote mode (#357) 2026-03-25 05:38:25 +08:00
a200fe9628 feat(claude): add effort setting parity with model across stack (#353)
Co-authored-by: Xiaoyi <xiaoyizhang@microsoft.com>
2026-03-24 21:15:48 +08:00
dotblueandGitHub ea45797b6f web: add terminal font size setting (#324) 2026-03-20 08:43:36 +08:00
ROOOOandGitHub eb18530fed fix(web): warn before creating missing session directories (#317) 2026-03-19 15:11:54 +08:00
ROOOOandGitHub cb09f0b898 feat: add codex reasoning effort option (#297) 2026-03-17 22:55:55 +08:00