mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* 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>
858 lines
31 KiB
TypeScript
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({})
|
|
})
|
|
}
|
|
}
|