mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
* 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>
341 lines
13 KiB
TypeScript
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)
|
|
}
|
|
}
|