* refactor: add invoked_at column and propagate via messages-consumed
- Bump hub schema to V8: add `invoked_at INTEGER` to messages table
- Add `migrateFromV7ToV8` (idempotent ALTER TABLE ADD COLUMN)
- Add migration chain entries for V4/V5/V6/V7 → V8
- Expose `StoredMessage.invokedAt: number | null` and `markMessagesInvoked`
- Record server-side `Date.now()` in hub on `messages-consumed` socket event
- Propagate `invokedAt` through SSE (`messages-consumed` payload)
- Update `markMessagesConsumed` in web store to accept and store `invokedAt`
- Preserve optimistic `invokedAt` in `mergeMessages` (server echo path)
- Add migration unit tests (fresh V8, V7→V8 ALTER, markMessagesInvoked)
* feat(web): float queued messages above composer until invocation
Show queued (uninvoked) user messages in a dedicated floating bar above
the composer instead of inline in the thread timeline. Once the CLI acks
the batch via messages-consumed, the bar disappears and the messages
appear in the thread at their invocation position (invokedAt ordering).
- Add QueuedMessagesBar component: subscribes to message-window-store,
filters user messages with invokedAt==null, shows clock icon + text
preview; disappears when all messages are invoked
- Filter queued messages from thread (visibleMessages), sort by
invokedAt ?? createdAt so invoked messages land at the right position
- Extend markMessagesConsumed to update server-loaded messages (status
undefined) in addition to optimistic (status 'queued'), enabling
multi-device and post-refresh scenarios
- Remove opacity-60 from UserMessage: queued messages no longer appear
in the thread so the dimming branch is unreachable
- Include invokedAt in getMessagesPage/getMessagesAfter API responses
so the web client can restore floating-bar state after page refresh
- Add invokedAt field to DecryptedMessageSchema for shared protocol type
* fix(hub,web): make sort use invokedAt and V8 backfill idempotent
- compareMessages: prioritize invokedAt/createdAt over seq so invoked
messages land at their invocation position rather than their
send-time seq position
- migrateFromV7ToV8: move backfill outside the ALTER guard so it
re-runs if a previous attempt crashed between ALTER and UPDATE
before the user_version bump (idempotent WHERE invoked_at IS NULL)
* fix(hub,web): cover localId-less messages and live-ack invokedAt
- addMessage: messages without a localId have no ack path
(markMessagesInvoked matches by localId). Treat them as
already-invoked at insert time so they land in the thread instead of
sitting in the queued floating bar forever.
- markMessagesConsumed: apply the ack even when the message is already
'sent' optimistically, so the live window receives invokedAt instead
of waiting until a full refetch.
* fix(hub): propagate invokedAt in live message-received SSE payload
The SSE `message-received` event omitted `invokedAt` while REST
pagination included it, so localId-less CLI/local user messages arrived
on the live wire as queued (`invokedAt == null`) and stayed in the
floating bar until a full refetch replaced them with the stored row.
* fix(hub): propagate invokedAt in CLI socket message-received handler
The CLI socket 'message' handler fans out to web via a separate
`onWebappEvent` publisher; the previous fix only touched the
`MessageService` publisher. Aligns the live SSE payload shape with
the REST/page-load shape so localId-less CLI/local user messages with
`invokedAt = createdAt` (set in addMessage) reach web filters with the
field already populated, instead of being misclassified as queued
until a full refetch.
* fix(hub,web): add byPosition pagination to fix long-session queued message loss
Pagination used seq-based windows, so queued messages with low seq but late
invokedAt fell outside the visible window on refresh. Fix by adding a V8
byPosition mode that orders by COALESCE(invoked_at, created_at) DESC, seq DESC
with a composite cursor, while keeping the V7 seq path fully intact for
backward compatibility.
- hub/store/index: add idx_messages_session_position (createSchema + V7→V8 migration)
- hub/store/messages: add getMessagesByPosition with composite cursor SQL
- hub/store/messageStore: delegate getMessagesByPosition
- hub/sync/messageService: add getMessagesPageByPosition with nextBeforeAt response
- hub/sync/syncEngine: expose getMessagesPageByPosition
- hub/web/routes/messages: byPosition=1 query param dispatches to V8 path
- web/types/api: MessagesResponse.page gains optional nextBeforeAt
- web/api/client: getMessages gains byPosition + beforeAt options
- web/lib/message-window-store: fetchLatestMessages/fetchOlderMessages use V8
composite cursor; fallback to seq cursor when hub returns no nextBeforeAt
- hub/store/migration-v8.test: 7 new tests covering position sort, composite
cursor pagination, long-session scenario, V7 compat, and index existence
* fix(hub,web): re-sort on consume and use position cursor for next fetch
- markMessagesConsumed: re-merge with empty list to re-sort by position
key after invokedAt is set. A queued user message becomes visible
with the consume event; without re-sort it stays at its send-time
array slot until the next fetch overwrites it.
- getMessagesPageByPosition: pick the cursor from stored[0] (oldest in
position order) instead of scanning for minimum seq. With the page
already in ascending position order, scanning for min seq could land
on a low-seq, late-invoked row that is actually the newest in the
page, causing the next older fetch to overlap.
* fix(web): trust invokedAt as the only invocation signal and pin cursor pair
- visibleMessages predicate (SessionChat + QueuedMessagesBar): drop the
status === 'sent' check. status='sent' only means the REST write
returned, not that the CLI consumed the message; an optimistic 'sent'
with no invokedAt is still queued. invokedAt is the single source of
truth for invocation.
- byPosition cursor: track oldestPositionSeq alongside oldestPositionAt
so the server's cursor pair travels through the next older fetch
unchanged. Recomputing beforeSeq from the local window's minimum seq
could combine it with a server beforeAt that referred to a different
row, causing the SQL cursor to skip or overlap.
* fix(hub): include uninvoked local messages in latest page
Long sessions can push a queued user message (invokedAt = null, sort key
= createdAt) outside the latest position-ordered page once the agent
emits more than `limit` later rows. A refresh or secondary client then
never receives the row, the floating bar stays empty, and the later
`messages-consumed` event only carries localIds — there is no way to
materialize the missing row at invocation time.
Pin uninvoked local user messages to every latest-page response
out-of-band. The pagination cursor still anchors to the position-ordered
page rows, so older-page fetches are unaffected.
* fix(web): preserve queued messages across trimVisible
The visible-window trim drops the oldest entries beyond
VISIBLE_WINDOW_SIZE, but a queued user message (invokedAt = null) sorts
by send time and is the oldest item. Once a long agent stream pushes
it past the window the row is gone from the client store, and the
`messages-consumed` SSE carries only localIds — there is no way to
restore or reposition the dropped row without a full refetch.
Pull queued rows out before slicing the regular budget, then merge
them back in. Queued rows are bounded by composer/CLI queue depth and
do not meaningfully grow the window.
* fix(web): use strict null for queued check and fall back invokedAt
- Optimistic message sets invokedAt: null explicitly so the strict-null
queued check matches the local opt-in. Pre-V8 hub responses that
omit the field (`undefined`) are treated as already-invoked and
stay in the thread instead of being misclassified as queued.
- markMessagesConsumed: when the consume SyncEvent omits invokedAt
(older hub) fall back to client time, otherwise a message that
receives an ack with no server timestamp stays queued forever under
the new strict-null filter. The persisted server value is still
authoritative on next fetch.
* fix: comprehensive invokedAt propagation hardening (review feedback batch)
Bot review surfaced 11 propagation bugs incrementally; this batch fixes
9 additional adjacent issues found by hostile-review to break the
incremental discovery cycle:
- legacy DB (user_version=0 with HAPI tables): step ladder runs V1→V8
before createSchema so pre-existing tables get all later columns/indexes
- step ladder includes V1/V2/V3 entries; previously V1-V3 DBs threw
- mergeSessionMessages collision branch forces invoked_at = created_at
so unmergeable rows can't strand in the floating bar
- session-end auto-invokes still-queued user messages and broadcasts
messages-consumed; the floating bar no longer pins ghost rows after
the CLI is gone
- trimPending preserves queued rows symmetrically with trimVisible
- markMessagesInvoked is first-write-wins; duplicate acks are no-ops
rather than re-stamping invoked_at and reordering the thread
- markMessagesConsumed migrates just-acked pending entries into the
visible thread so non-at-bottom users see their own messages without
scrolling
- mergeMessages dedup window compares by position key (invokedAt ?? createdAt)
instead of createdAt only, so late-invoked optimistic copies don't
duplicate the server echo
- isQueuedForInvocation centralized in lib/messages.ts (single
predicate used by SessionChat, QueuedMessagesBar, and the store)
* fix(web): mirror hub's first-write-wins on markMessagesConsumed
The hub's markMessagesInvoked is first-write-wins, but the web store
was still overwriting any non-null invokedAt with the latest
messages-consumed timestamp. A duplicate ack (CLI re-emit) would leave
the SQLite row at the original timestamp while live clients moved
the message to the duplicate ack time, diverging until refetch.
Mirror the guard: only set invokedAt when it is null.
* fix: in-scope hostile-review polish
Web:
- fetchLatestMessages: persist the V8 composite cursor pair on the
non-at-bottom branch too. Without this, a refresh while scrolled
up dropped the cursor and the next loadMore fell back to V7 seq
mode against a V8 hub — same asymmetric class of bug commit
30df6b2 fixed for the at-bottom path.
- markMessagesConsumed: tighten the loose-null check on invokedAt
to strict null, consistent with isQueuedForInvocation and the
rest of the file. The idSet filter already shields V7-stamped
rows from this path, but the strict-null contract should not
vary by call site.
- messages: drop the upsertMessagesInCache export. It has no
callers (verified with grep) and is the only user of the
InfiniteData / MessagesResponse imports, so the imports go
with it.
Hub tests:
- migration-v8.test.ts: add a session-end auto-invoke test
(getUninvokedLocalMessages + markMessagesInvoked clears every
queued row and stamps them all with the same invokedAt) and
two byPosition union tests covering (1) a low-position queued
row pushed out of the latest page is still surfaced via the
uninvoked set, and (2) pageRows[0] is the oldest row in the
page so the web client can safely anchor the next-older
cursor on it.
* fix(hub,web): bot-13 polish — atomic SSE on DB success and attachment chip text
- sessionHandlers messages-consumed: emit messages-consumed only after
markMessagesInvoked succeeds. Otherwise a transient SQLite failure
would broadcast an invokedAt that was never persisted; live clients
would hide the queued rows while a refresh / secondary client would
see them as queued again, diverging the state.
- QueuedMessagesBar: fall back to attachment filenames when the
message text is empty. The composer / POST /messages allow
attachment-only sends; without the fallback those queued messages
rendered as blank chips until invocation.
* feat(cli): support extra headers for hub requests
* fix(types): normalize missing session fields to null
* refactor(cli): simplify socket extra headers config
* fix(hub,web): deduplicate sessions by agent session ID
When multiple CLI wrappers independently resume the same Codex thread,
each generates a random tag, causing the hub to create duplicate session
records for a single underlying thread. This leads to duplicate
conversations in the web UI and messages routing to the wrong session.
Add two-layer deduplication:
- Hub: when a metadata update sets an agent session ID (codexSessionId,
claudeSessionId, etc.) that already exists on another session in the
same namespace, automatically merge the duplicate into the current
session using the existing mergeSessions logic.
- Web: deduplicate the session list display by agentSessionId as a
safety net, keeping the active/most-recent session visible.
Closes#446
* chore: add review-driven comments for dedup clarity
- Explain single-threaded assumption in before/after metadata comparison
- Document merge direction rationale (duplicate → active session)
- Document deduplicateInProgress guard as known limitation
- Add catch comment explaining web safety net fallback
* fix: address review feedback from bot, Opus, and Codex
- Skip active duplicates during hub-side dedup to avoid deleting
sessions with live CLI sockets and pending agent state
- Pass selectedSessionId into web dedup sort to prevent hiding
the session the user is currently viewing
- Add test for active-duplicate-not-merged case
* fix: retry dedup on session-end and preserve agentState in merge
- Trigger dedup when a session ends (handleSessionEnd), so active
duplicates skipped during earlier dedup get merged once they disconnect
- Preserve agentState from old session during mergeSessions when the
new session has no agentState (mirrors existing model/effort/todos
preservation pattern)
- Extract triggerDedupIfNeeded helper for reuse across trigger points
* fix(web): prefer active session over selected in dedup sort
Active session always wins the dedup tie-break so the live connection
is never hidden in favor of a selected inactive duplicate. Among
inactive duplicates the selected one is still preferred.
* fix: dedup on inactivity timeout and deep-merge agentState
- expireInactive now returns expired session IDs so SyncEngine can
trigger dedup for sessions that timed out (crash/network drop)
instead of only on explicit session-end
- mergeSessions now deep-merges agentState requests/completedRequests
from both sessions instead of only copying when new is null
* fix: exclude completed requests from merged pending set
Filter out request IDs that already appear in completedRequests when
merging agentState, preventing completed permission prompts from
resurrecting as pending after session dedup.
* fix: guard resume merge against prior auto-dedup
The automatic dedup (triggered when the spawned CLI sets its agent
session ID) can delete the old session before resumeSession reaches
its own explicit mergeSessions call. Skip the merge if the old session
no longer exists instead of failing the resume with a false error.
* test: add coverage for dedup retry paths and web dedup sort
Hub tests:
- session-end triggers dedup retry for previously-active duplicates
- inactivity timeout expiry triggers dedup retry
- agentState deep merge filters completed requests from pending set
Web tests:
- basic dedup by agentSessionId
- active session wins over inactive duplicate
- selected session preferred among inactive duplicates
- active always wins over selected inactive
- sessions without agentSessionId pass through
- independent dedup across different agentSessionIds
* fix: read latest agentState before merge write to avoid overwriting live updates
Re-read the target session's agentState right before writing the merged
result, with a version-mismatch retry loop, so concurrent update-state
events from the active CLI are not lost during dedup merge.
* fix: sort expired sessions by recency before dedup
When multiple duplicates for the same agent thread expire in a single
sweep, process the most recent one first so it becomes the merge target
and survives, rather than keeping the oldest by arbitrary iteration order.
* fix: select most recent session as merge target in dedup
deduplicateByAgentSessionId now collects all inactive candidates
(including the caller) and picks the one with the highest activeAt
(then updatedAt) as the merge target. This ensures the newest session
survives regardless of which trigger point or ordering calls the dedup.
* feat: Add Claude Code Agent Teams support
- Add TeamState schemas and types for team collaboration
- Extract team state from TeamCreate, SendMessage, Task tools
- Add database migration V3→V4 for team_state storage
- Add TeamPanel component to display team members, tasks, messages
- Add team tool icons and presentation rules
- Support vite proxy configuration via VITE_HUB_PROXY env var
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: Add timestamp protection for team_state updates
Prevent old messages from overwriting newer team state by checking
team_state_updated_at before updating.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: Extract team tasks from Task/TaskCreate/TaskUpdate tools
- Enhance processTaskToolWithTeam to also generate task entries from
the Task tool's description field when spawning teammates
- Add processTaskCreate handler for TaskCreate tool calls
- Add processTaskUpdate handler for TaskUpdate tool calls
- Register both new tools in the extraction switch statement
This fixes the gap where the Tasks section in TeamPanel could never
populate because team task data was not being extracted from the
message stream.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: Skip orphan TaskUpdate without title to prevent schema validation failure
When TaskUpdate arrives before TaskCreate (message ordering), skip inserting
incomplete tasks that lack required title field, preventing entire teamState
from being dropped by schema validation.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: Add unit tests for orphan TaskUpdate handling
Verify that applyTeamStateDelta correctly skips inserting tasks without
title field (orphan TaskUpdate) while still allowing normal task creation
and updates to existing tasks.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: tfq <tfq@gmail.com>
Co-authored-by: HAPI <noreply@hapi.run>
* feat(cursor): add support for Cursor Agent CLI integration
- Introduced new command `hapi cursor` to start Cursor Agent sessions.
- Added functionality for resuming sessions and managing permission modes.
- Updated documentation to include Cursor Agent usage and installation instructions.
- Enhanced existing codebase to accommodate Cursor as a recognized agent flavor.
- Implemented local and remote session handling for Cursor Agent.
This update expands HAPI's capabilities by integrating support for the Cursor Agent, allowing users to leverage its features alongside existing agents.
* Remove TODO.md file as it is no longer needed following the integration of Cursor Agent CLI support. This cleanup helps streamline project documentation and reflects the completion of the associated tasks.
* feat(cursor): implement remote mode and fix --hapi-starting-mode
- Consume --hapi-starting-mode in cursor command (do not forward to agent)
- Implement cursorRemoteLauncher: spawn agent -p with stream-json, --trust
- Add cursorEventConverter for NDJSON parsing (system/assistant/tool_call/result)
- Multi-turn via --resume session_id
- Update docs: cursor supports both local and remote modes
Made-with: Cursor
* fix: type error
* fix(cursor): address PR review - model UI, sessionId metadata, duplicate flags
- HappyComposer: use isClaudeFlavor for model mode (cursor has no model modes)
- cursorLocalLauncher: call onSessionFound for resume so cursorSessionId in metadata
- cursorCommand: do not forward parsed flags to cursorArgs (avoid duplicates)
Made-with: Cursor
- Define PROTOCOL_VERSION constant in shared/src/version.ts
- Server sets X-Hapi-Protocol-Version header on /cli/* responses
- Server includes protocolVersion in /health endpoint
- CLI extracts serverProtocolVersion from API responses
- CLI shows version mismatch hints in both directions
- Add tests for error utilities and version extraction
close#104
Implement full support for the request_user_input tool with both Web UI and CLI
components. This includes:
- New view and footer components for request_user_input tool UI
- Tool registration in knownTools and view registries
- Nested answer format support (Record<string, { answers: string[] }>)
- Backward compatibility with flat answer format (Record<string, string[]>)
- Permission handler updates for CLI request_user_input acceptance
- Type definitions and schema updates across shared/cli/server/web packages
- Translation strings for request_user_input UI elements
- Conditional footer rendering in ToolCard for question tools
- Replace .passthrough() with explicit field definitions across all schemas
- Add missing optional fields (homeDir, happyHomeDir, happyLibDir, displayName)
- Refactor RawJSONLinesSchema to use structured base schema for clarity
- Improve schema validation strictness and type safety
- Update Machine interface to reflect explicit fields instead of index signature
Move socket-related type definitions and schemas from cli and server packages
into a shared @hapi/protocol package for centralized type management. This
includes migrating Update types, socket event interfaces (ClientToServerEvents,
ServerToClientEvents), and terminal payload schemas. Add strongly-typed socket
handlers that reference the protocol package instead of duplicating definitions.
Move duplicate isObject, asString, asNumber, and safeStringify functions from multiple modules into a centralized shared/src/utils.ts module and update imports across cli, server, and web packages. This eliminates code duplication and improves maintainability.
Add support for users to select voice language preference, which is passed through to ElevenLabs agents via platform settings overrides. Includes new language mapping utilities, UI controls in settings page, and updated voice session initialization.
Implement a visibility-aware notification system that delivers toast messages via SSE to visible browser tabs instead of sending push notifications, reducing unnecessary push requests. Includes VisibilityTracker for monitoring connection visibility, toast UI components, and enhanced SSEManager with toast delivery capability.
Extract permission mode display logic into shared utilities for better reusability and maintainability. Add PermissionModeTone type and related helpers to centralize mode-based styling rules across components. Update components to use new PermissionModeOption type for consistent permission mode presentation.