mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
docs: align documentation with current implementation
This commit is contained in:
@@ -15,7 +15,7 @@ The hub's base token (`CLI_API_TOKEN`) is auto-generated on first run (32 random
|
||||
|
||||
## Pairing
|
||||
|
||||
Source of truth: `hub/src/startHub.ts` (lines ~317–366), `web/src/components/settings/CompanionPairing.tsx`.
|
||||
Source of truth: `hub/src/startHub.ts`, `web/src/components/settings/CompanionPairing.tsx`.
|
||||
|
||||
The hub terminal (when started with `--relay`) prints two QR codes; the web app's Settings → Companion pairing screen renders the second one as well:
|
||||
|
||||
@@ -119,10 +119,10 @@ Clients should hide the usage/storage screens entirely when the paired namespace
|
||||
|
||||
- Store the **access token** in platform-secure storage: iOS Keychain, Android `EncryptedSharedPreferences` (behind an interface so the mechanism can be swapped). Never plain files, never logs.
|
||||
- Key credentials **per hub base URL** (normalized), since a client can pair with several hubs. Web reference: localStorage key `hapi_access_token::<baseUrl>` (`web/src/hooks/useAuth.ts`, `web/src/components/settings/CompanionPairing.tsx`).
|
||||
- The JWT is a cache, not a secret worth keeping: it is fine to hold it in memory only and re-exchange on cold start. If persisted (to save one round-trip at launch), store it alongside the access token with the same protection.
|
||||
- On unpair/sign-out: delete both credentials, and unregister FCM (`DELETE /api/devices/register`) first while you still hold a valid JWT.
|
||||
- The JWT is a sensitive bearer credential, but need not be persisted: hold it in memory and re-exchange on cold start. If persisted (to save one round-trip at launch), store it alongside the access token with the same protection.
|
||||
- On unpair/sign-out: delete both credentials, and unregister native push (`DELETE /api/devices/register`) first while you still hold a valid JWT.
|
||||
|
||||
## 401 error bodies
|
||||
## 401 error bodies {#401-error-bodies}
|
||||
|
||||
All are JSON with an `error` string; none carry a `code` field except Telegram's `not_bound` (which reuses `error` as the discriminator — natives never see it):
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ When no `code` is present, branch on status alone and treat the failure generica
|
||||
| 409 | `resume_unavailable` | `sessions.ts` resume/reopen result mapping | Session can't be resumed (e.g. unsupported state) |
|
||||
| 409 | `metadata_conflict` | `sessions.ts` reopen result mapping | Refetch session, retry once at most |
|
||||
| 409 | `runner_upgrade_required` | `machines.ts` Agent availability | Upgrade and restart the runner; disable session creation |
|
||||
| 409 | `control_mode_not_applicable` | `sessions.ts` switch | Concurrent clients do not use ownership switching; hide takeover controls |
|
||||
| 409 | — (version conflict) | `sessions.ts` PATCH rename/summary, `machines.ts` PATCH rename — message mentions `version`/`concurrently`; **no code** | Concurrent edit — refetch and reapply |
|
||||
| 409 | — | `sessions.ts` delete-while-active, archive of plain inactive row, fork/rewind refusals, remote-only config on terminal-controlled sessions (`controlledByUser`) | Surface message; refresh session state |
|
||||
| 413 | — | `sessions.ts` upload (> 50 MB decoded), export too large (`{error, count, limit}`); `voice.ts` transcription (`Audio file too large`, 25 MB audio / ~26 MB body) | Reduce payload |
|
||||
@@ -56,7 +57,10 @@ Many endpoints do not answer from hub state — the hub relays the request over
|
||||
2. **CLI offline / handler missing** → depends on the route: the model-catalog routes in `machines.ts` map `RpcTargetMissingError` to **503 `rpc_target_missing`**; `git.ts`-style routes fold it into the 200 `{success: false}` envelope; resume/reopen surface **503 `no_machine_online`**.
|
||||
3. **Hub subsystems not up** → **503 `Not connected`** from `requireSyncEngine` (brief startup/shutdown window).
|
||||
|
||||
Practical rule: treat `success: false`, 503 `rpc_target_missing`, and 503 `no_machine_online` as the same user-facing condition — "the computer running this session is not reachable" — with the raw `error` string available in a details view.
|
||||
Explicit `rpc_target_missing` / `no_machine_online` failures mean the execution
|
||||
host or handler is unavailable. Do not infer that from `success: false` alone:
|
||||
a reachable CLI can report a command, path, permission, or validation failure.
|
||||
Preserve that error for display; HTTP success is not operation success.
|
||||
|
||||
One route qualifies that rule. `GET /api/machines/:id/agy-models` keeps serving the last catalog the machine got out of `agy models` while the CLI re-checks in the background, so it can answer `success: true` **and** carry an `error`: the list is usable, and `error` says why it may be stale (typically the machine's agy sign-in has lapsed). Render it beside the catalog rather than instead of it, and offer `?refresh=true` as the way to ask again — a plain repeat is answered from the same cache.
|
||||
|
||||
@@ -68,5 +72,5 @@ When that background re-check lands a different listing, the machine says so ove
|
||||
|-------|--------|
|
||||
| 400 / 403 / 404 / 409 / 413 / 422 | No (fix input, refresh state, or hide surface) |
|
||||
| 401 (middleware) | Once, after silent re-auth ([Auth](./auth.md#silent-re-auth-401-handling)) |
|
||||
| 429 / 502 / 503 | Yes, with backoff |
|
||||
| 200 `{success: false}` | Manual retry only (user-initiated) — the CLI answered and said no |
|
||||
| 429 / 502 / 503 | Reads: retry with backoff. Mutations: follow the endpoint's recovery contract; do not replay a write whose outcome is unknown |
|
||||
| 200 `{success: false}` | Inspect the error and endpoint contract; manual retry only when safe. This envelope can also represent a missing RPC target or timeout |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Audience:** Implementers of native HAPI clients — the iOS app (`ios/`), the Android app (`android/`), and any other non-web client that talks to a hub's client API. These pages are the primary spec for that work: every claim is grounded in hub/web source, and each section names its source file so implementers (human or AI agent) can verify against code.
|
||||
|
||||
**Scope:** The HTTP contract between a client and one hub — pairing and auth, REST endpoints, SSE streaming, message pagination, message decoding, and error semantics. A client using only this contract can replicate the web app's core feature set over **REST + SSE alone** (no Socket.IO — that transport is CLI↔hub internal).
|
||||
**Scope:** The HTTP contract between a client and one hub — pairing and auth, REST endpoints, SSE streaming, message pagination, message decoding, and error semantics. Core session/chat features use **REST + SSE**. Web terminals additionally use the JWT-authenticated Socket.IO `/terminal` namespace, outside this contract; `/cli` is the internal CLI↔hub namespace and is not a native-client API.
|
||||
|
||||
## Pages
|
||||
|
||||
@@ -39,8 +39,15 @@ Source of truth: `hub/src/web/server.ts` (`/health` route), `shared/src/version.
|
||||
|
||||
The prose in [Messages](./messages.md) describes the decoding tree, but the *normative* artifact is `shared/fixtures/` — machine-generated golden files produced from the web implementation's chat pipeline (`web/src/chat/`). A native client's protocol module must reproduce those fixtures exactly; CI regenerates them whenever the web pipeline changes, so drift is caught automatically.
|
||||
|
||||
`shared/fixtures/` is a companion deliverable of this contract and may not exist yet when you first read this — the fixture generator and batches land in later work packages of the same track. Until then, `web/src/chat/` itself is the reference implementation.
|
||||
The fixtures and native conformance suites are present in this repository.
|
||||
Regenerate fixtures from the repo root with `bun run gen:fixtures`; never
|
||||
hand-edit generated JSON. See the native package READMEs for conformance checks.
|
||||
|
||||
## Relationship to the companion push contract
|
||||
|
||||
[`docs/api/native-companion-contract.md`](../native-companion-contract.md) is the **FCM push contract**: device registration (`POST /api/devices/register`) and the outbound push payload the hub sends through Firebase. It predates this contract and is unchanged. A native client implements *both*: this contract for everything interactive, the companion contract for background push. Where the two overlap (auth, send-message, approve/deny), this contract is the more detailed spec.
|
||||
The [native companion contract](../native-companion-contract.md) specifies
|
||||
Android/iOS device registration (`POST /api/devices/register`), encrypted
|
||||
push envelopes, direct FCM/APNs delivery, and the shared push relay. A native
|
||||
client implements this contract for interactive features and that contract
|
||||
for background push. Where they overlap (auth, send-message, approve/deny),
|
||||
this contract is the more detailed spec.
|
||||
|
||||
@@ -21,7 +21,10 @@ type DecryptedMessage = {
|
||||
}
|
||||
```
|
||||
|
||||
`content` is deliberately `unknown` on the wire. **Decoding must be total**: malformed content degrades to a stringified fallback — a client must never drop or crash on a message it does not recognize (with the two precise exceptions listed in [Fallback rules](#fallback-rules)).
|
||||
`content` is deliberately `unknown` on the wire. **Decoding must be total**:
|
||||
never crash on unfamiliar content. Use stringified fallbacks for unknown
|
||||
envelopes, and follow the family-specific skip/validation rules below for
|
||||
known transport records (see [Fallback rules](#fallback-rules)).
|
||||
|
||||
---
|
||||
|
||||
@@ -211,13 +214,22 @@ Several event rows are also synthesized by the other two families (system subtyp
|
||||
| `'output'` family, visible but unknown `data.type` | stringify as agent text |
|
||||
| `'event'` family, `data` lacks a string `type` | stringify as agent text |
|
||||
|
||||
"Stringify" = a stable JSON serialization (web: `safeStringify`) rendered as plain text. These are the only two legitimate drop paths; everything else must render something.
|
||||
"Stringify" = a stable JSON serialization (web: `safeStringify`) rendered as
|
||||
plain text. Known event types also have the validation/empty-content skip
|
||||
rules listed above (for example, an image without an ID or unparseable usage).
|
||||
The golden fixtures and normalizer are authoritative; do not turn those
|
||||
transport-only records into fallback chat bubbles.
|
||||
|
||||
---
|
||||
|
||||
## Truncation marker
|
||||
|
||||
At ingest the hub head+tail-truncates any **string longer than 64 KiB found anywhere inside agent-role content** (`hub/src/store/contentCodec.ts`): the stored value becomes first 48 KiB + `\n…[hapi: truncated N chars]…\n` + last 12 KiB. User-role content is never truncated (it is delivered verbatim to the CLI). The operation is idempotent and applied deep (arrays/objects).
|
||||
At ingest the hub head+tail-truncates strings longer than `64 * 1024`
|
||||
**UTF-16 code units** (`String.length`, not bytes) inside agent-role content
|
||||
(`hub/src/store/contentCodec.ts`): first `48 * 1024` units +
|
||||
`\n…[hapi: truncated N chars]…\n` + last `12 * 1024` units. User-role content
|
||||
is never truncated (it is delivered verbatim to the CLI). The operation is
|
||||
idempotent and applied deep (arrays/objects).
|
||||
|
||||
Clients must render truncated strings as-is (recognizing the `…[hapi: truncated N chars]…` marker is optional polish), must not assume tool results are complete, and must never choke on the marker.
|
||||
|
||||
@@ -232,7 +244,7 @@ session.agentState = {
|
||||
requests?: Record<requestId, { tool: string, toolCallId?: string, arguments: unknown, createdAt?: number | null }>
|
||||
completedRequests?: Record<requestId, {
|
||||
tool, toolCallId?: string, arguments, createdAt?, completedAt?,
|
||||
status: 'canceled' | 'denied' | 'approved',
|
||||
status: 'canceled' | 'denied' | 'approved' | 'resolved',
|
||||
reason?, mode?, allowTools?: string[],
|
||||
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort',
|
||||
answers?: Record<string, string[]> // flat (AskUserQuestion)
|
||||
@@ -243,6 +255,10 @@ session.agentState = {
|
||||
|
||||
`agentState` updates arrive as a versioned SSE patch — apply it under the version gate described in [sse.md](./sse.md#versioned-patch-algorithm). Render pending `requests` as approval cards interleaved with the chat (the web reducer keys them to the matching `tool_use` when one exists); on resolution the entry moves to `completedRequests`, whose `status`/`answers` back-fill the tool card's permission state. Decide via `POST /api/sessions/:id/permissions/:requestId/approve` (`{mode?, allowTools?, decision?, answers?}`) or `…/deny` (`{decision?}`) — see [rest.md](./rest.md). Session-list badges come precomputed on `SessionSummary.pendingRequestsCount` / `pendingRequests` (≤ 5 entries).
|
||||
|
||||
`resolved` means native completion is known, but the winning answer/decision
|
||||
is not. Render it neutrally; never infer approval or fill answers from an
|
||||
unconfirmed local draft.
|
||||
|
||||
Correlate a permission with the transcript using **`entry.toolCallId ?? requestId`**,
|
||||
but always submit approval/denial using **`requestId`**. Claude local-mode requests
|
||||
use independent, one-shot reply IDs so stale responses cannot answer a later
|
||||
|
||||
@@ -140,7 +140,7 @@ Lifecycle:
|
||||
2. On POST success: status → `queued` if the session is currently thinking, else `sent`. On failure: drop the row and restore the composer (or keep it as `failed` with a retry affordance when attachments are involved).
|
||||
3. **Echo**: the hub emits `message-received` carrying the stored row (server `id`, real `seq`, same `localId`). Merging a stored row whose `localId` matches an optimistic row **replaces** the optimistic one, preserving the client-side `status` and any already-known `invokedAt` the server row lacks. Fallback when no `localId` echo matches: drop an optimistic `sent` row when a server user message lands within **10 s** of the same position.
|
||||
4. **`messages-consumed {localIds, invokedAt}`** (SSE): stamp `invokedAt` and flip status to `sent` on matching rows (skip `failed` ones). This is what moves a message out of the queued bar and into the thread at its invocation position.
|
||||
5. **`messages-indeterminate {localIds}`** (SSE): the steer outcome is unknown. Keep `invokedAt: null`, mark `deliveryState:'indeterminate'`, exclude the row from automatic replay, and show explicit Retry/Cancel actions.
|
||||
5. **`messages-indeterminate {localIds}`** (SSE): a native dispatch or queue mutation has an unknown outcome. Keep `invokedAt: null`, mark `deliveryState:'indeterminate'`, and exclude the row from automatic replay. Retry/Cancel are explicit resolution actions and may remain unavailable until the native outcome can be reconciled.
|
||||
6. **`messages-requeued {localIds}`** (SSE): an explicit Retry restored normal queue delivery; clear `deliveryState`.
|
||||
7. **`message-cancelled {messageId, localId?}`** (SSE): remove the row (match either id).
|
||||
|
||||
@@ -152,8 +152,8 @@ After a reconnect whose handshake said `resume: 'gap'` (an `ok` resume replayed
|
||||
|
||||
1. Finish a tail sync.
|
||||
2. Collect candidate `localId`s: user rows with `invokedAt === null`, excluding optimistic rows still `sending`/`failed`.
|
||||
3. `POST /api/sessions/:id/messages/queued-state` with `{"localIds": […]}` (max 1000 per call; batch above that) → `{queuedLocalIds: string[], invokedLocalMessages: [{localId, invokedAt}]}`.
|
||||
4. Apply `invokedLocalMessages` exactly like `messages-consumed`; drop candidates that are in **neither** list (deleted server-side).
|
||||
3. `POST /api/sessions/:id/messages/queued-state` with `{"localIds": […]}` (max 1000 per call; batch above that) → `{queuedLocalIds: string[], indeterminateLocalIds: string[], invokedLocalMessages: [{localId, invokedAt}]}`.
|
||||
4. Apply `invokedLocalMessages` exactly like `messages-consumed`; mark `indeterminateLocalIds` as unresolved delivery. Retain both queued and indeterminate rows; drop only candidates absent from **all three** result groups. An in-flight native dispatch is reported as indeterminate, not as a deleted message.
|
||||
|
||||
---
|
||||
|
||||
@@ -181,14 +181,20 @@ Response `{"ok": true}`. Sending to an inactive session returns `409 {"error":"S
|
||||
|---|---|---|
|
||||
| `{"status":"cancelled","localId":string\|null}` | Row deleted (or already gone). Bumps the epoch. | Remove the row. |
|
||||
| `{"status":"invoked","message":DecryptedMessage}` | Too late — the agent consumed it before the cancel landed. | **Ingest the returned message** as the authoritative row (correct `invokedAt`, status `sent`); do not resurrect the queued snapshot. |
|
||||
| `{"status":"busy","localId":string}` | A live steer is still resolving. | Restore the row as indeterminate; reconcile queued state before allowing Retry/Cancel. |
|
||||
| `{"status":"busy","localId":string}` | Native delivery/removal is unresolved; cancellation cannot be confirmed. | Restore the row as indeterminate; reconcile queued state before allowing Retry/Cancel. |
|
||||
|
||||
Other subscribers learn the same outcome via `message-cancelled` / `messages-consumed` SSE events.
|
||||
|
||||
**Steer a queued message into the current turn**: `POST /api/sessions/:id/messages/:messageId/steer` (Pi sessions) → `SteerQueuedMessageResponseSchema`:
|
||||
**Steer a queued message into the current turn**: `POST /api/sessions/:id/messages/:messageId/steer` → `SteerQueuedMessageResponseSchema`. Unlike the send-time `deliveryMode` option above, this endpoint supports Pi, Codex, and Cursor ACP sessions (`isSteeringSupportedForSession` in `shared/src/modes.ts`). It rejects all scheduled messages, and rejects terminal-controlled sessions unless they advertise `concurrentClients`.
|
||||
|
||||
| Response | Client action |
|
||||
|---|---|
|
||||
| `{"status":"steered","localId"}` | Keep the row queued-side; it is being injected into the live turn. |
|
||||
| `{"status":"invoked","message"}` | Already consumed — ingest the message. |
|
||||
| `{"status":"failed","error","localId":string\|null}` | Surface the error; the row remains queued. |
|
||||
| `{"status":"failed","error","localId":string\|null}` | Surface the error. Do not infer delivery state from this alone; reconcile before retrying when the native outcome is unknown. |
|
||||
|
||||
**Retry indeterminate delivery**: `POST /api/sessions/:id/messages/:messageId/retry`
|
||||
is user-initiated only. `retried` or `already-queued` means normal queue delivery;
|
||||
`invoked` carries the authoritative message; `not-found` means the row is gone.
|
||||
`retry-unavailable` leaves the row unresolved: the hub could not prove that
|
||||
retrying would avoid duplicate work. Reconcile instead of automatically retrying.
|
||||
|
||||
@@ -4,7 +4,7 @@ Endpoint tables for native clients, grouped by feature. Request/response shapes
|
||||
|
||||
## Conventions
|
||||
|
||||
- All paths below are relative to the hub base URL. Everything under `/api` requires `Authorization: Bearer <JWT>` ([Auth](./auth.md)).
|
||||
- All paths below are relative to the hub base URL. `/api` routes require `Authorization: Bearer <JWT>` except the `/api/auth` exchange and Telegram `/api/bind` ([Auth](./auth.md)).
|
||||
- Path params (`:id`, `:messageId`, …) must be URL-encoded (the web client uses `encodeURIComponent` throughout).
|
||||
- Request bodies are JSON (`content-type: application/json`) with **one exception**: `POST /api/voice/transcription` is `multipart/form-data`. Responses are JSON unless noted (generated images and scratchlist attachments return raw bytes).
|
||||
- Bodies are validated with Zod; failures return `400` (see [Errors](./errors.md)).
|
||||
@@ -66,10 +66,10 @@ Source: `hub/src/web/routes/messages.ts`; schemas `MessagesQuerySchema`, `SendMe
|
||||
|---|---|---|
|
||||
| `GET /api/sessions/:id/messages` | Query: `limit?` (1–200, default 50), cursor pairs `beforeSeq+beforeAt` \| `afterSeq+afterAt` (+ optional `untilSeq+untilAt`, `epoch` with `after`) | `MessagesResponse` `{messages: DecryptedMessage[], page: {direction, limit, epoch, reset, nextBefore*/nextAfter*, snapshotHead*, hasMore}}` — full cursor semantics in [Pagination](./pagination.md) |
|
||||
| `POST /api/sessions/:id/messages` | `{text, localId?, attachments?, scheduledAt?, deliveryMode?: 'queue'\|'steer'}` — text or attachments required; `scheduledAt` requires `localId`, must be ≤ 7 days out, excludes attachments and steer | `{ok: true}` — the message itself arrives via SSE (`message-received`), reconciled by `localId` |
|
||||
| `DELETE /api/sessions/:id/messages/:messageId` | — | `{status: 'cancelled', localId}` \| `{status: 'invoked', message}` \| `{status: 'busy', localId}` (cancel; `busy` = steer still resolving) |
|
||||
| `DELETE /api/sessions/:id/messages/:messageId` | — | `{status: 'cancelled', localId}` \| `{status: 'invoked', message}` \| `{status: 'busy', localId}` (cancel; `busy` = native delivery/removal is unresolved) |
|
||||
| `POST /api/sessions/:id/messages/:messageId/steer` | — | `{status: 'steered', localId}` \| `{status: 'invoked', message}` \| `{status: 'failed', error, localId}` |
|
||||
| `POST /api/sessions/:id/messages/:messageId/retry` | — | `{status: 'retried', localId}` \| `{status: 'already-queued', localId}` \| `{status: 'retry-unavailable', localId}` \| `{status: 'invoked', message}` \| `{status: 'not-found'}` — explicit retry only; never automatic replay |
|
||||
| `POST /api/sessions/:id/messages/queued-state` | `{localIds: string[]}` (≤ 1000, deduped) | `{queuedLocalIds: string[], invokedLocalMessages: [{localId, invokedAt}]}` — resync optimistic sends after reconnect |
|
||||
| `POST /api/sessions/:id/messages/queued-state` | `{localIds: string[]}` (≤ 1000, deduped) | `{queuedLocalIds: string[], indeterminateLocalIds: string[], invokedLocalMessages: [{localId, invokedAt}]}` — resync after reconnect; preserve indeterminate rows without auto-replaying them |
|
||||
|
||||
The hub stamps `sentFrom: 'webapp'` on REST-sent messages server-side; the request body has no such field.
|
||||
|
||||
@@ -97,8 +97,8 @@ Source: `hub/src/web/routes/sessions.ts`; flavor gates in `shared/src/modes.ts`
|
||||
|
||||
| Method & path | Request | Applies to |
|
||||
|---|---|---|
|
||||
| `POST /api/sessions/:id/permission-mode` | `{mode: PermissionMode}` | All flavors except `pi` (per-flavor allowed sets in `modes.ts`) |
|
||||
| `POST /api/sessions/:id/model` | `{model: string \| {provider, modelId} \| null}` | All flavors (`supportsModelChange` is true for every current flavor); remote-only for codex/cursor/grok |
|
||||
| `POST /api/sessions/:id/permission-mode` | `{mode: PermissionMode}` | All flavors except `pi` and `dsh` (per-flavor allowed sets in `modes.ts`) |
|
||||
| `POST /api/sessions/:id/model` | `{model: string \| {provider, modelId} \| null}` | Flavors with `supportsModelChange` (not `dsh`); remote-only for codex/cursor/grok |
|
||||
| `POST /api/sessions/:id/effort` | `{effort: string \| null}` | claude, grok, pi (`supportsEffort`) |
|
||||
| `POST /api/sessions/:id/model-reasoning-effort` | `{modelReasoningEffort: string \| null}` | codex, opencode (remote-only) |
|
||||
| `POST /api/sessions/:id/service-tier` | `{serviceTier: 'fast' \| 'standard'}` | codex (remote-only) |
|
||||
@@ -110,6 +110,8 @@ Source: `hub/src/web/routes/sessions.ts`; flavor gates in `shared/src/modes.ts`
|
||||
ownership mode. `/switch` returns HTTP 409 with
|
||||
`code: 'control_mode_not_applicable'`; do not offer takeover. The remote-only
|
||||
Codex configuration restrictions above do not apply to these sessions.
|
||||
Shared Codex rejects `safe-yolo` even though it remains in the historical
|
||||
Codex permission-mode schema; do not offer it for concurrent sessions.
|
||||
|
||||
Shared `/clear` has no global `supersededBySessionId` change; clients other than
|
||||
the caller stay on the original thread. Fork may return an already-bound shared
|
||||
@@ -262,7 +264,7 @@ These exist on the hub but v1 native clients must not implement or call them:
|
||||
| Codex Desktop import | `/api/codex/*` (`hub/src/web/routes/codexDesktop.ts`) | Desktop-import tooling |
|
||||
| Pi session import | `/api/pi/*`, `/api/sessions/:id/pi-*` (`hub/src/web/routes/piSessions.ts`, `sessions.ts`) | Import tooling (the `pi-models` catalog above is the one exception) |
|
||||
| Work graph | `/api/work-graph/*` (`hub/src/web/routes/workGraph.ts`) | Web-only feature |
|
||||
| Web Push | `/api/push/*` (`hub/src/web/routes/push.ts`) | Browser Push API; natives use `/api/devices` (FCM) |
|
||||
| Web Push | `/api/push/*` (`hub/src/web/routes/push.ts`) | Browser Push API; natives use `/api/devices/register` (Android/iOS push contract) |
|
||||
| Hub settings write | `PUT /api/hub-settings` | Owner-only hub administration |
|
||||
| Telegram | `POST /api/bind` | Telegram Mini App binding only |
|
||||
| Voice assistant | `/api/voice/token`, `/voices`, `/backend`, `/gemini-token`, `/qwen-token`, `/qwen-ws`, `/gemini-ws`, `/transcription/realtime-token`, `/telemetry`, credentials endpoints | Realtime assistant, not v1 dictation |
|
||||
|
||||
@@ -192,9 +192,9 @@ Reference list sort (web): `globalPinned` > `pinned` > `active` > `pendingReques
|
||||
|
||||
`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-companion-contract.md).
|
||||
Semantics (`hub/src/visibility/visibilityTracker.ts`, `hub/src/push/pushNotificationChannel.ts`): Android/iOS native delivery is independent of Web visibility. When a native provider accepts a notification for at least one device, the hub skips the Web Push/toast duplicate for that dispatch. Otherwise, if **any** connection in the namespace is visible, the hub first sends **`toast` SSE frames to visible connections**. Web Push is the fallback when no connection is visible or toast delivery reaches zero connections. Provider acceptance is not a handset delivery receipt — see the [native companion contract](../native-companion-contract.md).
|
||||
|
||||
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.
|
||||
Native rule: report `visible` on foreground and `hidden` on background, every time. A stale `visible` report can divert the namespace's Web Push fallback into unseen toast frames; it does not disable native push. The native app separately suppresses its local notification when the corresponding chat is already open in the foreground.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user