Commit Graph
54 Commits
Author SHA1 Message Date
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
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
f086949a8a feat(web,hub): export session conversation (#808)
* test: reproduce issue #793

* fix: add session conversation export (closes #793)

* fix(hub): sort session export by display time for invoked scheduled messages

Export now uses COALESCE(invoked_at, created_at) ordering so JSON/Markdown
exports match the visible chat chronology after scheduled messages are invoked.

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

* fix(web): escape newlines in session export YAML front matter

Prevent session metadata containing newlines or quotes from breaking
Markdown export front matter.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 21:40:59 +08:00
59c29e8423 feat(voice): pluggable voice backend with Gemini Live & Qwen Realtime (#692)
* feat(voice): pluggable voice backend with Gemini Live & Qwen Realtime

Rebased from Overbaker/hapi#401 onto current main. Adds a pluggable voice
backend architecture that extends the existing ElevenLabs integration:

- Gemini 2.5 Live (gemini-live): Google real-time audio via WebSocket
  with full function calling (messageCodingAgent, processPermissionRequest)
- Qwen Realtime (qwen-realtime): Alibaba DashScope via hub WebSocket
  proxy (browser cannot set Authorization header directly)
- VoiceBackendSession: dynamic backend selector with React.lazy loading,
  gates voice button until backend module is registered
- Hub WS proxies: JWT-authenticated /api/voice/gemini-ws and
  /api/voice/qwen-ws endpoints in Bun.serve, with message queueing during
  upstream connect to prevent dropped setup frames
- AudioWorklet pipeline: inline Blob URL recorder, 24 kHz PCM player,
  serial tool call execution, AudioContext created in user gesture for mobile
- Backend discovery: GET /voice/backend + POST /voice/gemini-token /
  POST /voice/qwen-token hub routes; frontend auto-detects active backend

Merge notes:
- Rebased 135 upstream commits cleanly; HappyComposer keeps upstream's
  configurable enter-behavior setting (supersedes hard-coded Ctrl+Enter)
- Converted gemini test files from bun:test to vitest (web package uses vitest)
- All 221 hub tests and 636 web tests pass; TypeScript clean

* fix(voice): restore user mic mute state after Gemini turn completes

turnComplete handler was unconditionally calling setMuted(false), which
re-enabled the mic track even when the user had manually muted. Now
restores to state.micMuted instead.

* fix(voice): remove hard-coded Chinese language from Gemini backend

buildGeminiLiveConfig was appending VOICE_CHINESE_LANGUAGE_BLOCK which
forced Gemini to always respond in Mandarin regardless of user locale.
Gemini now uses the neutral base prompt and responds in the language the
user speaks to it, consistent with the ElevenLabs behaviour.

* fix(voice): reset modelSpeaking in cleanup to unblock mic on restart

If the session closes while Gemini is mid-speech, cleanup() left
state.modelSpeaking=true. The next startSession() would then drop all
mic audio in sendAudioChunk() until a model turn eventually flipped
the flag — effectively deaf until page reload.

* fix(voice): guard stale close handlers in Gemini and Qwen sessions

ws.onclose operated on module-level state.ws, not the socket that fired
the event. A rapid stop/restart could cause the old socket's onclose to
call cleanup() after the new socket was assigned, tearing down the live
session. Guard with `if (state.ws !== ws) return` before cleanup.

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

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

* fix(voice): remove hard-coded Chinese language from Qwen backend

Matches the Gemini fix — both backends now use VOICE_SYSTEM_PROMPT
without the Chinese language block, giving consistent English-default
behaviour across all non-ElevenLabs backends.

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

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

* feat(voice): proactive/reactive toggle in voice settings

Adds a "Proactive voice" toggle (default: off = reactive) to the Voice
Assistant settings section.

Reactive (default): initial context and agent-ready events are fed
silently; the assistant waits for the user to speak first.

Proactive: original behaviour — Gemini/Qwen narrate context on connect
and speak unprompted when the agent finishes a task. ElevenLabs is also
affected via onReady sending a user message rather than a silent update.

Covers all three backends uniformly. localStorage key: hapi-voice-proactive.

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

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

* fix(voice): normalize WS close codes, drop barrel re-exports, fix SSE visibility

- hub/server.ts: add toClientCloseCode() to normalize reserved upstream
  close codes (1005/1006/1015) to 1011 before forwarding to browser;
  abnormal upstream drops (1006) would otherwise throw on clientWs.close()
  and leave the browser socket open

- realtime/index.ts: remove static GeminiLiveVoiceSession and QwenVoiceSession
  barrel exports; VoiceBackendSession lazy-imports both, so barrel re-exports
  created static dependencies that defeated the intended code-split

- App.tsx: gate global useVisibilityReporter on !sessionEventSubscription so
  the always-on SSE connection does not suppress native Web Push notifications
  for sessions the user is not currently viewing

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

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

* fix(voice): respect language setting in Gemini/Qwen; fix voice-start toggle label

- buildGeminiLiveConfig() now accepts optional language param; appends
  VOICE_CHINESE_LANGUAGE_BLOCK only when language === 'zh'
- GeminiLiveVoiceSession passes config.language through
- QwenVoiceSession conditionally builds basePrompt from language setting
- Fixes silent no-op when user selects Chinese in voice settings on
  Gemini/Qwen backends (was ElevenLabs-only)

- Rename voice-start toggle label to 'Start voice session with summary'
- Fix description: clarifies the choice is about session-open behaviour
  (summary vs greeting), not ongoing narration

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

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

* fix(voice): send greeting trigger in reactive mode for Gemini

Gemini Live has no built-in first-message like ElevenLabs agents do;
without an explicit turnComplete:true it sits silently. In reactive mode
(default, toggle off) now sends a greeting instruction after any silent
context feed so Gemini introduces itself and invites the user to speak.

Proactive mode is unchanged: the context summary is the opening speech.

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

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

* fix(voice): suppress Gemini self-identification and context leak in greeting

- VOICE_SYSTEM_PROMPT: explicit instruction never to call itself Gemini,
  Google, or any underlying model/provider name — always HAPI
- Greeting trigger text: instruct to greet as HAPI only, suppress model
  name and any reference to context/recent activity in the opening line

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

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

* fix(voice): address code review findings — error handling, proxy, audio

Gemini + Qwen client:
- onerror now sets setupDone/sessionReady and nulls state.ws before
  calling reject(), so the stale-close guard trips in onclose and
  prevents a duplicate statusCallback('error') on WS failure

Gemini client:
- Proactive mode with no initialContext now falls through to the
  greeting trigger instead of sitting silently
- Remove unused handleBargeIn callback (dead code)

Qwen client:
- Add input_audio_sample_rate: 16000 to session.update so PCM rate
  is declared explicitly rather than relying on DashScope's default

Hub proxy:
- Remove no-op ternary in Gemini flush loop and message handler
  (typeof x === 'string' ? x : x); use upstream.send(msg) directly
- Qwen onerror now calls upstreamMap.delete() before closing client,
  eliminating the stale map entry window
- Align Qwen hub fallback model string with QWEN_REALTIME_MODEL
  constant ('qwen3-omni-flash-realtime')

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

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

* fix(voice): trailing-slash WS URL, Qwen session.update schema

hub/voice.ts:
- Replace string-concat WS URL construction with buildVoiceWsUrl() which
  uses URL API to set protocol/pathname cleanly — fixes double-slash when
  HAPI_PUBLIC_URL has a trailing slash (would silently skip the proxy route)

QwenVoiceSession.tsx:
- Wrap tool definitions in {type:'function', function:{...}} as required
  by Qwen-Omni realtime schema — previous flat shape caused session.update
  rejection before audio capture could start
- Use pcm16/pcm24 audio formats matching DashScope spec; remove
  input_audio_sample_rate (encoded in format name)

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

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

* fix(voice): await audio capture before setMuted; sanitize upstream close codes

GeminiLiveVoiceSession + QwenVoiceSession:
- startAudioCapture() is now async and awaits recorder.start() before
  calling setMuted() — previously setMuted ran before getUserMedia resolved
  so a session restarted while muted would open the mic anyway
- statusCallback('connected') now fires after audio is ready
- setMuted() called unconditionally (not just when true) to correctly
  apply saved state in either direction

hub/src/web/server.ts:
- Both Gemini and Qwen close() handlers now pass the client code through
  toClientCloseCode() before forwarding to upstream — prevents reserved
  codes (e.g. 1006) from causing WebSocket.close() to throw and leave
  the upstream session open until provider timeout
- Reason string capped at 123 bytes (WebSocket protocol limit)

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

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

* fix(voice): wrap startAudioCapture in try/catch to propagate mic errors

An unhandled rejection inside the async onmessage callback does not
propagate to the outer startSession Promise — the UI hangs on
'connecting' and the provider socket stays partially open. Wrapping
the await in try/catch calls cleanup()/statusCallback('error')/reject()
so failures surface correctly.

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

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

* fix(voice): propagate backend discovery failure instead of silently falling back to ElevenLabs

fetchVoiceBackend no longer catches errors and defaults to 'elevenlabs' — any
network or server failure now throws so VoiceBackendSession can surface it via
onStatusChange('error', ...) rather than silently mounting the wrong backend.

VoiceBackendSession also resets backend state to null when api changes, so
a stale ElevenLabs registration from a prior discovery cannot persist into
a new session.

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

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

* fix(voice): throw on unrecognised backend value instead of silently falling back to ElevenLabs

Unknown backend strings (future values, typos) now throw rather than defaulting
to elevenlabs, closing the narrow remaining form of the original misrouting bug.
Also removes the unnecessary `as VoiceBackendResponse` cast.

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

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

* fix(voice): add Qwen greeting/proactive trigger; fix socket buffer for base64 uploads

Qwen session.updated handler now sends the same proactive summary or greeting
trigger that Gemini does — previously it started silently in both proactive and
reactive modes.

maxHttpBufferSize raised to 68 MiB to account for base64 expansion: 50 MiB
decoded files become ~66.7 MiB as base64 JSON, so the previous 55 MiB ceiling
would disconnect uploads above ~41 MiB before they reached the CLI.

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

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

* fix(voice): replace unsupported conversation.item.create with session.update for Qwen text

Qwen's realtime API only supports conversation.item.create for function_call_output.
Sending it with type:'message' for greetings/context was invalid and could fail
before the user spoke.

sendTextMessage and sendContextualUpdate now update session instructions via
session.update (accumulating context into the system prompt) and trigger
response.create only when a spoken reply is needed — matching Qwen's supported
client event surface.

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

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

* fix(voice): guard session.updated re-entry and reset config on session start

session.updated now returns early after the first ack — subsequent session.update
calls (instruction appends) also echo session.updated but must not re-trigger
audio capture or the greeting path.

currentSessionConfig is now reset to null at the top of startSession so a stale
config from a failed previous session cannot leak into the new one.

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

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

* fix(voice): assert wsUrl presence for Gemini proxy connections

Without this guard, a missing wsUrl in the hub token response would
silently attempt to connect directly to Google with "proxied" as the
API key — producing a confusing auth failure instead of a clear error.

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

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

* fix(voice): correct Qwen audio formats and default voice

DashScope realtime API accepts only 'pcm' for both input and output
audio formats. The pcm16/pcm24 values caused session.update rejection
before audio capture could start, leaving the Qwen backend unusable.

Also updates the default voice from Mia (not in the qwen3-omni-flash-
realtime voice list) to Cherry, which is documented as supported.

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

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

* fix(voice): close AudioContext on failed voice session start

Failed token fetch, microphone denial, or WebSocket error during
setup left state.playbackContext open. Each failure path now calls
cleanup() before throwing/rejecting, preventing AudioContext leaks
on mobile browsers with hard limits on concurrent contexts.

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

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

* chore: restore non-voice files to upstream/main state

Reverts changes to files that shouldn't differ from upstream:
- .gitignore: remove fork-only AGENTS.local.md entry
- web/src/App.tsx: restore dual-subscription SSE pattern (scope-aware)
- web/src/hooks/useSSE.ts: restore SSEScope/scope parameter
- web/src/hooks/useSSE.test.ts: restore (was accidentally deleted)
- web/src/lib/appSseSubscriptions.ts: restore (was accidentally deleted)
- web/src/lib/appSseSubscriptions.test.ts: restore (was accidentally deleted)
- hub/src/sync/syncEngine.ts: restore (off-topic change)

* fix(voice): harden Gemini and Qwen WS proxies against client abuse

Hub sends HAPI-owned Gemini setup on proxy connect and rejects client
setup frames. Qwen proxy always uses QWEN_REALTIME_MODEL instead of a
client query parameter. Shared buildGeminiLiveSetupMessage() keeps wire
format in one place.

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

* fix(voice): harden Qwen proxy — hub-owned setup, client frame allowlist

Mirror the Gemini proxy security model for Qwen:

- Hub sends initial session.update (voice/tools/instructions) on upstream
  connect so the browser cannot override config fields.
- Proxy message() now calls isQwenSafeClientFrame() and closes the
  connection (1008) if a client session.update touches any field other
  than 'instructions' (blocks tool/voice/modality overrides).
- QwenVoiceSession no longer sends session.update on session.created;
  it waits for the hub-relayed session.updated and then sends only
  instruction-only updates for context/proactive content.
- Language passed as query param (?language=zh) so hub builds the
  correct Chinese system prompt without a client-supplied session.update.
- buildQwenSessionUpdateMessage() and isQwenSafeClientFrame() added to
  @hapi/protocol/voice; 9 new unit tests cover filter edge cases.

* fix(voice): respect Qwen session.created→session.update protocol ordering

DashScope requires session.update to be sent AFTER session.created is
received, not immediately on WebSocket open. Previously the hub sent
session.update in upstream.onopen, which violated this ordering and
risked the config being processed in an uninitialized session context.

Add pendingSetupMap to buffer the hub-owned session.update payload.
The onmessage handler now relays session.created to the browser first,
then immediately sends the pending session.update to DashScope — matching
the protocol ordering the old browser-side code used (which waited for
session.created before sending session.update).

Also remove maxHttpBufferSize from the socket.io Engine config. That
setting is unrelated to voice backends; upstream/main had no such limit
set and it is not introduced by this PR.

* fix(voice): use Realtime tool shape for Qwen session.update (not chat-completions)

Qwen Realtime session.update expects tools as flat objects:
  { type: 'function', name, description, parameters }

The previous code used the chat-completions shape:
  { type: 'function', function: { name, description, parameters } }

DashScope may reject session.update or silently ignore tools with the
nested shape, causing tool calls to fail at runtime. Fix applied in
buildQwenSessionUpdateMessage(); test updated to assert flat shape and
that no nested `function` key is present.

* fix(voice): update Qwen Realtime model, voice, and endpoint for intl service

Live-tested against DashScope international API:
- Model: qwen3-omni-flash-realtime → qwen3.5-omni-flash-realtime
  (previous model ID did not exist on DashScope)
- Default voice: Cherry → Tina
  (confirmed from session.created response on qwen3.5-omni-flash-realtime)
- Default WS base: dashscope.aliyuncs.com → dashscope-intl.aliyuncs.com
  (international accounts use the -intl endpoint; China endpoint rejects
  international API keys; QWEN_REALTIME_WS_URL env var still overrides)

* fix(voice): correct Qwen text injection and generalise language handling

Two dogfooding fixes verified against live Qwen Realtime session:

sendTextMessage: switch from instruction-injection to conversation.item.create
  Qwen Realtime requires a user conversation item before response.create.
  The previous approach (updateInstructions + response.create) produced
  "input messages do not contain elements with role user" errors. Now sends
  {type:message, role:user, content:[{type:input_text}]} then response.create.
  sendContextualUpdate is unchanged (instruction-only, no response trigger).

Language handling: replace zh-only branch with buildVoiceLanguageBlock()
  Previously, only language='zh' added any instruction; all other languages
  (including English) sent no language block, causing Qwen to drift to Chinese.
  buildVoiceLanguageBlock() now covers three cases:
    - 'zh'/'zh-*': existing Chinese block (unchanged)
    - explicit code ('en','es','fr',...): "Always respond in [Language]"
    - undefined/auto: "Detect the user's language and maintain it"
  Applied to buildGeminiLiveConfig, buildQwenSessionUpdateMessage, and the
  client-side currentInstructions mirror in QwenVoiceSession.
  Also removes the Gemini hub proxy's zh-only filter, which was discarding
  explicit language selections other than Chinese.

* fix(hub): gate Gemini client frames until upstream setupComplete

Hub sends its owned setup on upstream open, then waits for Google's
setupComplete acknowledgment before flushing queued client frames.
isGeminiSetupCompleteFrame() detects the {"setupComplete":{}} message;
message() queues instead of forwarding while pendingMap is live.

Addresses the repeated Major finding from bot review on PR #743.

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

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

* fix(hub): cap Gemini setup-window pending queue at 1 MiB

An authenticated client could flood the queue between upstream.onopen
and Google's setupComplete acknowledgment. Add pendingBytesMap tracking
and close with 1009 if the budget is exceeded.

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

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

* fix(gemini): pass all language codes to hub proxy, not just zh

Language selection for French, Spanish, Japanese etc. was silently
dropped — only 'zh' was forwarded as a query param.

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

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

* fix(voice): expand LANGUAGE_NAMES to cover full ElevenLabs language set

Codes like 'no', 'da', 'fi', 'pt-br', 'bg', 'ro', 'cs', 'el', 'ms',
'tl', 'uk', 'hu', 'hr', 'sk' were falling through to raw-code prompts
("Always respond in no"). Now resolve to proper display names.

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:56:52 +08:00
f9ef3a4489 feat(codex): import local Codex sessions into Hapi (#796)
* local: add Codex Desktop session sync controls

* feat(codex): import local Codex sessions into Hapi

---------

Co-authored-by: Codex Local <codex-local@example.invalid>
2026-06-04 17:53:12 +08:00
HeavyGeeandGitHub d5a67b717c feat(voice): dynamic settings voice picker with safe fallback + preview (#690)
* feat(voice): dynamic settings voice picker with safe fallback + preview

* fix(voice): honor picker with configured agent and stop preview on unmount

* fix(voice): apply PR review feedback for agent selection and preview cleanup
2026-05-27 11:17:01 +08:00
SSU-WEI HUANGandGitHub 1d03f186d6 feat(cursor): support model selection (#684) 2026-05-26 16:13:38 +08:00
weishu d6f97065c1 Share REST and RPC response types 2026-05-21 10:31:50 +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
Junmo KimandGitHub b2a30c2e39 feat(hub,web): support scheduling messages for future delivery (#590) 2026-05-18 09:09:17 +08:00
MapleStoryIdleandGitHub 86780e94d0 feat(codex): preview generated images in chat (#630) 2026-05-17 11:52:03 +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
Junmo KimandGitHub 04fbc0d37f fix(hub,web): apply selected permission mode when resuming inactive sessions (#540)
Previously, toggling the permission mode on an inactive session had no
effect on resume: the /permission-mode endpoint rejected inactive sessions
(HTTP 409), so the cache was never updated, and the spawned CLI always
received the stored default value.

- Remove the `requireActive` guard from POST /sessions/:id/permission-mode
  so inactive sessions can have their in-memory permission mode updated.
- In `SyncEngine.applySessionConfig`, skip the RPC call for inactive
  sessions and update the in-memory cache directly; the value is then
  available when the session is resumed.
- Accept an optional `{ permissionMode }` body in POST /sessions/:id/resume
  and forward it to `resumeSession` (takes precedence over the cached value),
  with flavor-compatibility validation.
- Extend `SyncEngine.resumeSession` with an optional `opts` argument so
  callers can supply a permission mode override at resume time.
- Update the web client (`api.resumeSession`) and `router.tsx` to pass
  `session.permissionMode` in the resume request body.
2026-04-28 17:42:05 +08:00
010dc41369 feat: workspace browser with --workspace-root opt-in scoping (#526)
* feat(web): add workspace browser for multi-directory navigation

Add /browse route with a folder browser that lets users navigate
filesystem directories on connected machines and launch sessions
from any folder. Supports saved workspace paths and direct path
input. The "Start Session" action pre-fills the NewSession form.

- CLI: register machine-level `list-directory` RPC handler
- Hub: add POST /machines/:id/list-directory route
- Web: add WorkspaceBrowser component with git repo detection
- Web: add /browse route with navigation from sessions sidebar
- Web: support initialDirectory/initialMachineId in NewSession

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add --workspace-root opt-in scoping for /browse and session spawn

Adds a single new flag, \`--workspace-root <path>\` (with \`~\` / \`~/foo\`
expansion), on \`hapi runner start\` and \`hapi runner start-sync\`.

When set:
- The runner reports the path in machine metadata.
- The list-directory and spawn-session RPC handlers reject paths outside
  the root, so the web UI can't escape the configured tree even if
  someone crafts a request manually.
- The /browse page in the web UI auto-opens that root, restricts the
  breadcrumb / go-up to its subtree, and shows directory entries with
  git-repo annotations.
- The /sessions/new form keeps its existing free-text directory input
  plus autocomplete + recent-paths chips, and gains a small "Browse"
  button (next to the input) that opens /browse for picking a folder.
- Reconnect-time metadata sync ensures stale records get the field
  filled in (or cleared when the flag is dropped on a later restart),
  so the hub state matches the CLI's intent.

When unset:
- Runner behaves like the legacy hapi (no scoping, no browse feature).
- /browse renders an informative state pointing at the flag instead of
  blocking the user.
- The /sessions/new form looks identical to the pre-change behavior;
  the "Browse" button is hidden.

Includes a startup banner so \`runner start-sync\` no longer looks like
it hung, and surfaces the workspace-root sync result on stdout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hub): preserve workspaceRoot when rehydrating machines from store

MachineCache.refreshMachine() rebuilt the metadata object from an
explicit field allowlist, so any field not in the list (including the
new workspaceRoot) was silently dropped on every read — even though it
was correctly written to the store.

Add workspaceRoot to the zod schema, the Machine interface, and the
hand-rolled projection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): friendlier empty state on /sessions

When there are zero sessions the page used to be a vast blank
rectangle with just the "0 sessions in 0 projects" caption. Render a
centered empty state instead: a calendar/agenda icon, a short heading
and hint, and two buttons — "Start a session" (→ /sessions/new) and
"Browse workspace" (→ /browse).

SessionList gains an optional onBrowse prop. Router wires it on the
sessions page so the secondary button resolves; other callers can leave
it unset to hide that button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: document --workspace-root flag in cli/README and root README

Add a short paragraph under "Runner management" in cli/README.md
explaining what \`--workspace-root\` enables (scoped /browse tree,
list/spawn enforcement, tilde expansion) and that omitting it keeps
the legacy behavior. Mention the workspace browser in the top-level
README's Features list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR #526 review feedback

Three findings from the review bot:

1. [Major] Workspace-scope check was lexical only. With workspaceRoot
   = /safe, a symlink such as /safe/out -> /etc would pass the relative-
   path test and let list-directory / spawn-happy-session reach paths
   outside the configured root. realpath the workspaceRoot at construction
   time, and resolve every incoming path through realpath (walking up to
   the nearest existing parent for spawn targets that haven't been
   created yet) before the containment check.

2. [Minor] \`hapi runner start --workspace-root\` with no value used to
   drop the flag silently and start the runner unscoped. Now treats a
   missing or flag-shaped next argument as an error.

3. [Minor] /sessions/new's "Browse" button always opened /browse using
   localStorage's last-used machine, ignoring the user's current
   selection. NewSession already passes machineId in its callback;
   forward it through the /browse search params and seed
   WorkspaceBrowser with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): gate list-directory RPC behind --workspace-root opt-in

Without a configured workspaceRoot, isWithinWorkspaceRoot() returns
true unconditionally, leaving the new list-directory RPC able to
enumerate any path on the runner. The Web UI already hides Browse
for these machines, but the backend should enforce the opt-in too.

Refuse the RPC up front when no workspace root is configured.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 09:59:18 +08:00
weishu 97be34e21c Add Codex model selection 2026-04-25 10:48:12 +08:00
weishu 79a13d26c6 Fix Codex reasoning effort resume and updates 2026-04-10 11:50:03 +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
ROOOOandGitHub cb09f0b898 feat: add codex reasoning effort option (#297) 2026-03-17 22:55:55 +08:00
weishu 16829b7c78 Add support for codex plan mode 2026-03-16 20:48:39 +08:00
weishu 329d28a93c remove , using instead 2026-03-16 18:29:09 +08:00
Mao MrandGitHub c9be2894ac feat(cursor): add support for Cursor Agent CLI integration (#236)
* feat(cursor): add support for Cursor Agent CLI integration

- Introduced new command `hapi cursor` to start Cursor Agent sessions.
- Added functionality for resuming sessions and managing permission modes.
- Updated documentation to include Cursor Agent usage and installation instructions.
- Enhanced existing codebase to accommodate Cursor as a recognized agent flavor.
- Implemented local and remote session handling for Cursor Agent.

This update expands HAPI's capabilities by integrating support for the Cursor Agent, allowing users to leverage its features alongside existing agents.

* Remove TODO.md file as it is no longer needed following the integration of Cursor Agent CLI support. This cleanup helps streamline project documentation and reflects the completion of the associated tasks.

* feat(cursor): implement remote mode and fix --hapi-starting-mode

- Consume --hapi-starting-mode in cursor command (do not forward to agent)
- Implement cursorRemoteLauncher: spawn agent -p with stream-json, --trust
- Add cursorEventConverter for NDJSON parsing (system/assistant/tool_call/result)
- Multi-turn via --resume session_id
- Update docs: cursor supports both local and remote modes

Made-with: Cursor

* fix: type error

* fix(cursor): address PR review - model UI, sessionId metadata, duplicate flags

- HappyComposer: use isClaudeFlavor for model mode (cursor has no model modes)
- cursorLocalLauncher: call onSessionFound for resume so cursorSessionId in metadata
- cursorCommand: do not forward parsed flags to cursorArgs (avoid duplicates)

Made-with: Cursor
2026-03-03 10:02:47 +08:00
weishu fb9abc18d4 feat: Add directory tree tab to session files 2026-02-06 20:18:46 +08:00
weishu 70b5c22c8f feat: support opencode 2026-01-29 10:34:57 +08:00
weishu 37e10a831b feat: rename server package to hub
Rename the `server/` directory to `hub/` and update all references
across CLI, docs, web, and workspace configuration.
2026-01-27 19:51:21 +08:00
weishu 87c79c3b94 feat: add session resume capability with automatic continuation
Add ability to resume inactive sessions and automatically continue with new
session ID. Includes message merging for preserving conversation history,
UI improvements to allow sending during inactive state, and database schema
migration. close #87
2026-01-27 14:44:53 +08:00
weishu ae9650ca7c feat: add support for Codex's request_user_input tool
Implement full support for the request_user_input tool with both Web UI and CLI
components. This includes:

- New view and footer components for request_user_input tool UI
- Tool registration in knownTools and view registries
- Nested answer format support (Record<string, { answers: string[] }>)
- Backward compatibility with flat answer format (Record<string, string[]>)
- Permission handler updates for CLI request_user_input acceptance
- Type definitions and schema updates across shared/cli/server/web packages
- Translation strings for request_user_input UI elements
- Conditional footer rendering in ToolCard for question tools
2026-01-26 11:58:36 +08:00
weishu e130dfd24b feat: add model selection for AI agents (claude, codex, gemini)
Add a new ModelSelector component that allows users to choose specific model variants for different AI agents. This includes:
- ModelSelector UI component with agent-specific model options
- Model parameter propagation through API, RPC, and spawning layers
- Support for auto model selection (default behavior)
- Localization strings for English and Chinese
- Type definitions for model options per agent
2026-01-23 11:55:29 +08:00
freespace8andGitHub 2cb8727874 feat: expose skills and $ autocomplete (#92) 2026-01-21 16:28:15 +08:00
weishu 8f5b773b23 feat: voice assistant 2026-01-19 18:16:57 +08:00
weishu 53e4de6684 feat: add file upload support 2026-01-17 19:38:12 +08:00
weishu 803c2f65a9 feat: add notification optimization with visibility tracking and toast messages
Implement a visibility-aware notification system that delivers toast messages via SSE to visible browser tabs instead of sending push notifications, reducing unnecessary push requests. Includes VisibilityTracker for monitoring connection visibility, toast UI components, and enhanced SSEManager with toast delivery capability.
2026-01-07 18:58:31 +08:00
weishu e2028b6914 Unify protocol types and modes 2026-01-03 14:43:59 +08:00
weishu bc602ff5ea fix: archive session 2026-01-03 10:31:54 +08:00
weishu 43519621f3 feat: add web push notifications support
Implement push notification system with VAPID keys, client service worker integration, and push subscription management. Includes server-side PushService for sending notifications and PushNotifier for reactive event handling, plus client-side usePushNotifications hook and service worker support.
2026-01-02 19:19:40 +08:00
weishu 41fd1abd53 feat: add session management (rename, archive, delete)
Implements comprehensive session lifecycle management with user-friendly interactions:

Rename: Update session metadata.name via PATCH endpoint with conflict detection
Archive: Abort active sessions via DELETE endpoint with validation
Delete: Permanently remove inactive sessions with cascade cleanup

Backend:
- Store.deleteSession() removes session and cascade-deletes messages
- SyncEngine.renameSession() with concurrency error handling
- SyncEngine.deleteSession() with active session validation
- PATCH /sessions/:id for rename, DELETE /sessions/:id for delete

Frontend Components:
- RenameSessionDialog: Text input with auto-focus and error display
- SessionActionMenu: Modal with rename, archive, delete buttons
- ConfirmDialog: Reusable confirmation with error feedback
- SessionHeader: Menu button (⋮) triggering action menu
- SessionList: Long-press detection triggering item actions

Interactions:
- Long-press on session list items (500ms threshold) opens action menu
- Menu button in session header (non-Telegram environments only)
- Confirmation dialogs with descriptive warnings for destructive actions
- Real-time error display in dialogs on operation failure
- Haptic feedback on long-press via usePlatform hook

Accessibility:
- Keyboard support (Enter/Space) for long-press handler
- Focus management in RenameSessionDialog
- Proper ARIA labels and semantic HTML
2026-01-02 19:15:01 +08:00
weishu 8f7dcf848b refactor: Add Telegram binding storage and auth 2025-12-31 17:43:57 +08:00
weishu 334cebc2da feat: add real-time permission and model mode synchronization close #21
Implements bidirectional sync of permission/model modes between CLI sessions and web app. Adds Codex-specific permission modes (read-only, safe-yolo, yolo) alongside Claude's modes. Web can now control CLI session state via RPC set-session-config handler, while CLI broadcasts state changes through keep-alive payloads. UI controls are flavor-aware, showing appropriate modes for Claude vs Codex vs Gemini. Type centralization in api/types eliminates circular dependencies.
2025-12-28 20:51:18 +08:00
weishu 05deb6fff8 feat: add directory autocomplete with validation for new session form 2025-12-28 16:31:53 +08:00
weishu fbc0d601f8 feat: add slash command autocomplete to HappyComposer
Implements full-stack slash command autocomplete with agent-specific built-in commands and user-defined command discovery. Includes React Strict Mode fix for suggestion handling.
2025-12-28 15:19:08 +08:00
weishu c35a5ce827 feat: add git worktree session support with improved UI
Implement comprehensive worktree session support allowing users to spawn sessions in temporary git worktrees. Includes backend worktree management, full-stack integration, and refined UI for session type selection.

Backend:
- Add worktree creation/removal utilities with branch management
- Track worktree metadata (basePath, branch, name, path) in session metadata
- Automatic cleanup of worktrees when sessions fail or exit
- Enhanced error handling with stderr tail logging

UI improvements:
- Redesign session type toggle with improved alignment and spacing
- Move worktree description inline with label for cleaner layout
- Add branch name input field that appears when worktree mode selected
- Auto-focus on worktree input when switching modes
- Reduce gap between radio options from gap-3 to gap-1.5
- Update descriptive text and placeholders for clarity

Integration:
- Thread worktree parameters through API client, RPC handlers, and daemon
- Add worktreeEnv utility to read worktree info from environment
- Update session spawning to support both simple and worktree modes
2025-12-28 13:17:58 +08:00
58e61b3db9 feat: create new session with yolo mode (#13)
* feat: create new session with yolo mode

Signed-off-by: Ruihang Xia <waynestxia@gmail.com>

* fix: harden sequence typecheck

* feat: add unified --yolo flag across all agents

---------

Signed-off-by: Ruihang Xia <waynestxia@gmail.com>
Co-authored-by: weishu <twsxtd@gmail.com>
2025-12-27 21:03:34 +08:00
weishu 0be023b23b feat: add configurable server URL for standalone web hosting
Adds support for hosting the web UI separately from the hapi server on static hosts (GitHub Pages, Cloudflare Pages). Users can now set a custom server origin via a dialog on the login screen, with the ability to return to same-origin behavior.

Changes include:
- New useServerUrl hook for managing server URL configuration and storage
- Updated API client to support baseUrl parameter for all requests
- Enhanced login UI with server picker dialog (top-right button)
- Auth system now keys tokens per baseUrl to support multiple servers
- SSE connection updated to use configured baseUrl
- Documentation updates for standalone hosting setup
2025-12-25 17:42:30 +08:00
weishu 0c16b7b215 feat(web): improve token refresh handling with automatic 401 retry and focus-based refresh 2025-12-22 18:29:32 +08:00
weishu 7c86f83d5c feat: add switch to remote button for local mode 2025-12-22 00:05:55 +08:00
weishu 56ae2c85a2 feat(cli): add Agent Client Protocol (ACP) integration for external agent support
Implements comprehensive ACP backend enabling integration with ACP-compliant agents like Gemini. Includes stdio transport, message handling, permission flow, and registry for agent management. Adds new 'hapi gemini' command to launch ACP agent sessions.
2025-12-21 18:40:04 +08:00
weishu 8ebd4f6ab9 feat: add git integration with file browsing and diff viewing
Add comprehensive git support across the stack:
- CLI: register git RPC handlers (status, diff numstat, diff file)
- Server: create git routes with proper session path resolution
- Web: add file browser, diff viewer, and git status visualization
- Add FileIcon component and git parser utilities
- Add TanStack Router routes for /files and /file pages
- Add git-themed CSS variables for light and dark modes
2025-12-20 23:00:26 +08:00
weishu ac67718ec0 feat: implement AskUserQuestion tool with interactive UI and question flow
Add comprehensive support for the AskUserQuestion tool including:
- New footer component for handling user responses with multi-step question flow
- Type definitions and parsing utilities for question and answer formats
- View component for displaying questions and options
- Integration with permission system to capture user answers
- Support for single and multi-select questions with optional text input
- Haptic feedback and error handling for better UX
2025-12-18 23:24:41 +08:00