* fix(agy): say what the model picker is actually waiting on The spinner in the New Session AGY picker read "Checking Antigravity authentication…", but nothing at that point checks authentication — the machine is running `agy models`, and the sign-in prompt is a separate branch below it, shown only when agy reports the failure. Name the wait after the work: "Fetching available models…", the same words agy prints while it fetches. * refactor(agy): describe a probe by its outcome, not by its response The probe function returned a finished `AgyModelsResponse`, so "agy could not be reached" and "agy listed no models" both arrived as a successful response carrying the hardcoded mirror, and the caller could no longer tell which had happened. Every policy decision about that answer has to live inside the probe as a result. Hand back what the probe observed — a live catalog, an auth failure, or nothing usable — and let the caller turn it into a response. Same behaviour: the mirror still stands in for both failure modes, and the 60s cache still holds whatever came out. * fix(agy): serve the model catalog stale-while-revalidate The `agy models` probe is a whole agy invocation — around 3s on a good day, 15s when it times out — and the 60s window meant the New Session picker paid that again a minute after the last look. Keep the last listing agy actually returned and answer from it: fresh for ten minutes, then still answered while a probe refreshes behind it, until the entry is a day old and stops standing in for the machine at all. A probe that times out or loses auth leaves that entry alone, so a blip no longer empties a working picker, and the hardcoded mirror is no longer recorded as if the machine had reported it. Three things fall out of that and are handled here. A machine whose sign-in has actually gone bad would otherwise look healthy for a day, so an auth failure rides along with the catalog it can still serve — and, because nothing else would re-probe a catalog that is still fresh, a warning riding on the answer is itself a reason to look again. A failed probe is not repeated on the very next request either, or a machine where agy hangs would spawn it once per poll. An explicit refresh always costs a probe, and never rides one that was already running when it was asked for. * fix(agy): let Retry force a fresh model catalog probe With the catalog held for ten minutes, Retry would otherwise hand back the answer it was pressed to replace, so the intent travels to the machine: `?refresh=true` on the machine route, an optional RPC param, and a one-shot flag on the query so ordinary mount and focus refetches stay cheap. Every hop is optional, so a hub and a runner on different versions still talk — the older side ignores it and answers from its cache. Retry also has to be reachable, and honest, in the state that needs it. The machine now answers with both a usable catalog and the sign-in failure behind it, so the picker keeps the list and says why it may be out of date, with the button right there rather than only once there is nothing left to show. Pressing it runs agy, which can take tens of seconds, so the button says so while it does. The client contract covers both: `agy-models` is the one catalog route that can carry an `error` on a successful response, and the one that takes a refresh parameter. * fix(agy): use the machine catalog in the in-session model picker New Session already asks the machine what `agy models` lists, but a session that is already open offered the built-in list in `shared/src/models.ts`. That list is a hand-maintained mirror, so a model agy started offering after the last release could be picked for a new session and not for the one already running. Point the composer at the same machine catalog. The mirror stays as the fallback for the moment before the machine answers, and a model the session is already on is kept selectable — and readable, when it is one of the known presets — even after the catalog moves on without it. * fix(agy): announce a model catalog re-check that changed the answer Serving the last known catalog answers the picker instantly, but a picker that was already open kept showing that answer until the user closed and reopened it — the machine had no way to say it had found something newer. Say it on the stream that already carries machine changes. The machine daemon — the only process that answers `<machineId>:listAgyModels` — emits it, and the hub forwards it as `machine-agy-models-updated` with nothing but the machineId. Namespace resolution, per-machine delivery and reconnect replay all come from the existing path. What counts as a change is what the route would answer, not what sits in the cache. That distinction carries the cases: a sign-in that lapsed or came back changes no models yet changes what the user is told; a machine whose agy was signed out has been answering from the hardcoded mirror, and its first real listing is the largest change there is, for every client except the one awaiting it. * fix(agy): re-read the announced machine's model catalog On `machine-agy-models-updated`, cancel and refetch that one machine's catalog query — the app's global connection is always subscribed, so an open picker redraws wherever it is. Cancelling first is what makes it correct rather than merely likely. query-core cancels an in-flight fetch only when the query already holds data, so a picker opening for the first time would otherwise join the request already on its way and settle on the listing the announcement replaced. The refetch is answered from the machine's cache, so it starts no probe and cannot bounce another announcement back. A reconnect the hub could not replay takes the resync path, which clears the agy catalogs the same way — that path has no announcement to fall back on, so it is the one that can least afford to join a stale request. * fix(agy): keep a model the user picked when the catalog moves under them The catalog can now change while the New Session form is open, and the form dropped any selection the machine no longer advertised — including one the user had just made. Keep that one, and list it as no longer listed so the form does not imply agy is still offering it. A model restored from a draft or a saved preference is still dropped: it may never have been runnable here. * fix(agy): announce uncached authentication changes
18 KiB
SSE stream (GET /api/events)
Audience: Implementers of native HAPI clients (iOS / Android). This page specifies the hub's server-sent-events stream: subscription model, framing, resume protocol, the SyncEvent union, and the versioned session-patch algorithm. Companion pages: auth, REST, pagination, messages.
Source of truth: hub/src/web/routes/events.ts, hub/src/sse/sseManager.ts, hub/src/web/sseCompression.ts, shared/src/schemas.ts (SyncEventSchema), reference client web/src/hooks/useSSE.ts.
Endpoint
GET /api/events — long-lived text/event-stream response.
| Query param | Values | Notes |
|---|---|---|
token |
JWT | Browser EventSource cannot set headers, so the auth middleware accepts ?token= on this path only (hub/src/web/middleware/auth.ts). Clients that can set headers may use Authorization: Bearer instead. |
all |
true | 1 |
Global subscription: every event in the token's namespace. |
sessionId |
session id | Session-scoped subscription. |
machineId |
machine id | Machine-scoped subscription (web does not use this). |
visibility |
visible | hidden |
Initial visibility state. Anything other than the literal visible is treated as hidden (the default). See Visibility. |
lastEventId |
event id | Resume cursor for manually rebuilt connections. The standard Last-Event-ID request header wins over this param when both are present (auto-reconnecting EventSource implementations send the header). |
Up-front checks, before any bytes stream:
| Condition | Response |
|---|---|
| Hub sync engine not ready | 503 {"error":"Not connected"} |
sessionId unknown |
404 {"error":"Session not found"} |
sessionId in another namespace |
403 {"error":"Session access denied"} |
machineId unknown / foreign namespace |
404 / 403 (same pattern) |
A sessionId may resolve to a canonical id (superseded/merged sessions); the subscription binds to the resolved id.
Framing
Every frame is a standard SSE message whose data: line is one JSON-encoded SyncEvent:
id: 018f3c2a:412:9b1f00aa
data: {"type":"session-updated","sessionId":"...","data":{...}}
- Broadcast events (replayed and live) carry an
id:field. connection-changed,heartbeat, andtoastframes carry NOid. SSE cursors are sticky: a frame withoutidmust keep the previously seen id (nativeEventSourcedoes this automatically; hand-rolled parsers must replicate it). A heartbeat must never reset or blank your cursor.- The server never sends
retry:; reconnect policy is entirely client-owned (see Reconnect policy).
Event id format
{epoch}:{seq}:{nsTag} — treat as opaque; store and echo it back, never interpret it.
| Part | Meaning |
|---|---|
epoch |
8-char random string, fixed per hub process. A cursor from before a hub restart can never match. |
seq |
Integer, monotonically increasing per hub process (shared across all namespaces/sessions). |
nsTag |
First 8 hex chars of `sha256("{epoch} |
Replay ring
The hub keeps the last 256 broadcast events, capped at 2 MiB of JSON (oldest evicted first; the byte cap always keeps at least one entry). toast frames are not recorded (they are visibility-targeted, not broadcast).
Handshake and resume
On subscribe the hub emits, in this guaranteed order:
connection-changed—{"type":"connection-changed","data":{"status":"connected","subscriptionId":"<uuid>","resume":"ok"|"gap"}}(noid).- Replayed events (each with its
id), whenresumeisok. - Live traffic.
Live broadcasts that occur while the replay is being written are queued server-side and flushed after it, so ordering is preserved.
resume verdict |
Meaning | Client action |
|---|---|---|
ok |
The replay that follows contains every event missed since the cursor. | Skip the REST resync entirely. |
gap (or field absent — older hubs) |
The hub cannot prove continuity. | Full refetch: session list, session detail(s), message tail sync, and queued-state reconcile for the open session (see pagination). |
gap is returned whenever: no cursor was sent, the cursor is malformed, epoch differs (hub restarted), nsTag differs (cursor issued under a different namespace — e.g. after a token swap on the same hub), seq is out of range, or events between the cursor and the ring's oldest entry were evicted.
Cursor rules (normative)
- Keep one cursor per subscription filter set (the
all/sessionId/machineIdtuple, plus hub + namespace). Never replay a cursor recorded under a different filter set — the hub would replay against the wrong filter and theokverdict would be wrong for what you actually missed. - Update the cursor after the event is durably handled. If handling throws, leave the cursor behind the event so the hub redelivers it (at-least-once delivery; handlers must be idempotent).
- Send the cursor on reconnect via
Last-Event-IDheader or?lastEventId.
Reconnect policy (normative recommendation)
These constants come from the web reference client (web/src/hooks/useSSE.ts) and the hub (hub/src/sse/sseManager.ts); native clients should adopt them.
| Constant | Value | Notes |
|---|---|---|
| Server heartbeat interval | 30 s | {"type":"heartbeat","namespace":"…","data":{"timestamp":<ms>}} |
| Staleness threshold | 90 s | No frames (of any kind) for 90 s ⇒ tear down and reconnect. |
| Watchdog tick | 10 s | Staleness check interval; skip checks while backgrounded. |
| Foreground-resume staleness check | 45 s | On app-foreground, if the last frame is older than 45 s, reconnect immediately (an OS suspend can kill the socket without any error ever surfacing; one missed heartbeat interval is already enough to distrust it). |
| Connect timeout | 10 s | An attempt that has not reached OPEN in 10 s is likely hung on a dead pooled socket — abandon it and retry on a fresh connection. |
| Backoff | 1 s base, ×2, cap 30 s | Delay for attempt n (n ≥ 1) = min(cap, 1000 · 2^(n-1)). First retry is immediate (jitter only). |
| Jitter | +0–500 ms | Uniform, added to every delay. |
| Slow ceiling | 300 s after 8 attempts | A hub that stays unreachable is usually down for hours; each retry through a relay costs a TLS handshake. |
| Backgrounded | defer retries | Do not schedule retries while backgrounded; reconnect immediately on foreground. Reset the attempt counter to 0 on every successful open. |
Any received frame — heartbeat included — counts as activity for the staleness clock. Do not rely on a platform SSE library's built-in auto-reconnect: it will not honor the backoff, the background deferral, or the connect timeout.
Dual-subscription model
The reference client holds two concurrent connections (web/src/App.tsx, web/src/lib/appSseSubscriptions.ts):
- Global —
all=true, alive for the whole app session. Drives the session list, machine list, badges, toasts. - Session —
sessionId=<open session>, recreated on every session switch. Drives the open chat.
Hub-side delivery (SSEManager.shouldSend):
| Event type | Delivered to |
|---|---|
connection-changed |
the connection itself |
heartbeat |
every connection |
toast |
every visible connection in the namespace, regardless of filter (no id, never replayed) |
message-received, scheduled-matured |
all=true connections + matching sessionId connections |
session-added / session-updated / session-removed / session-ended / messages-invalidated / messages-consumed / messages-indeterminate / messages-requeued / message-cancelled |
all=true connections + matching sessionId connections |
machine-updated, machine-agy-models-updated |
all=true connections + matching machineId connections |
The global connection must also handle the message-stream events (message-received, messages-consumed, messages-indeterminate, messages-requeued, message-cancelled, scheduled-matured): while a session connection is down (reconnect gap) or the session isn't open, the global pipe is the only one alive, and it must still keep queued/optimistic bookkeeping correct — mark local messages consumed, remove cancelled rows, and refresh session-list scheduled counts. The session-scoped connection additionally ingests message-received into the message window.
The two connections have no ordering relationship with each other — the same session-updated patch can arrive on both, in either order. That is why the versioned-patch gate below exists.
SyncEvent union (16 types)
Schema: SyncEventSchema in shared/src/schemas.ts (discriminated on type). All events except connection-changed carry namespace?: string. Ignore unknown event types.
type |
Payload (beyond type, namespace?) |
Client handling |
|---|---|---|
session-added |
sessionId, data?: unknown |
Handle exactly like session-updated (the reference client shares the branch): a full Session upserts; anything else falls back to refetching the session list. |
session-updated |
sessionId, data?: Session | SessionPatch |
See Versioned patch algorithm. |
session-removed |
sessionId |
Drop the session from the list, drop its detail cache, clear its message window. |
message-received |
sessionId, message: DecryptedMessage |
Ingest into the message window; advance the tail cursor (see pagination). Also fired for the caller's own send (the localId echo). |
messages-invalidated |
sessionId; rewind may also include reason: 'rewind' and truncateFromLocalId |
Message history changed structurally (rewind, fork, import, clear). For a rewind, retain only the known prefix through the client boundary before tail-syncing; for every other invalidation, discard the whole window and run a fresh tail sync. Global scope: refetch the session list. |
scheduled-matured |
sessionId |
A scheduled message became due and was handed to the agent. Refetch list/queue indicators. |
session-ended |
sessionId, reason?: 'completed'|'terminated'|'error'|'handoff'|'cleared' |
Session lifecycle signal (the session-updated flow still carries the state change). |
machine-updated |
machineId, data?: Machine | MachinePatch | null |
Full Machine: upsert (remove when active:false). null: machine removed. Patch {active?, activeAt?, updatedAt?}: active:false ⇒ remove, otherwise refetch machines. data absent ⇒ refetch. |
machine-agy-models-updated |
machineId |
The machine's agy models listing changed on a background re-check. Refetch GET /api/machines/:id/agy-models for that machine (answered from the machine's cache; it starts no agy run and produces no further event). Emitted when the re-check changes what that route would answer — a different listing, or a sign-in warning that appeared or cleared — never for the machine's first listing. |
toast |
data: {title, body, sessionId, url} |
Show as in-app toast/banner. Only delivered to visible connections (see Visibility). |
messages-consumed |
sessionId, localIds: string[], invokedAt: number |
The agent consumed queued user messages: stamp invokedAt, flip status to sent, remove from the queued bar. |
messages-indeterminate |
sessionId, localIds: string[] |
A steer was dispatched but its outcome is unknown. Keep the row uninvoked, show an explicit Retry/Cancel resolution, and do not auto-replay it. |
messages-requeued |
sessionId, localIds: string[] |
An explicit Retry restored delivery to the normal queue. Clear the indeterminate marker. |
message-cancelled |
sessionId, messageId, localId? |
A queued message was cancelled: remove the row (match by messageId or localId). |
heartbeat |
data?: {timestamp} |
Feed the staleness watchdog. No other action. Carries no id. |
connection-changed |
data?: {status, subscriptionId?, resume?: 'ok'|'gap'} |
Handshake; see Handshake and resume. Store subscriptionId for visibility reporting. Carries no id. |
Versioned patch algorithm
The most bug-prone part of the protocol. session-updated.data is either a full Session or a SessionPatch (shared/src/schemas.ts); reference implementation applySessionDetailPatch in web/src/hooks/useSSE.ts.
- Full session (validates against
SessionSchemaanddata.id === event.sessionId): replace the cached session wholesale. - Patch (validates against the strict
SessionPatchSchema— unknown keys make it fail — and is non-empty): apply field-by-field as below. - Absent or unparseable
data: fall back to refetching the session detail and list over REST.
Patch application, field by field:
-
Flat fields —
active,thinking,activeAt,model,modelReasoningEffort,effort,serviceTier,permissionMode,collaborationMode,copilotAgentMode,backgroundTaskCount: last-write-wins assignment when present.activeTurnStartedAtappears in patches but the reference implementation deliberately never applies it from a patch (web/src/lib/sessionPatch.ts) — take it from full-session payloads only; thesse/fixtures pin this. -
updatedAt— max-monotonic:updatedAt = max(cached.updatedAt, patch.updatedAt). A stale replay must never move the clock backward. -
Versioned sub-patches —
metadata,agentState,todos,teamStateeach arrive as a wrapper{version: number, value: …}. Applyvalueand storeversiononly whenversionis strictly greater than the cached watermark:Wrapper Cached watermark on SessionvaluetypemetadatametadataVersionMetadata | nullagentStateagentStateVersionAgentState | nulltodostodosUpdatedAt(treat absent as 0)TodoItem[]teamStateteamStateUpdatedAt(treat absent as 0)TeamState | null—nullmeans "team deleted": clear itStrictly greater, because the two SSE connections have no shared ordering — the same version can arrive twice and an older version can arrive after a newer one. Applying a stale
agentStatewould resurrect resolved permission requests; a stalemetadatawould regress the resume/session-id state. (The web session-list path tolerates>=because re-deriving its summary from an equal version is idempotent; for a single-cache native client, strict>is the rule.) -
Never wholesale-spread the wrapper.
session.metadatamust becomewrapper.value— assigning{version, value}itself into the session is a classic porting bug. -
scratchlistUpdatedAt— a bare refetch trigger: the patch carries no entries; its presence means "refetchGET /api/sessions/:id/scratchlist". Nothing else to apply.
Keep-alive noise
The CLI keep-alive makes the hub re-broadcast a patch roughly every 10 s per active session, in which typically only activeAt moves. Recommendation (web: isRenderIrrelevantSessionPatch): treat a patch as render-irrelevant when the only effective change is an activeAt delta < 60 s (relative-time labels only change at minute boundaries); the session-list path ignores activeAt entirely. Apply the data if you like, but do not re-render or re-sort six times a minute for it.
Reference list sort (web): globalPinned > pinned > active > pendingRequestsCount (among active) > updatedAt desc.
Visibility
POST /api/visibility with body {"subscriptionId": "<from connection-changed>", "visibility": "visible" | "hidden"} → {"ok": true}. Errors: 400 invalid body, 404 unknown subscriptionId (or namespace mismatch), 503 hub not ready. Each new connection has a new subscriptionId — re-report after every reconnect (the web reference reports both of its connections on every foreground/background transition and retries a failed report after 2 s).
Semantics (hub/src/visibility/visibilityTracker.ts, hub/src/push/pushNotificationChannel.ts): when any connection in the namespace is visible, the hub delivers notification events (ready / permission request / task result) as in-app toast SSE frames to the visible connections and suppresses Web Push for the namespace; Web Push fires only when no visible connection exists (or toast delivery reached zero connections). Native FCM devices (POST /api/devices/register) are independent of visibility and fire unconditionally — see native-companion-contract.
Native rule: report visible on foreground and hidden on background, every time. A native client that stays visible while backgrounded suppresses its own (and every PWA's) hub-side push for the namespace, and receives its notifications only as toast frames nobody is looking at.
Gzip
SSE responses are gzip-compressed when Accept-Encoding allows it (hub/src/web/sseCompression.ts): the hub drives zlib directly and issues a sync flush after every chunk, so events arrive immediately despite compression (~75 % ratio on real traffic). Negotiation is q-value-aware (gzip;q=0 refuses, * honored); the response carries Content-Encoding: gzip with no Content-Length.
Native clients must verify that their HTTP stack decompresses the stream incrementally (frames visible per flush, not buffered until EOF). If it does not — or if it only auto-decompresses when it injected Accept-Encoding itself — send Accept-Encoding: identity and take the uncompressed stream.