init claude-code
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getOauthConfig } from 'src/constants/oauth.js'
|
||||
import { getOrganizationUUID } from 'src/services/oauth/client.js'
|
||||
import z from 'zod/v4'
|
||||
import { getClaudeAIOAuthTokens } from '../auth.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { parseGitHubRepository } from '../detectRepository.js'
|
||||
import { errorMessage, toError } from '../errors.js'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import { logError } from '../log.js'
|
||||
import { sleep } from '../sleep.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
|
||||
// Retry configuration for teleport API requests
|
||||
const TELEPORT_RETRY_DELAYS = [2000, 4000, 8000, 16000] // 4 retries with exponential backoff
|
||||
const MAX_TELEPORT_RETRIES = TELEPORT_RETRY_DELAYS.length
|
||||
|
||||
export const CCR_BYOC_BETA = 'ccr-byoc-2025-07-29'
|
||||
|
||||
/**
|
||||
* Checks if an axios error is a transient network error that should be retried
|
||||
*/
|
||||
export function isTransientNetworkError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Retry on network errors (no response received)
|
||||
if (!error.response) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Retry on server errors (5xx)
|
||||
if (error.response.status >= 500) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Don't retry on client errors (4xx) - they're not transient
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an axios GET request with automatic retry for transient network errors
|
||||
* Uses exponential backoff: 2s, 4s, 8s, 16s (4 retries = 5 total attempts)
|
||||
*/
|
||||
export async function axiosGetWithRetry<T>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_TELEPORT_RETRIES; attempt++) {
|
||||
try {
|
||||
return await axios.get<T>(url, config)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
// Don't retry if this isn't a transient error
|
||||
if (!isTransientNetworkError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Don't retry if we've exhausted all retries
|
||||
if (attempt >= MAX_TELEPORT_RETRIES) {
|
||||
logForDebugging(
|
||||
`Teleport request failed after ${attempt + 1} attempts: ${errorMessage(error)}`,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
const delay = TELEPORT_RETRY_DELAYS[attempt] ?? 2000
|
||||
logForDebugging(
|
||||
`Teleport request failed (attempt ${attempt + 1}/${MAX_TELEPORT_RETRIES + 1}), retrying in ${delay}ms: ${errorMessage(error)}`,
|
||||
)
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// Types matching the actual Sessions API response from api/schemas/sessions/sessions.py
|
||||
export type SessionStatus = 'requires_action' | 'running' | 'idle' | 'archived'
|
||||
|
||||
export type GitSource = {
|
||||
type: 'git_repository'
|
||||
url: string
|
||||
revision?: string | null
|
||||
allow_unrestricted_git_push?: boolean
|
||||
}
|
||||
|
||||
export type KnowledgeBaseSource = {
|
||||
type: 'knowledge_base'
|
||||
knowledge_base_id: string
|
||||
}
|
||||
|
||||
export type SessionContextSource = GitSource | KnowledgeBaseSource
|
||||
|
||||
// Outcome types from api/schemas/sandbox.py
|
||||
export type OutcomeGitInfo = {
|
||||
type: 'github'
|
||||
repo: string
|
||||
branches: string[]
|
||||
}
|
||||
|
||||
export type GitRepositoryOutcome = {
|
||||
type: 'git_repository'
|
||||
git_info: OutcomeGitInfo
|
||||
}
|
||||
|
||||
export type Outcome = GitRepositoryOutcome
|
||||
|
||||
export type SessionContext = {
|
||||
sources: SessionContextSource[]
|
||||
cwd: string
|
||||
outcomes: Outcome[] | null
|
||||
custom_system_prompt: string | null
|
||||
append_system_prompt: string | null
|
||||
model: string | null
|
||||
// Seed filesystem with a git bundle on Files API
|
||||
seed_bundle_file_id?: string
|
||||
github_pr?: { owner: string; repo: string; number: number }
|
||||
reuse_outcome_branches?: boolean
|
||||
}
|
||||
|
||||
export type SessionResource = {
|
||||
type: 'session'
|
||||
id: string
|
||||
title: string | null
|
||||
session_status: SessionStatus
|
||||
environment_id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
session_context: SessionContext
|
||||
}
|
||||
|
||||
export type ListSessionsResponse = {
|
||||
data: SessionResource[]
|
||||
has_more: boolean
|
||||
first_id: string | null
|
||||
last_id: string | null
|
||||
}
|
||||
|
||||
export const CodeSessionSchema = lazySchema(() =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
status: z.enum([
|
||||
'idle',
|
||||
'working',
|
||||
'waiting',
|
||||
'completed',
|
||||
'archived',
|
||||
'cancelled',
|
||||
'rejected',
|
||||
]),
|
||||
repo: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
owner: z.object({
|
||||
login: z.string(),
|
||||
}),
|
||||
default_branch: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
turns: z.array(z.string()),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
// Export the inferred type from the Zod schema
|
||||
export type CodeSession = z.infer<ReturnType<typeof CodeSessionSchema>>
|
||||
|
||||
/**
|
||||
* Validates and prepares for API requests
|
||||
* @returns Object containing access token and organization UUID
|
||||
*/
|
||||
export async function prepareApiRequest(): Promise<{
|
||||
accessToken: string
|
||||
orgUUID: string
|
||||
}> {
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (accessToken === undefined) {
|
||||
throw new Error(
|
||||
'Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.',
|
||||
)
|
||||
}
|
||||
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
throw new Error('Unable to get organization UUID')
|
||||
}
|
||||
|
||||
return { accessToken, orgUUID }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches code sessions from the new Sessions API (/v1/sessions)
|
||||
* @returns Array of code sessions
|
||||
*/
|
||||
export async function fetchCodeSessionsFromSessionsAPI(): Promise<
|
||||
CodeSession[]
|
||||
> {
|
||||
const { accessToken, orgUUID } = await prepareApiRequest()
|
||||
|
||||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions`
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const response = await axiosGetWithRetry<ListSessionsResponse>(url, {
|
||||
headers,
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to fetch code sessions: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Transform SessionResource[] to CodeSession[] format
|
||||
const sessions: CodeSession[] = response.data.data.map(session => {
|
||||
// Extract repository info from git sources
|
||||
const gitSource = session.session_context.sources.find(
|
||||
(source): source is GitSource => source.type === 'git_repository',
|
||||
)
|
||||
|
||||
let repo: CodeSession['repo'] = null
|
||||
if (gitSource?.url) {
|
||||
// Parse GitHub URL using the existing utility function
|
||||
const repoPath = parseGitHubRepository(gitSource.url)
|
||||
if (repoPath) {
|
||||
const [owner, name] = repoPath.split('/')
|
||||
if (owner && name) {
|
||||
repo = {
|
||||
name,
|
||||
owner: {
|
||||
login: owner,
|
||||
},
|
||||
default_branch: gitSource.revision || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
title: session.title || 'Untitled',
|
||||
description: '', // SessionResource doesn't have description field
|
||||
status: session.session_status as CodeSession['status'], // Map session_status to status
|
||||
repo,
|
||||
turns: [], // SessionResource doesn't have turns field
|
||||
created_at: session.created_at,
|
||||
updated_at: session.updated_at,
|
||||
}
|
||||
})
|
||||
|
||||
return sessions
|
||||
} catch (error) {
|
||||
const err = toError(error)
|
||||
logError(err)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates OAuth headers for API requests
|
||||
* @param accessToken The OAuth access token
|
||||
* @returns Headers object with Authorization, Content-Type, and anthropic-version
|
||||
*/
|
||||
export function getOAuthHeaders(accessToken: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single session by ID from the Sessions API
|
||||
* @param sessionId The session ID to fetch
|
||||
* @returns The session resource
|
||||
*/
|
||||
export async function fetchSession(
|
||||
sessionId: string,
|
||||
): Promise<SessionResource> {
|
||||
const { accessToken, orgUUID } = await prepareApiRequest()
|
||||
|
||||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const response = await axios.get<SessionResource>(url, {
|
||||
headers,
|
||||
timeout: 15000,
|
||||
validateStatus: status => status < 500,
|
||||
})
|
||||
|
||||
if (response.status !== 200) {
|
||||
// Extract error message from response if available
|
||||
const errorData = response.data as { error?: { message?: string } }
|
||||
const apiMessage = errorData?.error?.message
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error('Session expired. Please run /login to sign in again.')
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
apiMessage ||
|
||||
`Failed to fetch session: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the first branch name from a session's git repository outcomes
|
||||
* @param session The session resource to extract from
|
||||
* @returns The first branch name, or undefined if none found
|
||||
*/
|
||||
export function getBranchFromSession(
|
||||
session: SessionResource,
|
||||
): string | undefined {
|
||||
const gitOutcome = session.session_context.outcomes?.find(
|
||||
(outcome): outcome is GitRepositoryOutcome =>
|
||||
outcome.type === 'git_repository',
|
||||
)
|
||||
return gitOutcome?.git_info?.branches[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Content for a remote session message.
|
||||
* Accepts a plain string or an array of content blocks (text, image, etc.)
|
||||
* following the Anthropic API messages spec.
|
||||
*/
|
||||
export type RemoteMessageContent =
|
||||
| string
|
||||
| Array<{ type: string; [key: string]: unknown }>
|
||||
|
||||
/**
|
||||
* Sends a user message event to an existing remote session via the Sessions API
|
||||
* @param sessionId The session ID to send the event to
|
||||
* @param messageContent The user message content (string or content blocks)
|
||||
* @param opts.uuid Optional UUID for the event — callers that added a local
|
||||
* UserMessage first should pass its UUID so echo filtering can dedup
|
||||
* @returns Promise<boolean> True if successful, false otherwise
|
||||
*/
|
||||
export async function sendEventToRemoteSession(
|
||||
sessionId: string,
|
||||
messageContent: RemoteMessageContent,
|
||||
opts?: { uuid?: string },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { accessToken, orgUUID } = await prepareApiRequest()
|
||||
|
||||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const userEvent = {
|
||||
uuid: opts?.uuid ?? randomUUID(),
|
||||
session_id: sessionId,
|
||||
type: 'user',
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: messageContent,
|
||||
},
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
events: [userEvent],
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[sendEventToRemoteSession] Sending event to session ${sessionId}`,
|
||||
)
|
||||
// The endpoint may block until the CCR worker is ready. Observed ~2.6s
|
||||
// in normal cases; allow a generous margin for cold-start containers.
|
||||
const response = await axios.post(url, requestBody, {
|
||||
headers,
|
||||
validateStatus: status => status < 500,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
logForDebugging(
|
||||
`[sendEventToRemoteSession] Successfully sent event to session ${sessionId}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[sendEventToRemoteSession] Failed with status ${response.status}: ${jsonStringify(response.data)}`,
|
||||
)
|
||||
return false
|
||||
} catch (error) {
|
||||
logForDebugging(`[sendEventToRemoteSession] Error: ${errorMessage(error)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the title of an existing remote session via the Sessions API
|
||||
* @param sessionId The session ID to update
|
||||
* @param title The new title for the session
|
||||
* @returns Promise<boolean> True if successful, false otherwise
|
||||
*/
|
||||
export async function updateSessionTitle(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { accessToken, orgUUID } = await prepareApiRequest()
|
||||
|
||||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
|
||||
const headers = {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[updateSessionTitle] Updating title for session ${sessionId}: "${title}"`,
|
||||
)
|
||||
const response = await axios.patch(
|
||||
url,
|
||||
{ title },
|
||||
{
|
||||
headers,
|
||||
validateStatus: status => status < 500,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
logForDebugging(
|
||||
`[updateSessionTitle] Successfully updated title for session ${sessionId}`,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[updateSessionTitle] Failed with status ${response.status}: ${jsonStringify(response.data)}`,
|
||||
)
|
||||
return false
|
||||
} catch (error) {
|
||||
logForDebugging(`[updateSessionTitle] Error: ${errorMessage(error)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user