mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
9cbb24258047dee5d0cf2f3872eecda900c96ebd
133
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e425761c41 | Release version 0.25.3 | ||
|
|
1ca7af44d2 | fix(pi): keep archived sessions visible (#1297) | ||
|
|
545af9b4e0 | fix(pi): expose native skills through $ completion (#1286) | ||
|
|
bbe99f5c4c |
feat(codex): /personality slash + in-session app-server params (#1265)
* feat(codex): support /personality via in-session override Intercept /personality in the Codex slash layer, keep the value in CLI memory only, and forward it on thread/turn start when set. Unset means omit the field so Codex config/thread defaults apply—no Hub DB or web UI. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): refuse fake /personality clear (sticky thread setting) turn/start.personality sticks for later turns; omitting the field does not restore config.toml. Drop default|auto|clear success paths and require an explicit friendly|pragmatic|none instead. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
0d2d029c81 | Release version 0.25.2 | ||
|
|
fdd286138a |
fix(cli): remap stale Cursor ACP model wires on resume (grok-4.5[fast=…] → cursor-grok-4.5-*) (#1271)
* fix(cli): remap stale Cursor grok wires on ACP resume (#1270) When hub sessions still store legacy grok-4.5[fast=…] wires, remap to live cursor-grok-4.5-* catalog ids before spawn and retry once on model_not_found. Keeps #1198 honest errors when remap cannot find a candidate. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): address HAPI bot review on grok wire remap (#1271) Stop Available-models parsing at newline/Tip; remap legacy wires even when stale id remains in mixed availableModels+cliModelSkus cache. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(shared): rank catalog SKUs by fast hint before effort score When medium-fast is absent, grok-4.5[fast=true] must not lose to slow medium just because default effort scoring double-counts medium. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): stderr remap fallback + queued model sync (#1271) Retry model_not_found remaps on the original legacy wire when cache pre-resolution picked a stale SKU; enqueue user turns from session model. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(shared): reject unavailable SKU variants without ACP wires matchCliSkuToAcpWireId no longer nearest-matches same-base CLI SKUs when no wire exists; legacy grok remap stays on remapStaleCursorModelId. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): suppress transient model rejection on remap retry Defer surfacing Cannot use this model stderr until initialize/load retry fails; success path no longer shows a false error in chat. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
a742fdf1a8 |
feat(hub+web): include scratchlist in session export (#1235) (#1237)
Bump export schema to v2 with scratchlist text and attachment metadata so operators keep notes when they export-then-delete. Markdown gets a Scratchlist section; attachment bytes stay out of the JSON. Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
659913c0c9 |
fix(shared): hide tool_progress heartbeat events from chat delivery (#1094)
서브에이전트(sidechain)가 오래 걸리는 도구를 실행할 때 SDK가 주기적으로 내보내는 tool_progress heartbeat 이벤트가 isClaudeChatVisibleMessage()의 기본 통과 분기를 거쳐 raw JSON 그대로 채팅에 노출되던 문제를 고친다. rate_limit_event 필터링(#423)과 동일한 패턴으로 타입 전체를 deny한다. |
||
|
|
46ab828daa | feat(web): show Hub SQLite storage usage in Settings (#1225) | ||
|
|
4c203f17cb |
feat(web,hub): scratchlist v2.2 hub attachment storage (#921) (#1205)
* feat(hub,shared): scratchlist v2.2 hub attachment storage foundation (#921) Hub stores scratchlist attachment bytes on filesystem; SQLite holds AttachmentMetadata[] JSON via session_scratchlist.attachments (v11→v12). Upstream ladder: v10→v11 text-only scratchlist table (#896), v11→v12 attachments column. Configurable limits via HAPI_SCRATCHLIST_* env vars. Upload, serve, and limits REST routes; delete entry cleans hub files. Web promote/rehydrate still TODO. Soup renumber branch follows. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): scratchlist v2.2 attachment UX (#921) Route scratchlist-mode composer submits with attachments to hub storage, show image thumbnails in the drawer, and rehydrate attachments on promote to composer or queue (hub fetch → CLI upload for send). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): scratchlist attach submit, float thumbs, copy tooltip (#921) Hub upload adapter now sets path on ready attachments so the composer send button unlocks in scratchlist mode; routing label matches attachments too. Entry thumbnails float left with text wrap; copy tooltip clarifies text-only. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): adapt scratchlist update tests to patch API (#921) update() now takes { text?, attachments? }; v12 CRUD tests still passed a string. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): harden scratchlist attachment ownership and orphan cleanup Resolve claimed hub paths against the current session before persist, count on-disk session bytes for upload caps, delete blobs dropped on entry update, and DELETE pending uploads when composer remove runs. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: drop accidental .cursor files from attachment PR Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): exit scratchlist mode before rehydrate; delete raced uploads Promote-to-composer flushes mode exit so attachments use the chat adapter. Cancel-during-upload deletes the hub blob once upload returns. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): exact UUID delete match; stage hub paths on chat send Reject partial attachment ids on disk delete, and restage scratchlist hub attachments through uploadFile when sending after leaving scratchlist mode. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): skip text-only PUT resolve; cleanup session attachment dirs Text-only edits keep existing attachment metadata after session-id transfer. Require full UUID on resolve. Delete scratchlist attachment files when a session is deleted. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): scratchlist attach route, PUT bytes, orphan deletes Park only hub-resident attachments; subtract removed blobs from the PUT session cap; delete attachment files only when no other entry still references them. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): canonicalize scratchlist attachment filenames Resolve stores the on-disk sanitized name (not claimed.filename) and hardens Content-Disposition against CR/LF/quote injection. Co-authored-by: Cursor <cursoragent@cursor.com> * test(hub): cover toxic filename canonicalize on resolve Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): serialize scratchlist uploads; drop hub blobs after chat stage Per-session upload lock keeps disk byte caps honest under concurrency. After a successful toggle-off chat send, delete the staged hub copies so they no longer count against the session attachment budget. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(shared,web): allow clearing scratchlist attachments; cleanup staged uploads PUT may send attachments:[] without a text change. Staging to chat rolls back partial normal-upload copies on failure. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): re-key scratchlist attachment files on session merge Move hub blobs when scratchlist rows transfer between session ids so quota and path ownership stay correct. Reject PUT that would leave an empty textless entry after clearing attachments. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): reuse restored scratchlist hub attachments without re-upload Composer draft remount was re-uploading blobs that already had a hapi-hub:scratchlist path, orphaning the originals against session quota. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
8d1f84e20b |
feat: name your machines from web settings (#1214)
Machines are labelled by hostname with no way to give them a friendlier
name. `MachineMetadataSchema` has declared `displayName` all along and the
whole read path already honours it (`displayName → host → id`), but nothing
could ever write it: the CLI never sends the field, the hub exposed no route
that sets it, and the web UI had no editor.
Add the missing write path:
- `PATCH /api/machines/:id` with `{ displayName }`, guarded by the existing
`requireMachine`. An empty value removes the key so the label falls back to
the hostname; the empty string is never stored.
- `machineCache.renameMachine` merges that one key into the stored metadata
and lets `refreshMachine` publish `machine-updated`, which `useSSE` already
invalidates on — so every connected client relabels without new plumbing.
- A `/settings/machines` page listing online machines with inline rename,
placed between Voice and About so the existing preference pages keep their
order. Each row keeps the hostname visible, so a renamed machine is still
identifiable.
The merge reads the raw stored metadata rather than the cached `Machine`
view. That view is narrowed by `MachineMetadataSchema`, which strips unknown
keys and yields `null` for a row that fails validation — reachable, since the
CLI's `machine-update-metadata` handler accepts `z.unknown()`. Merging
against it would have written those fields out of existence.
The row's save is guarded by a ref rather than `isPending`: disabling the
focused input forces a blur, so Enter otherwise reaches `save` twice and
fires two PATCHes, the second of which can lose the version race and report
a failure for a rename that succeeded.
`mergeMachineMetadata` already preserves hub-side fields on CLI
re-registration, so a reconnect does not clobber the name.
Closes #1210
|
||
|
|
f0e7e6ad20 | Release version 0.25.1 | ||
|
|
02c23222b4 | Release version 0.25.0 | ||
|
|
faf70c64dd | refactor(sync): replace message reloads with incremental tail sync | ||
|
|
2235b924a7 |
feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback (#896)
* feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback (#893) Promotes scratchlist persistence from per-device localStorage to a hub- backed typed table so entries follow the operator across devices. v1 panel UI / FUE / shortcut / styling are deliberately unchanged - this is a backend + sync-layer feature. Hub side - New `session_scratchlist` typed table (sessionId, entryId, text, createdAt, updatedAt) with composite PK and FK ON DELETE CASCADE from sessions. Schema bumped V9 -> V10; idempotent migration added to the legacy + step ladders. - REST CRUD under `/api/sessions/:id/scratchlist[/:entryId]`, all routed through the existing `requireSessionFromParam` guard so namespace / ownership enforcement is identical to other session-scoped routes. - Per-session 200-entry cap enforced on POST. Duplicate entryId reported idempotently (200) so the migration retry path is safe. - `SessionPatchSchema` extended with `scratchlistUpdatedAt?: number`; every successful mutation emits a `session-updated` SSE patch with the token. (Following operator's piggyback decision; aligns with the parallel #884 patch-shape extension.) Web side - Hub becomes source of truth via TanStack Query (`queryKeys.scratchlist(sessionId)`); localStorage demoted to offline cache. Add / delete / update mutations are optimistic with rollback on error. - Silent first-load migration: existing localStorage entries are pushed to the hub preserving id + createdAt, and a one-time banner (mirroring `CursorMigrationBanner`) tells the operator their notes are now in the hub. Banner dismissal is per-session and persistent. - SSE handler queues a `scratchlist` invalidation when the patch carries `scratchlistUpdatedAt`, so cross-device + cross-tab updates land within an SSE round-trip. - Delete-session confirm copy now includes a count of scratchlist entries that will be cascade-deleted. Out of scope (separate tracking issue #894): "delete with summarize-and- migrate" UX flow. Tests - Hub: V9->V10 migration (fresh + multi-hop legacy + idempotent reopen + cascade-delete), `ScratchlistStore` CRUD + ordering, REST routes (happy path + 400/403/404/409), SyncEngine SSE emission. - Web: hook covers initial fetch, optimistic add/delete/update with rollback, localStorage migration + banner, cap enforcement, local-only reorder. Banner component renders only on `'completed'`. - Existing Playwright e2e (10 tests, panel UI regression) all pass unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): address HAPI Bot Major findings on PR #896 Two real data-correctness paths the bot caught on the initial review. 1. Migration partial-failure data loss The migration loop swallowed each failed POST and still wrote the `migrated` flag, while the offline-cache effect mirrored the (partial) hub state back into `hapi.scratchlist.v1.<sessionId>` - so a transient error or cap rejection could leave entries neither on the hub nor in localStorage. Fix: - Track failed entries during migration and persist them back to localStorage; do NOT advance the flag if any entry failed, so a future mount retries. - Gate the offline-cache effect on the migration flag. Pre- migration, localStorage holds the v1 entries the migration reads; mirroring an empty hub fetch over them was the wipe. - Drop the "skip migration when hub is non-empty" gate. Combined with the duplicate-idempotent POST short-circuit (below), a retry against a session that another device already populated is a safe union. 2. Duplicate POST returned 409 at cap The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking the store whether the supplied `entryId` already existed, so an idempotent migration retry against a 200-row session returned 409 instead of 200. Fix: check duplicate first via a new `SyncEngine.getScratchlistEntry`, return the existing row with 200, and only run the cap check for genuinely new ids. Tests added: - hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap + new entryId still 409. - web/hook: partial-failure persists the failed entries back to localStorage and leaves the flag unset; offline-cache effect does not wipe pre-migration localStorage. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web/scratchlist): per-entry age indicator (clock icon + tooltip) Surfaces the smart-relative time the entry was last saved on every scratchlist row, mirroring the bucketing used in the session list: just-now -> Nm -> Nh -> Nd -> absolute date. Implementation: - Extract the existing `formatRelativeTime` helper out of SessionList into `web/src/lib/relative-time.ts` so the panel can reuse the same buckets and i18n keys (no copy-paste drift between surfaces). Also add `formatAbsoluteDateTime` for the precise-stamp tooltip line. - Add `updatedAt?: number` to the local `ScratchlistEntry` shape. v1-only callers stay valid (the field is optional and `isEntry` now accepts rows that omit it). The hub hook forwards the hub's `updatedAt` so the indicator reflects edits, not just creation. - New `EntryAgeIndicator` component: clock SVG in the same style as the existing action icons, rendered inside both panel surfaces (the older `ScratchlistList` and the drawer variant). Falls back to `createdAt` when `updatedAt` is missing (legacy v1 rows during the migration window) and renders nothing if neither timestamp is usable. - Tooltip carries the relative bucket plus the absolute timestamp on a second line; aria-label carries the relative bucket only so screen readers stay terse. - Mirror `updatedAt` into the localStorage offline cache so an offline reload still has accurate ages. Tests: - `relative-time.test.ts`: bucket math, seconds-vs-ms detection, non-finite guard. - `ScratchlistPanel.test.tsx`: indicator renders with the right smart-relative bucket, falls back to `createdAt` when `updatedAt` is absent, and renders nothing when both timestamps are zero. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): bound client-supplied entryId length (HAPI Bot, PR #896) The POST /api/sessions/:id/scratchlist body validator left `entryId` unbounded (`z.string().min(1)`), but that string is persisted as part of the SQLite primary key. An authenticated/direct client could grow the table and its index well beyond the intended scratchlist limits by submitting oversized keys. Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a UUID's 36 chars plus any prefix scheme we might layer on later) and applies `.max(...)` to the optional `entryId` in `ScratchlistEntryCreateRequestSchema`. Anything longer is rejected with 400 before the row hits SQLite. Test pins the new behavior: a 129-char id returns 400 and never reaches the engine. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): banner state machine - 'completed' is sticky until dismissed (HAPI Bot, PR #896) The previous state machine swallowed the migration banner if the operator reloaded the page before clicking dismiss: the migration flag was set on success, and on remount the init logic mapped a flag-set/dismiss-not-set session to 'pre-migrated', a state the banner explicitly refuses to render. Net effect: a migrated session never prompted for affirmative dismissal. Fixes: - Drop the 'pre-migrated' state. The dismissal flag is now the only signal that suppresses the banner; the migration flag alone means 'banner shows until dismissed' (now or after a reload). - Sessions that had nothing to migrate (no v1 entries in localStorage) pre-emptively write BOTH flags - migrated AND dismissed - so the bot's banner-stickiness fix doesn't surface a banner that has nothing to announce on freshly-created v2 sessions. Tests: - New `reload-before-dismiss leaves the banner visible` test pins the fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2 on the same session reads the localStorage flags and stays 'completed'. - New `opts fresh sessions out of the banner pre-emptively` test pins the no-v1-entries shortcut. - Existing `does not re-migrate on a mount where the migrated flag is already set` updated to assert 'completed' (not the dropped 'pre-migrated'). - Existing `skips migration when localStorage is empty` updated to assert the new 'dismissed' status + the banner-dismissed flag. - Banner test for the 'pre-migrated -> nothing' case removed (the state no longer exists). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): transfer rows during session merge so cascade-delete does not strand them (closes #920 for v2.0) `mergeSessionData` in `sessionCache.ts` ends every merge codepath with `deleteSession(oldSessionId)`, which fires `ON DELETE CASCADE` on every FK-tied table. `session_scratchlist.session_id` is FK'd with cascade, so without an explicit transfer step every dedup (#448 agent-id collision) and every resume-of-inactive (`syncEngine.resumeSession` -> mergeSessions) silently destroys the operator's per-session notes. This is the gap upstream-discovery agent flagged on #920 against PR #896. With the 2026-06-15 hub-restart cascade incident as evidence (23 sessions auto-archived in a single bounce, 4 confirmed HAPI-id rotations across 2 bounces), unmitigated this would violate v2.0's "survives reloads / second laptop / clear-site-data" promise the first time the operator hits a hub bounce. Fix: - New `transferScratchlistEntries(db, fromSessionId, toSessionId)` in `hub/src/store/scratchlist.ts`. Atomic via BEGIN/COMMIT. Uses `UPDATE OR IGNORE` so rows that would collide on PRIMARY KEY (session_id, entry_id) simply do not move - the dedup target's copy wins, matching the operator's mental model that the consolidated session is authoritative. Cleans up any collision-loser rows so the no-delete codepath (`mergeSessionHistory`) is symmetric with the delete path. - Wired into `mergeSessionData` BEFORE the `deleteSession()` call, alongside the existing message-merge step. Both `mergeSessions` (deleteOld=true) and `mergeSessionHistory` (deleteOld=false) get coverage because both can rotate the visible session id. - Emits `session-updated{scratchlistUpdatedAt}` on the new session so any web client looking at the consolidated id invalidates and refetches; for the keep-old codepath the emit also fires on the old id since it stays alive but is now empty of scratchlist. Tests (`sessionCache-merge-scratchlist.test.ts`, 7 cases): - mergeSessions (deleteOld=true): rows move, old is gone, no stranded rows. - mergeSessions PK collision: dedup target wins, unique-to-old rows still come across. - mergeSessions SSE: exactly one scratchlist patch on the new id. - mergeSessions no-op: zero rows -> zero emits. - mergeSessionHistory (deleteOld=false): rows move, old session stays alive but empty of scratchlist. - mergeSessionHistory SSE: emits on BOTH old and new ids. - Cascade-delete safety smoke: post-merge, an explicit operator delete of the new session DOES cascade-delete its scratchlist (i.e. the FK cascade we want is intact; the bug was triggering it on the wrong id). Web layer note: v1 localStorage is keyed by HAPI session id; on rotation the old key is orphaned but no longer represents data loss because the hub now holds the canonical state and the offline-cache mirror re-populates `hapi.scratchlist.v1.<newId>` on first read of the consolidated session. Documented as a known limitation; not a blocker for v2.0 because the hub is the source of truth. #894 (v2.1 migrate-on-delete) inherits a related concern about operator-Delete vs merge-Delete consent flow - flagged in the upstream-discovery handoff, separate scope. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): rebase onto upstream/main - scratchlist migration is V10→V11 Upstream landed V9→V10 as sessions.service_tier (#898/#904). Scratchlist v2 moves to V10→V11 so both migrations coexist without clobbering each other. - mergeSessionData conflict resolved: keep upstream migrateFromV9ToV10 (service_tier) and add migrateFromV10ToV11 (session_scratchlist) - SCHEMA_VERSION bumped 10 → 11 - Rename migration-v10.test.ts → migration-v11.test.ts with updated multi-hop coverage (V9→V10→V11) - Add serviceTier: null to scratchlist route test session fixture (required by upstream Session type after #898) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): emit SSE on all-collision merge when old session stays alive (HAPI Bot, PR #896) When mergeSessionHistory deletes every old scratchlist row via PK collision (moved=0, collided>0) the still-alive old session kept showing stale cached entries until an unrelated refetch. Emit scratchlistUpdatedAt on the old id whenever collided>0 on the keep-old codepath, not only when moved>0. New-session emit stays gated on moved>0 since the target row is unchanged on full collision. Test pins the all-collision mergeSessionHistory case. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): stabilize migration queryKey to stop POST retry loop (HAPI Bot, PR #896) useMemo on queryKeys.scratchlist(sessionId) so the migration effect does not re-fire every render after a failed POST clears migrationAttemptedRef. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): dedupe optimistic add when SSE refetch wins race (HAPI Bot, PR #896) onSuccess now drops both the temporary optimistic id and any existing row with the canonical entryId so a fast SSE invalidation cannot leave twins. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): treat 404 on update/delete as stale cache, not rollback (HAPI Bot, PR #896) When another client already removed an entry, keep it gone locally and invalidate instead of restoring previousData from optimistic rollback. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): drop optimistic add ghost when previousData missing (HAPI Bot, PR #896) onError now filters by optimisticEntryId if the initial fetch never populated cache, so a rejected POST cannot leave an unsaved note. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse> |
||
|
|
07db10f86d |
fix(web): expose Codex Fast and Plan on Create Session (#1017)
* fix(web): expose Codex Fast and Plan on Create Session Wire serviceTier and collaborationMode through spawn so Create can set the same Codex options chat Settings already supports (#1015). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): forward collaborationMode through machine spawn RPC Create Session Plan was accepted by the hub but dropped in apiMachine before buildCliArgs; also preserve collaborationMode on resume spawn. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): correct stopSession mock type in spawn RPC test Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep Fast mode across Create draft restore while models load Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): preserve pending Fast selection * fix: apply Fast and Plan to imported Codex sessions * test: narrow imported Codex session id * fix: forward explicit Standard service tier * fix: integrate create-session controls with current main * test: close Codex RPC suite * fix: preserve existing session spawn field * fix(web): integrate Codex controls with current New Session form * fix(web): reconcile draft types and submit state * fix(hub): integrate spawn arguments with current resume flow * test(cli): isolate spawn RPC suite --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
226b2d066a |
feat(hub): native companion (FCM) push channel + device registry + pairing QR (#803)
* feat(hub): native companion (FCM) push channel + device registry
Adds opt-in FCM HTTP v1 notification delivery so a companion mobile/wearable
app can receive permission, ready, and task notifications end-to-end. The
channel is gated entirely on FCM_SERVICE_ACCOUNT_PATH + FCM_PROJECT_ID being
set; operators not running a companion see zero behavior change.
What lands:
- POST/DELETE /api/devices/register — JWT-authed FCM token registry,
upsert on (namespace, deviceId, platform), platforms `phone` | `wear`.
- Sqlite v9 → v10 migration adds `fcm_devices` (idx on namespace + token).
- FcmService — minimal HTTP v1 client, RS256 service-account JWT via
jose (dep already in tree), 5-minute access-token cache, 401 retry.
- FcmNotificationChannel — implements NotificationChannel, sends data-only
FCM (so companion can route to phone+watch surfaces). Body composition
parses an optional trailing `AGENT_NOTIFY_SUMMARY {json}` line for richer
ready summaries; truncates plain assistant text to 280 chars otherwise.
Tags each payload with `severity` (info/warning/success/error) so clients
can color/categorise the notification.
- PushNotificationChannel gains a NativeFallbackProbe — when a namespace
has at least one registered FCM device, web-push and SSE in-page toast
are skipped so the operator does not double-notify on phone+browser.
Probe is no-op when no FCM device is registered; PWA-only setups
unchanged. Branch trace gated on HAPI_NOTIFY_DEBUG=1.
- shared/src/messages.ts — `extractAssistantPlainText` (codex + Claude SDK
shapes) and `extractNotifySummary` (strict end-anchored line parser).
- hub/src/notifications/toolArgs.ts — tool-arg formatters lifted out of
telegram/sessionView (kept duplicated there in this PR; refactor of
Telegram is a follow-up).
- docs/api/native-companion-contract.md — payload + endpoints + env vars,
versioned at contract v1.
Test coverage:
- 260 hub tests pass (incl. 23 new across FCM channel, push dedup,
v10 migration, devices route).
- 60 shared tests pass (messages parsers).
Notes for reviewers:
- Reference companion implementation lives in a separate Android repo
(Kotlin, phone APK + Wear OS APK) — this PR is hub-side only.
- No new runtime deps (`jose` and `zod` already declared in hub).
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(contract): clarify scope - companion is remote-hub client, not hub-on-phone
Adds a Scope section to the native-companion contract so anyone
implementing it knows the audience: operators running the hub on a
server who want phone/watch as a notification surface, not users
expecting a Termux-bundled hub. Mirrors the framing now in
heavygee/hapi-companion README.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(contract): correct Scope section - hub topology is unchanged
Removes the prior framing that referenced a non-existent 'Termux
hub-on-phone' alternative. This contract describes a native client to
the same hub the PWA talks to; it does not change where the hub runs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): companion app pairing QR in Settings
Companion section in Settings renders a QR code encoding the deeplink
hapicompanion://bind?hub=<base>&code=<token>. Scanning it from the HAPI
companion app (Android phone or Wear OS) auto-fills the bind form and
authenticates against this hub - no manual URL/token paste.
QR is gated behind a Show button so the access token doesn't sit visible
on screen by default; a Copy link affordance and the textual deeplink
are also exposed for manual onboarding.
Adds qrcode + @types/qrcode to web/ (already a hub dep, no new resolved
package - just a workspace declaration).
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(hub): terminal QR for companion app pairing alongside PWA QR
After the existing PWA access QR is rendered on tunnel start, also print
the hapicompanion://bind?hub=...&code=... deeplink and a matching QR.
Same tunnel + token, different scheme: phones with the companion app
installed pick up the deeplink via the manifest intent filter; phones
without it ignore it and fall back to the PWA QR above.
QR rendering failure is non-fatal in both cases - the textual deeplink
above the QR is sufficient for manual paste.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(fcm): address HAPI Bot review on PR #803
Two bugs surfaced by the upstream review bot:
1) Web Push silently dropped when FCM is not actually configured.
The native-fallback probe only checked the device registry; it did
not check whether resolveFcmConfig() actually succeeded. So an
operator who previously enabled FCM, registered a phone, then later
started the hub WITHOUT FCM_SERVICE_ACCOUNT_PATH would see the probe
return true (devices still in DB) -> Web Push suppressed -> no FCM
channel registered -> notifications go to /dev/null.
Fix: extracted the probe construction into buildNativeFallbackProbe()
which short-circuits to () => false when fcmConfig is missing. Probe
never even consults the device store in the no-config branch, so
stale rows can never matter.
2) Transient FCM failures permanently unregistered devices.
sendToToken() returned a single boolean and sendToNamespace() removed
any device whose send returned false. A 429 (rate limit), 503
(server error), 401 (auth glitch), or even an ECONNREFUSED would
delete the device row, after which the user would need to re-pair to
get notifications again. The bot caught it; the fix is the obvious
one.
Fix: sendToToken() now returns 'sent' | 'invalid' | 'failed'.
- 'invalid' is reserved for the responses that genuinely indicate a
dead token: HTTP 404 with UNREGISTERED/NOT_FOUND, and HTTP 400
with INVALID_ARGUMENT explicitly referencing the token field.
- Everything else (429, 5xx, 401, 403, network errors) is 'failed'
and counts toward the failed tally without removing the device.
sendToNamespace() only calls removeDeviceByToken() on 'invalid'.
Tests: 11 new tests across two new files. fcmService.test.ts covers
all six branches (200, 404 unregistered, 429, 503, 401, network error)
plus a mixed-batch case that proves invalid tokens get removed in the
same call where transient-failure tokens survive. nativeFallbackProbe
.test.ts covers both no-config and configured branches plus the
explicit "no-config never touches the store" guarantee.
Hub test count: 273 -> 284 (all passing).
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(contract): correct FCM visibility rule and remove unsupported event type
HAPI Bot review on PR #803 caught two contract-doc accuracy gaps:
1) Visibility rule was wrong. Doc said "FCM fires when Web Push would
fire AND client not visible via SSE", but FcmNotificationChannel
ALWAYS fires regardless of PWA visibility (deliberately - native
companion is the canonical wrist-first surface, and there is a
passing test asserting this). Companion app implementers reading
the contract would have built foreground-suppression logic and
then dropped notifications when the PWA tab was open.
2) Documented `session-completed` event doesn't exist. NotificationHub
never calls into a 'session-completed' channel method on
FcmNotificationChannel; the type would never reach a native client.
Removed from the documented enum, leaving only the three actual
events: ready, permission-request, task-notification.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(contract): drop trailing whitespace, use blank line for paragraph break
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): persist CLI access token after Telegram bind so pairing QR works
The Settings -> Companion pairing QR reads the original CLI access token
from localStorage (hapi_access_token::<baseUrl>) so it can be encoded into
the hapicompanion://bind deeplink. For browser/CLI logins useAuthSource
already persists the token via setAccessToken, but the Telegram Mini App
bind path went through useAuth.bind() which exchanged the typed CLI token
for a JWT and never persisted it. Telegram users therefore always saw the
"signed in via Telegram..." fallback and got no usable QR.
After a successful client.bind() we now mirror useAuthSource's behavior
and write the same accessToken to the same localStorage key, restoring
parity between the two auth paths. No change for browser/CLI users.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(fcm): gate native-fallback probe on rolling FCM health
The native-fallback probe previously returned true whenever FCM was
configured AND devices were registered, which suppressed web-push for
the namespace. The HAPI Bot correctly pointed out the gap: if the FCM
pipeline silently breaks (expired service-account key, sustained 5xx,
OAuth token-fetch failure, network blackhole) the operator gets nothing
on either channel until they manually intervene.
Approach (deliberate, not the bot's exact suggested fix):
- FcmService now keeps a small rolling window (last 8 outcomes) of send
attempts and exposes `isHealthy()`. The threshold is 5+/8 failures =
unhealthy; the buffer starts empty so a freshly-booted hub is
optimistic ("innocent until proven guilty") and does not double-fire
on event #1.
- Token-fetch failure (`getFcmAccessToken` throws) now records exactly
one health-failure (not one per device), short-circuits the send
loop, and returns a result so `sendToNamespace` no longer leaks the
exception.
- `invalid` token responses are explicitly excluded from the health
buffer because they are per-device facts (rotated/uninstalled token),
not pipeline failures - FCM was reachable, it just rejected one
stale token.
- `buildNativeFallbackProbe` now optionally accepts the FcmService and
short-circuits to "let web-push fire" when health is bad, before it
even queries the device registry. The single-arg call shape is still
supported for back-compat.
Why not the bot's exact suggestion ("invert: call FCM first, fall back
on result.sent === 0"):
- Couples PushNotificationChannel to FcmService and FcmSendPayload,
reversing the clean parallel-channel architecture established earlier
in this PR.
- Treats every transient single-event failure as fallback-worthy, which
re-opens the duplicate-notification race that the suppression logic
was added to close (FCM HTTP timeout that delivers later + the web
push we sent in the meantime = two pings).
- A rolling health window only flips on sustained breakage, which is
the actual operational scenario the bot is worried about.
The wrist-first design intent ("FCM fires unconditionally, web-push is
suppressed for the same namespace") documented in
docs/api/native-companion-contract.md is preserved on the happy path.
The probe only re-enables web-push when there is concrete evidence the
native pipeline is not delivering.
Tests:
- New FcmService.isHealthy suite covers empty-buffer, threshold flip,
recovery as failures age out of the window, invalid-token exclusion,
and network-error path.
- nativeFallbackProbe gains coverage for the unhealthy-but-registered,
healthy-and-registered, and absent-fcmService (back-compat) cases.
- All 292 hub tests still pass; typecheck clean.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(telegram): drop duplicate tool-args formatter, use shared module
The Telegram session view had its own copy of formatToolArgumentsDetailed
identical to the one in hub/src/notifications/toolArgs.ts (already used by
the FCM channel). Replace the local copy with an import.
Removes ~70 lines of duplication, plus the now-unused MAX_TOOL_ARGS_LENGTH
constant and `truncate` import. The shared signature accepts an optional
opts arg whose default maxArgLength is 150 - matching the prior constant -
so the call site is unchanged.
Two benign upgrades come along for the ride from the shared module:
?? instead of || on field fallbacks (no real-world difference; permission
arguments never carry empty-string fields), and String(...) wrapping plus
a typeof object guard that makes non-string values render gracefully
instead of throwing into the catch block.
Hub tests: 311 pass / 0 fail. Telegram subset: 5 pass / 0 fail. typecheck
green.
Cold-reviewed by an out-of-context Claude Opus peer before push.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(fcm): require positive evidence in health window before suppressing web-push
Addresses HAPI Bot Major review on PR #803.
The previous health gate treated an empty outcome buffer as healthy
("innocent until proven guilty"). That created a silent-blackhole window
on cold start with broken FCM credentials: the push channel suppressed
SSE/Web Push for the first ~5 events while the FCM channel attempted
each delivery and recorded failures, until enough stacked to flip the
threshold. Every notification in that gap was silently lost.
New invariant: isHealthy() requires at least one successful FCM send in
the recent window (HEALTH_WINDOW=8) AND failures below threshold
(HEALTH_FAILURE_THRESHOLD=5). Both conditions are necessary; either
alone is insufficient evidence to safely suppress web-push fallback.
Trade-off: one duplicated notification per hub restart per namespace.
On the first event after restart, web-push fires alongside FCM (because
the gate has no positive evidence yet). Once FCM records that first
success, the gate engages and subsequent events are FCM-only. Worth it
for guaranteed delivery during cold-start outages.
Tests reworked to match new semantics:
- "starts UNHEALTHY with empty buffer" (was: healthy)
- "flips to healthy after first successful send" (new)
- "stays unhealthy across failures-only run" (new, exercises the exact
blackhole scenario the bot flagged)
- "flips back to unhealthy after threshold breach with prior successes"
(renamed, establishes successes first)
- "invalid tokens don't count against health" (reworked: send a mixed
batch first to establish health, then verify invalids don't flip it)
- "network errors count as failures" (reworked: establish health first)
Hub tests: 313 pass / 0 fail. typecheck green.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): bump FCM migration to V10→V11 after upstream service_tier V9→V10
Upstream/main landed sessions.service_tier at schema v10. The companion
FCM device registry now migrates at v11 so both changes compose cleanly
after the courtesy rebase onto current upstream/main.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): per-dispatch native gate instead of stale FCM probe
FCM runs before web-push; PushNotificationChannel skips web/SSE only
when the same notify() dispatch already delivered via FCM. Removes the
isHealthy()+device-row probe that could suppress web-push after warm
FCM outages.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): cap notifySummary for FCM limits; fix PWA test cast
Rebase follow-up: truncate AGENT_NOTIFY_SUMMARY summary/action before
FCM data payload (bot Major). Fix usePwaUpdate.test.ts setTimeout mock
cast so bun typecheck passes on current main.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): cap all FCM notifySummary fields and task bodies
Whitelist and truncate AGENT_NOTIFY_SUMMARY auxiliary fields before
JSON serialization; cap task-notification summaries to glance limit.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): FCM fetch timeouts and cap Grep/Glob permission args
10s AbortSignal.timeout on OAuth + FCM send so sequential web-push
fallback is not blocked on hung Google endpoints; truncate Grep/Glob
pattern in permission detail formatter.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): bind FCM token to one namespace on re-pair
Delete stale fcm_devices rows sharing the same token when a native
install registers under a different namespace.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): localize Companion settings and pairing copy
Add en/zh-CN keys for the Companion section title and CompanionPairing
strings; matches locale-driven Settings pattern (bot Minor on #803).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): tighten FCM token-invalid detection and truncation edge cases
Parse FCM error JSON: only UNREGISTERED or token-field INVALID_ARGUMENT
unregister devices; generic NOT_FOUND stays transient. Guard limit<=3
in truncateReadyText so tiny action budgets cannot blow the glance cap.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): parse FcmError details.errorCode for UNREGISTERED tokens
FCM v1 often returns HTTP 404 with root NOT_FOUND plus
details[].errorCode UNREGISTERED; prune those tokens while keeping
generic project/resource NOT_FOUND transient.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): mock AppContext for About Companion pairing in settings tests
Settings About now mounts CompanionPairing via useAppContext after the
#1027 hub redesign rebase; wrap the About route test with AppContext and
Companion mocks so the suite stays green.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(contract): point companion auth at POST /api/auth, not /api/bind
Pairing QR carries the CLI access token as `code`. /api/bind requires
Telegram initData; native companions must use /api/auth with accessToken.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): mount Companion pairing under Settings General
About is version/links only after the settings hub redesign; pairing is
setup, so keep Companion with language prefs and update the route tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
|
||
|
|
8297383dc0 | Release version 0.24.0 | ||
|
|
500407c6b1 |
fix(codex): show catalog-default Fast tier (#1179)
* fix(codex): show catalog-default Fast tier * fix(web): show inherited Fast tier in header |
||
|
|
bd5e87898a | feat(codex): support proactive /agent mode (#1172) | ||
|
|
8eac26726b | Release version 0.23.4 | ||
|
|
df36cec01e | feat(web): sort file search results (#1109) | ||
|
|
a965b0ab21 |
fix codex session import merge (#1123) (#1127)
修复 Codex 会话导入合并后列表为空的问题。 Fix Codex session import merge so a session is not detected as a duplicate of itself and deleted during merge. |
||
|
|
782b523fb0 | Release version 0.23.3 | ||
|
|
db1444fe2e | Release version 0.23.2 | ||
|
|
b74a11ecc3 | Release version 0.23.1 | ||
|
|
64834467e3 |
feat(codex): import and resume sessions from runners (#1088)
* fix codex import resume flow * fix hub restart session active state * fix codex transcript workspace scoping * Address Codex import review findings * Fix Codex import machine selection * Update Codex sessions error test * Address Codex import review findings * Preserve forked Codex session id on sync * Make Codex duplicate cleanup source-aware * Handle Codex archive failures * Limit existing session flag to Codex * Preserve Codex import machine binding * fix: rebase runner Codex import onto current main * fix: preserve runner-scoped Codex import behavior --------- Co-authored-by: syy <815728149@qq.com> |
||
|
|
289c9f2218 |
feat(cli,web): show Claude Code's away recap in local-mode chat (#1089)
* feat(shared,cli): whitelist away_summary so auto recap reaches the hub Claude Code's local TUI writes an automatic away-summary recap to the session transcript on window blur/focus (5min+ idle), but VISIBLE_CLAUDE_SYSTEM_SUBTYPES dropped it before it ever reached the hub. Add it to the whitelist so the local launcher forwards it like the other system subtypes, and cover the forwarding + Zod passthrough of the recap `content` field with tests. * feat(web): render Claude Code's automatic away recap in the chat Once away_summary reaches the hub (previous commit), the web chat still dropped it silently: normalizeAgent had no branch for the subtype, so it fell through to `return null`. Add a `recap` AgentEvent, a normalizeAgent branch mirroring the existing turn_duration/compact subtype branches, and a presentation entry that prefixes the text with `recap:` so it reads distinctly from the manual /recap assistant bubble (which already renders as a normal message). No new render component needed: it flows through the existing generic system-event row (SystemMessage.tsx + getEventPresentation) that every other system subtype already uses. * fix(web): drop inaccurate manual-/recap comparison from recap comments |
||
|
|
2211888f04 | Release version 0.23.0 | ||
|
|
d809fca433 |
fix: reconcile stale queued messages (#1063)
Recover missed messages-consumed events from authoritative Hub state after session SSE reconnects. |
||
|
|
520c3f511a |
fix: verify Cursor chat store before reopen (#1037)
* test: reproduce issue #841 * test: cover Cursor chat store discovery * fix: verify Cursor chat store before resume (closes #841) * test: preserve non-Cursor resume behavior * test: cover conservative Cursor resume gating * fix: gate Cursor reopen until store verification * test: cover legacy Cursor drawer fallback * fix: scan unique legacy Cursor store drawer * test: preserve raw Cursor workspace path hashing * fix: hash raw Cursor workspace path * test: pin Cursor probe owner and machine * fix: probe Cursor store on recorded owner * test: normalize Cursor probe owner home * fix: normalize Cursor probe owner home |
||
|
|
3290bc9ca9 |
feat(pi): add 'max' thinking level (#1032)
* feat(pi): add 'max' thinking level Pi's --thinking flag accepts 7 levels: off, minimal, low, medium, high, xhigh, max. The shared constant and UI only exposed 6 levels (missing max). Add 'max' to PI_THINKING_LEVELS and PI_THINKING_LEVEL_LABELS. Like xhigh, max requires explicit opt-in via the model's thinkingLevelMap — models that support it will include max in their map and the UI will show it accordingly. * fix(pi): close max thinking-level branch |
||
|
|
1c834607a2 | Release version 0.22.3 | ||
|
|
8ee04500b9 |
fix(hub,cli): coerce null session activeAt so resume cannot 500 (#1026)
Legacy rows and inserts left sessions.active_at NULL while SessionSchema required a number, so CLI GET /cli/sessions/:id failed Zod and resume surfaced HTTP 500. Persist active_at on insert, harden hub read coerce, and nullish-transform activeAt in SessionSchema (output stays number). Fixes #1025 Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse> Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
b9eed7c071 |
feat: add Grok Build support (#1030)
* test: define Grok Build integration behavior * feat: add Grok Build agent integration * test: cover Grok permissions and resume paths * docs: add Grok Build setup guide * fix: scope Grok ACP discovery to session cwd * fix: align Grok permission UI semantics * docs: clarify Grok runner setup * test: require Grok create model and effort options * feat: add Grok create model and effort pickers * test: define Grok runtime parity behavior * feat: add Grok runtime ACP controls and discovery * fix: tighten Grok runtime controls * fix: suppress nonfatal Grok title quota errors * feat: support Grok Auto permission mode * feat: forward ACP native session titles for Grok * fix: guard Grok Windows shell arguments |
||
|
|
73584e925a |
feat(cursor): multitask slash, autoReview mode, native worktree/add-dir (#1014)
* feat(cursor): multitask slash, autoReview mode, native worktree/add-dir Close the highest-value Cursor Agent gaps for remote HAPI: expand ACP-safe slash pass-through (/multitask, worktree, add-dir, …), add autoReview permission mode (--auto-review spawn + mid-session slash), and route Cursor New Session worktrees through agent --worktree instead of HAPI sibling trees. Fixes #1013 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): accept --mode autoReview for hapi cursor Align --mode parsing with CURSOR_PERMISSION_MODES so documented `hapi cursor --mode autoReview` enables Smart Auto instead of silently falling back to default. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
942a1dfff8 | Release version 0.21.0 | ||
|
|
5a377e38b9 | fix(codex): defer session persistence until user activity | ||
|
|
a2465c782b |
[codex] fix Qwen realtime compatibility (#977)
* fix: improve Qwen realtime compatibility * fix: preserve Qwen endpoint query parameters |
||
|
|
b44885ae67 |
feat(gemini): remove launchable Gemini CLI agent, keep old sessions readable (#953)
* feat(gemini): remove launchable Gemini CLI agent, keep sessions readable Google sunset the consumer Gemini CLI (Pro/Ultra/free tiers stopped serving requests 2026-06-18). This removes the ability to launch/create Gemini CLI sessions while keeping existing stored Gemini sessions fully readable in the web UI. Removed (no longer launchable): - cli/src/gemini/ runtime (runGemini, loop, local/remote launchers, session, ACP backend, config, scanner) + GeminiDisplay ink view - `hapi gemini` command + registry entry + usage line - runner spawn branch & buildCliArgs mapping now reject gemini with a clear error; resume dispatch throws a clear "no longer supported" error - gemini dropped from the new-session agent selector via new CREATABLE_AGENT_FLAVORS, and from preferred-agent defaults Kept (read path — existing sessions still validate, load, render): - `gemini` in AGENT_FLAVORS / AgentFlavorSchema, FLAVOR_CAPS / FLAVOR_LABELS - AgentFlavorIcon badge, model-option labels, ACP message normalization, metadata.geminiSessionId, hub session dedup/resume-id Note: the Gemini *Live voice* backend is a separate feature and is untouched. Adds read-guarantee tests (stored gemini validates; excluded from creatable). typecheck + full suite green. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gemini): reject gemini resume before handoff (#953 review) HAPI Bot [Major]: `hapi resume <active-gemini-session>` called handoffSessionToLocal() — which tells the running remote agent to exit — before reaching the gemini-unsupported throw in dispatchLocalResume, so it could stop the live/readable session and then fail locally. Move the gemini guard into resumeCommand.run before the handoff, so an active Gemini session is left running/readable instead of being stopped. Keep the dispatch-layer guard as defense-in-depth. Adds a regression test asserting handoffSessionToLocal is not called for an active gemini target. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gemini): harden against stale gemini input (#953 review) Two [Minor] follow-ups from HAPI Bot: - newSessionFormDraft: coerce a restored browse draft's agent to a creatable flavor, so a pre-removal 'gemini' draft cannot submit agent:'gemini' even though the selector no longer offers it. - buildCliArgs: reject 'gemini' explicitly instead of silently falling through to the 'claude' command if the exported helper is reused outside the guarded spawnSession path. Updated the buildCliArgs precedence test to a creatable agent and added a test asserting buildCliArgs('gemini') throws. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gemini): reset dependent draft fields when coercing stale agent (#953 review) Follow-up [Minor]: coercing a stale gemini draft's agent to claude left model/base/effort untouched, so a { agent:'gemini', model:'gemini-2.5-pro' } draft restored as claude *with* a Gemini model, which handleCreate() then sent to the runner. Now reset model / cursorSelectedBase / effort / modelReasoningEffort to defaults whenever the agent is coerced. Adds a regression test. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gemini): tombstone `hapi gemini` so it errors clearly (#953 review) HAPI Bot [Major]: after removing geminiCommand from the registry, resolveCommand() treats `gemini` as an unknown subcommand and falls through to the default Claude command (forwarding "gemini" as an arg), so `hapi gemini` silently started Claude instead of reporting the sunset. Add an explicit tombstone `gemini` command that prints the sunset error and exits 1. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(web): assert AgentSelector hides the sunset Gemini agent (#953) Render regression test confirming the new-session AgentSelector offers exactly CREATABLE_AGENT_FLAVORS and never shows a Gemini radio. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: HAPI <noreply@hapi.run> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
26a24bb6ce |
feat(web,hub,cli): show machine health in session sidebar (#962)
* feat(web,hub,cli): show machine load in session sidebar Runners attach OS health snapshots to machine-alive heartbeats; the hub caches them and the web session list renders load or CPU between the machine label and session count. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web,cli): show CPU and RAM pressure in machine health badge Sidebar label now combines CPU and RAM percentages for overload signaling; load stays in the tooltip on Unix. Prime CPU sampling so the first heartbeat includes usage, not just memory. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): visual machine health meters with tooltip Replace bare CPU/RAM text with labeled mini bar gauges, chip border tint by severity, and a HoverTooltip explaining capacity and overload guidance. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): widen machine health tooltip with horizontal layout Allow a generous popover width and lay CPU/RAM/load out side by side so the capacity tooltip reads wider and less tall than the chip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): anchor machine health tooltip to row left edge Wide tooltip was align=end on the chip, so it grew left off-screen. Use row-span positioning on the machine tile button instead. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): machine host card with OS label and inline health Turn the session sidebar machine row into a bordered host panel with OS metadata and side-by-side CPU/RAM meters embedded in the tile instead of a flat label line matching project rows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep machine host tile single-row height Collapse the machine header back to one py-1.5 row with OS and compact inline health beside the name, and restore the original project indent without the extra nested rail or second header line. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): show CPU core count in machine health tooltip When the runner reports cpuCount, the tooltip reads "CPU across all 6 cores" instead of the generic all-cores label. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: add machine health sidebar screenshots Dogfood captures for the session sidebar machine tile and capacity tooltip, for upstream PR review. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): clear machine-alive priming timeout on disconnect Track the 50ms CPU priming setTimeout and clear it in stopKeepAlive so disconnect/shutdown during the delay cannot leave a stray interval alive. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: drop dogfood screenshots from upstream PR diff Review evidence lives in the PR discussion only; no need to ship PNGs in the repo long-term. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): truncate long machine OS/host metadata in sidebar row Bound the metadata span so a long hostname cannot push the health chip or session count off-screen in narrow sidebars. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): reveal machine health tooltip on keyboard row focus Wire MACHINE_ROW_TOOLTIP_FOCUS_CLASS and aria-describedby on the machine header button so keyboard users can read the health tooltip like session rows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): use MemAvailable for Linux RAM pressure on Bun Bun's os.freemem() reflects MemFree (~1% on cache-heavy hosts), which made sidebar RAM read ~99% while btop showed ~40% used. Parse /proc/meminfo MemAvailable instead so used percent matches operator tools. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web,cli): show machine uptime in sidebar tiles and tooltip Collect os.uptime() as uptimeSeconds on keepalive and render compact up 1h 54m in the machine meta row plus an Uptime line in the health tooltip. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): anchor machine health tooltip to chip not row align=row positioned the tooltip below the full machine header button, so the collapsible project panel painted over it on hover. Use align=end with a min-width panel so mouse and keyboard tooltips stay visible. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
d1a686f8d0 |
fix(cursor): map base-only CLI sku to fast=false to avoid silent variant no-op (#887)
Without an explicit -fast suffix, inferSkuParamHints returned no fast hint, so matchCliSkuToAcpWireId tied between fast=true and fast=false wires and kept the first one. For composer-2.5 that meant the picker's "non-fast" sku silently resolved to composer-2.5[fast=true] — the same wire the fast sku resolves to — producing the "selected but no response" symptom in #883. Treat absence of -fast as fast=false so base-only skus pick the slow variant and round-trip back to the matching radio. Fixes #883 via [HAPI](https://hapi.run) Co-authored-by: HAPI <noreply@hapi.run> |
||
|
|
ce67823fc3 |
feat(web,hub): rich hover tooltips on session-list attention indicators (#941)
* feat(web): rich hover tooltips on session-list attention indicators
The session-row attention dots and the future-scheduled clock icon used
plain `title=""` attributes which gave only a one-word label ("Permission
required"). Replace those with hover/focus-revealed tooltips that name
*which* tools are blocking, count background tasks, surface the
"updated Nm ago" timestamp, and explain the pending schedule.
To make per-tool copy possible without an extra round trip,
`SessionSummary` now carries a structured slice of the pending tool
requests, capped at `PENDING_REQUEST_SUMMARY_CAP = 5` oldest-first:
pendingRequests: Array<{ id; kind; tool; since }>
`pendingRequestsCount` remains the authoritative total;
`pendingRequestKinds` is still derived from the FULL request set so a
single `'input'` request beyond the cap still surfaces its kind on the
session row.
The tooltip primitive (`HoverTooltip`) is a CSS-driven reveal — no
portal, no positioning JS — so it composes cheaply inside the existing
session-row `<button>` and stays out of the way on touch devices, which
keep getting the same `aria-label` the old `title=""` attribute provided
to screen readers.
Test coverage: shared derivation + cap + tie-break + full-set kind
behaviour; web tooltip render across all four attention kinds plus
mixed-kind overflow suppression and aria-label exposure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): opaque tooltip surface; drop redundant 'updated Nm ago' body
Two operator-feedback fixes on the new session-list HoverTooltip:
1. Tooltip background was bg-[var(--app-bg)] - the same variable as the
session row underneath - so the tooltip looked translucent and the row
text bled through. Switch to bg-[var(--app-secondary-bg)] (#2C2C2E
dark / #f3f4f6 light, both opaque) and bump shadow-md -> shadow-lg.
Telegram-themed clients still pick up tg-theme-secondary-bg-color so
the tooltip stays on-theme.
2. The 'unread' attention dot tooltip rendered 'New activity / Updated 5m
ago', but the relative-time pill ('5m ago') is already on the right
edge of the same session row. The tooltip body just duplicated info.
Render only the title for the unread case; drop the
session.tooltip.unread.body i18n key from en + zh-CN.
The other tooltip kinds (permission/input list tools, background lists
task count) keep their bodies - those facts are not visible elsewhere on
the row.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web,hub): show scheduled fire time in session-list clock tooltip
The schedule clock tooltip previously said only "Will fire when due."
while the row already showed a relative updated-at pill. Extend the
session-list API with nextScheduledAt (MIN future scheduled_at per
session, same filter as futureScheduledMessageCount) and render:
- single scheduled: "Fires in 5m · Jun 16, 1:45 PM"
- multiple: "Next in 5m · Jun 16, 1:45 PM · +2 more"
Extract formatScheduledTime from QueuedMessagesBar into web/lib/
scheduledTime.ts alongside formatFutureRelativeTime and the tooltip
composer. SSE upsert preserves nextScheduledAt until the list refetch
that already runs on schedule-related events.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): wire session-row keyboard focus to HoverTooltip a11y
Address PR #941 Major review: aria-describedby and tooltip visibility
were on a non-focusable inner span, so keyboard users tabbing the session
row button never received the rich tooltip description and
group-focus-within never matched.
- Session row button owns aria-describedby (attention + schedule ids)
- Add group/session-row + SESSION_ROW_TOOLTIP_FOCUS_CLASS reveal on
:focus-visible
- HoverTooltip takes required id; drop inner aria-label/describedby
- useSessionRowTooltipIds helper composes stable row tooltip ids
- Tests for id wiring and parent-focus reveal classes
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
|
||
|
|
4bc3393904 |
fix(cli): stateful MCP HTTP transport for display_image (#944)
* fix(cli): stateful MCP HTTP transport for display_image MCP SDK 1.29+ rejects stateless StreamableHTTP reuse across separate POSTs (initialize, notifications/initialized, tools/call), so display_image 500'd on the second request. Generate per-session IDs instead. Add hapi-display-image.mjs to call the live session CLI's MCP via hostPid so generated-image bytes stay in the owning process. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): multi-session MCP transport + hapiMcpUrl metadata Route streamable HTTP by mcp-session-id so agent bridge and hapi-display-image can each initialize without "already initialized". Publish metadata.hapiMcpUrl at MCP start; helper uses that instead of guessing loopback ports (hook server collision). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): preserve namespaced CLI_API_TOKEN in display-image helper Do not append :default; namespace is already encoded in the stored token. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scripts): read settings only when CLI_API_TOKEN unset Env-only auth must not require ~/.hapi/settings.json to exist. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
26d3c2eb34 |
fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/load succeeds (closes #939) (#948)
* fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/load succeeds Emit session-ready from the CLI after ACP load/newSession completes; hub resumeSession and cursor dedup wait for that signal before merging rows so a failed session/load no longer deletes the archived session the operator can retry. Refs #917. Closes #939. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): gate session-ready wait on cursor ACP protocol only Legacy stream-json Cursor resumes use cursorLegacyRemoteLauncher, which does not emit session-ready; limiting the defer-merge and dedup gates to ACP avoids 60s resume_failed timeouts on those sessions. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): block ACP dedup until session-ready, including on session-end Inactive ACP spawns that never emitted session-ready could still trigger deduplicateByAgentSessionId on session-end and delete the original row. Require session-ready for all ACP dedup paths and skip end-of-session dedup when load never succeeded. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): restore session-end dedup for non-ACP cursor duplicates Only skip the session-end dedup retry for Cursor ACP rows that never emitted session-ready. Codex/Claude/legacy Cursor duplicates still merge when the live row ends. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
e23ae1b265 |
feat: add Pi Coding Agent support (#862)
* docs: spec for hapi-pi-agent-backend
* docs: spec retrospect for hapi-pi-agent-backend
* docs: plan for hapi-pi-agent-backend
* docs: plan retrospect for hapi-pi-agent-backend
* feat(pi): add hapi pi command with JSONL transport and event converter
- PiTransport: spawn pi --mode rpc, JSONL stdio, ENOENT/EPIPE handling
- PiEventConverter: Pi AgentEvent → HAPI AgentMessage conversion
- runPi: session lifecycle, dual-track event routing, model switching
- pi command: CLI registration with PI_PERMISSION_MODES
- Shared: add 'pi' to AGENT_FLAVORS, FLAVOR_CAPS, FLAVOR_LABELS
30 tests passing (15 transport + 15 converter)
* fix(pi): add Pi RPC types, fix double-cleanup/double-start/converter safety net
- Add cli/src/pi/types.ts with PiAgentEvent/PiResponseEvent discriminated unions
- PiTransport: constructor uses options object, double-start guard, drop log
- PiEventConverter: typed events via type assertions, top-level try/catch
- runPi: safeCleanup guard prevents double-cleanup race, sendAgentMessage
for converted events, keepAlive() for session pings
- 33 tests passing
* docs: dev phase reviews and test results for hapi-pi-agent-backend
- Business logic review: pass (0 must_fix)
- Standards review: pass (0 must_fix)
- Taste review: P0 types issue fixed in code
- Robustness review v2: pass (v1 3 MUST_FIX all fixed)
- Integration review: pass (0 must_fix)
- Test results: 33 passing, all type errors resolved
* docs: taste review v2 pass after type definition fixes
* docs: dev retrospect for hapi-pi-agent-backend
* test: test execution for hapi-pi-agent-backend (20/20 pass)
* fix: add taste_review symlink for gate pattern match
* docs: test retrospect for hapi-pi-agent-backend
* fix(web): add pi to MODEL_OPTIONS Record type
* ci: PR and CI evidence for hapi-pi-agent-backend
* docs: overall retrospect for hapi-pi-agent-backend (all 5 phases)
* test(pi): add buffer split, missing fields, and handleResponse tests
- PiTransport: buffer cross-chunk reassembly test
- PiEventConverter: tool_execution_end with missing result/toolCallId
- handleResponse: 10 tests covering all branches (error, get_state,
set_model, new_session, abort, prompt, unknown command)
- Extract handleResponse to accept onUpdate callback for testability
- Total: 46 tests passing (was 33)
* fix(pi): set requiresRuntimeAssets to false — pi runs as subprocess, no native tools needed
* refactor(cli): lazy import ensureRuntimeAssets to reduce startup overhead
* docs: add 15 manual E2E protocol test cases (TC-4-xx) based on real Pi RPC capture
- TC-4-01 to TC-4-15: manual tests covering tool execution, thinking
lifecycle, multi-turn, abort, error scenarios, model switch, cleanup
- Priority: P0 (tool fields, failure, thinking, multi-turn, abort)
> P1 (basic conversation, write tool, model switch, usage) > P2 (edge cases)
- Includes actual Pi RPC event sequence from live capture as reference
- e2e-test-plan.md updated with test environment setup instructions
- Total test cases: 35 (6 unit + 14 integration + 15 manual)
* test: E2E protocol test results for hapi-pi (11/15 pass)
P0/P1 automated tests (8/8 pass):
- TC-4-01: Basic text conversation ✓
- TC-4-02: Tool read (field names verified) ✓
- TC-4-03: Tool write (file created) ✓
- TC-4-04: Tool failure (isError=true) ✓
- TC-4-05: Thinking lifecycle + usage ✓
- TC-4-06: Multi-turn context retention ✓
- TC-4-07: Abort generation ✓
- TC-4-14: Token count ✓
- TC-4-15: Extension UI events ignored ✓
P2 results:
- TC-4-10: Invalid token → 401 ✓
- TC-4-12: Ctrl+C cleanup, no orphans ✓
- TC-4-08: ENOENT (harness issue, exit code correct)
- TC-4-11: set_model not supported by Pi (success=false)
- TC-4-13: Pi crash (harness output capture issue)
* test: fix TC-4-11 result — Pi set_model works with correct provider/modelId
Previous test used invalid provider='' + modelId='deepseek-chat'.
Re-tested with provider='deepseek' + modelId='deepseek-v4-flash':
- set_model success=true
- model switched glm-5.1 → deepseek-v4-flash
- subsequent prompt confirmed working
Final E2E results: 12/15 PASS, 2 FAIL (test harness), 1 SKIP
* chore: remove .xyz-harness/ from git tracking, add to .gitignore
Local harness workflow artifacts should not be tracked in the repo.
* fix(pi): resolve web UI bugs for hapi-pi integration
Five bugs fixed for end-to-end pi session via hapi web UI:
1. runner buildCliArgs: add 'pi' branch to spawn correct command
(was falling back to 'claude', launching wrong agent)
2. runPi: implement real keep-alive (2s interval) to prevent hub
30s timeout marking session inactive
3. runPi: bump keep-alive to active state during agent/turn_start
4. sessionResume: add 'pi' to flavor switch and resume condition
(was returning undefined, causing 'cannotResume' on inactive session)
5. PiEventConverter: emit codex-compatible {type:'message',message:...}
/{type:'reasoning',message:...} with streamId; dedup by skipping
text_start/text_end (only send deltas) to avoid triple-rendered text
6. PiTransport: fallback to stdout 'end' event when child process
close event doesn't fire (bun spawn quirk)
Verified end-to-end: web UI shows pi reasoning + reply correctly,
session stays online, no duplicate text.
* fix(pi): address 4 web UI display bugs in hapi-pi integration
Three of four follow-up bugs reported after the initial fix (6c28949):
1. Stuck in 'queued' status — fix
Pi's runner doesn't use MessageQueue2, so the base session's
onBatchConsumed hook never fires. Add a FIFO of pending localIds
in runPi and emit messages-consumed on agent_start. turn_start
is intentionally skipped (it can fire multiple times per agent
run after tool calls). A prompt rejection from Pi also consumes
the localId so the next prompt isn't poisoned.
2. AI thinking only displays ':' — fix
Pi emits pure incremental deltas (text_delta / thinking_delta)
per token. The web reducer dedupes reasoning by streamId WITHIN
one message's content array only — separate wire messages
produce separate renders. Without accumulation, 50 deltas = 50
reasoning renders, of which the reducer keeps only the last
delta (a single character like ':').
3. Output text on separate lines — fix
Same root cause as #2 but for text: the reducer appends each
text AgentMessage as a new agent-text block (no dedup), so 50
deltas become a 50-row character-by-character column.
4. Tool call execution status (in_progress -> completed)
The tool result wire CodexMessage type is 'tool-call-result'
(with callId + is_error?); the internal AgentMessage 'tool_result'
is converted to that. Status mapping is preserved.
Implementation: extract a PiMessageAccumulator class (testable in
isolation) that mirrors codex's ReasoningProcessor pattern:
- message_start resets state and streamId
- text_delta / thinking_delta append to internal text / reasoning
- text_start/thinking_start/text_end/thinking_end ignored (they
carry full partial state — would duplicate)
- message_end flushes (max 1 reasoning + 1 text message, in order)
- turn_end safety net flushes if active
- flushIfActive() exposed for transport close / crash
The converter now routes AgentMessage through convertAgentMessage
so the wire format is codex-shaped (matches opencode/gemini/kimi
path). AgentMessage 'text' and CodexMessage 'message' both gain
optional id; convertAgentMessage preserves caller-provided id for
streamId-based dedup on the web side.
Tests: 16 new PiMessageAccumulator tests + 5 updated
PiEventConverter tests + 4 messageConverter tests, all passing.
Full suite: 909/910 (1 unrelated macOS path normalization). tsc
clean.
* fix(pi): review round 1 - 1 must-fix issue
The web session-resume helper referenced metadata.piSessionId, but the
shared MetadataSchema does not define the field, and the back-end has no
path to populate it (Pi session resume is out of scope per spec.md).
This caused web typecheck to fail and would also have produced a
runtime 'resume_unavailable' from the hub if a user tried to resume a Pi
session that had any user messages (the stale 'flavor === pi' branch in
inactiveSessionCanResume claimed resume was supported).
Revert the two early Pi branches from the web resume helper. Add a
comment pointing at the spec and noting what to undo when back-end
resume ships (re-add 'case pi' + 'piSessionId' on MetadataSchema +
extend hub resolveAgentResumeId).
* fix(pi): review round 2 - 4 must-fix issues
1. cli/src/runner/run.ts buildCliArgs: stop forwarding --resume to the pi
binary. Pi session resume is out of scope (no piSessionId on
Metadata), so forwarding would create an orphan session the hub can't
track. Hub already returns null from resolveAgentResumeId for
flavor='pi' and falls through to fresh spawn; this just hardens the
runner layer to match.
2. cli/src/pi/runPi.ts: cache currentProvider from get_state and use it
for subsequent set_model RPCs. Pi's set_model requires both provider
and modelId, but the bootstrap-time code emitted provider: '' which
Pi rejects. The bootstrap-time model is still applied by Pi at
startup, so suppressing set_model until get_state arrives is a no-op
for same-model configs rather than a wrong-model emit.
3. web/src/components/AssistantChat/modelOptions.ts: add explicit pi
branches to getModelOptionsForFlavor and getNextModelForFlavor.
Without them, Pi sessions fell through to the Claude preset cycler,
which would push sonnet/opus ids into a Pi session via
set-session-config. Mirrors the opencode handling introduced earlier.
Tests added/updated: buildCliArgs covers pi + claude resume; handleResponse
mirror test covers provider caching; modelOptions tests cover pi
no-fallback behavior for both option list and cycler.
* fix(pi): add session resume support and fix review issues
- Add piSessionId to MetadataSchema (shared/src/schemas.ts)
- Persist piSessionId from get_state response to metadata (cli/src/pi/runPi.ts)
- Pass --session-id to Pi spawn on resume (cli/src/pi/runPi.ts)
- Add pi branch to resolveAgentResumeId (hub/src/sync/syncEngine.ts)
- Add case 'pi' to resolveAgentSessionIdFromMetadata (web/src/lib/sessionResume.ts)
- Replace pi resume skip guard with --session-id forwarding (cli/src/runner/run.ts)
- Preserve piSessionId in pickExistingSessionMetadata (cli/src/agent/sessionFactory.ts)
- Add pi badge to AgentFlavorIcon (web/src/components/AgentFlavorIcon.tsx)
- Fix transport.onClose crash-marking on normal shutdown (cli/src/pi/runPi.ts)
* fix(pi): review round 1 - 3 must-fix issues
- resume.ts: add pi branch to dispatchLocalResume() so hapi resume
dispatches to runPi instead of falling through to cursor
- runPi.ts: accept existingSessionId and use bootstrapExistingSession
when resuming, matching other agents' pattern
- agentCommandOptions.ts: parse --session-id in addition to --resume
so runner-spawned pi resume actually forwards the session ID
- types.ts: export PiPermissionMode alongside other agent permission
mode types for consistent import convention
* fix(pi): review round 2 - 2 must-fix issues
* refactor(workflow): improve pi-adaptation-review-loop robustness
- Switch from structured output to file-based JSON output for reliability
- Replace per-round file limit (20→30) with clear wording (remove misleading split-commits instruction)
- Return { data, error } from readResultFile() to surface parse/validation failures in abortReason
- Fix lastMustFix sentinel: initialize to null, use ?? for explicit N/A reporting
- Add getAgentDirs() to dynamically discover agent dirs from cli/src/
- Document rollbackTo() atomic-round design intent
- Add isValidIssue() validation, runFinalCleanup() helper, git repo pre-check
* test(pi): add coverage for pi flavor across shared, cli, and web
- shared/flavors.test.ts: pi/kimi capability, label, known, supports
- shared/modes.test.ts: PI_PERMISSION_MODES contract, per-mode checks
(7-mode allowed/denied matrix)
- web/AssistantChat/modelOptions.test.ts: pi shortcut vs Claude
cycler, normalize filter (auto/default/whitespace), kimi/cursor/
opencode cross-flavor consistency
- web/lib/sessionResume.test.ts: piSessionId resolver, cross-flavor
stale-id protection, inactiveSessionCanResume for pi, regression
coverage for all 6 other flavors
- web/components/AgentFlavorIcon.test.tsx: pi badge styling
(bg-[#5b21b6]), Un fallback, case/whitespace normalize,
className override
- cli/commands/agentCommandOptions.test.ts: --session-id
(pi-specific flag), --resume alias, PI mode validation,
--yolo vs explicit-mode priority
137 new test cases, all passing. Full suite: 96 files / 933 tests
green (unrelated apiMachine.test.ts macOS /private/var path issue
remains as documented in handoff).
* feat(pi): implement P0 — context budget bar + dynamic model discovery
P0-1: Context Budget Bar
- Add pi branch to modelConfig.ts getContextBudgetTokens()
- Conservative 200K default context window for Pi sessions
P0-2: CLI-side model discovery
- Add get_available_models to PiRpcCommand type
- Auto-send get_available_models after get_state in runPi.ts
- Cache model list and push to session metadata
- Register ListPiModels RPC handler with promise-based transport query
P0-3: Hub-side routing
- Add listPiModelsForSession to rpcGateway and syncEngine
- Add REST endpoint GET /sessions/:id/pi-models (pi sessions only)
P0-4: Web-side rendering
- Add PiModelSummary type to shared apiTypes
- Add usePiModels hook (TanStack Query, stale 60s)
- Add getSessionPiModels to API client
- Add sessionPiModels query key
- Wire piModelOptions into SessionChat availableModelOptions
- Model dropdown renders discovered models or falls back to Default
* fix(pi): address code review findings + pre-existing test issue
Review fixes:
- Fix race condition in sendPiRpcAndWait: use incremental id as key
instead of command type, preventing resolver overwrite on concurrent
calls (e.g. auto-discovery + ListPiModels RPC)
- Extract parsePiModels() to eliminate duplicated model parsing logic
between handleResponse and ListPiModels RPC handler (DRY)
- Add resolvePendingRpc() call in error response path to prevent
promise leaks when Pi rejects an RPC with an id
- Add piModelsState.error guard to onModelChange in SessionChat,
matching the pattern used by codex and cursor flavors
Pre-existing fix:
- Fix apiMachine.test.ts symlink assertion on macOS (/var vs
/private/var) by applying realpathSync to the expected path
* feat(pi): P1 — session rename sync, thinking level UI, skills/commands
P1-1: Session Rename → Pi notification
- Add set_session_name to PiRpcCommand
- Register RenamePiSession RPC handler in CLI
- Hub syncEngine.renameSession now forwards to Pi CLI for active sessions
- Hub rpcGateway + REST endpoint added
P1-2: Thinking Level support
- Add Pi thinking level constants to shared/src/piThinkingLevel.ts
(off/minimal/low/medium/high/xhigh)
- Add ThinkingLevel capability to Pi flavor in flavors.ts
- sessionConfigRpc now supports effortMode for Pi thinking level
- runPi captures thinkingLevel from get_state and forwards via
set_thinking_level
- Hub effort endpoint accepts pi sessions (was claude-only)
- Web: piThinkingLevelOptions.ts + HappyComposer renders Pi options
when flavor=pi
P1-3: Skills/Commands discovery
- Add get_commands to PiRpcCommand, auto-discover after get_state
- Register ListPiCommands + ListSlashCommands RPC handlers in CLI
(maps Pi commands to HAPI SlashCommand format)
- Hub: listPiCommandsForSession + REST GET /sessions/:id/pi-commands
- Web: usePiCommands hook + api client + query keys
Also fixes:
- Pre-existing ZodError.errors → ZodError.issues in hub/socket/server.ts
- Updated test expectation for effort endpoint error message
* feat(pi): implement P2 features — steer, queue modes, history, native images
P2-1: Steer/Follow-up
- Track piIsStreaming state from agent_start/turn_start/turn_end/agent_end
- When streaming, onUserMessage sends steer instead of prompt
- Added PiSteer/PiFollowUp RPC methods + hub routing + REST endpoints
P2-2: Queue modes
- Added set_steering_mode/set_follow_up_mode to PiRpcCommand
- CLI RPC handlers with mode state tracking
- Hub routing + REST POST endpoints
- Web API client methods
P2-3: History replay
- Added get_messages to PiRpcCommand
- CLI handler converts Pi AgentMessage to PiMessageEntry format
- Hub RPC routing + REST GET /sessions/:id/pi-messages
- Web usePiMessages hook + query key
P2-4: Native image passing
- Added PiImageContent type for base64 image data
- extractPiImages() helper reads attachment files as base64
- prompt/steer commands now include images field
- Falls back to @path text reference for non-image/unreadable files
* feat(pi): implement P3 advanced features — compact, fork, clone, switch, stats, export
P3 features for Pi agent integration:
- Compact: compact RPC with custom instructions, set_auto_compaction toggle
- Fork: fork at entry ID, get_fork_messages for fork context
- Clone: clone current Pi session
- Switch Session: switch Pi to a different session by path
- Session Stats: get token counts, message counts, cost
- HTML Export: export session as HTML file
All features follow existing P2 pattern:
- CLI: RPC handlers in runPi.ts with sendPiRpcAndWait
- Hub: rpcGateway + syncEngine routing + REST endpoints
- Web: API client methods + query keys + type exports + hooks (stats, fork messages)
Total: 8 new REST endpoints, 9 RPC handlers, 6 web API methods
Typecheck: all 3 packages pass (cli+hub+web)
Tests: 1155 pass (263 hub + 803 web + 89 shared), 0 failures
* refactor(pi): clean up runPi.ts imports and readability
- Replace require('fs') with top-level import { readFileSync } from 'fs'
- Extract handleGetState() as standalone function from handleResponse
switch case (get_state case: 35 lines → 4 lines dispatch)
Typecheck: all 3 packages pass
Tests: 1066 pass (263 hub + 803 web), 0 failures
* fix(pi): remove native image passing, fix version pollution
- Remove extractPiImages helper and PiImageContent type: all
attachments now use @path text references via
formatMessageWithAttachments, consistent with every other agent
- Remove images field from prompt/steer/follow_up RPC commands
- Remove unused readFileSync import
- Restore cli/package.json version from test pollution
(0.0.0-integration-test-should-be-auto-cleaned-up-51369 → 0.20.0)
Typecheck: all 3 packages pass
Tests: 1286 pass, 0 failures
* refactor(pi): extract hub helper, unify web hooks, fix import style
- Hub: extract withPiSession helper eliminating boilerplate across 15
Pi REST endpoints (~400 lines → ~150 lines)
- Web: unify usePiForkMessages and usePiSessionStats to return
destructured typed fields matching usePiModels/usePiCommands pattern
- Web: move 15 Pi response types from inline import() to top-level
named imports in api/client.ts
- CLI: remove duplicate PiCommandSummary/PiCommandsResponse from
types.ts, re-export from @hapi/protocol/apiTypes
Typecheck: all 3 packages pass
Tests: 1286 pass, 0 failures
* chore: untrack .agents/skills and .pi, fix .xyz-harness in gitignore
* refactor: remove unused text message id from converter layer, update gitignore
* fix: update tests for pi resume support and text id removal
* fix: restore cursor resume branch in buildCliArgs
* refactor: remove pi-specific rename from syncEngine, align with other agents
* refactor: remove effort field from sessionConfigRpc, Pi self-handles RPC
Pi agent now self-handles SetSessionConfig RPC (like Claude) using
the existing field, instead of adding a parallel
field to the shared sessionConfigRpc helper which only knows about
.
- Remove effort/effortMode from sessionConfigRpc types and logic
- runPi.ts: self-register RPC handler with PiThinkingLevel validation
- Reuse resolveSessionConfigPermissionMode from sessionConfigRpc
* refactor: consolidate Pi RPC layer from 36 methods to 3 generics
rpcGateway: 12 methods → callPiRpc<T>
syncEngine: 12 passthroughs → callPiRpc<T> delegate
web client: 12 methods → callPiEndpoint<T>
routes: use engine.callPiRpc with RPC_METHODS constants
hooks: use callPiEndpoint, add missing type imports
* chore: revert unrelated apiMachine test change
* refactor: remove unused ThinkingLevel capability from flavors
Pi's thinking level is an effort variant, not a separate capability.
The ThinkingLevel constant and supportsThinkingLevel() had zero callers
— the frontend uses flavor-based branching for effort option rendering.
* refactor: drop Pi prefix from generic RPC method names
* refactor: remove 13 Pi RPC methods with no UI consumers
Steer: already handled by onUserMessage auto-routing
Follow-up: redundant with HAPI message queue
ListPiCommands/GetMessages/ForkMessages/SessionStats: no UI
Compact/SetAutoCompaction/Fork/Clone/SwitchSession/ExportHtml: no UI
SetSteeringMode/SetFollowUpMode: no UI
Kept: ListPiModels (has UI), SetSessionConfig, ListSlashCommands, Abort, Switch
Deleted: 4 web hooks, 13 RPC handlers, 12 REST routes, 13 rpcMethods entries
Net: -730 lines
* refactor: extract session.ts and loop.ts from runPi.ts
Restructure Pi agent following Codex pattern (without Local/Remote
splitting since Pi only has remote mode):
- session.ts: PiSession class managing state + hub communication
- loop.ts: response parsing, RPC resolver, transport event wiring
- runPi.ts: thin entry (bootstrap, RPC handlers, lifecycle)
Changes from review:
- Encapsulate RPC resolver in PiRpcResolver class (session-scoped,
not module-level singleton)
- Remove unused extractTextFromPiMessage export
- Fix inline import('./types') → top-level import
* refactor: normalize Pi file naming and improve test coverage
- Rename PiTransport.ts → piTransport.ts, PiEventConverter.ts →
piEventConverter.ts, PiMessageAccumulator.ts → piMessageAccumulator.ts
(match project-wide camelCase convention)
- Delete handleResponse.test.ts (tested stale copy of inline function)
- Add loop.test.ts with 20 tests covering parsePiModels,
parsePiCommands, wireTransportEvents integration, and sendPiRpcAndWait
- Total Pi tests: 73 (was 53)
* test: add E2E harness with 4 core helpers and integration specs
Helper functions in e2e/harness.ts capture the four non-obvious
interactions discovered during the 2026-06-09 retest:
- longPress: SessionActionMenu is triggered by 500ms press, not click
- mockOffline: useOnlineStatus hook listens to navigator.onLine +
window offline event, not CDP Network.emulateNetworkConditions
- pollForText: thinking indicator flickers in <1s, 3s polling misses
- isVisible: element.offsetParent returns null for position:fixed
dialogs even when visible; use getBoundingClientRect
Plus Chrome lifecycle (startChrome/stopChrome, never pkill chrome)
and hub API helpers (loginWithToken, listSessions).
5 integration specs (e2e/integration/) cover:
- yolo-permission: toggle + localStorage persistence (4 cases)
- codex-dialog: pre-flight check + dialog render (3 cases)
- stress: 10 concurrent + invalid JWT + malformed + unknown
endpoint (5 cases, all PASS)
All 12 integration cases pass. Full E2E results in
.xzy-harness/2026-06-09-full-e2e-retest/ (67 cases, 0 functional
bugs found).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: resolve Pi model selection and thinking level issues
- Fix PiModelPanel: use provider+modelId composite for selection check
and React key, preventing duplicate highlights for same-name models
across different providers
- Fix PiThinkingLevelPanel: unify thinkingLevelMap filtering logic by
extracting shared isThinkingLevelSupported utility
- Fix HappyComposer: auto-reset effort to highest supported level when
switching models, update label to reflect effective level
* refactor: remove 29 dead exports from feat-pi-support
Remove unused types, methods, and re-exports identified by dead code audit:
shared/src/apiTypes.ts (19):
- SessionModelIdentifier, ListPiCommandsResponse
- PiSteeringMode, PiFollowUpMode, PiSteerResponse, PiFollowUpResponse
- PiQueueModeResponse, PiMessageEntry, PiMessagesResponse
- PiCompactResponse, PiSetAutoCompactionResponse
- PiForkResponse, PiForkMessageEntry, PiForkMessagesResponse
- PiCloneResponse, PiSwitchSessionResponse
- PiSessionStats, PiSessionStatsResponse, PiExportHtmlResponse
cli/src/pi/types.ts (6):
- PiSessionStats, PiCompactionResult, PiForkMessageEntry (dead local duplicates)
- PiCommandsResponse, PI_THINKING_LEVELS, PI_THINKING_LEVEL_LABELS (dead re-exports)
cli/src/pi/piMessageAccumulator.ts (1):
- flushIfActive() method (comment claimed runPi calls it, but it doesn't)
cli/src/pi/piTransport.ts (1):
- isRunning() method (never called in production code)
web/ (2):
- ProviderGroup, PiThinkingLevelOption (unnecessary exports, made local)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: resolve 7 PR review issues in Pi support
#3 Remove duplicated PI_THINKING_LEVELS in schemas.ts, import from @hapi/protocol
#2 Add piAvailableModels field to MetadataSchema (schema-runtime consistency)
#6 Replace hardcoded flavor names with supportsEffort() in effort route
#1 Move PiRpcResolver from module-level singleton to PiSession instance
#4 Add piCachedModels fallback in piModelOptions useMemo
#7 Merge message_update dead branch into unified not-converted case
#10 Fix misleading Pi model list comments in modelOptions.ts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: normalize Pi model object to string in hub sessionCache (#5), remove extra blank line in rpcGateway (#8)
#5: applySessionConfig now extracts modelId from { provider, modelId }
before passing to setSessionModel / session.model, preventing
[object Object] from being stored in SQLite when Pi switches models.
#8: Remove double blank line before RpcGateway class declaration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(pi): preserve piAvailableModels on resume, document SetSessionConfig divergence
- sessionFactory: preserve piAvailableModels in pickExistingSessionMetadata
so web shows cached models on inactive-session view without RPC round-trip
- sessionConfigRpc: extend resolveNullableSessionModel to accept
{provider, modelId} object form for schema consistency
- runPi: document why Pi manually registers SetSessionConfig instead of
reusing registerSessionConfigRpc (wire protocol needs separate fields)
- package.json: restore version to 0.20.0
* refactor: remove unused Pi types, extract JsonLineParser, clean up review findings
- Remove 13 unused PiRpcCommand variants and PiStreamingBehavior type (YAGNI)
- Remove unnecessary exports on 3 internal Zod schemas in pi/schemas.ts
- Extract JsonLineParser base class to utils/, shared by PiTransport,
CodexAppServerClient, and AcpStdioTransport (eliminates 3x duplicate
handleStdout buffer logic)
- Remove DEV-only duplicate session ID detection from SessionList.tsx
(debug code unrelated to Pi support scope)
- Add comments explaining key prefix rationale in SessionChat.tsx
* chore: remove unrelated E2E test harness from Pi support PR
E2E harness (codex-dialog, stress, yolo-permission, scratchlist specs)
was introduced in this branch but tests generic HAPI behavior unrelated
to Pi agent support. Should live in a separate PR.
* fix: wrap cursor model change handler for union type compatibility
* fix: apply startup --model to Pi and remove duplicate lockfile entry
1. --model startup bug:
- Add initialModel to PiSession to preserve startup model
- handleGetState preserves initialModel instead of overwriting with Pi default
- get_available_models handler resolves provider from cached models and sends set_model
2. bun.lock duplicate key:
- Remove duplicate @twsxtd/hapi-win32-x64@0.20.0 entry
- Fixes CI lockfile regeneration that caused hono type errors
* fix: update test expectation for effort endpoint error message
* fix(pi): resolve 8 link-review defects + abort session termination
- W1C-D-1: hasSameAgentSessionIds missing piSessionId/kimiSessionId
+ extractAgentSessionId also needs piSessionId recognition
- D-1: dispatchLocalResume pi branch missing effort param
- W1B-1-01: buildCliArgs only passes --effort for claude, not pi
- W2B-D-2: effort=null does not send set_thinking_level to Pi
- D-3: turn_start does not consume pendingLocalIds
- D-7: keep_alive falls into default case in convertPiEvent
- D-9: finally overwrites sessionEndReason set by Switch/Abort
- W2B-D-3: ListPiModels RPC does not update metadata
- Abort handler: remove cleanupAndExit, only cancel current turn
Also: Switch handler returns { success: true } for consistency
Test coverage: 13 new test cases across 5 files
* fix: restore cli version from integration test placeholder
* fix(pi): send restored thinking level to Pi subprocess on startup
opts.effort was stored in piSession.currentThinkingLevel but never
forwarded via set_thinking_level during the startup sequence, causing
runner-spawned and resumed sessions to show the restored effort in
HAPI while Pi kept its default.
* fix: restore cli package version from integration test residue
* fix(pi): switch-to-remote handler preserves session instead of terminating
Replace lifecycle.cleanupAndExit() with createModeChangeHandler + keepAlive
in the Switch RPC handler. Pi runs as a single long-lived subprocess
without BaseLocalLauncher's restart loop, so cleanupAndExit() permanently
destroyed the session on mode switch. The web handoff button now correctly
changes control mode while keeping Pi alive.
* fix(pi): remove permission mode selector (Pi RPC has no runtime switching)
Pi's --mode rpc is non-interactive and auto-approves all tool execution;
there is no set_permission_mode command in the protocol. The selector
reported success without changing Pi's behavior, misleading users.
Remove the concept across all four packages:
- shared: getPermissionModesForFlavor('pi') returns [] (cascades to
hub 400 + web UI auto-hide via length===0 guards); drop
PI_PERMISSION_MODES / PiPermissionMode
- cli: strip permissionMode from PiSession/runPi/pi command/resume;
drop the no-op SetSessionConfig permission branch that stored state
without forwarding to the subprocess
- web: delete PiPermissionPanel.tsx; remove panel block + imports
from HappyComposer
* fix(cli): realpath workspace root in apiMachine test assertion
The handler realpaths the cwd as a symlink-escape guard, so on macOS
/var/folders/... resolves to /private/var/folders/... The test compared
against the un-resolved path and failed on macOS. Use realpathSync on
the expected value for cross-platform consistency (no-op on Linux where
/tmp has no symlink prefix).
* fix(pi): keepalive reads current mode instead of constructor-time startingMode
The Switch handler updated controlledByUser but PiSession.pushKeepAlive()
still emitted the readonly startingMode every 2s, so a runner-started
session switched to local would flip back to remote on the next keepalive.
Replace readonly startingMode with a mutable mode field; add setMode()
that updates it and re-pushes keepAlive immediately. The Switch RPC
handler now calls setMode() before handleModeChange.
* fix(pi): runner no longer passes permission flags to Pi subprocess
After removing the Pi permission selector, the Pi command parser rejects
--permission-mode and ignores --yolo. But the shared buildCliArgs tail in
the runner still appended these flags for Pi sessions, making runner-
spawned Pi children exit before registering a session.
Guard the permission/yolo append with agent !== 'pi'.
* fix(pi): preserve provider identity when persisting selected Pi model
The hub's applySessionConfig normalized Pi's { provider, modelId } object
down to a plain modelId string for the shared session.model field, losing
the provider. On reload or next render, web's selectedPiModel lookup
matched by modelId alone — if two providers share a modelId, the wrong
one was highlighted, and subsequent model/thinking-level changes sent the
wrong provider to the Pi subprocess.
Add a provider-qualified piSelectedModel field to session metadata
(schema + persistPiSelectedModel mirroring persistPreferredPermissionMode).
Web's selectedPiModel now prefers the provider-qualified match and only
falls back to modelId-only matching when absent.
* fix(pi): model picker checkmark follows provider-qualified selection
selectedPiModel already resolves via provider+modelId, but the model
panel's currentPiModel still matched by modelId alone — so with two
providers sharing a modelId the checkmark pointed at the wrong row.
Reuse selectedPiModel directly.
* fix(pi): steer messages consumed immediately, not queued in pendingLocalIds
onUserMessage unconditionally pushed localId into pendingLocalIds, but a
steer (sent while piIsStreaming) does not start a new turn — so the
steer's localId was never drained by turn_start. The next normal prompt's
turn_start would consume the stale steer localId instead, leaving the
new prompt's bubble stuck in the queued bar.
Only queue localId for the prompt path. Steer path emits
messages-consumed immediately.
* fix(pi): clear stale thinking level when switching to non-reasoning model
The model-change effect early-returned when selectedPiModel.reasoning ===
false, leaving the previously-set effort (e.g. 'high') persisted on the
session. The UI hid the thinking picker for the non-reasoning model, but
the hub still forwarded the stale effort as set_thinking_level — with no
visible control to clear it.
Call onEffortChange(null) for non-reasoning models.
* fix(pi): return provider-qualified model in SetSessionConfig applied
The CLI handler returned only currentModel (bare string), so the hub's
applySessionConfig saw a non-object model and cleared
metadata.piSelectedModel via persistPiSelectedModel(session, null) —
undoing the provider that was just stored on the inbound config.
Return { provider, modelId } when both are known so the hub keeps the
provider-qualified metadata intact across active model changes.
* fix(pi): preserve piSelectedModel in bootstrapExistingSession metadata
The metadata whitelist rebuild kept piAvailableModels but omitted
piSelectedModel, so the first resume/local-handoff update dropped the
provider identity — after which web fell back to modelId-only matching
and could select the wrong provider for duplicate modelIds.
* fix(pi): await Pi confirmation before reporting model/effort applied
SetSessionConfig was fire-and-forget — transport.send wrote JSONL to
stdin and returned immediately. If Pi rejected an invalid provider/model
or thinking level, the hub still persisted the new value and the UI
reported success while Pi kept the old runtime state.
Use sendPiRpcAndWait so a failed set_model/set_thinking_level rejects
the web request and leaves the session config unchanged.
* fix(pi): resolve set_model RPC so awaited model switch does not time out
SetSessionConfig awaits sendPiRpcAndWait(set_model) before reporting the
model applied, but handleResponse's set_model branch updated state and
fell through without calling resolvePendingRpc. The pending RPC promise
then waited the full 10s timeout and rejected, making /sessions/:id/model
return 409 even though Pi accepted the change. Mirror every other branch
by resolving the pending RPC after updating currentModel/currentProvider.
* fix(pi): drain pending localId on turn_start only; throw when set_model suppressed
- loop.ts: split agent_start/turn_start branches. Pi emits both per prompt;
draining on both popped the FIFO twice and shipped an undefined localId to
the hub. agent_start now only sets thinking state; turn_start drains.
- runPi.ts: when set_model is suppressed (provider unknown), throw instead of
silently returning applied, so the hub returns 409 rather than persisting a
piSelectedModel Pi never received.
- loop.test.ts: assert agent_start does not drain; add regression test that a
single turn drains exactly one real localId.
* fix(pi): exclude Pi from generic Ctrl/Cmd+M model cycler
SessionChat fed piModelOptions into HappyComposer.availableModelOptions,
so the global Ctrl/Cmd+M shortcut ran getNextModelForFlavor over the Pi
list and called onModelChange with a bare modelId string. Pi needs
{ provider, modelId } to disambiguate duplicate model IDs across
providers; a bare string made runPi fall back to the first cached
provider match (wrong provider) or throw when the provider was unknown.
Drop the piModelOptions useMemo and pass undefined for Pi, mirroring
modelOptions.ts where the Pi branch already returns the current model
unchanged (no-op) when no custom options are supplied. Pi model changes
now go only through the dedicated provider-qualified picker (piModels).
* fix(pi): commit PiSession config only after Pi confirms the RPC
SetSessionConfig previously mutated piSession.currentModel /
currentProvider / currentThinkingLevel BEFORE awaiting
sendPiRpcAndWait(set_model / set_thinking_level). When Pi rejected the
value or the RPC timed out, the handler threw and the route returned
409, but PiSession kept the unconfirmed values; the 2s keepalive then
reported them back to the hub, where handleSessionAlive persisted a
model/effort Pi never accepted.
Resolve the requested model/effort into locals first, send the RPCs,
and only commit to PiSession after each await resolves. The null
(clear-model) path needs no RPC so it still commits immediately; the
unknown-provider path still throws without committing.
* fix(pi): apply startup model only after Pi confirms set_model
Two startup paths persisted the requested --model before Pi confirmed it:
1. handleGetState set session.currentModel = session.initialModel as soon
as get_state returned, using the unconfirmed startup model instead of
Pi's actual default. If the model was unavailable or rejected, the 2s
keepAlive reported it to the hub, which persisted/showed a model Pi
never accepted.
2. get_available_models then sent set_model fire-and-forget, so a Pi
rejection was never observed and currentModel stayed on the bad value.
Fix: handleGetState now reports Pi's real current model (newModel) while
a startup model is merely requested. get_available_models resolves the
provider from the cached list, awaits set_model, and commits
currentModel/currentProvider only on success — on rejection it logs and
keeps Pi's default. The await is fired detached so the
get_available_models RPC itself still resolves for ListPiModels.
* fix(pi): do not persist startup model before Pi confirms set_model
The startup --model still reached the hub unconfirmed via two paths the
previous Fix #13 left open:
1. bootstrapSession({ model: opts.model }) seeded the hub session model
at creation time, and SessionCache.handleSessionAlive persists every
non-undefined keepAlive model — so an unavailable/rejected model was
stored and shown before get_available_models/set_model ran.
2. PiSession constructor set this.currentModel = opts.model, so the very
first keepAlive (sent by startKeepAlive before any RPC confirms the
model) reported the unconfirmed value.
Pass model: undefined to bootstrapSession and start PiSession.currentModel
at null; opts.model is still captured as initialModel and applied/committed
only after get_available_models confirms it exists and set_model succeeds
(Fix #13). The hub now sees Pi's real current model from the first
get_state keepAlive and switches to the requested model only once accepted.
Also add sendPiRpcAndWait contract tests pinning the await<->resolve
symmetry (Fix #10): set_model/set_thinking_level/get_available_models must
resolve before timeout on a success response, and reject on a Pi error.
* fix(pi): apply startup effort only after Pi confirms set_thinking_level
runPi restored opts.effort straight into piSession.currentThinkingLevel
before startKeepAlive ran, and pushKeepAlive persists effort — so a
resumed/runner-spawned session could store/show a thinking level Pi
rejected or ignored. This is the effort analog of the startup-model
confirmation contract (Fix #13/#14).
Capture the requested effort into a local startupThinkingLevel instead of
mutating currentThinkingLevel up front. After transport.start() and the
get_state/get_available_models/get_commands sends, await set_thinking_level
and commit currentThinkingLevel + push a keepAlive only on success; on
rejection keep Pi's default (already reported by get_state). The await is
detached so the run loop is not blocked, and get_state is sent before the
set so its authoritative baseline lands first and cannot clobber the
confirmed value.
* fix(pi): omit unknown runtime config from keepalive, don't clear persisted state
Fix #14 changed PiSession.currentModel to start at null so the startup
--model was not leaked before confirmation. But the hub treats keepAlive
model:null as an explicit clear (sessionCache.ts only skips when the
field is undefined), so the first heartbeat (startKeepAlive runs before
get_state) now erased a resumed Pi session's persisted model/effort
before Pi reported its real state.
Distinguish "unknown" from "clear": currentModel/currentThinkingLevel
start undefined and keepAlive omits undefined fields (via
getKeepAliveRuntime), so the hub leaves persisted values alone until Pi
confirms. null remains an explicit clear and is still forwarded. Once
get_state/set_model/set_thinking_level confirm a value it is set and
reported normally.
* fix(pi): disable Ctrl/Cmd+M model cycler for Pi entirely
Fix #11 removed piModelOptions from availableModelOptions, assuming
getNextModelForFlavor('pi', model, undefined) was a no-op. It is not:
the Pi branch returns normalizeCurrentModel(model), i.e. the current
modelId as a bare string, so the shortcut still called onModelChange with
a bare modelId. That loses the provider and can pick the wrong cached
match, clear the model when session.model is empty, or hit 'provider is
not yet known'. Short-circuit the handler for Pi so model changes go only
through the dedicated provider-qualified PiModelPanel.
* fix(pi): persist piSelectedModel from get_state and startup set_model paths
Pi stores session.model as the bare modelId and relies on
metadata.piSelectedModel ({ provider, modelId }) to disambiguate
duplicate modelId values across providers in the web picker and
thinking-level filtering. But piSelectedModel was only written by the web
/sessions/:id/model path (hub persistPiSelectedModel). The runtime paths
that set currentModel/currentProvider — get_state, the startup
get_available_models set_model, and the set_model response — only
keepAlive'd the bare modelId, so a Pi session on Pi's default model,
resumed from CLI, or started with --model had no provider identity in
metadata and could render/filter against the wrong provider.
Add persistSelectedPiModel(session) (no-op unless both fields are known)
and call it after get_state, after a successful startup set_model, and
after the set_model response updates the fields. This mirrors what the
web picker already does.
* fix(pi): default startingMode to remote — Pi has no local TUI path
A terminal `hapi pi` launch defaulted to startingMode 'local' and marked
the session controlledByUser, but Pi only runs as `pi --mode rpc` with
piped stdio — there is no local terminal/TUI input path like Claude/Codex
have. The terminal user could not drive the session and the web treated
it as local-controlled, so the first terminal Pi session was stuck until
manually switched from the web.
Default to 'remote' so the session is immediately drivable from the web.
An explicit opts.startingMode (runner path) still takes precedence.
* fix(pi): resume with remote startingMode — no local TUI path
The previous Fix #19 changed the `hapi pi` default to remote, but
`hapi resume` still passed startingMode: 'local' into runPi for Pi
sessions, re-introducing the same unsupported local-control state on the
resume path: setControlledByUser publishes controlledByUser while Pi has
no terminal/TUI input, hiding/rejecting remote-only controls until a web
switch. Pass 'remote' here too and update the resume test accordingly.
* fix: restore e2e/scratchlist.spec.ts deleted from main by mistake
The earlier "remove unrelated E2E harness" commit (d1e5b4c) deleted the
whole e2e/ directory this branch had added, but scratchlist.spec.ts is a
main-branch Playwright spec (the only file under playwright testDir
./e2e). Its removal left `bun run test:e2e` with no tests to run while
the script and playwright.config.ts still point at that directory.
Restore scratchlist.spec.ts from main; the unrelated harness files
(HARNESS.md, harness.*, integration/*.mts) that were genuinely
branch-only additions stay removed.
---------
Co-authored-by: pi <pi@local>
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
c311afddca |
fix(codex): Fast mode (service tier) toggle + /fast command (closes #898) (#904)
* test: reproduce issue #898 (Codex fast mode service tier) * fix(codex): add Fast mode (service tier) toggle and /fast command (closes #898) * feat(codex+web): Fast mode UI toggle with full persistence Wires the Codex Fast mode (service tier) end-to-end so it can be toggled from the web composer and survives reload/handoff: - shared: serviceTier on Session/SessionPatch, session-alive payload, resume target, and a SessionServiceTierRequest schema - cli: AgentSessionBase carries serviceTier through keepAlive; runCodex syncs it to the session instance - hub: service_tier column (schema v10 + migration), store setter, sessionCache + syncEngine plumbing, POST /sessions/:id/service-tier - web: api.setServiceTier + mutation, a Fast/Standard toggle in the composer settings (gated to Codex GPT-5.5/5.4), and StatusBar now reflects the real tier instead of the effort heuristic Refs #898 * fix(codex): preserve unset/persisted service tier on startup keepalive Addresses HAPI Bot [Major] on PR #904: applyCurrentConfigToSession ran setServiceTier(currentServiceTier ?? null) on wrapper-ready, collapsing the untouched `undefined` state into explicit Standard. The immediate setCollaborationMode keepalive then persisted serviceTier: null, silently downgrading resumed Fast sessions and disabling account-default Fast. - Seed currentServiceTier from the persisted session (sessionInfo.serviceTier), so a resumed Fast thread keeps running Fast. - Only call setServiceTier when the tier is explicit (!== undefined), preserving the three-state omit semantics at the keepalive boundary. - Add regression tests: persisted Fast is re-asserted; untouched omits the tier. * feat(codex+web): gate Fast toggle on catalog-advertised service tier The Fast toggle was gated on a model-name regex (gpt-5.5/5.4), which still showed a no-op control to API-key users — Fast credits only apply with ChatGPT login. Codex's model/list catalog advertises the service tiers actually available for each model in the current auth/plan context, so gate on that instead: - cli: capture serviceTiers (ids) per model in ModelListItem + normalizeModel - shared: CodexModelSummary.serviceTiers (flows through the existing getSessionCodexModels pass-through; no hub change needed) - web: codexModelAdvertisesFastTier(sessionModel, models) replaces the regex; SessionChat gates the toggle on it (hidden while the catalog is loading/errored). The toggle now only appears when toggling it will actually take effect. Refs #898 * fix(codex): make explicit Standard service tier sticky across resume Addresses HAPI Bot [Major] (round 2): a single persisted null conflated "untouched" with "explicit Standard". A user who turned Fast off persisted null, but startup mapped null -> undefined (untouched) and omitted serviceTier, so an account/thread-default Fast could silently return after restart/resume. Introduce a distinct stored representation: - 'fast' / 'standard' are explicit user choices; null/undefined = untouched. - Translate 'standard' -> Codex app-server serviceTier: null ONLY when building thread/turn params (toAppServerServiceTier); untouched omits the field. - /fast off now stores 'standard'; the web Standard option sends 'standard'. - Tighten SessionServiceTierRequest to enum(['fast','standard']) so stray tier strings are never forwarded. Tests: sticky-Standard-on-resume regression; turn/thread params translate 'standard'->null and omit on untouched; hub route applies fast/standard and rejects unsupported values + local sessions. Refs #898 * fix(codex): recognize real Fast tier (id 'priority', name 'Fast') in catalog gate Live E2E against an authed Codex session revealed the model catalog advertises the Fast tier with id 'priority' and display name 'Fast' (not id 'fast'), so the /fast/i gate — which only saw tier ids — wrongly hid the toggle for valid ChatGPT users on gpt-5.5/gpt-5.4. Capture both the tier id and name as lowercased tokens so the existing name-based match recognizes 'Fast'. The sent value stays 'fast' (the documented service_tier value / raw additionalSpeedTiers request tier). Verified end-to-end: gpt-5.5/gpt-5.4 gate on, gpt-5.4-mini off. Refs #898 * fix(codex): preserve service tier across session resume Resuming a Codex session spawns a fresh session (serviceTier null) and merges the old one in. Unlike model/effort/permissionMode, serviceTier was neither threaded through the resume spawn nor preserved in mergeSessionData, so a resumed Fast (or explicit Standard) session silently reverted to the account default. Thread serviceTier through the spawn path like its siblings: - hub: resumeSession passes session.serviceTier to spawnSession; rpcGateway + syncEngine carry it in the spawn RPC payload; mergeSessionData preserves it old->new (safety net). - cli: SpawnSessionOptions.serviceTier; apiMachine forwards it; buildCliArgs emits --service-tier for codex; the codex command parses it; runCodex seeds currentServiceTier from the spawn override first (opts.serviceTier ?? sessionInfo.serviceTier), so a resumed thread immediately runs the right tier. Verified end-to-end: set Fast -> kill process -> reopen -> resumed session (new id) still runs Fast. Tests: buildCliArgs --service-tier (codex only), runCodex spawn-override seed, mergeSessionData service-tier preservation. Refs #898 * fix(codex): send advertised 'priority' tier id for Fast, not 'fast' The model catalog advertises the Fast tier with request id 'priority' (display name 'Fast'), and OpenAI docs confirm service_tier='fast' maps to the request value 'priority'. The app-server serviceTier override is a raw request value that does not validate unknown strings (a live probe accepted 'bogus-xyz'), so sending 'fast' risks being silently ignored — no Fast applied. Translate the stored 'fast' state to app-server 'priority' at the thread/turn param boundary (toAppServerServiceTier); the stored/UI/command representation stays 'fast'/'standard'. Verified live: a turn with serviceTier='priority' runs and consumes the Fast-tier rate budget. Addresses HAPI Bot [Major]. Refs #898 * fix(codex): validate --service-tier CLI value (fast|standard) Addresses HAPI Bot [Minor]: the internal --service-tier spawn arg accepted any non-empty string, unlike the web /service-tier enum, so a malformed value could be seeded into currentServiceTier and persisted via keepalive. Parse it to 'fast'|'standard' and reject anything else, matching the web endpoint. Refs #898 |
||
|
|
93d004148d |
feat: support Claude Code 'auto' permission mode (closes #858) (#879)
Add 'auto' as a first-class HAPI permission mode for claude-flavored sessions, enforced by Claude's classifier rather than emulated in canCallTool. Includes mode configuration, CLI respawn on auto transitions, plan-exit targeting, API extensions, and documentation updates. |
||
|
|
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> |