Files
hapi/cli/src/api/api.ts
T
988f8f183f feat: settings toggle for AGENT_NOTIFY_SUMMARY contract injection (#1376)
* feat: settings toggle for AGENT_NOTIFY_SUMMARY contract injection

Add a hub-persisted, default-off Settings control so operators can opt agents into emitting the trailing AGENT_NOTIFY_SUMMARY line. Propagate the resolved flag on CLI session bootstrap and inject at call time for Claude, Codex, OpenCode, and Grok (Cursor still unsupported).

Closes #1375

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

* fix: lock hub settings RMW and restore abort deliveryMode

Serialize settings.json updates with the shared .lock protocol and unique
temp files so the new hub toggle cannot clobber CLI/relay fields under
concurrency. Also supply deliveryMode on abort send-error restore so web
typecheck (and CI) pass.

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

* fix(web): wrap SettingsGeneralPage tests with QueryClientProvider

The hub-settings toggle uses TanStack Query; the settings page suite
was rendering without a QueryClient and blew up CI.

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

* fix(web): defer summary-contract toggle until settings load

Avoid rendering an interactive false switch while the hub GET is still
in flight, which could overwrite an enabled preference on early click.

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

* fix(web): clarify Grok coverage for summary-contract toggle

Local Grok has no instruction inject path; settings copy now matches
remote-only Grok support (and still excludes Cursor).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:51:35 +08:00

341 lines
13 KiB
TypeScript

import axios from 'axios'
import type { AgentState, ClearOpencodeSessionCallbackRequest, ClearOpencodeSessionResponse, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types'
import { applyHubSessionSummaryContract } from '@/modules/common/sessionSummaryInstruction'
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
import {
AgentStateSchema,
ClearOpencodeSessionResponseSchema,
CreateMachineResponseSchema,
CreateSessionResponseSchema,
GetSessionResponseSchema,
LocalHandoffResponseSchema,
LocalResumeTargetResponseSchema,
RunnerStateSchema,
MachineMetadataSchema,
MetadataSchema,
ResumableSessionsResponseSchema
} from '@/api/types'
import { configuration } from '@/configuration'
import { getAuthToken } from '@/api/auth'
import { apiValidationError } from '@/utils/errorUtils'
import { ApiMachineClient } from './apiMachine'
import { ApiSessionClient, type ApiSessionClientOptions } from './apiSession'
import { buildHubRequestHeaders } from './hubExtraHeaders'
export class ApiClient {
static async create(): Promise<ApiClient> {
return new ApiClient(getAuthToken())
}
private constructor(private readonly token: string) { }
private authHeaders(): Record<string, string> {
return buildHubRequestHeaders({
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json'
})
}
async getOrCreateSession(opts: {
id?: string
tag: string
metadata: Metadata
state: AgentState | null
model?: string
modelReasoningEffort?: string
effort?: string
machine?: {
id: string
metadata: MachineMetadata
runnerState?: RunnerState
}
timeoutMs?: number
signal?: AbortSignal
}): Promise<Session> {
const response = await axios.post<CreateSessionResponse>(
`${configuration.apiUrl}/cli/sessions`,
{
id: opts.id,
tag: opts.tag,
metadata: opts.metadata,
agentState: opts.state,
model: opts.model,
modelReasoningEffort: opts.modelReasoningEffort,
effort: opts.effort,
machine: opts.machine
? {
id: opts.machine.id,
metadata: opts.machine.metadata,
runnerState: opts.machine.runnerState ?? null
}
: undefined
},
{
headers: buildHubRequestHeaders({
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json'
}),
timeout: opts.timeoutMs ?? 60_000,
signal: opts.signal
}
)
const parsed = CreateSessionResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/sessions response', response)
}
if (typeof parsed.data.sessionSummaryContract === 'boolean') {
applyHubSessionSummaryContract(parsed.data.sessionSummaryContract)
}
const raw = parsed.data.session
const metadata = (() => {
if (raw.metadata == null) return null
const parsedMetadata = MetadataSchema.safeParse(raw.metadata)
return parsedMetadata.success ? parsedMetadata.data : null
})()
const agentState = (() => {
if (raw.agentState == null) return null
const parsedAgentState = AgentStateSchema.safeParse(raw.agentState)
return parsedAgentState.success ? parsedAgentState.data : null
})()
return {
id: raw.id,
namespace: raw.namespace,
seq: raw.seq,
createdAt: raw.createdAt,
updatedAt: raw.updatedAt,
active: raw.active,
activeAt: raw.activeAt,
metadata,
metadataVersion: raw.metadataVersion,
agentState,
agentStateVersion: raw.agentStateVersion,
thinking: raw.thinking,
thinkingAt: raw.thinkingAt,
todos: raw.todos,
model: raw.model,
modelReasoningEffort: raw.modelReasoningEffort,
effort: raw.effort,
serviceTier: raw.serviceTier,
permissionMode: raw.permissionMode,
collaborationMode: raw.collaborationMode
}
}
async getSession(sessionId: string): Promise<Session> {
const response = await axios.get(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}`,
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = GetSessionResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/sessions/:id response', response)
}
if (typeof parsed.data.sessionSummaryContract === 'boolean') {
applyHubSessionSummaryContract(parsed.data.sessionSummaryContract)
}
const raw = parsed.data.session
const metadata = (() => {
if (raw.metadata == null) return null
const parsedMetadata = MetadataSchema.safeParse(raw.metadata)
return parsedMetadata.success ? parsedMetadata.data : null
})()
const agentState = (() => {
if (raw.agentState == null) return null
const parsedAgentState = AgentStateSchema.safeParse(raw.agentState)
return parsedAgentState.success ? parsedAgentState.data : null
})()
return {
id: raw.id,
namespace: raw.namespace,
seq: raw.seq,
createdAt: raw.createdAt,
updatedAt: raw.updatedAt,
active: raw.active,
activeAt: raw.activeAt,
metadata,
metadataVersion: raw.metadataVersion,
agentState,
agentStateVersion: raw.agentStateVersion,
thinking: raw.thinking,
thinkingAt: raw.thinkingAt,
todos: raw.todos,
model: raw.model,
modelReasoningEffort: raw.modelReasoningEffort,
effort: raw.effort,
serviceTier: raw.serviceTier,
permissionMode: raw.permissionMode,
collaborationMode: raw.collaborationMode
}
}
async getOrCreateMachine(opts: {
machineId: string
metadata: MachineMetadata
runnerState?: RunnerState
}): Promise<Machine> {
const response = await axios.post<CreateMachineResponse>(
`${configuration.apiUrl}/cli/machines`,
{
id: opts.machineId,
metadata: opts.metadata,
runnerState: opts.runnerState ?? null
},
{
headers: buildHubRequestHeaders({
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json'
}),
timeout: 60_000
}
)
const parsed = CreateMachineResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/machines response', response)
}
const raw = parsed.data.machine
const metadata = (() => {
if (raw.metadata == null) return null
const parsedMetadata = MachineMetadataSchema.safeParse(raw.metadata)
return parsedMetadata.success ? parsedMetadata.data : null
})()
const runnerState = (() => {
if (raw.runnerState == null) return null
const parsedRunnerState = RunnerStateSchema.safeParse(raw.runnerState)
return parsedRunnerState.success ? parsedRunnerState.data : null
})()
return {
id: raw.id,
namespace: raw.namespace,
seq: raw.seq,
createdAt: raw.createdAt,
updatedAt: raw.updatedAt,
active: raw.active,
activeAt: raw.activeAt,
metadata,
metadataVersion: raw.metadataVersion,
runnerState,
runnerStateVersion: raw.runnerStateVersion
}
}
async listResumableSessions(machineId?: string): Promise<ResumableSession[]> {
const qs = machineId ? `?machineId=${encodeURIComponent(machineId)}` : ''
const response = await axios.get(
`${configuration.apiUrl}/cli/sessions/resumable${qs}`,
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = ResumableSessionsResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/sessions/resumable response', response)
}
return parsed.data.sessions
}
async getLocalResumeTarget(sessionId: string): Promise<LocalResumeTarget> {
const response = await axios.get(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/resume-target`,
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = LocalResumeTargetResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/sessions/:id/resume-target response', response)
}
return parsed.data.target
}
async handoffSessionToLocal(sessionId: string): Promise<void> {
const response = await axios.post(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/handoff-local`,
{},
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = LocalHandoffResponseSchema.safeParse(response.data)
if (!parsed.success || !parsed.data.ok) {
throw apiValidationError('Invalid /cli/sessions/:id/handoff-local response', response)
}
}
async clearOpenCodeSession(sessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode`,
{},
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/sessions/:id/clear-opencode response', response)
}
return parsed.data.sessionId
}
async reserveOpenCodeClearSession(sessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/reserve`, {},
{ headers: this.authHeaders(), timeout: 60_000 }
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) throw apiValidationError('Invalid clear reservation response', response)
return parsed.data.sessionId
}
async abortOpenCodeClearSession(sessionId: string, replacementSessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/abort`,
{ replacementSessionId } satisfies ClearOpencodeSessionCallbackRequest,
{ headers: this.authHeaders(), timeout: 60_000 }
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) throw apiValidationError('Invalid clear abort response', response)
return parsed.data.sessionId
}
async confirmOpenCodeClearCleanup(sessionId: string, replacementSessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/confirm-cleanup`,
{ replacementSessionId } satisfies ClearOpencodeSessionCallbackRequest,
{ headers: this.authHeaders(), timeout: 60_000 }
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) throw apiValidationError('Invalid clear cleanup confirmation response', response)
return parsed.data.sessionId
}
sessionSyncClient(session: Session, options?: ApiSessionClientOptions): ApiSessionClient {
return new ApiSessionClient(this.token, session, options)
}
machineSyncClient(machine: Machine, options?: { workspaceRoots?: string[] }): ApiMachineClient {
return new ApiMachineClient(this.token, machine, options?.workspaceRoots)
}
}