import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' import { logForDebugging } from '../utils/debug.js' import { errorMessage } from '../utils/errors.js' import { extractErrorDetail } from './debugUtils.js' import { toCompatSessionId } from './sessionIdCompat.js' type GitSource = { type: 'git_repository' url: string revision?: string } type GitOutcome = { type: 'git_repository' git_info: { type: 'github'; repo: string; branches: string[] } } // Events must be wrapped in { type: 'event', data: } for the // POST /v1/sessions endpoint (discriminated union format). type SessionEvent = { type: 'event' data: SDKMessage } /** * Create a session on a bridge environment via POST /v1/sessions. * * Used by both `claude remote-control` (empty session so the user has somewhere to * type immediately) and `/remote-control` (session pre-populated with conversation * history). * * Returns the session ID on success, or null if creation fails (non-fatal). */ export async function createBridgeSession({ environmentId, title, events, gitRepoUrl, branch, signal, baseUrl: baseUrlOverride, getAccessToken, permissionMode, }: { environmentId: string title?: string events: SessionEvent[] gitRepoUrl: string | null branch: string signal: AbortSignal baseUrl?: string getAccessToken?: () => string | undefined permissionMode?: string }): Promise { const { getClaudeAIOAuthTokens } = await import('../utils/auth.js') const { getOrganizationUUID } = await import('../services/oauth/client.js') const { getOauthConfig } = await import('../constants/oauth.js') const { getOAuthHeaders } = await import('../utils/teleport/api.js') const { parseGitHubRepository } = await import('../utils/detectRepository.js') const { getDefaultBranch } = await import('../utils/git.js') const { getMainLoopModel } = await import('../utils/model/model.js') const { default: axios } = await import('axios') const accessToken = getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken if (!accessToken) { logForDebugging('[bridge] No access token for session creation') return null } const orgUUID = await getOrganizationUUID() if (!orgUUID) { logForDebugging('[bridge] No org UUID for session creation') return null } // Build git source and outcome context let gitSource: GitSource | null = null let gitOutcome: GitOutcome | null = null if (gitRepoUrl) { const { parseGitRemote } = await import('../utils/detectRepository.js') const parsed = parseGitRemote(gitRepoUrl) if (parsed) { const { host, owner, name } = parsed const revision = branch || (await getDefaultBranch()) || undefined gitSource = { type: 'git_repository', url: `https://${host}/${owner}/${name}`, revision, } gitOutcome = { type: 'git_repository', git_info: { type: 'github', repo: `${owner}/${name}`, branches: [`claude/${branch || 'task'}`], }, } } else { // Fallback: try parseGitHubRepository for owner/repo format const ownerRepo = parseGitHubRepository(gitRepoUrl) if (ownerRepo) { const [owner, name] = ownerRepo.split('/') if (owner && name) { const revision = branch || (await getDefaultBranch()) || undefined gitSource = { type: 'git_repository', url: `https://github.com/${owner}/${name}`, revision, } gitOutcome = { type: 'git_repository', git_info: { type: 'github', repo: `${owner}/${name}`, branches: [`claude/${branch || 'task'}`], }, } } } } } const requestBody = { ...(title !== undefined && { title }), events, session_context: { sources: gitSource ? [gitSource] : [], outcomes: gitOutcome ? [gitOutcome] : [], model: getMainLoopModel(), }, environment_id: environmentId, source: 'remote-control', ...(permissionMode && { permission_mode: permissionMode }), } const headers = { ...getOAuthHeaders(accessToken), 'anthropic-beta': 'ccr-byoc-2025-07-29', 'x-organization-uuid': orgUUID, } const url = `${baseUrlOverride ?? getOauthConfig().BASE_API_URL}/v1/sessions` let response try { response = await axios.post(url, requestBody, { headers, signal, validateStatus: s => s < 500, }) } catch (err: unknown) { logForDebugging( `[bridge] Session creation request failed: ${errorMessage(err)}`, ) return null } const isSuccess = response.status === 200 || response.status === 201 if (!isSuccess) { const detail = extractErrorDetail(response.data) logForDebugging( `[bridge] Session creation failed with status ${response.status}${detail ? `: ${detail}` : ''}`, ) return null } const sessionData: unknown = response.data if ( !sessionData || typeof sessionData !== 'object' || !('id' in sessionData) || typeof sessionData.id !== 'string' ) { logForDebugging('[bridge] No session ID in response') return null } return sessionData.id } /** * Fetch a bridge session via GET /v1/sessions/{id}. * * Returns the session's environment_id (for `--session-id` resume) and title. * Uses the same org-scoped headers as create/archive — the environments-level * client in bridgeApi.ts uses a different beta header and no org UUID, which * makes the Sessions API return 404. */ export async function getBridgeSession( sessionId: string, opts?: { baseUrl?: string; getAccessToken?: () => string | undefined }, ): Promise<{ environment_id?: string; title?: string } | null> { const { getClaudeAIOAuthTokens } = await import('../utils/auth.js') const { getOrganizationUUID } = await import('../services/oauth/client.js') const { getOauthConfig } = await import('../constants/oauth.js') const { getOAuthHeaders } = await import('../utils/teleport/api.js') const { default: axios } = await import('axios') const accessToken = opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken if (!accessToken) { logForDebugging('[bridge] No access token for session fetch') return null } const orgUUID = await getOrganizationUUID() if (!orgUUID) { logForDebugging('[bridge] No org UUID for session fetch') return null } const headers = { ...getOAuthHeaders(accessToken), 'anthropic-beta': 'ccr-byoc-2025-07-29', 'x-organization-uuid': orgUUID, } const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}` logForDebugging(`[bridge] Fetching session ${sessionId}`) let response try { response = await axios.get<{ environment_id?: string; title?: string }>( url, { headers, timeout: 10_000, validateStatus: s => s < 500 }, ) } catch (err: unknown) { logForDebugging( `[bridge] Session fetch request failed: ${errorMessage(err)}`, ) return null } if (response.status !== 200) { const detail = extractErrorDetail(response.data) logForDebugging( `[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`, ) return null } return response.data } /** * Archive a bridge session via POST /v1/sessions/{id}/archive. * * The CCR server never auto-archives sessions — archival is always an * explicit client action. Both `claude remote-control` (standalone bridge) and the * always-on `/remote-control` REPL bridge call this during shutdown to archive any * sessions that are still alive. * * The archive endpoint accepts sessions in any status (running, idle, * requires_action, pending) and returns 409 if already archived, making * it safe to call even if the server-side runner already archived the * session. * * Callers must handle errors — this function has no try/catch; 5xx, * timeouts, and network errors throw. Archival is best-effort during * cleanup; call sites wrap with .catch(). */ export async function archiveBridgeSession( sessionId: string, opts?: { baseUrl?: string getAccessToken?: () => string | undefined timeoutMs?: number }, ): Promise { const { getClaudeAIOAuthTokens } = await import('../utils/auth.js') const { getOrganizationUUID } = await import('../services/oauth/client.js') const { getOauthConfig } = await import('../constants/oauth.js') const { getOAuthHeaders } = await import('../utils/teleport/api.js') const { default: axios } = await import('axios') const accessToken = opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken if (!accessToken) { logForDebugging('[bridge] No access token for session archive') return } const orgUUID = await getOrganizationUUID() if (!orgUUID) { logForDebugging('[bridge] No org UUID for session archive') return } const headers = { ...getOAuthHeaders(accessToken), 'anthropic-beta': 'ccr-byoc-2025-07-29', 'x-organization-uuid': orgUUID, } const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive` logForDebugging(`[bridge] Archiving session ${sessionId}`) const response = await axios.post( url, {}, { headers, timeout: opts?.timeoutMs ?? 10_000, validateStatus: s => s < 500, }, ) if (response.status === 200) { logForDebugging(`[bridge] Session ${sessionId} archived successfully`) } else { const detail = extractErrorDetail(response.data) logForDebugging( `[bridge] Session archive failed with status ${response.status}${detail ? `: ${detail}` : ''}`, ) } } /** * Update the title of a bridge session via PATCH /v1/sessions/{id}. * * Called when the user renames a session via /rename while a bridge * connection is active, so the title stays in sync on claude.ai/code. * * Errors are swallowed — title sync is best-effort. */ export async function updateBridgeSessionTitle( sessionId: string, title: string, opts?: { baseUrl?: string; getAccessToken?: () => string | undefined }, ): Promise { const { getClaudeAIOAuthTokens } = await import('../utils/auth.js') const { getOrganizationUUID } = await import('../services/oauth/client.js') const { getOauthConfig } = await import('../constants/oauth.js') const { getOAuthHeaders } = await import('../utils/teleport/api.js') const { default: axios } = await import('axios') const accessToken = opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken if (!accessToken) { logForDebugging('[bridge] No access token for session title update') return } const orgUUID = await getOrganizationUUID() if (!orgUUID) { logForDebugging('[bridge] No org UUID for session title update') return } const headers = { ...getOAuthHeaders(accessToken), 'anthropic-beta': 'ccr-byoc-2025-07-29', 'x-organization-uuid': orgUUID, } // Compat gateway only accepts session_* (compat/convert.go:27). v2 callers // pass raw cse_*; retag here so all callers can pass whatever they hold. // Idempotent for v1's session_* and bridgeMain's pre-converted compatSessionId. const compatId = toCompatSessionId(sessionId) const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${compatId}` logForDebugging(`[bridge] Updating session title: ${compatId} → ${title}`) try { const response = await axios.patch( url, { title }, { headers, timeout: 10_000, validateStatus: s => s < 500 }, ) if (response.status === 200) { logForDebugging(`[bridge] Session title updated successfully`) } else { const detail = extractErrorDetail(response.data) logForDebugging( `[bridge] Session title update failed with status ${response.status}${detail ? `: ${detail}` : ''}`, ) } } catch (err: unknown) { logForDebugging( `[bridge] Session title update request failed: ${errorMessage(err)}`, ) } }