/ bridge / createSession.ts
createSession.ts
  1  import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
  2  import { logForDebugging } from '../utils/debug.js'
  3  import { errorMessage } from '../utils/errors.js'
  4  import { extractErrorDetail } from './debugUtils.js'
  5  import { toCompatSessionId } from './sessionIdCompat.js'
  6  
  7  type GitSource = {
  8    type: 'git_repository'
  9    url: string
 10    revision?: string
 11  }
 12  
 13  type GitOutcome = {
 14    type: 'git_repository'
 15    git_info: { type: 'github'; repo: string; branches: string[] }
 16  }
 17  
 18  // Events must be wrapped in { type: 'event', data: <sdk_message> } for the
 19  // POST /v1/sessions endpoint (discriminated union format).
 20  type SessionEvent = {
 21    type: 'event'
 22    data: SDKMessage
 23  }
 24  
 25  /**
 26   * Create a session on a bridge environment via POST /v1/sessions.
 27   *
 28   * Used by both `claude remote-control` (empty session so the user has somewhere to
 29   * type immediately) and `/remote-control` (session pre-populated with conversation
 30   * history).
 31   *
 32   * Returns the session ID on success, or null if creation fails (non-fatal).
 33   */
 34  export async function createBridgeSession({
 35    environmentId,
 36    title,
 37    events,
 38    gitRepoUrl,
 39    branch,
 40    signal,
 41    baseUrl: baseUrlOverride,
 42    getAccessToken,
 43    permissionMode,
 44  }: {
 45    environmentId: string
 46    title?: string
 47    events: SessionEvent[]
 48    gitRepoUrl: string | null
 49    branch: string
 50    signal: AbortSignal
 51    baseUrl?: string
 52    getAccessToken?: () => string | undefined
 53    permissionMode?: string
 54  }): Promise<string | null> {
 55    const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
 56    const { getOrganizationUUID } = await import('../services/oauth/client.js')
 57    const { getOauthConfig } = await import('../constants/oauth.js')
 58    const { getOAuthHeaders } = await import('../utils/teleport/api.js')
 59    const { parseGitHubRepository } = await import('../utils/detectRepository.js')
 60    const { getDefaultBranch } = await import('../utils/git.js')
 61    const { getMainLoopModel } = await import('../utils/model/model.js')
 62    const { default: axios } = await import('axios')
 63  
 64    const accessToken =
 65      getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
 66    if (!accessToken) {
 67      logForDebugging('[bridge] No access token for session creation')
 68      return null
 69    }
 70  
 71    const orgUUID = await getOrganizationUUID()
 72    if (!orgUUID) {
 73      logForDebugging('[bridge] No org UUID for session creation')
 74      return null
 75    }
 76  
 77    // Build git source and outcome context
 78    let gitSource: GitSource | null = null
 79    let gitOutcome: GitOutcome | null = null
 80  
 81    if (gitRepoUrl) {
 82      const { parseGitRemote } = await import('../utils/detectRepository.js')
 83      const parsed = parseGitRemote(gitRepoUrl)
 84      if (parsed) {
 85        const { host, owner, name } = parsed
 86        const revision = branch || (await getDefaultBranch()) || undefined
 87        gitSource = {
 88          type: 'git_repository',
 89          url: `https://${host}/${owner}/${name}`,
 90          revision,
 91        }
 92        gitOutcome = {
 93          type: 'git_repository',
 94          git_info: {
 95            type: 'github',
 96            repo: `${owner}/${name}`,
 97            branches: [`claude/${branch || 'task'}`],
 98          },
 99        }
100      } else {
101        // Fallback: try parseGitHubRepository for owner/repo format
102        const ownerRepo = parseGitHubRepository(gitRepoUrl)
103        if (ownerRepo) {
104          const [owner, name] = ownerRepo.split('/')
105          if (owner && name) {
106            const revision = branch || (await getDefaultBranch()) || undefined
107            gitSource = {
108              type: 'git_repository',
109              url: `https://github.com/${owner}/${name}`,
110              revision,
111            }
112            gitOutcome = {
113              type: 'git_repository',
114              git_info: {
115                type: 'github',
116                repo: `${owner}/${name}`,
117                branches: [`claude/${branch || 'task'}`],
118              },
119            }
120          }
121        }
122      }
123    }
124  
125    const requestBody = {
126      ...(title !== undefined && { title }),
127      events,
128      session_context: {
129        sources: gitSource ? [gitSource] : [],
130        outcomes: gitOutcome ? [gitOutcome] : [],
131        model: getMainLoopModel(),
132      },
133      environment_id: environmentId,
134      source: 'remote-control',
135      ...(permissionMode && { permission_mode: permissionMode }),
136    }
137  
138    const headers = {
139      ...getOAuthHeaders(accessToken),
140      'anthropic-beta': 'ccr-byoc-2025-07-29',
141      'x-organization-uuid': orgUUID,
142    }
143  
144    const url = `${baseUrlOverride ?? getOauthConfig().BASE_API_URL}/v1/sessions`
145    let response
146    try {
147      response = await axios.post(url, requestBody, {
148        headers,
149        signal,
150        validateStatus: s => s < 500,
151      })
152    } catch (err: unknown) {
153      logForDebugging(
154        `[bridge] Session creation request failed: ${errorMessage(err)}`,
155      )
156      return null
157    }
158    const isSuccess = response.status === 200 || response.status === 201
159  
160    if (!isSuccess) {
161      const detail = extractErrorDetail(response.data)
162      logForDebugging(
163        `[bridge] Session creation failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
164      )
165      return null
166    }
167  
168    const sessionData: unknown = response.data
169    if (
170      !sessionData ||
171      typeof sessionData !== 'object' ||
172      !('id' in sessionData) ||
173      typeof sessionData.id !== 'string'
174    ) {
175      logForDebugging('[bridge] No session ID in response')
176      return null
177    }
178  
179    return sessionData.id
180  }
181  
182  /**
183   * Fetch a bridge session via GET /v1/sessions/{id}.
184   *
185   * Returns the session's environment_id (for `--session-id` resume) and title.
186   * Uses the same org-scoped headers as create/archive — the environments-level
187   * client in bridgeApi.ts uses a different beta header and no org UUID, which
188   * makes the Sessions API return 404.
189   */
190  export async function getBridgeSession(
191    sessionId: string,
192    opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
193  ): Promise<{ environment_id?: string; title?: string } | null> {
194    const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
195    const { getOrganizationUUID } = await import('../services/oauth/client.js')
196    const { getOauthConfig } = await import('../constants/oauth.js')
197    const { getOAuthHeaders } = await import('../utils/teleport/api.js')
198    const { default: axios } = await import('axios')
199  
200    const accessToken =
201      opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
202    if (!accessToken) {
203      logForDebugging('[bridge] No access token for session fetch')
204      return null
205    }
206  
207    const orgUUID = await getOrganizationUUID()
208    if (!orgUUID) {
209      logForDebugging('[bridge] No org UUID for session fetch')
210      return null
211    }
212  
213    const headers = {
214      ...getOAuthHeaders(accessToken),
215      'anthropic-beta': 'ccr-byoc-2025-07-29',
216      'x-organization-uuid': orgUUID,
217    }
218  
219    const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
220    logForDebugging(`[bridge] Fetching session ${sessionId}`)
221  
222    let response
223    try {
224      response = await axios.get<{ environment_id?: string; title?: string }>(
225        url,
226        { headers, timeout: 10_000, validateStatus: s => s < 500 },
227      )
228    } catch (err: unknown) {
229      logForDebugging(
230        `[bridge] Session fetch request failed: ${errorMessage(err)}`,
231      )
232      return null
233    }
234  
235    if (response.status !== 200) {
236      const detail = extractErrorDetail(response.data)
237      logForDebugging(
238        `[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
239      )
240      return null
241    }
242  
243    return response.data
244  }
245  
246  /**
247   * Archive a bridge session via POST /v1/sessions/{id}/archive.
248   *
249   * The CCR server never auto-archives sessions — archival is always an
250   * explicit client action. Both `claude remote-control` (standalone bridge) and the
251   * always-on `/remote-control` REPL bridge call this during shutdown to archive any
252   * sessions that are still alive.
253   *
254   * The archive endpoint accepts sessions in any status (running, idle,
255   * requires_action, pending) and returns 409 if already archived, making
256   * it safe to call even if the server-side runner already archived the
257   * session.
258   *
259   * Callers must handle errors — this function has no try/catch; 5xx,
260   * timeouts, and network errors throw. Archival is best-effort during
261   * cleanup; call sites wrap with .catch().
262   */
263  export async function archiveBridgeSession(
264    sessionId: string,
265    opts?: {
266      baseUrl?: string
267      getAccessToken?: () => string | undefined
268      timeoutMs?: number
269    },
270  ): Promise<void> {
271    const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
272    const { getOrganizationUUID } = await import('../services/oauth/client.js')
273    const { getOauthConfig } = await import('../constants/oauth.js')
274    const { getOAuthHeaders } = await import('../utils/teleport/api.js')
275    const { default: axios } = await import('axios')
276  
277    const accessToken =
278      opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
279    if (!accessToken) {
280      logForDebugging('[bridge] No access token for session archive')
281      return
282    }
283  
284    const orgUUID = await getOrganizationUUID()
285    if (!orgUUID) {
286      logForDebugging('[bridge] No org UUID for session archive')
287      return
288    }
289  
290    const headers = {
291      ...getOAuthHeaders(accessToken),
292      'anthropic-beta': 'ccr-byoc-2025-07-29',
293      'x-organization-uuid': orgUUID,
294    }
295  
296    const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`
297    logForDebugging(`[bridge] Archiving session ${sessionId}`)
298  
299    const response = await axios.post(
300      url,
301      {},
302      {
303        headers,
304        timeout: opts?.timeoutMs ?? 10_000,
305        validateStatus: s => s < 500,
306      },
307    )
308  
309    if (response.status === 200) {
310      logForDebugging(`[bridge] Session ${sessionId} archived successfully`)
311    } else {
312      const detail = extractErrorDetail(response.data)
313      logForDebugging(
314        `[bridge] Session archive failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
315      )
316    }
317  }
318  
319  /**
320   * Update the title of a bridge session via PATCH /v1/sessions/{id}.
321   *
322   * Called when the user renames a session via /rename while a bridge
323   * connection is active, so the title stays in sync on claude.ai/code.
324   *
325   * Errors are swallowed — title sync is best-effort.
326   */
327  export async function updateBridgeSessionTitle(
328    sessionId: string,
329    title: string,
330    opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
331  ): Promise<void> {
332    const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
333    const { getOrganizationUUID } = await import('../services/oauth/client.js')
334    const { getOauthConfig } = await import('../constants/oauth.js')
335    const { getOAuthHeaders } = await import('../utils/teleport/api.js')
336    const { default: axios } = await import('axios')
337  
338    const accessToken =
339      opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
340    if (!accessToken) {
341      logForDebugging('[bridge] No access token for session title update')
342      return
343    }
344  
345    const orgUUID = await getOrganizationUUID()
346    if (!orgUUID) {
347      logForDebugging('[bridge] No org UUID for session title update')
348      return
349    }
350  
351    const headers = {
352      ...getOAuthHeaders(accessToken),
353      'anthropic-beta': 'ccr-byoc-2025-07-29',
354      'x-organization-uuid': orgUUID,
355    }
356  
357    // Compat gateway only accepts session_* (compat/convert.go:27). v2 callers
358    // pass raw cse_*; retag here so all callers can pass whatever they hold.
359    // Idempotent for v1's session_* and bridgeMain's pre-converted compatSessionId.
360    const compatId = toCompatSessionId(sessionId)
361    const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${compatId}`
362    logForDebugging(`[bridge] Updating session title: ${compatId} → ${title}`)
363  
364    try {
365      const response = await axios.patch(
366        url,
367        { title },
368        { headers, timeout: 10_000, validateStatus: s => s < 500 },
369      )
370  
371      if (response.status === 200) {
372        logForDebugging(`[bridge] Session title updated successfully`)
373      } else {
374        const detail = extractErrorDetail(response.data)
375        logForDebugging(
376          `[bridge] Session title update failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
377        )
378      }
379    } catch (err: unknown) {
380      logForDebugging(
381        `[bridge] Session title update request failed: ${errorMessage(err)}`,
382      )
383    }
384  }