mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
413fbb8714ecbbd11d3f699665065fac8f0423a0
135
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d464651870 | Release version 0.20.2 | ||
|
|
3e2e48222a |
fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression) (#877)
* fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression) The legacy-to-ACP migrator's `findLegacyChatStore()` walks `~/.cursor/chats/<workspace-hash>/<cursorSessionId>/store.db` via `readdirSync()` and returns the FIRST match. When the same cursor session id exists in more than one workspace-hash drawer (operator opened the session from a worktree, an old workspace clone, etc.) the readdir order picks an arbitrary candidate. The migrator then transplants alien content into the ACP target, deletes the source drawer, and reports success - because the verify probe only checks "loads cleanly", not "loaded the right content". Operator session resurrects with no recall of its real history. Four-part fix (all four must land together): 1. Path-priority discovery in `findLegacyChatStore(id, home, cwd?)`: - Optional 3rd arg = canonical workspace path (caller passes `session.metadata.path`). - Compute md5(cwd) and check that drawer FIRST. - Fall back to readdir scan only if the canonical drawer is empty. - If 2+ candidates remain after fallback, throw `AmbiguousLegacyStoreError` listing all of them (workspaceHash, sizeBytes, mtimeMs). 2. Ambiguity surface in `maybeAutoMigrateLegacyCursorSession`: - Catch `ambiguous_legacy_store` / `size_mismatch` refusals and promote `cursorMigrationState` from 'in_progress' to a new 'ambiguous' state instead of silently clearing the banner. Operator sees an actionable web-banner. 3. Size sanity check before transplant: - Compare HAPI's known message count (new `MessageStore.countMessages` + `CursorLegacyMigratorDeps.getHapiMessageCount` dep) against the candidate `store.db`'s blob count. If message count > 100 AND blob count < messageCount/4, refuse with `size_mismatch`. - Skipped when message count is 0 (brand-new session) or the dep is unwired (unit tests, CLI direct callers). 4. Diagnostic logging on every successful transplant: - `[migrator] transplanted` info log capturing cursorSessionId, picked workspaceHash, candidate count discovered, sourceBytes, sourceBlobCount, targetAcpPath, sourceRemoved, canonical-path md5. Future regressions of this bug shape are diagnosable from `journalctl -u hapi-hub` without blob-overlap forensics. Tests added in `hub/src/cursor/cursorLegacyMigrator.test.ts`: - regression guard for single-drawer discovery - canonical-path wins over readdir order - ambiguity throws with all candidates listed (3-drawer + 2-drawer no-canonical-arg variants) - canonical-path resolves ambiguity cleanly - listLegacyChatStoreCandidates enumeration - workspaceHashFromPath shape - migrateOne happy path with canonical workspace + 3 sibling decoys - migrateOne refuses with ambiguous_legacy_store (3 drawers, no canonical match) and leaves all sources untouched - migrateOne proceeds when canonical path resolves - size_mismatch refuses tiny candidate when messageCount=6000 - size_mismatch passes when candidate blob count meets the floor - size sanity skipped on messageCount=0, missing dep, throwing dep, boundary (messageCount=100) - countLegacyStoreBlobs returns counts / null on bad path And in `hub/src/sync/syncEngineAutoMigrate.test.ts`: - cursorMigrationState promoted to 'ambiguous' on ambiguous_legacy_store / size_mismatch refusals. Schema: - `shared/src/schemas.ts`: cursorMigrationState enum gains 'ambiguous'. - `shared/src/apiTypes.ts`: CursorMigrateRefusalReason gains 'ambiguous_legacy_store' + 'size_mismatch'. Real-world repro (operator's tooling session, 2026-06-09): three legacy drawers contained one cursor session id - one with the real 21k-blob history, two with stale 19/568-blob diagnostic snapshots. Migrator silently transplanted the 568-blob alien content; resurrected session had no memory of prior history. Manual rescue completed; this fix prevents recurrence and surfaces the ambiguity to the operator instead. * fix(cursor): address cold review on migrator path-priority fix Self-review against the cold-PR rubric surfaces four polish items on the previous commit; all four addressed in-loop before push. - Major: `migrator:transplanted` candidate count was captured AFTER the source rm, so for the dominant single-candidate happy path the log reported `candidateCount=0, sourceRemoved=true`. Useless for diagnosing a future regression of the bug shape this PR is fixing. Snapshot candidates + source-side size + source-side blob count BEFORE any destructive step and use those for the log. - Minor: `sourceBytes` and `sourceBlobCount` were read from the destination path (acpSessionDir/store.db). The cp guarantees they match, but the field names imply source-side measurement. Now they measure the source directly. - Minor: `setCursorMigrationStateAmbiguous` silently returned false on cache miss / repeated version mismatch / write failure, letting the finally{} block clear the banner without any log. Now emits a warn-level log so the gap is diagnosable from journalctl. - Minor: `findLegacyChatStore` is exported public API and used as a free function in unit tests. An out-of-band caller bypassing preflightSession could pass `..` or `/etc/passwd` and have the inner `join(chatsRoot, wsh, id, 'store.db')` resolve to an arbitrary on- disk path. The probe is read-only `statSync` so blast radius is small, but enforce the same CURSOR_SESSION_ID_RE at the function boundary as a defence-in-depth. New unit test locks the behaviour. Hub test suite: 414 pass, 0 fail. Typecheck clean across cli/web/hub. * fix(cursor): cold-review polish on migrator path-priority (tiann/hapi#873) - Web `CursorMigrationBanner` now renders a "Manual review needed" state for `cursorMigrationState === 'ambiguous'` (Major #1: caller was promoting the metadata flag but no UI surfaced it). - Pin the md5-fixture contract for `workspaceHashFromPath`: raw, no-normalization, trailing-slash-distinct hashes computed via `printf '%s' <path> | md5sum` (Major #2: prevents algorithm drift that would silently revert path-priority discovery to fallback). - Snapshot full candidate set BEFORE the canonical fast-path resolves a single drawer so the `migrator:transplanted` log reports the decision-time count, not a post-rm undercount (Minor #1). - Warn log when canonical-path drawer is missing but readdir hands back exactly one candidate - regression-equivalent behaviour, but the size mismatch warrants a journalctl trail (path-normalization corner case the maintainer can grep for). - Boundary test: `messageCount = 101` (first value above the skip threshold) engages the size sanity check, pinning the cutoff contract (Nit). - Schema docstring on `cursorMigrationState` enum spelling out the banner contract per value (Nit). - syncEngine `getHapiMessageCount` warn-logs `countMessages` throws instead of silently downgrading to 0 (would chronically disable the floor). Drafted with claude-4.6-sonnet-thinking via Cursor; reviewed and tested by the operator. tiann/hapi#873. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): correct log-search strings in ambiguous banner copy The en/zh-CN locale strings told users to grep for 'migrator:ambiguous_legacy_store' and 'migrator:size_mismatch' but the hub emits '[migrator] ambiguous legacy store; refusing transplant' and '[migrator] size sanity check refused transplant'. Fix both locale files to quote the actual log prefix so the journalctl grep the operator is directed to actually hits. Addresses tiann/hapi#877 bot finding (Minor). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): address #877 bot Minor findings (trim + boundary guard) - Remove .trim() from canonical path before hashing: Cursor hashes raw workspace-path bytes; trimming a POSIX path with leading/ trailing spaces would hash to the wrong drawer, causing a false canonical miss and potential ambiguity refusal. - Add CURSOR_SESSION_ID_RE guard to listLegacyChatStoreCandidates: the function was exported without the same traversal-ID boundary check present in findLegacyChatStore. A future direct caller bypassing findLegacyChatStore could stat paths outside the intended <wsh>/<cursorSessionId>/store.db shape. - Move CURSOR_SESSION_ID_RE declaration above both functions that reference it so there is no temporal-dead-zone hazard. Addresses tiann/hapi#877 bot review Minor findings. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
55d1bbb7bd |
feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP (#844)
* feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP Closes #824 When the operator reopens a legacy stream-json Cursor session in HAPI, the hub now transparently transplants its `~/.cursor/chats/<wsh>/<uuid>/store.db` into `~/.cursor/acp-sessions/<uuid>/`, verifies it loads via `agent acp`, flips `metadata.cursorSessionProtocol = 'acp'`, and removes the legacy source - all before `resumeSession` returns. Subsequent opens are pure ACP. The primary justification is safety, not feature parity. #784 (`cursor-agent` fabricates `Questions skipped by the user` responses in legacy stream-json mode) still fires regularly in dogfood despite #801's mitigation: the agent ships destructive side effects against fabricated consent. Migration to ACP closes the protocol-level door because the `AskQuestion` tool does not exist on the ACP side, so there is nothing to fabricate. working. That tradeoff was reasonable at the time. The accumulated #784 evidence makes legacy sessions actively unsafe; this PR makes the upgrade path invisible enough that users stop avoiding it. A pre-PR spike established that legacy and ACP `store.db` files use the identical SQLite schema; only the directory layout differs. The migrator therefore: 1. Sanity-checks the source store and pre-flips state (`session.active`, `lifecycleState`, on-disk presence, target collision) 2. Optionally archives a stale-running row (`forceArchiveRunning: true` is the default for the auto-migrate path because the caller already verified `session.active === false`) 3. Atomically creates `~/.cursor/acp-sessions/<uuid>/` with mode `0o700` 4. Copies `store.db` and chmods to `0o600` (multi-user-host hardening) 5. Writes a minimal `meta.json` sidecar (`schemaVersion`, `cwd`, optional `title`) with mode `0o600` 6. Spawns `agent acp` under HAPI_HOME isolation and verifies the session loads via `session/load`. On long histories the verify also drives a trivial single-turn prompt; on short ones load-only is enough 7. Flips `cursorSessionProtocol = 'acp'` AND clears the `cursorMigrationState` banner flag in a SINGLE metadata write 8. Removes the legacy source store (only after verify succeeded and the protocol flip committed). The legacy `~/.cursor/chats` parent dir is left as-is Every failure leaves the legacy state intact. No `rm` fires without a verify success AND a committed protocol flip. The transplant takes 15-20s on long histories (copy a multi-hundred-MB store, spawn `agent acp`, replay thousands of notifications, tear down the probe). Without a progress indicator the wait reads as "broken" to a fresh reviewer. A minimal banner ships alongside the migrator: - Hub sets `metadata.cursorMigrationState = 'in_progress'` BEFORE the long-running transplant. The session-cache refresh emits the existing `session-updated` SSE event (no new event type), so the web client picks it up in milliseconds. No client-side polling needed. - Hub clears the flag in the SAME metadata write that flips `cursorSessionProtocol` to `'acp'` on success, so the banner disappears in the same render tick the chat re-renders as ACP - no flicker window. - Hub clears the flag explicitly in the auto-migrate helper's `finally` on failure/exception, so the banner never gets stuck if migration falls back to the legacy launcher. - Web renders an accessible (role=status, aria-live=polite) banner with an indeterminate spinner. Deliberately no fake percentage - we do not have phase data and a fake progress bar would lie. This PR is intentionally sequenced AFTER swear01's three ACP mop-up PRs (merged today as |
||
|
|
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> |
||
|
|
e2625b8b47 |
feat: add Fable model presets for Claude sessions (#860)
* feat: add Fable model presets for Claude sessions Claude Code 2.x accepts the fable / fable[1m] model aliases for Fable 5. hapi passes the model string through verbatim, so adding the presets to CLAUDE_MODEL_LABELS surfaces them in the new-session and composer model pickers, labels, and the 1M context-window heuristic. * test: update modelOptions full-list assertions for Fable presets Addresses review feedback on #860: getModelOptionsForFlavor appends every Claude preset, so the two complete-array expectations must include the new fable entries. |
||
|
|
1f92a31b12 | Release version 0.20.1 | ||
|
|
8094b500f3 |
fix(web): hide sidebar fake sessions for Cursor resume/archive (#836)
* fix(web): dedupe sidebar sessions by flavor resume id Wire deduplicateSessionsByAgentId into SessionList and resolve cursor threads via cursorSessionId so resume/archive no longer shows duplicate inactive rows for the same ACP session. Fixes #833 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): use SessionSummary.agentSessionId for sidebar dedup SessionList only receives SessionSummary from the API; native ids like cursorSessionId are already mapped into metadata.agentSessionId by toSessionSummary. Drop resolveAgentSessionIdFromMetadata to fix typecheck. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): hide inactive empty session stubs in sidebar Filter inactive rows with no agentSessionId and no title signal before grouping sessions, and expose lifecycleState on SessionSummary for future sidebar rules. Completes the #833 P0 follow-up alongside agent-id dedup. Fixes #833 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): scope sidebar dedup key by flavor Prevent cross-flavor collisions when flattened agentSessionId retains a stale native id. Add regression test and relax claudeRemote CI timeout. Fixes #833 Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
d82ff6127d | Release version 0.20.0 | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
4aee1e4f84 | Release version 0.19.0 | ||
|
|
449cf6af0a |
feat(cli): wire Cursor /summarize and /clear slash builtins (#747)
* feat(cursor): wire /summarize and /clear slash builtins for remote sessions Seed cursor builtins for web autocomplete, parse summarize/clear in cursorRemoteLauncher (pass-through to agent -p; reject /clear with args). Fixes tiann/hapi#738 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): isolate slash commands before message queue batching Parse summarize/clear at enqueue time (runCursor) with pushIsolateAndClear so waitForMessagesAndGetAsString never merges a slash with the next prompt. Adds queue policy tests for invalid /clear + following message. Addresses PR #747 review. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): preserve pending messages when isolating slash commands pushIsolateAndClear() wipes the entire queue, so a normal prompt queued before /summarize or /clear would be silently dropped. Add pushIsolated() - isolation without clearing - and route Cursor slash commands through it instead. Adds queue tests covering the preserve-then-isolate path. Addresses PR #747 review. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
5b797bb95d | feat(opencode): slash command support (#671) (#753) | ||
|
|
6200ae1370 |
feat(web): align Claude effort options with Claude Code --effort levels (#731)
The Claude effort selector (New Session config + in-session composer) only offered auto/medium/high/max, missing `low` and `xhigh` — yet `claude --effort` actually accepts low/medium/high/xhigh/max. Add the two missing levels in both places so the selector faithfully mirrors the CLI. Extract the level list + labels into one shared constant (@hapi/protocol: shared/src/effort.ts, mirroring CLAUDE_MODEL_PRESETS) so the two UIs derive from a single source and can't drift again. No backend change: the effort string is free-form end-to-end through to the --effort flag. ultracode is intentionally excluded — it is a TUI-only /effort session setting, not an --effort value (the CLI rejects `--effort ultracode`). |
||
|
|
ec3722aba9 | feat(web): session list status indicators (attention + scheduled) (#699) | ||
|
|
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> |
||
|
|
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 |
||
|
|
45cf002510 |
fix(hub): persist permissionMode across hub restart (#710)
Store the last known permission mode in session metadata so YOLO and other modes survive hub restarts, resume after archive, and reconnect without waiting for CLI keepalive. Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
1d03f186d6 | feat(cursor): support model selection (#684) | ||
|
|
763f45acdd |
feat: add support for Kimi Code CLI and fixed some bugs (#659)
* Add Kimi agent support via ACP protocol Add full integration for the Kimi Code CLI agent using the standard Agent Client Protocol (ACP). Includes: - kimi command and CLI registry wiring - Local launcher spawning kimi directly - Remote launcher with ACP stdio transport via AcpSdkBackend - Session management with resume support - Permission handler supporting all Kimi permission modes - Terminal UI display component - Runtime config resolving model from env and ~/.kimi/config.toml via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Fix Kimi ACP tool call input decoding on web Kimi streams tool arguments as JSON text inside the content array (e.g. {\"command\": \"df -h\"}) instead of rawInput/kind. The handler now extracts input from three sources in priority order: 1. rawInput (Claude/Codex path) 2. kind + title fallback (Gemini path) 3. content JSON text (Kimi path) Also handles: - rawInput: null no longer blocks the kind+title fallback - Title prefixes like \"Shell: free -h\" are stripped to extract args - Stale placeholder inputs are re-derived when the title updates - Normalized kind aliases (shell, run, read_file, write, etc.) via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Add kimi support to web UI * Fix some bugs * fix(kimi): dedupe repeated tool_call display in terminal UI * fix(web): keep tool block immutable so React detects input/state changes * fix(web): recognise Kimi subagent titles like 'Agent: ...' as subagent tools * fix(web): allow-for-session for ACP agents (kimi, cursor) PermissionFooter treated all non-codex sessions as Claude, sending Claude-specific acceptEdits/allowTools to ACP agents that don't support them. Hub rejected acceptEdits for kimi, and the ACP PermissionAdapter ignored allowTools. - Only show 'allow all edits' for Claude sessions - Send decision: approved_for_session for non-Claude ACP agents - Update status display to check decision field * fix(web): lookup subagent sidechains by tool-call id instead of msg id * fix(web): don't trim newest messages when loading older history fetchOlderMessages was using trimVisible(merged, 'prepend') which kept the oldest 400 messages and dropped the newest ones. This caused: 1. Latest messages to disappear when user loaded older history 2. User to see no visible change when new old messages were drowned in the 400-message window. Remove the incorrect trim so all fetched older messages are retained alongside the current window. Subsequent ingestIncomingMessages (append mode) will naturally keep the window bounded when new agent messages arrive. * fix(cli): route Kimi session resume to runKimi instead of runCursor Kimi was present in AGENT_FLAVORS but dispatchLocalResume had no branch for it, so resuming a Kimi session fell through to the Cursor launcher. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(cli): pass selected model to Kimi ACP backend via KIMI_MODEL env createKimiBackend was ignoring opts.model and only setting KIMI_PROJECT_DIR. Use buildKimiEnv so the selected model reaches the subprocess as KIMI_MODEL. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): bound message window on older loads with dedicated larger cap fetchOlderMessages was keeping all messages unbounded, causing sessionStorage bloat on repeated pagination. Reintroduce trimming with OLDER_LOAD_WINDOW_SIZE (800) so growth is capped while the newest messages are still preserved for far longer than before. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): revert sidechain lookup to message id, matching tracer/grouping pipeline tracer.ts sets sidechainId to the parent message id, and reducer.ts groups by sidechainId. A prior commit changed reducerTimeline.ts to look up by tool-call id (c.id), which broke sidechain attachment. Revert to msg.id so the lookup matches the actual grouping key end-to-end. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(cli): gate ACP title prefix stripping to known tool-kind labels extractTitleArgument stripped at the first colon unconditionally, corrupting commands/paths like curl http://localhost:3000 or Windows paths. Now it only strips when the prefix normalizes to the same tool kind as the event, verified via regex. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(shared): include kimi in isCodexFamilyFlavor for ACP permission UI Kimi is an ACP-style agent that supports the abort decision, but isCodexFamilyFlavor excluded it, so PermissionFooter rendered the non-Codex Allow/Deny UI without the Abort button. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
6aa7274851 | Release version 0.18.4 | ||
|
|
8e3bba9303 | refactor: share REST mutation schemas | ||
|
|
e88a9075df | refactor: share core REST payload types | ||
|
|
7c26c1e749 | refactor: reuse shared machine socket types | ||
|
|
9704227ce2 | refactor: share machine runner schemas | ||
|
|
b79f50f815 | Share RPC method constants | ||
|
|
41f6b37d69 | Structure SSE update patches | ||
|
|
2e1e2d39db | Share slash command definitions | ||
|
|
d6f97065c1 | Share REST and RPC response types | ||
|
|
9698aa9f4c | Unify agent flavor definitions | ||
|
|
15113668cc | Release version 0.18.3 | ||
|
|
856af6d8b2 | Show first user message in resume picker | ||
|
|
1954920753 | Release version 0.18.2 | ||
|
|
83795c0630 | Clean up cross-package build coupling | ||
|
|
6759cf4657 | Remove old protocol compatibility layers | ||
|
|
74e40b8a1a | fix(codex): stabilize goal status UI events (#652) | ||
|
|
197f327590 | feat: add hapi resume command (#647) | ||
|
|
ce2e76a42e | Add Windows remote terminal support (#642) | ||
|
|
b2a30c2e39 | feat(hub,web): support scheduling messages for future delivery (#590) | ||
|
|
089ddad476 | feat: support Codex goal slash command | ||
|
|
8185f0287e | feat(web,hub): cancel queued messages (#568) | ||
|
|
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.
|
||
|
|
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. |
||
|
|
4ec9537e4a |
feat(hub): add ServerChan task notifications (#515)
* fix(hub): 修复发送后状态显示延迟 * feat(hub): 接入Server酱任务通知 * fix(hub): 仅在会话结束时发送完成通知 * fix(hub): avoid reviving inactive queued sessions * fix(hub): address notification review feedback * fix(hub): expire queued thinking on hub clock * fix(hub): 修复任务通知 review 反馈 |
||
|
|
97be34e21c | Add Codex model selection | ||
|
|
f097f10716 |
Preserve history when deduplicating agent sessions (#471)
* fix(hub): merge histories for duplicate agent sessions * fix(hub,web): refresh active duplicate history merges * fix(hub): avoid active-active history merges * fix(web): reset message window on history invalidation --------- Co-authored-by: Liu-KM <Liu-KM@users.noreply.github.com> |
||
|
|
32755f9056 | feat(web): show queued status for messages pending inference (#492) |