Files
hapi/web/src/api/client.ts
T
2235b924a7 feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback (#896)
* feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback (#893)

Promotes scratchlist persistence from per-device localStorage to a hub-
backed typed table so entries follow the operator across devices. v1
panel UI / FUE / shortcut / styling are deliberately unchanged - this is
a backend + sync-layer feature.

Hub side
- New `session_scratchlist` typed table (sessionId, entryId, text,
  createdAt, updatedAt) with composite PK and FK ON DELETE CASCADE from
  sessions. Schema bumped V9 -> V10; idempotent migration added to the
  legacy + step ladders.
- REST CRUD under `/api/sessions/:id/scratchlist[/:entryId]`, all routed
  through the existing `requireSessionFromParam` guard so namespace /
  ownership enforcement is identical to other session-scoped routes.
- Per-session 200-entry cap enforced on POST. Duplicate entryId reported
  idempotently (200) so the migration retry path is safe.
- `SessionPatchSchema` extended with `scratchlistUpdatedAt?: number`;
  every successful mutation emits a `session-updated` SSE patch with the
  token. (Following operator's piggyback decision; aligns with the
  parallel #884 patch-shape extension.)

Web side
- Hub becomes source of truth via TanStack Query
  (`queryKeys.scratchlist(sessionId)`); localStorage demoted to offline
  cache. Add / delete / update mutations are optimistic with rollback
  on error.
- Silent first-load migration: existing localStorage entries are pushed
  to the hub preserving id + createdAt, and a one-time banner (mirroring
  `CursorMigrationBanner`) tells the operator their notes are now in
  the hub. Banner dismissal is per-session and persistent.
- SSE handler queues a `scratchlist` invalidation when the patch carries
  `scratchlistUpdatedAt`, so cross-device + cross-tab updates land
  within an SSE round-trip.
- Delete-session confirm copy now includes a count of scratchlist
  entries that will be cascade-deleted.

Out of scope (separate tracking issue #894): "delete with summarize-and-
migrate" UX flow.

Tests
- Hub: V9->V10 migration (fresh + multi-hop legacy + idempotent reopen
  + cascade-delete), `ScratchlistStore` CRUD + ordering, REST routes
  (happy path + 400/403/404/409), SyncEngine SSE emission.
- Web: hook covers initial fetch, optimistic add/delete/update with
  rollback, localStorage migration + banner, cap enforcement,
  local-only reorder. Banner component renders only on `'completed'`.
- Existing Playwright e2e (10 tests, panel UI regression) all pass
  unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): address HAPI Bot Major findings on PR #896

Two real data-correctness paths the bot caught on the initial review.

1. Migration partial-failure data loss
   The migration loop swallowed each failed POST and still wrote the
   `migrated` flag, while the offline-cache effect mirrored the
   (partial) hub state back into `hapi.scratchlist.v1.<sessionId>` -
   so a transient error or cap rejection could leave entries neither
   on the hub nor in localStorage. Fix:
   - Track failed entries during migration and persist them back to
     localStorage; do NOT advance the flag if any entry failed, so a
     future mount retries.
   - Gate the offline-cache effect on the migration flag. Pre-
     migration, localStorage holds the v1 entries the migration
     reads; mirroring an empty hub fetch over them was the wipe.
   - Drop the "skip migration when hub is non-empty" gate. Combined
     with the duplicate-idempotent POST short-circuit (below), a
     retry against a session that another device already populated
     is a safe union.

2. Duplicate POST returned 409 at cap
   The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking
   the store whether the supplied `entryId` already existed, so an
   idempotent migration retry against a 200-row session returned 409
   instead of 200. Fix: check duplicate first via a new
   `SyncEngine.getScratchlistEntry`, return the existing row with 200,
   and only run the cap check for genuinely new ids.

Tests added:
- hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap
  + new entryId still 409.
- web/hook: partial-failure persists the failed entries back to
  localStorage and leaves the flag unset; offline-cache effect does
  not wipe pre-migration localStorage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web/scratchlist): per-entry age indicator (clock icon + tooltip)

Surfaces the smart-relative time the entry was last saved on every
scratchlist row, mirroring the bucketing used in the session list:
just-now -> Nm -> Nh -> Nd -> absolute date.

Implementation:
- Extract the existing `formatRelativeTime` helper out of SessionList
  into `web/src/lib/relative-time.ts` so the panel can reuse the
  same buckets and i18n keys (no copy-paste drift between surfaces).
  Also add `formatAbsoluteDateTime` for the precise-stamp tooltip
  line.
- Add `updatedAt?: number` to the local `ScratchlistEntry` shape.
  v1-only callers stay valid (the field is optional and `isEntry`
  now accepts rows that omit it). The hub hook forwards the hub's
  `updatedAt` so the indicator reflects edits, not just creation.
- New `EntryAgeIndicator` component: clock SVG in the same style as
  the existing action icons, rendered inside both panel surfaces
  (the older `ScratchlistList` and the drawer variant). Falls back
  to `createdAt` when `updatedAt` is missing (legacy v1 rows during
  the migration window) and renders nothing if neither timestamp is
  usable.
- Tooltip carries the relative bucket plus the absolute timestamp
  on a second line; aria-label carries the relative bucket only so
  screen readers stay terse.
- Mirror `updatedAt` into the localStorage offline cache so an
  offline reload still has accurate ages.

Tests:
- `relative-time.test.ts`: bucket math, seconds-vs-ms detection,
  non-finite guard.
- `ScratchlistPanel.test.tsx`: indicator renders with the right
  smart-relative bucket, falls back to `createdAt` when `updatedAt`
  is absent, and renders nothing when both timestamps are zero.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): bound client-supplied entryId length (HAPI Bot, PR #896)

The POST /api/sessions/:id/scratchlist body validator left `entryId`
unbounded (`z.string().min(1)`), but that string is persisted as part
of the SQLite primary key. An authenticated/direct client could grow
the table and its index well beyond the intended scratchlist limits
by submitting oversized keys.

Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a
UUID's 36 chars plus any prefix scheme we might layer on later) and
applies `.max(...)` to the optional `entryId` in
`ScratchlistEntryCreateRequestSchema`. Anything longer is rejected
with 400 before the row hits SQLite.

Test pins the new behavior: a 129-char id returns 400 and never reaches
the engine.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): banner state machine - 'completed' is sticky until dismissed (HAPI Bot, PR #896)

The previous state machine swallowed the migration banner if the
operator reloaded the page before clicking dismiss: the migration flag
was set on success, and on remount the init logic mapped a
flag-set/dismiss-not-set session to 'pre-migrated', a state the banner
explicitly refuses to render. Net effect: a migrated session never
prompted for affirmative dismissal.

Fixes:

- Drop the 'pre-migrated' state. The dismissal flag is now the only
  signal that suppresses the banner; the migration flag alone means
  'banner shows until dismissed' (now or after a reload).
- Sessions that had nothing to migrate (no v1 entries in localStorage)
  pre-emptively write BOTH flags - migrated AND dismissed - so the bot's
  banner-stickiness fix doesn't surface a banner that has nothing to
  announce on freshly-created v2 sessions.

Tests:

- New `reload-before-dismiss leaves the banner visible` test pins the
  fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2
  on the same session reads the localStorage flags and stays
  'completed'.
- New `opts fresh sessions out of the banner pre-emptively` test pins
  the no-v1-entries shortcut.
- Existing `does not re-migrate on a mount where the migrated flag is
  already set` updated to assert 'completed' (not the dropped
  'pre-migrated').
- Existing `skips migration when localStorage is empty` updated to
  assert the new 'dismissed' status + the banner-dismissed flag.
- Banner test for the 'pre-migrated -> nothing' case removed (the state
  no longer exists).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): transfer rows during session merge so cascade-delete does not strand them (closes #920 for v2.0)

`mergeSessionData` in `sessionCache.ts` ends every merge codepath with
`deleteSession(oldSessionId)`, which fires `ON DELETE CASCADE` on every
FK-tied table. `session_scratchlist.session_id` is FK'd with cascade,
so without an explicit transfer step every dedup (#448 agent-id
collision) and every resume-of-inactive (`syncEngine.resumeSession` ->
mergeSessions) silently destroys the operator's per-session notes.

This is the gap upstream-discovery agent flagged on #920 against PR
#896. With the 2026-06-15 hub-restart cascade incident as evidence
(23 sessions auto-archived in a single bounce, 4 confirmed HAPI-id
rotations across 2 bounces), unmitigated this would violate v2.0's
"survives reloads / second laptop / clear-site-data" promise the
first time the operator hits a hub bounce.

Fix:

- New `transferScratchlistEntries(db, fromSessionId, toSessionId)`
  in `hub/src/store/scratchlist.ts`. Atomic via BEGIN/COMMIT.
  Uses `UPDATE OR IGNORE` so rows that would collide on
  PRIMARY KEY (session_id, entry_id) simply do not move - the
  dedup target's copy wins, matching the operator's mental
  model that the consolidated session is authoritative. Cleans
  up any collision-loser rows so the no-delete codepath
  (`mergeSessionHistory`) is symmetric with the delete path.
- Wired into `mergeSessionData` BEFORE the `deleteSession()`
  call, alongside the existing message-merge step. Both
  `mergeSessions` (deleteOld=true) and `mergeSessionHistory`
  (deleteOld=false) get coverage because both can rotate the
  visible session id.
- Emits `session-updated{scratchlistUpdatedAt}` on the new
  session so any web client looking at the consolidated id
  invalidates and refetches; for the keep-old codepath the
  emit also fires on the old id since it stays alive but is
  now empty of scratchlist.

Tests (`sessionCache-merge-scratchlist.test.ts`, 7 cases):

- mergeSessions (deleteOld=true): rows move, old is gone, no
  stranded rows.
- mergeSessions PK collision: dedup target wins, unique-to-old
  rows still come across.
- mergeSessions SSE: exactly one scratchlist patch on the new id.
- mergeSessions no-op: zero rows -> zero emits.
- mergeSessionHistory (deleteOld=false): rows move, old session
  stays alive but empty of scratchlist.
- mergeSessionHistory SSE: emits on BOTH old and new ids.
- Cascade-delete safety smoke: post-merge, an explicit operator
  delete of the new session DOES cascade-delete its scratchlist
  (i.e. the FK cascade we want is intact; the bug was triggering
  it on the wrong id).

Web layer note:
v1 localStorage is keyed by HAPI session id; on rotation the old
key is orphaned but no longer represents data loss because the
hub now holds the canonical state and the offline-cache mirror
re-populates `hapi.scratchlist.v1.<newId>` on first read of the
consolidated session. Documented as a known limitation; not a
blocker for v2.0 because the hub is the source of truth.

#894 (v2.1 migrate-on-delete) inherits a related concern about
operator-Delete vs merge-Delete consent flow - flagged in the
upstream-discovery handoff, separate scope.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): rebase onto upstream/main - scratchlist migration is V10→V11

Upstream landed V9→V10 as sessions.service_tier (#898/#904). Scratchlist
v2 moves to V10→V11 so both migrations coexist without clobbering each
other.

- mergeSessionData conflict resolved: keep upstream migrateFromV9ToV10
  (service_tier) and add migrateFromV10ToV11 (session_scratchlist)
- SCHEMA_VERSION bumped 10 → 11
- Rename migration-v10.test.ts → migration-v11.test.ts with updated
  multi-hop coverage (V9→V10→V11)
- Add serviceTier: null to scratchlist route test session fixture
  (required by upstream Session type after #898)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): emit SSE on all-collision merge when old session stays alive (HAPI Bot, PR #896)

When mergeSessionHistory deletes every old scratchlist row via PK
collision (moved=0, collided>0) the still-alive old session kept
showing stale cached entries until an unrelated refetch.

Emit scratchlistUpdatedAt on the old id whenever collided>0 on the
keep-old codepath, not only when moved>0. New-session emit stays
gated on moved>0 since the target row is unchanged on full collision.

Test pins the all-collision mergeSessionHistory case.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): stabilize migration queryKey to stop POST retry loop (HAPI Bot, PR #896)

useMemo on queryKeys.scratchlist(sessionId) so the migration effect does not
re-fire every render after a failed POST clears migrationAttemptedRef.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): dedupe optimistic add when SSE refetch wins race (HAPI Bot, PR #896)

onSuccess now drops both the temporary optimistic id and any existing row
with the canonical entryId so a fast SSE invalidation cannot leave twins.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): treat 404 on update/delete as stale cache, not rollback (HAPI Bot, PR #896)

When another client already removed an entry, keep it gone locally and
invalidate instead of restoring previousData from optimistic rollback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): drop optimistic add ghost when previousData missing (HAPI Bot, PR #896)

onError now filters by optimisticEntryId if the initial fetch never
populated cache, so a rejected POST cannot leave an unsaved note.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
2026-07-28 12:16:59 +08:00

858 lines
31 KiB
TypeScript

import type {
AttachmentMetadata,
AuthResponse,
CodexLocalSessionsResponse,
CodexDuplicateSessionsResponse,
CodexMergeDuplicateSessionsResponse,
CodexDesktopScriptResponse,
CodexDesktopSyncRequest,
CodexDesktopStatusResponse,
CodexArchiveSessionResponse,
CodexCollaborationMode,
FileSearchResponse,
MachinesResponse,
MessagesResponse,
PermissionMode,
PushSubscriptionPayload,
PushUnsubscribePayload,
PushVapidPublicKeyResponse,
SlashCommandsResponse,
SkillsResponse,
SpawnResponse,
VisibilityPayload,
HapiSessionExport,
SessionResponse,
SessionsResponse
} from '@/types/api'
import type {
CodexModelsResponse,
CursorMigrateOutcome,
CursorMigrateToAcpRequest,
CursorChatStoreStatus,
CursorModelsResponse,
DeleteUploadResponse,
FileReadResponse,
GitCommandResponse,
GrokModelsResponse,
GrokReasoningEffortResponse,
ListDirectoryResponse,
MachineListDirectoryResponse,
MachinePathsExistsResponse,
OpencodeModelsResponse,
OpencodeReasoningEffortResponse,
QueuedStateResponse,
ReopenSessionResponse,
UploadFileResponse
} from '@hapi/protocol/apiTypes'
import type { AgentFlavor } from '@hapi/protocol'
import type { CancelMessageResponse } from '@hapi/protocol/schemas'
type ApiClientOptions = {
baseUrl?: string
getToken?: () => string | null
onUnauthorized?: () => Promise<string | null>
}
type ErrorPayload = {
error?: unknown
code?: unknown
}
function parseErrorCode(bodyText: string): string | undefined {
try {
const parsed = JSON.parse(bodyText) as ErrorPayload
if (typeof parsed.code === 'string') return parsed.code
if (typeof parsed.error === 'string') return parsed.error
return undefined
} catch {
return undefined
}
}
export class ApiError extends Error {
status: number
code?: string
body?: string
constructor(message: string, status: number, code?: string, body?: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
this.body = body
}
}
export class ApiClient {
private token: string
private readonly baseUrl: string | null
private readonly getToken: (() => string | null) | null
private readonly onUnauthorized: (() => Promise<string | null>) | null
constructor(token: string, options?: ApiClientOptions) {
this.token = token
this.baseUrl = options?.baseUrl ?? null
this.getToken = options?.getToken ?? null
this.onUnauthorized = options?.onUnauthorized ?? null
}
private buildUrl(path: string): string {
if (!this.baseUrl) {
return path
}
try {
return new URL(path, this.baseUrl).toString()
} catch {
return path
}
}
private async request<T>(
path: string,
init?: RequestInit,
attempt: number = 0,
overrideToken?: string | null
): Promise<T> {
const headers = new Headers(init?.headers)
const liveToken = this.getToken ? this.getToken() : null
const authToken = overrideToken !== undefined
? (overrideToken ?? (liveToken ?? this.token))
: (liveToken ?? this.token)
if (authToken) {
headers.set('authorization', `Bearer ${authToken}`)
}
if (init?.body !== undefined && !headers.has('content-type')) {
headers.set('content-type', 'application/json')
}
const res = await fetch(this.buildUrl(path), {
...init,
headers
})
if (res.status === 401) {
if (attempt === 0 && this.onUnauthorized) {
const refreshed = await this.onUnauthorized()
if (refreshed) {
this.token = refreshed
return await this.request<T>(path, init, attempt + 1, refreshed)
}
}
throw new Error('Session expired. Please sign in again.')
}
if (!res.ok) {
const body = await res.text().catch(() => '')
const code = parseErrorCode(body)
throw new ApiError(
`HTTP ${res.status} ${res.statusText}: ${body}`,
res.status,
code,
body || undefined
)
}
return await res.json() as T
}
async authenticate(auth: { initData: string } | { accessToken: string }): Promise<AuthResponse> {
const res = await fetch(this.buildUrl('/api/auth'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(auth)
})
if (!res.ok) {
const body = await res.text().catch(() => '')
const code = parseErrorCode(body)
const detail = body ? `: ${body}` : ''
throw new ApiError(`Auth failed: HTTP ${res.status} ${res.statusText}${detail}`, res.status, code, body || undefined)
}
return await res.json() as AuthResponse
}
async bind(auth: { initData: string; accessToken: string }): Promise<AuthResponse> {
const res = await fetch(this.buildUrl('/api/bind'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(auth)
})
if (!res.ok) {
const body = await res.text().catch(() => '')
const code = parseErrorCode(body)
const detail = body ? `: ${body}` : ''
throw new ApiError(`Bind failed: HTTP ${res.status} ${res.statusText}${detail}`, res.status, code, body || undefined)
}
return await res.json() as AuthResponse
}
async getSessions(): Promise<SessionsResponse> {
return await this.request<SessionsResponse>('/api/sessions')
}
async getPushVapidPublicKey(): Promise<PushVapidPublicKeyResponse> {
return await this.request<PushVapidPublicKeyResponse>('/api/push/vapid-public-key')
}
async subscribePushNotifications(payload: PushSubscriptionPayload): Promise<void> {
await this.request('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(payload)
})
}
async syncCodexSession(payload?: CodexDesktopSyncRequest): Promise<CodexDesktopScriptResponse> {
// 中文注释:当前按钮语义已改为“从 Codex 导入到 Hapi”;这里提交的是本地 transcript 对应的 Codex thread ID 列表。
return await this.request<CodexDesktopScriptResponse>('/api/codex/sync-session', {
method: 'POST',
...(payload ? { body: JSON.stringify(payload) } : {})
})
}
async getCodexSessions(cwd?: string | null, machineId?: string | null): Promise<CodexLocalSessionsResponse> {
const params = new URLSearchParams()
if (cwd?.trim()) params.set('cwd', cwd.trim())
if (machineId?.trim()) params.set('machineId', machineId.trim())
const query = params.size ? `?${params.toString()}` : ''
return await this.request<CodexLocalSessionsResponse>(`/api/codex/sessions${query}`)
}
async archiveCodexSession(sessionId: string, machineId?: string | null): Promise<CodexArchiveSessionResponse> {
return await this.request<CodexArchiveSessionResponse>('/api/codex/archive-session', {
method: 'POST',
body: JSON.stringify({ sessionId, machineId: machineId ?? undefined })
})
}
async getCodexDesktopStatus(): Promise<CodexDesktopStatusResponse> {
return await this.request<CodexDesktopStatusResponse>('/api/codex/status')
}
async getCodexDuplicateSessions(payload: CodexDesktopSyncRequest): Promise<CodexDuplicateSessionsResponse> {
// 中文注释:重复会话检测只传本次用户勾选导入的 codexSessionId,避免把未选中的历史会话也纳入提示。
return await this.request<CodexDuplicateSessionsResponse>('/api/codex/duplicate-sessions', {
method: 'POST',
body: JSON.stringify(payload)
})
}
async mergeCodexDuplicateSessions(payload: CodexDesktopSyncRequest): Promise<CodexMergeDuplicateSessionsResponse> {
// 中文注释:真正执行合并时沿用同一批选中 codexSessionId,保证检测范围与执行范围一致。
return await this.request<CodexMergeDuplicateSessionsResponse>('/api/codex/merge-duplicate-sessions', {
method: 'POST',
body: JSON.stringify(payload)
})
}
async restartCodexDesktop(): Promise<CodexDesktopScriptResponse> {
return await this.request<CodexDesktopScriptResponse>('/api/codex/restart-desktop', {
method: 'POST'
})
}
async unsubscribePushNotifications(payload: PushUnsubscribePayload): Promise<void> {
await this.request('/api/push/subscribe', {
method: 'DELETE',
body: JSON.stringify(payload)
})
}
async setVisibility(payload: VisibilityPayload): Promise<void> {
await this.request('/api/visibility', {
method: 'POST',
body: JSON.stringify(payload)
})
}
async getSession(sessionId: string): Promise<SessionResponse> {
return await this.request<SessionResponse>(`/api/sessions/${encodeURIComponent(sessionId)}`)
}
async getSessionExport(sessionId: string, options?: { signal?: AbortSignal }): Promise<HapiSessionExport> {
return await this.request<HapiSessionExport>(
`/api/sessions/${encodeURIComponent(sessionId)}/export`,
{ signal: options?.signal }
)
}
async getMessages(
sessionId: string,
options: {
beforeSeq?: number | null
beforeAt?: number | null
limit?: number
}
): Promise<MessagesResponse> {
const params = new URLSearchParams()
if (options.beforeAt !== undefined && options.beforeAt !== null) {
params.set('beforeAt', `${options.beforeAt}`)
}
if (options.beforeSeq !== undefined && options.beforeSeq !== null) {
params.set('beforeSeq', `${options.beforeSeq}`)
}
if (options.limit !== undefined && options.limit !== null) {
params.set('limit', `${options.limit}`)
}
const qs = params.toString()
const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`
return await this.request<MessagesResponse>(url)
}
async getGitStatus(sessionId: string): Promise<GitCommandResponse> {
return await this.request<GitCommandResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/git-status`)
}
async getGitDiffNumstat(sessionId: string, staged: boolean): Promise<GitCommandResponse> {
const params = new URLSearchParams()
params.set('staged', staged ? 'true' : 'false')
return await this.request<GitCommandResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff-numstat?${params.toString()}`)
}
async getGitDiffFile(sessionId: string, path: string, staged?: boolean): Promise<GitCommandResponse> {
const params = new URLSearchParams()
params.set('path', path)
if (staged !== undefined) {
params.set('staged', staged ? 'true' : 'false')
}
return await this.request<GitCommandResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff-file?${params.toString()}`)
}
async searchSessionFiles(sessionId: string, query: string, limit?: number): Promise<FileSearchResponse> {
const params = new URLSearchParams()
if (query) {
params.set('query', query)
}
if (limit !== undefined) {
params.set('limit', `${limit}`)
}
const qs = params.toString()
return await this.request<FileSearchResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/files${qs ? `?${qs}` : ''}`)
}
async getGeneratedImageBlob(sessionId: string, imageId: string, attempt: number = 0, overrideToken?: string | null): Promise<Blob> {
const headers = new Headers()
const liveToken = this.getToken ? this.getToken() : null
const authToken = overrideToken !== undefined
? (overrideToken ?? (liveToken ?? this.token))
: (liveToken ?? this.token)
if (authToken) {
headers.set('authorization', `Bearer ${authToken}`)
}
const res = await fetch(this.buildUrl(`/api/sessions/${encodeURIComponent(sessionId)}/generated-images/${encodeURIComponent(imageId)}`), {
headers
})
if (res.status === 401 && attempt === 0 && this.onUnauthorized) {
const refreshed = await this.onUnauthorized()
if (refreshed) {
this.token = refreshed
return await this.getGeneratedImageBlob(sessionId, imageId, attempt + 1, refreshed)
}
}
if (!res.ok) {
throw new ApiError(`HTTP ${res.status}`, res.status, undefined, await res.text().catch(() => undefined))
}
return await res.blob()
}
async readSessionFile(sessionId: string, path: string): Promise<FileReadResponse> {
const params = new URLSearchParams()
params.set('path', path)
return await this.request<FileReadResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/file?${params.toString()}`)
}
async listSessionDirectory(sessionId: string, path?: string): Promise<ListDirectoryResponse> {
const params = new URLSearchParams()
if (path) {
params.set('path', path)
}
const qs = params.toString()
return await this.request<ListDirectoryResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/directory${qs ? `?${qs}` : ''}`
)
}
async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise<UploadFileResponse> {
return await this.request<UploadFileResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/upload`, {
method: 'POST',
body: JSON.stringify({ filename, content, mimeType })
})
}
async deleteUploadFile(sessionId: string, path: string): Promise<DeleteUploadResponse> {
return await this.request<DeleteUploadResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/upload/delete`, {
method: 'POST',
body: JSON.stringify({ path })
})
}
async resumeSession(sessionId: string, opts?: { permissionMode?: string }): Promise<string> {
const response = await this.request<{ sessionId: string }>(
`/api/sessions/${encodeURIComponent(sessionId)}/resume`,
{
method: 'POST',
...(opts?.permissionMode !== undefined && {
body: JSON.stringify({ permissionMode: opts.permissionMode })
})
}
)
return response.sessionId
}
async getCursorChatStoreStatus(sessionId: string): Promise<CursorChatStoreStatus> {
return await this.request<CursorChatStoreStatus>(
`/api/sessions/${encodeURIComponent(sessionId)}/cursor-chat-store`
)
}
async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[], scheduledAt?: number | null): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: 'POST',
body: JSON.stringify({
text,
localId: localId ?? undefined,
attachments: attachments ?? undefined,
scheduledAt: scheduledAt ?? undefined
})
})
}
async getQueuedState(sessionId: string, localIds: string[]): Promise<QueuedStateResponse> {
return await this.request<QueuedStateResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/messages/queued-state`,
{
method: 'POST',
body: JSON.stringify({ localIds })
}
)
}
async cancelMessage(sessionId: string, messageId: string): Promise<CancelMessageResponse> {
const response = await this.request(
`/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
{ method: 'DELETE' }
)
return response as CancelMessageResponse
}
async abortSession(sessionId: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/abort`, {
method: 'POST',
body: JSON.stringify({})
})
}
async archiveSession(sessionId: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/archive`, {
method: 'POST',
body: JSON.stringify({})
})
}
async reopenSession(sessionId: string): Promise<ReopenSessionResponse> {
return await this.request<ReopenSessionResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/reopen`,
{ method: 'POST', body: JSON.stringify({}) }
)
}
/**
* Migrate a legacy stream-json Cursor session to ACP. See tiann/hapi#824.
*
* Refusals (e.g. running session, missing on-disk store, target collision)
* are returned as structured `{ok: false, reason, message}` outcomes
* rather than thrown - the UI surfaces the reason to the operator and the
* underlying state on disk is unchanged.
*
* 401s trigger the same onUnauthorized refresh path as the shared
* `request()` helper so an expired JWT silently re-auths instead of
* hard-failing the migration dialog (Codex review #34 P2).
*/
async migrateCursorSessionToAcp(sessionId: string, body: CursorMigrateToAcpRequest = {}): Promise<CursorMigrateOutcome> {
const path = `/api/sessions/${encodeURIComponent(sessionId)}/migrate-to-acp`
const tryOnce = async (overrideToken: string | null): Promise<Response> => {
const headers = new Headers({ 'content-type': 'application/json' })
const liveToken = this.getToken ? this.getToken() : null
const authToken = overrideToken ?? liveToken ?? this.token
if (authToken) {
headers.set('authorization', `Bearer ${authToken}`)
}
return fetch(this.buildUrl(path), { method: 'POST', headers, body: JSON.stringify(body) })
}
let res = await tryOnce(null)
if (res.status === 401 && this.onUnauthorized) {
const refreshed = await this.onUnauthorized()
if (refreshed) {
this.token = refreshed
res = await tryOnce(refreshed)
}
}
if (res.status === 401) {
throw new Error('Session expired. Please sign in again.')
}
const text = await res.text()
let parsed: CursorMigrateOutcome | null = null
try {
parsed = text ? JSON.parse(text) as CursorMigrateOutcome : null
} catch {
parsed = null
}
if (parsed && typeof parsed === 'object' && 'ok' in parsed) {
return parsed
}
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`)
}
async switchSession(sessionId: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/switch`, {
method: 'POST',
body: JSON.stringify({})
})
}
async setPermissionMode(sessionId: string, mode: PermissionMode): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permission-mode`, {
method: 'POST',
body: JSON.stringify({ mode })
})
}
async setCollaborationMode(sessionId: string, mode: CodexCollaborationMode): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/collaboration-mode`, {
method: 'POST',
body: JSON.stringify({ mode })
})
}
async setModel(sessionId: string, model: { provider: string; modelId: string } | string | null): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, {
method: 'POST',
body: JSON.stringify({ model })
})
}
async setModelReasoningEffort(sessionId: string, modelReasoningEffort: string | null): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model-reasoning-effort`, {
method: 'POST',
body: JSON.stringify({ modelReasoningEffort })
})
}
async setEffort(sessionId: string, effort: string | null): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/effort`, {
method: 'POST',
body: JSON.stringify({ effort })
})
}
async setServiceTier(sessionId: string, serviceTier: string | null): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/service-tier`, {
method: 'POST',
body: JSON.stringify({ serviceTier })
})
}
async approvePermission(
sessionId: string,
requestId: string,
modeOrOptions?: 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan' | {
mode?: 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan'
allowTools?: string[]
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
answers?: Record<string, string[]> | Record<string, { answers: string[] }>
}
): Promise<void> {
const body = typeof modeOrOptions === 'string' || modeOrOptions === undefined
? { mode: modeOrOptions }
: modeOrOptions
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}/approve`, {
method: 'POST',
body: JSON.stringify(body)
})
}
async denyPermission(
sessionId: string,
requestId: string,
options?: {
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
}
): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}/deny`, {
method: 'POST',
body: JSON.stringify(options ?? {})
})
}
async getMachines(): Promise<MachinesResponse> {
return await this.request<MachinesResponse>('/api/machines')
}
async listMachineDirectory(
machineId: string,
path: string
): Promise<MachineListDirectoryResponse> {
return await this.request<MachineListDirectoryResponse>(
`/api/machines/${encodeURIComponent(machineId)}/list-directory`,
{
method: 'POST',
body: JSON.stringify({ path })
}
)
}
async checkMachinePathsExists(
machineId: string,
paths: string[]
): Promise<MachinePathsExistsResponse> {
return await this.request<MachinePathsExistsResponse>(
`/api/machines/${encodeURIComponent(machineId)}/paths/exists`,
{
method: 'POST',
body: JSON.stringify({ paths })
}
)
}
async spawnSession(
machineId: string,
directory: string,
agent?: AgentFlavor,
model?: string,
modelReasoningEffort?: string,
yolo?: boolean,
sessionType?: 'simple' | 'worktree',
worktreeName?: string,
effort?: string,
permissionMode?: PermissionMode,
serviceTier?: 'fast' | 'standard',
collaborationMode?: 'default' | 'plan'
): Promise<SpawnResponse> {
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
method: 'POST',
body: JSON.stringify({
directory,
agent,
model,
modelReasoningEffort,
yolo,
sessionType,
worktreeName,
effort,
permissionMode,
serviceTier,
collaborationMode
})
})
}
async getMachineCodexModels(machineId: string): Promise<CodexModelsResponse> {
return await this.request<CodexModelsResponse>(
`/api/machines/${encodeURIComponent(machineId)}/codex-models`
)
}
async getSessionOpencodeModels(sessionId: string): Promise<OpencodeModelsResponse> {
return await this.request<OpencodeModelsResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/opencode-models`
)
}
async getSessionOpencodeReasoningEffortOptions(sessionId: string): Promise<OpencodeReasoningEffortResponse> {
return await this.request<OpencodeReasoningEffortResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/opencode-reasoning-effort-options`
)
}
async getSessionCursorModels(sessionId: string): Promise<CursorModelsResponse> {
return await this.request<CursorModelsResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/cursor-models`
)
}
/** Generic Pi session endpoint — replaces per-method wrappers. */
async callPiEndpoint<T = unknown>(sessionId: string, path: string, init?: RequestInit): Promise<T> {
return await this.request<T>(
`/api/sessions/${encodeURIComponent(sessionId)}/pi-${path}`,
init
)
}
async getMachineCursorModels(machineId: string): Promise<CursorModelsResponse> {
return await this.request<CursorModelsResponse>(
`/api/machines/${encodeURIComponent(machineId)}/cursor-models`
)
}
async getMachineOpencodeModelsForCwd(machineId: string, cwd: string): Promise<OpencodeModelsResponse> {
return await this.request<OpencodeModelsResponse>(
`/api/machines/${encodeURIComponent(machineId)}/opencode-models?cwd=${encodeURIComponent(cwd)}`
)
}
async getMachineGrokModelsForCwd(machineId: string, cwd: string): Promise<GrokModelsResponse> {
return await this.request<GrokModelsResponse>(
`/api/machines/${encodeURIComponent(machineId)}/grok-models?cwd=${encodeURIComponent(cwd)}`
)
}
async getSessionGrokModels(sessionId: string): Promise<GrokModelsResponse> {
return await this.request<GrokModelsResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/grok-models`
)
}
async getSessionGrokReasoningEffortOptions(sessionId: string): Promise<GrokReasoningEffortResponse> {
return await this.request<GrokReasoningEffortResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/grok-reasoning-effort-options`
)
}
async getSlashCommands(sessionId: string): Promise<SlashCommandsResponse> {
return await this.request<SlashCommandsResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/slash-commands`
)
}
async getSkills(sessionId: string): Promise<SkillsResponse> {
return await this.request<SkillsResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/skills`
)
}
async renameSession(sessionId: string, name: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'PATCH',
body: JSON.stringify({ name })
})
}
async deleteSession(sessionId: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE'
})
}
/*
* Scratchlist v2 (tiann/hapi#893).
*
* The hub is the durable store; localStorage is demoted to an
* offline cache. Mutations return the canonical entry so optimistic
* updates can reconcile with the hub-stamped `updatedAt`.
*/
async getScratchlist(sessionId: string): Promise<{
entries: Array<{ entryId: string; text: string; createdAt: number; updatedAt: number }>
}> {
return await this.request(
`/api/sessions/${encodeURIComponent(sessionId)}/scratchlist`
)
}
async createScratchlistEntry(
sessionId: string,
body: { text: string; entryId?: string; createdAt?: number }
): Promise<{
entry: { entryId: string; text: string; createdAt: number; updatedAt: number }
}> {
return await this.request(
`/api/sessions/${encodeURIComponent(sessionId)}/scratchlist`,
{
method: 'POST',
body: JSON.stringify(body)
}
)
}
async updateScratchlistEntry(
sessionId: string,
entryId: string,
text: string
): Promise<{
entry: { entryId: string; text: string; createdAt: number; updatedAt: number }
}> {
return await this.request(
`/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/${encodeURIComponent(entryId)}`,
{
method: 'PUT',
body: JSON.stringify({ text })
}
)
}
async deleteScratchlistEntry(sessionId: string, entryId: string): Promise<void> {
await this.request(
`/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/${encodeURIComponent(entryId)}`,
{ method: 'DELETE' }
)
}
async fetchVoiceToken(options?: { customAgentId?: string; customApiKey?: string; voiceId?: string }): Promise<{
allowed: boolean
token?: string
agentId?: string
error?: string
}> {
return await this.request('/api/voice/token', {
method: 'POST',
body: JSON.stringify(options || {})
})
}
async fetchVoices(): Promise<{ voices: Array<{ id: string; name: string; previewUrl: string; category: string }> }> {
return await this.request('/api/voice/voices')
}
async sendVoiceTelemetry(event: {
stage: string
message: string
sessionId?: string
voiceId?: string
language?: string
details?: Record<string, unknown>
}): Promise<void> {
await this.request('/api/voice/telemetry', {
method: 'POST',
body: JSON.stringify(event)
})
}
/** Return the current auth token (for WebSocket query-param auth). */
getAuthToken(): string | null {
return this.getToken ? this.getToken() : this.token
}
async fetchVoiceBackend(): Promise<{ backend: string; backends: string[] }> {
return await this.request('/api/voice/backend')
}
async fetchQwenToken(): Promise<{
allowed: boolean
wsUrl?: string
error?: string
}> {
return await this.request('/api/voice/qwen-token', {
method: 'POST',
body: JSON.stringify({})
})
}
async fetchGeminiToken(): Promise<{
allowed: boolean
apiKey?: string
wsUrl?: string
baseUrl?: string
error?: string
}> {
return await this.request('/api/voice/gemini-token', {
method: 'POST',
body: JSON.stringify({})
})
}
}