mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-20 21:26:04 +00:00
* feat(shared): steer capability gates and live steered signal schemas - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which agents can deliver queued messages into the active turn (pi, codex, cursor ACP; legacy stream-json cursor excluded) - AgentState.steeringActive, DecryptedMessage.steered and messages-consumed live signal (never persisted by the hub) * feat(cli): queue reservations and steered messages-consumed option - MessageQueue2 gains takeByLocalId/restoreReservation/ beginReservationDispatch/commitReservation so an async steer can reserve a queued row without racing the main loop's turn/start drain - emitMessagesConsumed accepts steered: true to mark mid-turn delivery * feat(codex): mid-turn steer via app-server turn/steer (#888) - CodexAppServerClient.steerTurn + TurnSteerParams/Response types - CodexRemoteLauncher registers the steer-queued-message RPC handler: reserves the queued row, validates it against the active turn (no control commands, matching mode hash), injects via turn/steer with an epoch guard that invalidates in-flight steers on abort/cleanup - steeringActive agent state tracks the active-turn window - hub syncEngine gate opens to codex; messages-consumed relays steered * feat(web): Steered badge and steer gating for codex sessions - HappyUserMessage shows a ↳ Steered badge fed by the live messages-consumed steered signal, preserved across server echoes and refetches (mergeMessages carries the optimistic marker) - SessionChat gates canSteer via isSteeringSupportedForSession instead of the pi-only check - clearStaleQueuedStatus normalizes a queued status on an invoked message - fix(web): drop duplicate showSessionSummaryInChat in markdown test (upstream typecheck breakage) * fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise codex and pi only; cursor joins when its soft-steer handler lands (#1609) - turn/steer now splits dispatch (stdin accepted) from completion (turn finished): the hub RPC acks once dispatch succeeds — never on the concurrent turn's completion, which can exceed the 30s RPC window - queue row commits only after the turn settles; a rejected/aborted steer restores the row so the message still delivers via turn/start, and a dispatched steer is never restored (no duplicate delivery) - steer carries clientUserMessageId (echoed as userMessage.clientId) so ambiguous transport failures can reconcile the thread later - client tests cover dispatch/complete split and stdin-write failure * fix(codex): reconcile dispatched steers before restoring; align error copy - A dispatched turn/steer whose completion fails (disconnect / protocol error) is now reconciled via thread/read by clientUserMessageId before the queued row is restored — the instruction is only re-delivered by turn/start when the thread never received it - Reconcile targets the pinned steer thread, not whichever turn is current when completion fails - syncEngine unsupported-flavor error now matches the capability gate (Pi and Codex only until the cursor handler lands) - launcher tests cover steer success (ack on dispatch), reconcile-accepted and reconcile-rejected outcomes * fix(codex): consume the row at dispatch; drop background reconcile - The hub RPC acks and the queue row is consumed as soon as stdin accepts turn/steer; completion is background-only logging. A dispatched steer is never restored, so the same localId cannot be re-delivered via turn/start after the caller was told the steer succeeded - Dispatch failure (stdin write error) still restores the row and reports failure - steer.completed rejection is always handled (no unhandled rejection on the dispatch-failure path) - tests updated: completion failure after dispatch keeps the row consumed; dispatch failure restores it * fix(codex): distinguish definite rejection from indeterminate completion - Transport-level failures (timeout, abort, disconnect, spawn, protocol) carry an indeterminate marker; explicit JSON-RPC error responses do not - After a dispatched steer, turn completion resolves → commit + consumed; a definite app-server rejection restores the row (instruction was never accepted, so turn/start cannot duplicate it); an indeterminate outcome leaves the row reserved so it can never be delivered twice - Completion handling registers before awaiting dispatch so the dispatch-failure path cannot leak an unhandled rejection - client/launcher tests cover explicit rejection (restore), indeterminate outcome (row stays reserved) and dispatch failure * fix(codex): reconcile indeterminate steers instead of a permanent reservation - After an indeterminate completion (disconnect/protocol), reconcile the thread by clientUserMessageId immediately: accepted → commit + consumed, provably rejected → restore, still unreadable → keep the reservation and retry from the main-loop top on later passes (post-reconnect) - A row never sits in dispatching forever: the hub cannot stamp it invoked while the instruction may never have been accepted - tests: indeterminate keeps reserved while thread unreadable; accepted reconciliation consumes; rejected path restores * fix(codex): accept all thread item shapes; retry reconcile; ack through abort - Reconcile matcher accepts userMessage/user_message with clientId/ client_id, matching the shapes the thread parser supports — an accepted steer can no longer be misclassified as rejected - A pending reconciliation schedules a wakeLoop retry, so a temporary app-server outage cannot strand the reservation behind waitForTurnOrRecovery - The success-path ACK no longer checks the steer epoch: the hub already reported steered on dispatch, so commit + messages-consumed must reach it even when an abort resets the queue in between * fix(codex): reinit reconnected app-server; keep reconcile retries alive - thread/read after a disconnect auto-connects a fresh app-server, which must be initialized before any request — reconcile now ensures connect + initialize (isConnected getter added to the client) - every still-unknown loop-top reconciliation schedules the next retry, so recovery without external traffic is eventually observed - launcher mock gains isConnected * fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK - Reconciliation runs on a self-rescheduling 1s timer independent of the main loop (wakes it too), so idle loops and waitForTurnOrRecovery still observe app-server recovery; abort clears nothing implicitly — the ACK path commits and consumes even when the reservation was cancelled - Absence of a durable client id is ambiguous: unmatched reads stay 'unknown' and keep retrying instead of restoring the row - CodexAppServerClient tracks initialized state (reset on disconnect/exit) so ensureAppServerInitialized re-initializes a fresh process before thread/read; initialize failures leave the flag false for the next retry - tests: accepted reconciliation via scheduled timer, indeterminate keeps reserved, explicit rejection restores * fix(codex): bind reconciliation to the launcher lifecycle - runSteerReconciliation clears any armed retry timer on entry and never installs a second one, so loop-top and timer-driven passes cannot multiply - shuttingDown is set when the main loop ends: timers are cleared and the pending map is dropped, so an unresolved steer can never respawn an app-server after cleanup (remote-to-local switch included) * fix(codex): report steered only after app-server acceptance - The handler now awaits steer.completed (the inject-acceptance response): an explicit JSON-RPC rejection surfaces as failed and restores the row for the normal turn/start path instead of a false steered - Transport failure after dispatch reports 'Steer outcome is being reconciled' and keeps the row reserved while the timer-driven thread reconciliation runs - dispatch-failure path also swallows the paired completion rejection * fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait - MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching steer reservation: the hub neither deletes the row nor stamps invoked_at (new CancelMessageResponse 'busy' status; web restores the optimistic row); pushIsolateAndClear and reset/close share cancelReservations so /clear-style commands cannot have a rejected steer resurrect a discarded prompt - turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a lost response is indeterminate and funnels into thread reconciliation instead of stranding the reservation - tests updated for the tri-state cancel contract * fix(codex,web): busy-aware edit flow; bound reconciliation reads - QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it never prefills the composer when the row is inside an async steer, so a second client cannot send a duplicate - reconcileSteerByClientId bounds thread/read with a 5s timeout so a connected-but-silent app-server cannot hold the reservation in-flight indefinitely * fix(steer): inFlight-dominated cancel acks; bounded reconciliation - hub cancel-queued-message acks check inFlight before removed: a stale duplicate socket reporting removed can no longer delete the durable row while another socket is dispatching the steer - reconciliation entries expire after 60s and mark delivered: after the rejection window, a dispatched steer that the app-server never proved (client ids dropped on restart) is committed instead of polling thread/read forever - pre-dispatch failures (abort before write included) never enter reconciliation — they restore the row and report failure * fix(steer): persist indeterminate outcomes without replay * fix(steer): make ambiguous delivery restart-safe * fix(steer): recover crash-held rows and preserve retry dedup * fix(steer): ack retries and bound stdin dispatch * fix(steer): reconcile indeterminate dispatches and serialize retries * fix(codex): classify stdin callback failures as indeterminate * fix(steer): recheck indeterminate cancels after ACK * fix(steer): close retry and abort races * fix(steer): serialize live retries and abort admission * fix(steer): distinguish live dispatching from unknown * fix(steer): keep ACK failures held and reconcile busy cancel * fix(steer): distinguish held cancel from removal * fix(store): combine schema v24 migrations * fix(store): reserve schema v25 for steer delivery state * fix(steer): keep held cancel state and notify requeue * fix(steer): release explicitly cancelled unknown reservations * fix(codex): reject cancelled reservations before native steer * fix(codex): make reservation restore atomic with state * fix(codex): terminate abandoned transport writes * fix(steer): own abandoned app-server lifecycle and consume races * fix(codex): confirm dispatch and recover abandoned turns * test(codex): mock abandoned transport callback * fix(codex): clear visible turn state on transport loss * fix(steer): claim retries and cover native delivery state * fix(native): preserve indeterminate state on Android hydration * fix(steer): make retry claims single-winner * fix(steer): serialize concurrent retry claims * fix(socket): tolerate missing steer-state ACK callbacks * fix(native): serialize retry operations * docs(web): document unknown steer delivery and retry controls * fix(steer): handle retry failures and abort-before-connect * fix(steer): reinitialize after transport loss and finish iOS retry errors * fix(steer): preserve indeterminate rows across reconnect gaps * test(web): mock indeterminate queued recovery state * fix(steer): recover consumed ACK tombstones * fix(steer): expose consumed cancel tombstones
246 lines
20 KiB
Markdown
246 lines
20 KiB
Markdown
# REST endpoints
|
||
|
||
Endpoint tables for native clients, grouped by feature. Request/response shapes reference the Zod schemas in `shared/src/schemas.ts` and `shared/src/apiTypes.ts` (package `@hapi/protocol`) — those schemas, not the prose here, are the field-level source of truth. Route behavior is grounded in `hub/src/web/routes/*.ts`; `web/src/api/client.ts` is the reference consumer.
|
||
|
||
## Conventions
|
||
|
||
- All paths below are relative to the hub base URL. Everything under `/api` requires `Authorization: Bearer <JWT>` ([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)).
|
||
- **gzip:** the hub gzips `/api/*` JSON responses when `Accept-Encoding` accepts gzip. Negotiation is q-value aware (`acceptsGzip` in `hub/src/web/sseCompression.ts`): `gzip;q=0` is honored as a refusal, `*` counts unless a `gzip` entry overrides it. Send a normal `Accept-Encoding: gzip` and decompress transparently. The SSE stream is gzip-compressed separately with per-event flush — see [SSE](./sse.md). Source: `hub/src/web/server.ts`.
|
||
- Several endpoints are **RPC-wrapped**: the hub forwards to the session's CLI process over Socket.IO and relays the result. These can fail with HTTP 200 + `{success: false, error}` or with 503 — see [Errors](./errors.md#rpc-wrapped-endpoints).
|
||
|
||
## Tier 1 — required for v1 clients
|
||
|
||
### Health
|
||
|
||
Source: `hub/src/web/server.ts`.
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `GET /health` (no auth) | — | `{status: 'ok', protocolVersion: number, capabilities: {workGraph?, titleSuggestion?}}` — additive capabilities, ignore unknown keys |
|
||
|
||
### Sessions — list & detail
|
||
|
||
Source: `hub/src/web/routes/sessions.ts`; shapes `SessionSchema` (`shared/src/schemas.ts`), `SessionSummary` (`shared/src/sessionSummary.ts`).
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `GET /api/sessions` | Query: `limit?` (1–500), `order?=updatedAt` | `{sessions: (SessionSummary & {futureScheduledMessageCount, nextScheduledAt})[]}` |
|
||
| `GET /api/sessions/:id` | — | `{session: Session}` (full record incl. `metadata`, `agentState`, `todos`, versions) |
|
||
|
||
Default list order: globalPinned → pinned → active → pending-request count → `updatedAt` desc; `order=updatedAt` gives pure recency. List badges come from `SessionSummary.pendingRequestsCount` (authoritative total) and `pendingRequests` (capped at 5, oldest-first) — do not derive counts from `pendingRequests.length`.
|
||
|
||
### Sessions — lifecycle
|
||
|
||
Source: `hub/src/web/routes/sessions.ts`; request schemas in `shared/src/apiTypes.ts`.
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `POST /api/sessions/:id/resume` | `{permissionMode?}` (`ResumeSessionRequestSchema`) | `{type: 'success', sessionId}` |
|
||
| `POST /api/sessions/:id/reopen` | `{}` | `{ok: true, sessionId, resumed: boolean, cursorSessionProtocol?}` (`ReopenSessionResponseSchema`); `422 {error, missing[]}` if metadata is incomplete |
|
||
| `POST /api/sessions/:id/abort` | `{}` | `{ok: true}` (active sessions only) |
|
||
| `POST /api/sessions/:id/archive` | `{}` | `{ok: true}` or `{ok: true, alreadyArchived: true}`; 409 for a plain inactive session |
|
||
| `DELETE /api/sessions/:id` | — | `{ok: true}`; 409 while active (archive first) |
|
||
| `PATCH /api/sessions/:id` | `{name}` (1–255 chars) | `{ok: true}` (rename) |
|
||
| `PATCH /api/sessions/:id/summary` | `{text}` (1–255 chars) | `{ok: true}` |
|
||
| `PUT /api/sessions/:id/pin` | `{mode: 'none'\|'project'\|'global'}` | `{ok: true}` |
|
||
| `POST /api/sessions/:id/switch` | `{}` | `{ok: true}` — hands terminal-controlled session over to remote control |
|
||
| `POST /api/sessions/:id/title-suggestion` | — | `{title}`; errors pass through 422/429/502/503 |
|
||
| `GET /api/sessions/:id/slash-commands` | — | `SlashCommandsResponse` `{success, commands?, error?}` |
|
||
| `GET /api/sessions/:id/skills` | — | `SkillsResponse`-shaped `{success, ...}` |
|
||
|
||
::: warning resume / reopen may return a different sessionId
|
||
Both endpoints return the id of the session that now carries the conversation — which **may differ from the id you called them on** (fresh spawn under a new id; the old row is superseded). Clients must migrate composer drafts and replace navigation to the returned id. Reference: `web/src/routes/sessions/followSupersedingSession.ts`; the durable link also appears as `metadata.supersededBySessionId` on the old session.
|
||
:::
|
||
|
||
Optional for v1 (endpoints exist; the v1 native scope does not require them): `POST /api/sessions/:id/fork` `{messageLocalId?}` → `{sessionId}`, `POST /api/sessions/:id/rewind` `{messageLocalId}` → `{success: true}`, `GET /api/sessions/:id/export` (413 when too large).
|
||
|
||
### Messages
|
||
|
||
Source: `hub/src/web/routes/messages.ts`; schemas `MessagesQuerySchema`, `SendMessageRequestSchema`, `QueuedStateRequestSchema` (`shared/src/apiTypes.ts`), responses `CancelMessageResponseSchema`, `SteerQueuedMessageResponseSchema` (`shared/src/schemas.ts`). Unknown steer delivery is durable and user-resolvable; native clients must implement the same transitions.
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `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) |
|
||
| `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 |
|
||
|
||
The hub stamps `sentFrom: 'webapp'` on REST-sent messages server-side; the request body has no such field.
|
||
|
||
### Permissions
|
||
|
||
Source: `hub/src/web/routes/permissions.ts`. Pending requests are **not messages**: they live in `session.agentState.requests` (keyed by request id) and move to `agentState.completedRequests` when resolved — schemas `AgentStateRequestSchema` / `AgentStateCompletedRequestSchema` in `shared/src/schemas.ts`.
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `POST /api/sessions/:id/permissions/:requestId/approve` | `{mode?, allowTools?: string[], decision?, answers?}` | `{ok: true}` |
|
||
| `POST /api/sessions/:id/permissions/:requestId/deny` | `{decision?}` | `{ok: true}` |
|
||
|
||
- `decision`: `'approved' | 'approved_for_session' | 'denied' | 'abort'`.
|
||
- `mode`: optionally switch permission mode while approving; validated against the session flavor.
|
||
- `answers` has **two formats**, matching the requesting tool: flat `Record<string, string[]>` (AskUserQuestion) or nested `Record<string, {answers: string[]}>` (request_user_input). Both routes 404 with `Request not found` if the id is not currently pending, and 409 `session_inactive` when the session is inactive.
|
||
|
||
### Session config — mode / model / effort (per flavor)
|
||
|
||
Source: `hub/src/web/routes/sessions.ts`; flavor gates in `shared/src/modes.ts` (`getPermissionModesForFlavor`) and `shared/src/flavors.ts` (`supportsModelChange`, `supportsEffort`). Wrong-flavor calls return 400; several are additionally rejected with 409 when the session is terminal-controlled (`agentState.controlledByUser === true`).
|
||
|
||
| 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/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) |
|
||
| `POST /api/sessions/:id/collaboration-mode` | `{mode: 'default' \| 'plan'}` | codex (remote-only) |
|
||
| `POST /api/sessions/:id/copilot-agent-mode` | `{mode}` | copilot (remote-only) |
|
||
|
||
All respond `{ok: true}`; apply-failures return 409 with a message. Model/effort **catalogs** (RPC-wrapped; all return `{success, ...} \| {success: false, error}`):
|
||
|
||
| Method & path | Notes |
|
||
|---|---|
|
||
| `GET /api/sessions/:id/codex-models`, `/opencode-models`, `/cursor-models`, `/grok-models`, `/copilot-models`, `/pi-models` | Active session of the matching flavor; 400 otherwise |
|
||
| `GET /api/sessions/:id/opencode-reasoning-effort-options`, `/grok-reasoning-effort-options` | Same pattern |
|
||
| `GET /api/machines/:id/agy-models`, `/pi-models`, `/codex-models`, `/cursor-models` | Machine-level (pre-spawn pickers) |
|
||
| `GET /api/machines/:id/opencode-models?cwd=`, `/grok-models?cwd=`, `/copilot-models?cwd=` | `cwd` query required (400 without) |
|
||
|
||
### Machines & spawning
|
||
|
||
Source: `hub/src/web/routes/machines.ts`; schemas `SpawnSessionRequestSchema`, `MachineListDirectoryRequestSchema`, `MachinePathsExistsRequestSchema`, `RenameMachineRequestSchema` (`shared/src/apiTypes.ts`), `MachineSchema` (`shared/src/schemas.ts`).
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `GET /api/machines` | — | `{machines: Machine[]}` (online machines in the caller's namespace) |
|
||
| `PATCH /api/machines/:id` | `{displayName}` (trimmed; ≤ 64 chars; empty clears back to hostname) | `{ok: true}` |
|
||
| `POST /api/machines/:id/spawn` | `{directory, agent?, model?, effort?, modelReasoningEffort?, yolo?, permissionMode?, sessionType?: 'simple'\|'worktree', worktreeName?, serviceTier?, collaborationMode?, copilotAgentMode?, startingMode?: 'remote'\|'pty'}` | `{type: 'success', sessionId}` \| `{type: 'error', message}` (agy accepts only `remote`) |
|
||
| `POST /api/machines/:id/list-directory` | `{path, includeHidden?}` | `{success, entries?: (DirectoryEntry & {isGitRepo?})[], error?}` |
|
||
| `POST /api/machines/:id/paths/exists` | `{paths: string[]}` (≤ 1000) | `{exists: Record<string, boolean>}` |
|
||
| `POST /api/machines/:id/restart-runner` | `{}` | `{message}`; errors carry `code: 'machine_not_found' \| 'machine_offline'` |
|
||
|
||
Note the spawn response is discriminated on `type`, not HTTP status — a failed spawn is still HTTP 200.
|
||
|
||
### Git & files (RPC-wrapped)
|
||
|
||
Source: `hub/src/web/routes/git.ts`. Git endpoints return the **raw command output** — `GitCommandResponse` `{success, stdout?, stderr?, exitCode?, error?}` — and the client parses `stdout` itself (reference parsers: `web/src/lib/gitParsers.ts`).
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `GET /api/sessions/:id/git-status` | — | `GitCommandResponse` (raw `git status` stdout) |
|
||
| `GET /api/sessions/:id/git-diff-numstat` | Query: `staged=true\|false` | `GitCommandResponse` (raw `git diff --numstat` stdout) |
|
||
| `GET /api/sessions/:id/git-diff-file` | Query: `path` (required), `staged?` | `GitCommandResponse` (raw unified diff) |
|
||
| `GET /api/sessions/:id/file` | Query: `path` (required) | `{success, content?, size?, modified?, error?}` — `content` is **base64** (decode before display; web ref: `web/src/routes/sessions/file.tsx`) |
|
||
| `GET /api/sessions/:id/files` | Query: `query?`, `limit?` (1–500, default 200) | `{success, files: [{fileName, filePath, fullPath, fileType: 'file', size?, modified?}]}` (ripgrep-backed search) |
|
||
| `GET /api/sessions/:id/directory` | Query: `path?` (empty = session root) | `{success, entries?: [{name, type: 'file'\|'directory'\|'other', size?, modified?}], error?}` |
|
||
|
||
When the session has no `metadata.path` yet, these return HTTP 200 `{success: false, error: 'Session path not available'}`.
|
||
|
||
### Generated images
|
||
|
||
Source: `hub/src/web/routes/git.ts` (same file).
|
||
|
||
| Method & path | Response |
|
||
|---|---|
|
||
| `GET /api/sessions/:id/generated-images/:imageId` | **Raw bytes** with `Content-Type`, `Content-Disposition`, `ETag: "<imageId>"`, `Cache-Control: private, max-age=31536000, immutable`; `404` JSON when missing |
|
||
|
||
The image id is an immutable content fingerprint, so it doubles as the ETag: send `If-None-Match` and the hub answers `304` *without* the CLI round-trip. Cache aggressively (iOS: URLCache honors this automatically; Android: OkHttp cache).
|
||
|
||
### Uploads (message attachments)
|
||
|
||
Source: `hub/src/web/routes/sessions.ts` (`UploadFileRequestSchema`).
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `POST /api/sessions/:id/upload` | JSON `{filename, content, mimeType}` — `content` is **base64**; decoded size limit 50 MB → `413` | `{success, path?, error?}` — pass the resulting metadata in `attachments` of send-message |
|
||
| `POST /api/sessions/:id/upload/delete` | `{path}` | `{success, error?}` |
|
||
|
||
Uploads are JSON+base64, **not** multipart. Both require an active session.
|
||
|
||
### Scratchlist
|
||
|
||
Source: `hub/src/web/routes/sessions.ts` (scratchlist section); schemas `ScratchlistEntryCreateRequestSchema`, `ScratchlistEntryUpdateRequestSchema`, caps `SCRATCHLIST_MAX_ENTRIES = 200`, `SCRATCHLIST_MAX_TEXT_LENGTH = 10000` (`shared/src/apiTypes.ts`).
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `GET /api/sessions/:id/scratchlist` | — | `{entries: ScratchlistEntry[]}` (`{entryId, text, createdAt, updatedAt, attachments[]}`) |
|
||
| `POST /api/sessions/:id/scratchlist` | `{text, entryId?, createdAt?, attachments?}` (text ≤ 10 000; text or attachments required) | `201 {entry}`; `200 {entry}` when `entryId` already exists (idempotent retry); `409 code: 'scratchlist_at_cap'` at 200 entries |
|
||
| `PUT /api/sessions/:id/scratchlist/:entryId` | `{text?, attachments?}` (at least one) | `{entry}` |
|
||
| `DELETE /api/sessions/:id/scratchlist/:entryId` | — | `{ok: true}` |
|
||
| `GET /api/sessions/:id/scratchlist/limits` | — | `{limits}` (attachment size/count/byte budgets) |
|
||
| `POST /api/sessions/:id/scratchlist/upload` | JSON `{filename, content (base64), mimeType}` | `{success, attachment}`; `413 code: 'scratchlist_attachment_too_large'` |
|
||
| `GET /api/sessions/:id/scratchlist/attachments/:attachmentId` | — | **Raw bytes** (`Content-Type` from stored metadata) |
|
||
| `DELETE /api/sessions/:id/scratchlist/attachments/:attachmentId` | — | `{ok: true}`; `409 code: 'scratchlist_attachment_in_use'` while referenced by an entry |
|
||
|
||
Mutations bump `scratchlistUpdatedAt` in the session's SSE patch — use it as a refetch trigger, not as data.
|
||
|
||
### Voice dictation (the one multipart endpoint)
|
||
|
||
Source: `hub/src/web/routes/voice.ts`.
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `GET /api/voice/transcription/providers` | — | `{providers: [{id, label, modes}]}` — only providers whose keys are configured on the hub |
|
||
| `POST /api/voice/transcription` | `multipart/form-data`: `file` (audio, ≤ 25 MB, `audio/*` or webm/mp4), `provider` (`openai`\|`elevenlabs`\|`deepgram`\|`groq`\|`openai-compatible`), `mode` = `standard`, `language?` (BCP-47-ish, ≤ 35 chars) | `{text, language?}`; `413` body too large, `400` bad field |
|
||
|
||
The realtime-token, voice-assistant token, and WebSocket-proxy endpoints under `/api/voice/*` belong to the live voice assistant — out of scope for v1.
|
||
|
||
### Usage & storage (owner-only)
|
||
|
||
Source: `hub/src/web/routes/usage.ts`, `hub/src/web/routes/storage.ts`. Both `403` unless namespace is `default` ([Auth → Namespaces](./auth.md#namespaces)).
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `GET /api/usage/summary` | Query: `range=7d\|30d\|all` (default 7d), `timeZone` (IANA, validated) | `UsageSummaryResponse` `{range, totals, daily[], byAgent[], byModel[], updatedAt}` |
|
||
| `GET /api/storage/sqlite` | — | `{path, databaseBytes, walBytes, shmBytes, totalBytes}` |
|
||
|
||
### Devices (FCM push)
|
||
|
||
Source: `hub/src/web/routes/devices.ts`; full push contract in [`native-companion-contract.md`](../native-companion-contract.md).
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `POST /api/devices/register` | `{token, platform: 'phone'\|'wear', deviceId}` (deviceId: any stable 1–128-char install id) | `{ok: true}` (upsert) |
|
||
| `DELETE /api/devices/register` | `{token}` | `{ok: true}` |
|
||
|
||
### Visibility
|
||
|
||
Source: `hub/src/web/routes/events.ts` (`POST /visibility`).
|
||
|
||
| Method & path | Request | Response |
|
||
|---|---|---|
|
||
| `POST /api/visibility` | `{subscriptionId, visibility: 'visible'\|'hidden'}` | `{ok: true}`; `404` when the SSE subscription is gone |
|
||
|
||
`subscriptionId` comes from the SSE `connection-changed` event ([SSE](./sse.md)). Report foreground/background transitions so the hub can suppress redundant push notifications while the app is visibly connected.
|
||
|
||
### Hub settings (read)
|
||
|
||
Source: `hub/src/web/routes/hubSettings.ts`.
|
||
|
||
| Method & path | Response |
|
||
|---|---|
|
||
| `GET /api/hub-settings` | `{sessionSummaryContract: boolean, sessionSummaryInChat: boolean}` — readable by any namespace |
|
||
|
||
### SSE
|
||
|
||
`GET /api/events` is the realtime channel — subscription params, resume handshake, and reconnect policy are specified in [SSE](./sse.md). Its gzip behavior differs from the JSON endpoints (streaming compression with per-event flush), also covered there.
|
||
|
||
## Tier 2 — out of scope for v1
|
||
|
||
These exist on the hub but v1 native clients must not implement or call them:
|
||
|
||
| Area | Paths | Why out of scope |
|
||
|------|-------|------------------|
|
||
| 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) |
|
||
| 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 |
|
||
| Cursor maintenance | `/api/sessions/:id/migrate-to-acp`, `/cursor-chat-store` | Desktop store migration |
|
||
| Session export | `GET /api/sessions/:id/export` | Feeds the share/export feature, excluded from v1 |
|
||
| **CLI plane** | `/cli/*` (`hub/src/web/routes/cli.ts`) | **Forbidden for clients** — internal CLI↔hub surface; authenticates with the raw access token instead of a JWT and bypasses the client middleware. Never call it from a client, and never send the access token as a bearer anywhere except `POST /api/auth`'s JSON body |
|