docs/plans/cli-v3-messages-api.md
Migrate happy-cli's ApiSessionClient from Socket.IO-based message read/write to the new v3 HTTP endpoints. The client will:
POST /v3/sessions/:sessionId/messages using InvalidateSync to batch outgoing messages from an outbox — fixes the current problem where messages are silently lost on disconnectGET /v3/sessions/:sessionId/messages?after_seq=X with cursor-based polling, triggered by Socket.IO event invalidationlastSeq from server responses, use it for incremental fetchesnew-message event arrives with seq === lastSeq + 1, apply it directly. On gap, invalidate to trigger server fetch.This replaces the current fire-and-forget socket.emit('message', ...) (5 separate send methods all using this pattern) and the direct Socket.IO update event handler for receiving.
packages/happy-cli/src/api/apiSession.ts — ApiSessionClient class (EventEmitter)socket.emit('message', { sid, message: encrypted })):
sendClaudeSessionMessage(body) — Claude JSONL outputsendCodexMessage(body) — Codex messagessendSessionProtocolMessage(envelope) — Session protocol envelopessendAgentMessage(provider, body) — ACP unified format (Gemini, Codex, Claude, OpenCode)sendSessionEvent(event) — Events (switch, message, permission-mode, ready)update event → decrypt → parse as UserMessage → forward to pendingMessageCallback or buffer in pendingMessages arraysendCodexMessage line 275)encrypt(key, variant, data) → Uint8Array → encodeBase64() → string (same format v3 POST expects as content)src/utils/sync.ts (identical to happy-app's version)src/utils/lock.tsapiSession.test.ts[x] immediately when donenpx eslint in packages/happy-cli currently fails because there is no eslint.config.(js|mjs|cjs) in this workspace, so lint verification could not be completed.pendingOutbox: Array<{ content: string, localId: string }> to ApiSessionClient — encrypted messages waiting to be sentsendSync: InvalidateSync to ApiSessionClient — triggers batch send, created in constructorflushOutbox() method: drain all pending messages, POST batch to POST /v3/sessions/:sessionId/messages via axiosenqueueMessage(content: any) method: encrypt content → base64, generate localId (randomUUID), add to outbox, invalidate sendSyncenqueueMessage(content) instead of socket.emit('message', ...)sendSync in close() methodlastSeq: number field (initially 0) to ApiSessionClientreceiveSync: InvalidateSync to ApiSessionClient — triggers fetch from v3fetchMessages() method: GET /v3/sessions/:sessionId/messages?after_seq=lastSeq&limit=100 via axios, loop while hasMorefetchMessages: decrypt each message, update lastSeq from highest seq in responsefetchMessages: filter for UserMessage (role === 'user') and forward to pendingMessageCallback or buffer in pendingMessages — same as current behavior'message' event — same as current behaviorsocket.on('update', ...) handler for new-message: read data.body.message.seq from the updatethis.lastSeq — if seq === lastSeq + 1, decrypt and apply directly (fast path), update lastSeqreceiveSync to trigger server fetchlastSeq === 0 (no messages fetched yet), invalidate receiveSyncupdate-session and update-machine handlers unchanged (they don't use messages API)flushOutbox(), after successful POST, update lastSeq from the highest seq in the responsesocket.emit('message', ...) calls (all replaced by enqueueMessage)flush() method still works (it pings the socket, unrelated to message sending — may need to also await sendSync drain)close() properly stops sendSync and receiveSyncpendingOutbox: Array<{ content: string, localId: string }>
enqueueMessage(rawContent):
1. encrypted = encodeBase64(encrypt(key, variant, rawContent))
2. localId = randomUUID()
3. pendingOutbox.push({ content: encrypted, localId })
4. sendSync.invalidate()
flushOutbox():
1. batch = [...pendingOutbox] // snapshot
2. POST /v3/sessions/:sessionId/messages { messages: batch }
3. On success: clear sent items from outbox, update lastSeq from response
4. On failure: throw → InvalidateSync retries with backoff, messages stay in outbox
fetchMessages():
1. Loop:
a. GET /v3/sessions/:sessionId/messages?after_seq=lastSeq&limit=100
b. For each message: decrypt, route (user → callback, other → emit)
c. Update lastSeq to max seq from response
d. If !hasMore, break
new-message event arrives with msg.seq:
1. if lastSeq > 0 AND msg.seq === lastSeq + 1:
→ decrypt + route directly, set lastSeq = msg.seq (fast path)
2. else:
→ receiveSync.invalidate() → triggers fetchMessages
session-alive (keepAlive) — volatile, ephemeralsession-end (sendSessionDeath) — lifecycleusage-report (sendUsageData) — analyticsupdate-metadata / update-state — uses emitWithAck for optimistic concurrencyrpc-request / rpc-call — bidirectional RPCupdate events for update-session / update-machine — metadata/state updatesping (flush) — connection checkFuture improvements:
GET /v3/sessions/:id/messages/stream)