mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
1523 lines
53 KiB
TypeScript
1523 lines
53 KiB
TypeScript
import axios from 'axios'
|
||
import chalk from 'chalk'
|
||
import { randomUUID } from 'crypto'
|
||
import React from 'react'
|
||
import { getOriginalCwd, getSessionId } from 'src/bootstrap/state.js'
|
||
import { checkGate_CACHED_OR_BLOCKING } from 'src/services/analytics/growthbook.js'
|
||
import {
|
||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
logEvent,
|
||
} from 'src/services/analytics/index.js'
|
||
import { isPolicyAllowed } from 'src/services/policyLimits/index.js'
|
||
import { z } from 'zod/v4'
|
||
import {
|
||
getTeleportErrors,
|
||
TeleportError,
|
||
type TeleportLocalErrorType,
|
||
} from '../components/TeleportError.js'
|
||
import { getOauthConfig } from '../constants/oauth.js'
|
||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||
import type { Root } from '@anthropic/ink'
|
||
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
|
||
import { queryHaiku } from '../services/api/claude.js'
|
||
import {
|
||
getSessionLogsViaOAuth,
|
||
getTeleportEvents,
|
||
} from '../services/api/sessionIngress.js'
|
||
import { getOrganizationUUID } from '../services/oauth/client.js'
|
||
import { AppStateProvider } from '../state/AppState.js'
|
||
import type { Message, SystemMessage } from '../types/message.js'
|
||
import type { PermissionMode } from '../types/permissions.js'
|
||
import {
|
||
checkAndRefreshOAuthTokenIfNeeded,
|
||
getClaudeAIOAuthTokens,
|
||
} from './auth.js'
|
||
import { checkGithubAppInstalled } from './background/remote/preconditions.js'
|
||
import {
|
||
deserializeMessages,
|
||
type TeleportRemoteResponse,
|
||
} from './conversationRecovery.js'
|
||
import { getCwd } from './cwd.js'
|
||
import { logForDebugging } from './debug.js'
|
||
import {
|
||
detectCurrentRepositoryWithHost,
|
||
parseGitHubRepository,
|
||
parseGitRemote,
|
||
} from './detectRepository.js'
|
||
import { isEnvTruthy } from './envUtils.js'
|
||
import { TeleportOperationError, toError } from './errors.js'
|
||
import { execFileNoThrow } from './execFileNoThrow.js'
|
||
import { truncateToWidth } from './format.js'
|
||
import { findGitRoot, getDefaultBranch, getIsClean, gitExe } from './git.js'
|
||
import { safeParseJSON } from './json.js'
|
||
import { logError } from './log.js'
|
||
import { createSystemMessage, createUserMessage } from './messages.js'
|
||
import { getMainLoopModel } from './model/model.js'
|
||
import { isTranscriptMessage } from './sessionStorage.js'
|
||
import { getSettings_DEPRECATED } from './settings/settings.js'
|
||
import { jsonStringify } from './slowOperations.js'
|
||
import { asSystemPrompt } from './systemPromptType.js'
|
||
import {
|
||
fetchSession,
|
||
type GitRepositoryOutcome,
|
||
type GitSource,
|
||
getBranchFromSession,
|
||
getOAuthHeaders,
|
||
type SessionResource,
|
||
} from './teleport/api.js'
|
||
import { fetchEnvironments } from './teleport/environments.js'
|
||
import { createAndUploadGitBundle } from './teleport/gitBundle.js'
|
||
|
||
export type TeleportResult = {
|
||
messages: Message[]
|
||
branchName: string
|
||
}
|
||
|
||
export type TeleportProgressStep =
|
||
| 'validating'
|
||
| 'fetching_logs'
|
||
| 'fetching_branch'
|
||
| 'checking_out'
|
||
| 'done'
|
||
|
||
export type TeleportProgressCallback = (step: TeleportProgressStep) => void
|
||
|
||
/**
|
||
* Creates a system message to inform about teleport session resume
|
||
* @returns SystemMessage indicating session was resumed from another machine
|
||
*/
|
||
function createTeleportResumeSystemMessage(
|
||
branchError: Error | null,
|
||
): SystemMessage {
|
||
if (branchError === null) {
|
||
return createSystemMessage('Session resumed', 'suggestion')
|
||
}
|
||
const formattedError =
|
||
branchError instanceof TeleportOperationError
|
||
? branchError.formattedMessage
|
||
: branchError.message
|
||
return createSystemMessage(
|
||
`Session resumed without branch: ${formattedError}`,
|
||
'warning',
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Creates a user message to inform the model about teleport session resume
|
||
* @returns User message indicating session was resumed from another machine
|
||
*/
|
||
function createTeleportResumeUserMessage() {
|
||
return createUserMessage({
|
||
content: `This session is being continued from another machine. Application state may have changed. The updated working directory is ${getOriginalCwd()}`,
|
||
isMeta: true,
|
||
})
|
||
}
|
||
|
||
type TeleportToRemoteResponse = {
|
||
id: string
|
||
title: string
|
||
}
|
||
|
||
const SESSION_TITLE_AND_BRANCH_PROMPT = `You are coming up with a succinct title and git branch name for a coding session based on the provided description. The title should be clear, concise, and accurately reflect the content of the coding task.
|
||
You should keep it short and simple, ideally no more than 6 words. Avoid using jargon or overly technical terms unless absolutely necessary. The title should be easy to understand for anyone reading it.
|
||
Use sentence case for the title (capitalize only the first word and proper nouns), not Title Case.
|
||
|
||
The branch name should be clear, concise, and accurately reflect the content of the coding task.
|
||
You should keep it short and simple, ideally no more than 4 words. The branch should always start with "claude/" and should be all lower case, with words separated by dashes.
|
||
|
||
Return a JSON object with "title" and "branch" fields.
|
||
|
||
Example 1: {"title": "Fix login button not working on mobile", "branch": "claude/fix-mobile-login-button"}
|
||
Example 2: {"title": "Update README with installation instructions", "branch": "claude/update-readme"}
|
||
Example 3: {"title": "Improve performance of data processing script", "branch": "claude/improve-data-processing"}
|
||
|
||
Here is the session description:
|
||
<description>{description}</description>
|
||
Please generate a title and branch name for this session.`
|
||
|
||
type TitleAndBranch = {
|
||
title: string
|
||
branchName: string
|
||
}
|
||
|
||
/**
|
||
* Generates a title and branch name for a coding session using Claude Haiku
|
||
* @param description The description/prompt for the session
|
||
* @returns Promise<TitleAndBranch> The generated title and branch name
|
||
*/
|
||
async function generateTitleAndBranch(
|
||
description: string,
|
||
signal: AbortSignal,
|
||
): Promise<TitleAndBranch> {
|
||
const fallbackTitle = truncateToWidth(description, 75)
|
||
const fallbackBranch = 'claude/task'
|
||
|
||
try {
|
||
const userPrompt = SESSION_TITLE_AND_BRANCH_PROMPT.replace(
|
||
'{description}',
|
||
description,
|
||
)
|
||
|
||
const response = await queryHaiku({
|
||
systemPrompt: asSystemPrompt([]),
|
||
userPrompt,
|
||
outputFormat: {
|
||
type: 'json_schema',
|
||
schema: {
|
||
type: 'object',
|
||
properties: {
|
||
title: { type: 'string' },
|
||
branch: { type: 'string' },
|
||
},
|
||
required: ['title', 'branch'],
|
||
additionalProperties: false,
|
||
},
|
||
},
|
||
signal,
|
||
options: {
|
||
querySource: 'teleport_generate_title',
|
||
agents: [],
|
||
isNonInteractiveSession: false,
|
||
hasAppendSystemPrompt: false,
|
||
mcpTools: [],
|
||
},
|
||
})
|
||
|
||
// Extract text from the response
|
||
const firstBlock = response.message!.content?.[0] as { type?: string; text?: string } | undefined
|
||
if (firstBlock?.type !== 'text') {
|
||
return { title: fallbackTitle, branchName: fallbackBranch }
|
||
}
|
||
|
||
const parsed = safeParseJSON(firstBlock.text!.trim())
|
||
const parseResult = z
|
||
.object({ title: z.string(), branch: z.string() })
|
||
.safeParse(parsed)
|
||
if (parseResult.success) {
|
||
return {
|
||
title: parseResult.data.title || fallbackTitle,
|
||
branchName: parseResult.data.branch || fallbackBranch,
|
||
}
|
||
}
|
||
|
||
return { title: fallbackTitle, branchName: fallbackBranch }
|
||
} catch (error) {
|
||
logError(new Error(`Error generating title and branch: ${error}`))
|
||
return { title: fallbackTitle, branchName: fallbackBranch }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Validates that the git working directory is clean (ignoring untracked files)
|
||
* Untracked files are ignored because they won't be lost during branch switching
|
||
*/
|
||
export async function validateGitState(): Promise<void> {
|
||
const isClean = await getIsClean({ ignoreUntracked: true })
|
||
if (!isClean) {
|
||
logEvent('tengu_teleport_error_git_not_clean', {})
|
||
const error = new TeleportOperationError(
|
||
'Git working directory is not clean. Please commit or stash your changes before using --teleport.',
|
||
chalk.red(
|
||
'Error: Git working directory is not clean. Please commit or stash your changes before using --teleport.\n',
|
||
),
|
||
)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetches a specific branch from remote origin
|
||
* @param branch The branch to fetch. If not specified, fetches all branches.
|
||
*/
|
||
async function fetchFromOrigin(branch?: string): Promise<void> {
|
||
const fetchArgs = branch
|
||
? ['fetch', 'origin', `${branch}:${branch}`]
|
||
: ['fetch', 'origin']
|
||
|
||
const { code: fetchCode, stderr: fetchStderr } = await execFileNoThrow(
|
||
gitExe(),
|
||
fetchArgs,
|
||
)
|
||
if (fetchCode !== 0) {
|
||
// If fetching a specific branch fails, it might not exist locally yet
|
||
// Try fetching just the ref without mapping to local branch
|
||
if (branch && fetchStderr.includes('refspec')) {
|
||
logForDebugging(
|
||
`Specific branch fetch failed, trying to fetch ref: ${branch}`,
|
||
)
|
||
const { code: refFetchCode, stderr: refFetchStderr } =
|
||
await execFileNoThrow(gitExe(), ['fetch', 'origin', branch])
|
||
if (refFetchCode !== 0) {
|
||
logError(
|
||
new Error(`Failed to fetch from remote origin: ${refFetchStderr}`),
|
||
)
|
||
}
|
||
} else {
|
||
logError(new Error(`Failed to fetch from remote origin: ${fetchStderr}`))
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ensures that the current branch has an upstream set
|
||
* If not, sets it to origin/<branchName> if that remote branch exists
|
||
*/
|
||
async function ensureUpstreamIsSet(branchName: string): Promise<void> {
|
||
// Check if upstream is already set
|
||
const { code: upstreamCheckCode } = await execFileNoThrow(gitExe(), [
|
||
'rev-parse',
|
||
'--abbrev-ref',
|
||
`${branchName}@{upstream}`,
|
||
])
|
||
|
||
if (upstreamCheckCode === 0) {
|
||
// Upstream is already set
|
||
logForDebugging(`Branch '${branchName}' already has upstream set`)
|
||
return
|
||
}
|
||
|
||
// Check if origin/<branchName> exists
|
||
const { code: remoteCheckCode } = await execFileNoThrow(gitExe(), [
|
||
'rev-parse',
|
||
'--verify',
|
||
`origin/${branchName}`,
|
||
])
|
||
|
||
if (remoteCheckCode === 0) {
|
||
// Remote branch exists, set upstream
|
||
logForDebugging(
|
||
`Setting upstream for '${branchName}' to 'origin/${branchName}'`,
|
||
)
|
||
const { code: setUpstreamCode, stderr: setUpstreamStderr } =
|
||
await execFileNoThrow(gitExe(), [
|
||
'branch',
|
||
'--set-upstream-to',
|
||
`origin/${branchName}`,
|
||
branchName,
|
||
])
|
||
|
||
if (setUpstreamCode !== 0) {
|
||
logForDebugging(
|
||
`Failed to set upstream for '${branchName}': ${setUpstreamStderr}`,
|
||
)
|
||
// Don't throw, just log - this is not critical
|
||
} else {
|
||
logForDebugging(`Successfully set upstream for '${branchName}'`)
|
||
}
|
||
} else {
|
||
logForDebugging(
|
||
`Remote branch 'origin/${branchName}' does not exist, skipping upstream setup`,
|
||
)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Checks out a specific branch
|
||
*/
|
||
async function checkoutBranch(branchName: string): Promise<void> {
|
||
// First try to checkout the branch as-is (might be local)
|
||
let { code: checkoutCode, stderr: checkoutStderr } = await execFileNoThrow(
|
||
gitExe(),
|
||
['checkout', branchName],
|
||
)
|
||
|
||
// If that fails, try to checkout from origin
|
||
if (checkoutCode !== 0) {
|
||
logForDebugging(
|
||
`Local checkout failed, trying to checkout from origin: ${checkoutStderr}`,
|
||
)
|
||
|
||
// Try to checkout the remote branch and create a local tracking branch
|
||
const result = await execFileNoThrow(gitExe(), [
|
||
'checkout',
|
||
'-b',
|
||
branchName,
|
||
'--track',
|
||
`origin/${branchName}`,
|
||
])
|
||
|
||
checkoutCode = result.code
|
||
checkoutStderr = result.stderr
|
||
|
||
// If that also fails, try without -b in case the branch exists but isn't checked out
|
||
if (checkoutCode !== 0) {
|
||
logForDebugging(
|
||
`Remote checkout with -b failed, trying without -b: ${checkoutStderr}`,
|
||
)
|
||
const finalResult = await execFileNoThrow(gitExe(), [
|
||
'checkout',
|
||
'--track',
|
||
`origin/${branchName}`,
|
||
])
|
||
checkoutCode = finalResult.code
|
||
checkoutStderr = finalResult.stderr
|
||
}
|
||
}
|
||
|
||
if (checkoutCode !== 0) {
|
||
logEvent('tengu_teleport_error_branch_checkout_failed', {})
|
||
throw new TeleportOperationError(
|
||
`Failed to checkout branch '${branchName}': ${checkoutStderr}`,
|
||
chalk.red(`Failed to checkout branch '${branchName}'\n`),
|
||
)
|
||
}
|
||
|
||
// After successful checkout, ensure upstream is set
|
||
await ensureUpstreamIsSet(branchName)
|
||
}
|
||
|
||
/**
|
||
* Gets the current branch name
|
||
*/
|
||
async function getCurrentBranch(): Promise<string> {
|
||
const { stdout: currentBranch } = await execFileNoThrow(gitExe(), [
|
||
'branch',
|
||
'--show-current',
|
||
])
|
||
return currentBranch.trim()
|
||
}
|
||
|
||
/**
|
||
* Processes messages for teleport resume, removing incomplete tool_use blocks
|
||
* and adding teleport notice messages
|
||
* @param messages The conversation messages
|
||
* @param error Optional error from branch checkout
|
||
* @returns Processed messages ready for resume
|
||
*/
|
||
export function processMessagesForTeleportResume(
|
||
messages: Message[],
|
||
error: Error | null,
|
||
): Message[] {
|
||
// Shared logic with resume for handling interruped session transcripts
|
||
const deserializedMessages = deserializeMessages(messages)
|
||
|
||
// Add user message about teleport resume (visible to model)
|
||
const messagesWithTeleportNotice = [
|
||
...deserializedMessages,
|
||
createTeleportResumeUserMessage(),
|
||
createTeleportResumeSystemMessage(error),
|
||
]
|
||
|
||
return messagesWithTeleportNotice
|
||
}
|
||
|
||
/**
|
||
* Checks out the specified branch for a teleported session
|
||
* @param branch Optional branch to checkout
|
||
* @returns The current branch name and any error that occurred
|
||
*/
|
||
export async function checkOutTeleportedSessionBranch(
|
||
branch?: string,
|
||
): Promise<{ branchName: string; branchError: Error | null }> {
|
||
try {
|
||
const currentBranch = await getCurrentBranch()
|
||
logForDebugging(`Current branch before teleport: '${currentBranch}'`)
|
||
|
||
if (branch) {
|
||
logForDebugging(`Switching to branch '${branch}'...`)
|
||
await fetchFromOrigin(branch)
|
||
await checkoutBranch(branch)
|
||
const newBranch = await getCurrentBranch()
|
||
logForDebugging(`Branch after checkout: '${newBranch}'`)
|
||
} else {
|
||
logForDebugging('No branch specified, staying on current branch')
|
||
}
|
||
|
||
const branchName = await getCurrentBranch()
|
||
return { branchName, branchError: null }
|
||
} catch (error) {
|
||
const branchName = await getCurrentBranch()
|
||
const branchError = toError(error)
|
||
return { branchName, branchError }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Result of repository validation for teleport
|
||
*/
|
||
export type RepoValidationResult = {
|
||
status: 'match' | 'mismatch' | 'not_in_repo' | 'no_repo_required' | 'error'
|
||
sessionRepo?: string
|
||
currentRepo?: string | null
|
||
/** Host of the session repo (e.g. "github.com" or "ghe.corp.com") — for display only */
|
||
sessionHost?: string
|
||
/** Host of the current repo (e.g. "github.com" or "ghe.corp.com") — for display only */
|
||
currentHost?: string
|
||
errorMessage?: string
|
||
}
|
||
|
||
/**
|
||
* Validates that the current repository matches the session's repository.
|
||
* Returns a result object instead of throwing, allowing the caller to handle mismatches.
|
||
*
|
||
* @param sessionData The session resource to validate against
|
||
* @returns Validation result with status and repo information
|
||
*/
|
||
export async function validateSessionRepository(
|
||
sessionData: SessionResource,
|
||
): Promise<RepoValidationResult> {
|
||
const currentParsed = await detectCurrentRepositoryWithHost()
|
||
const currentRepo = currentParsed
|
||
? `${currentParsed.owner}/${currentParsed.name}`
|
||
: null
|
||
|
||
const gitSource = sessionData.session_context.sources.find(
|
||
(source): source is GitSource => source.type === 'git_repository',
|
||
)
|
||
|
||
if (!gitSource?.url) {
|
||
// Session has no repo requirement
|
||
logForDebugging(
|
||
currentRepo
|
||
? 'Session has no associated repository, proceeding without validation'
|
||
: 'Session has no repo requirement and not in git directory, proceeding',
|
||
)
|
||
return { status: 'no_repo_required' }
|
||
}
|
||
|
||
const sessionParsed = parseGitRemote(gitSource.url)
|
||
const sessionRepo = sessionParsed
|
||
? `${sessionParsed.owner}/${sessionParsed.name}`
|
||
: parseGitHubRepository(gitSource.url)
|
||
if (!sessionRepo) {
|
||
return { status: 'no_repo_required' }
|
||
}
|
||
|
||
logForDebugging(
|
||
`Session is for repository: ${sessionRepo}, current repo: ${currentRepo ?? 'none'}`,
|
||
)
|
||
|
||
if (!currentRepo) {
|
||
// Not in a git repo, but session requires one
|
||
return {
|
||
status: 'not_in_repo',
|
||
sessionRepo,
|
||
sessionHost: sessionParsed?.host,
|
||
currentRepo: null,
|
||
}
|
||
}
|
||
|
||
// Compare both owner/repo and host to avoid cross-instance mismatches.
|
||
// Strip ports before comparing hosts — SSH remotes omit the port while
|
||
// HTTPS remotes may include a non-standard port (e.g. ghe.corp.com:8443),
|
||
// which would cause a false mismatch.
|
||
const stripPort = (host: string): string => host.replace(/:\d+$/, '')
|
||
const repoMatch = currentRepo.toLowerCase() === sessionRepo.toLowerCase()
|
||
const hostMatch =
|
||
!currentParsed ||
|
||
!sessionParsed ||
|
||
stripPort(currentParsed.host.toLowerCase()) ===
|
||
stripPort(sessionParsed.host.toLowerCase())
|
||
|
||
if (repoMatch && hostMatch) {
|
||
return {
|
||
status: 'match',
|
||
sessionRepo,
|
||
currentRepo,
|
||
}
|
||
}
|
||
|
||
// Repo mismatch — keep sessionRepo/currentRepo as plain "owner/repo" so
|
||
// downstream consumers (e.g. getKnownPathsForRepo) can use them as lookup keys.
|
||
// Include host information in separate fields for display purposes.
|
||
return {
|
||
status: 'mismatch',
|
||
sessionRepo,
|
||
currentRepo,
|
||
sessionHost: sessionParsed?.host,
|
||
currentHost: currentParsed?.host,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handles teleporting from a code session ID.
|
||
* Fetches session logs and validates repo.
|
||
* @param sessionId The session ID to resume
|
||
* @param onProgress Optional callback for progress updates
|
||
* @returns The raw session log and branch name
|
||
*/
|
||
export async function teleportResumeCodeSession(
|
||
sessionId: string,
|
||
onProgress?: TeleportProgressCallback,
|
||
): Promise<TeleportRemoteResponse> {
|
||
if (!isPolicyAllowed('allow_remote_sessions')) {
|
||
throw new Error(
|
||
"Remote sessions are disabled by your organization's policy.",
|
||
)
|
||
}
|
||
|
||
logForDebugging(`Resuming code session ID: ${sessionId}`)
|
||
|
||
try {
|
||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||
if (!accessToken) {
|
||
logEvent('tengu_teleport_resume_error', {
|
||
error_type:
|
||
'no_access_token' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
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.',
|
||
)
|
||
}
|
||
|
||
// Get organization UUID
|
||
const orgUUID = await getOrganizationUUID()
|
||
if (!orgUUID) {
|
||
logEvent('tengu_teleport_resume_error', {
|
||
error_type:
|
||
'no_org_uuid' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
throw new Error(
|
||
'Unable to get organization UUID for constructing session URL',
|
||
)
|
||
}
|
||
|
||
// Fetch and validate repository matches before resuming
|
||
onProgress?.('validating')
|
||
const sessionData = await fetchSession(sessionId)
|
||
const repoValidation = await validateSessionRepository(sessionData)
|
||
|
||
switch (repoValidation.status) {
|
||
case 'match':
|
||
case 'no_repo_required':
|
||
// Proceed with teleport
|
||
break
|
||
case 'not_in_repo': {
|
||
logEvent('tengu_teleport_error_repo_not_in_git_dir_sessions_api', {
|
||
sessionId:
|
||
sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
// Include host for GHE users so they know which instance the repo is on
|
||
const notInRepoDisplay =
|
||
repoValidation.sessionHost &&
|
||
repoValidation.sessionHost.toLowerCase() !== 'github.com'
|
||
? `${repoValidation.sessionHost}/${repoValidation.sessionRepo}`
|
||
: repoValidation.sessionRepo
|
||
throw new TeleportOperationError(
|
||
`You must run claude --teleport ${sessionId} from a checkout of ${notInRepoDisplay}.`,
|
||
chalk.red(
|
||
`You must run claude --teleport ${sessionId} from a checkout of ${chalk.bold(notInRepoDisplay)}.\n`,
|
||
),
|
||
)
|
||
}
|
||
case 'mismatch': {
|
||
logEvent('tengu_teleport_error_repo_mismatch_sessions_api', {
|
||
sessionId:
|
||
sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
// Only include host prefix when hosts actually differ to disambiguate
|
||
// cross-instance mismatches; for same-host mismatches the host is noise.
|
||
const hostsDiffer =
|
||
repoValidation.sessionHost &&
|
||
repoValidation.currentHost &&
|
||
repoValidation.sessionHost.replace(/:\d+$/, '').toLowerCase() !==
|
||
repoValidation.currentHost.replace(/:\d+$/, '').toLowerCase()
|
||
const sessionDisplay = hostsDiffer
|
||
? `${repoValidation.sessionHost}/${repoValidation.sessionRepo}`
|
||
: repoValidation.sessionRepo
|
||
const currentDisplay = hostsDiffer
|
||
? `${repoValidation.currentHost}/${repoValidation.currentRepo}`
|
||
: repoValidation.currentRepo
|
||
throw new TeleportOperationError(
|
||
`You must run claude --teleport ${sessionId} from a checkout of ${sessionDisplay}.\nThis repo is ${currentDisplay}.`,
|
||
chalk.red(
|
||
`You must run claude --teleport ${sessionId} from a checkout of ${chalk.bold(sessionDisplay)}.\nThis repo is ${chalk.bold(currentDisplay)}.\n`,
|
||
),
|
||
)
|
||
}
|
||
case 'error':
|
||
throw new TeleportOperationError(
|
||
repoValidation.errorMessage ||
|
||
'Failed to validate session repository',
|
||
chalk.red(
|
||
`Error: ${repoValidation.errorMessage || 'Failed to validate session repository'}\n`,
|
||
),
|
||
)
|
||
default: {
|
||
const _exhaustive: never = repoValidation.status
|
||
throw new Error(`Unhandled repo validation status: ${_exhaustive}`)
|
||
}
|
||
}
|
||
|
||
return await teleportFromSessionsAPI(
|
||
sessionId,
|
||
orgUUID,
|
||
accessToken,
|
||
onProgress,
|
||
sessionData,
|
||
)
|
||
} catch (error) {
|
||
if (error instanceof TeleportOperationError) {
|
||
throw error
|
||
}
|
||
|
||
const err = toError(error)
|
||
logError(err)
|
||
logEvent('tengu_teleport_resume_error', {
|
||
error_type:
|
||
'resume_session_id_catch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
throw new TeleportOperationError(
|
||
err.message,
|
||
chalk.red(`Error: ${err.message}\n`),
|
||
)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Helper function to handle teleport prerequisites (authentication and git state)
|
||
* Shows TeleportError dialog rendered into the existing root if needed
|
||
*/
|
||
async function handleTeleportPrerequisites(
|
||
root: Root,
|
||
errorsToIgnore?: Set<TeleportLocalErrorType>,
|
||
): Promise<void> {
|
||
const errors = await getTeleportErrors()
|
||
if (errors.size > 0) {
|
||
// Log teleport errors detected
|
||
logEvent('tengu_teleport_errors_detected', {
|
||
error_types: Array.from(errors).join(
|
||
',',
|
||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
errors_ignored: Array.from(errorsToIgnore || []).join(
|
||
',',
|
||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
// Show TeleportError dialog for user interaction
|
||
await new Promise<void>(resolve => {
|
||
root.render(
|
||
<AppStateProvider>
|
||
<KeybindingSetup>
|
||
<TeleportError
|
||
errorsToIgnore={errorsToIgnore}
|
||
onComplete={() => {
|
||
// Log when errors are resolved
|
||
logEvent('tengu_teleport_errors_resolved', {
|
||
error_types: Array.from(errors).join(
|
||
',',
|
||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
void resolve()
|
||
}}
|
||
/>
|
||
</KeybindingSetup>
|
||
</AppStateProvider>,
|
||
)
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Creates a remote Claude.ai session with error handling and UI feedback.
|
||
* Shows prerequisite error dialog in the existing root if needed.
|
||
* @param root The existing Ink root to render dialogs into
|
||
* @param description The description/prompt for the new session (null for no initial prompt)
|
||
* @param signal AbortSignal for cancellation
|
||
* @param branchName Optional branch name for the remote session to use
|
||
* @returns Promise<TeleportToRemoteResponse | null> The created session or null if creation fails
|
||
*/
|
||
export async function teleportToRemoteWithErrorHandling(
|
||
root: Root,
|
||
description: string | null,
|
||
signal: AbortSignal,
|
||
branchName?: string,
|
||
): Promise<TeleportToRemoteResponse | null> {
|
||
const errorsToIgnore = new Set<TeleportLocalErrorType>(['needsGitStash'])
|
||
await handleTeleportPrerequisites(root, errorsToIgnore)
|
||
return teleportToRemote({
|
||
initialMessage: description,
|
||
signal,
|
||
branchName,
|
||
onBundleFail: msg => process.stderr.write(`\n${msg}\n`),
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Fetches session data from the session ingress API (/v1/session_ingress/)
|
||
* Uses session logs instead of SDK events to get the correct message structure
|
||
* @param sessionId The session ID to fetch
|
||
* @param orgUUID The organization UUID
|
||
* @param accessToken The OAuth access token
|
||
* @param onProgress Optional callback for progress updates
|
||
* @param sessionData Optional session data (used to extract branch info)
|
||
* @returns TeleportRemoteResponse with session logs as Message[]
|
||
*/
|
||
export async function teleportFromSessionsAPI(
|
||
sessionId: string,
|
||
orgUUID: string,
|
||
accessToken: string,
|
||
onProgress?: TeleportProgressCallback,
|
||
sessionData?: SessionResource,
|
||
): Promise<TeleportRemoteResponse> {
|
||
const startTime = Date.now()
|
||
|
||
try {
|
||
// Fetch session logs via session ingress
|
||
logForDebugging(`[teleport] Starting fetch for session: ${sessionId}`)
|
||
onProgress?.('fetching_logs')
|
||
|
||
const logsStartTime = Date.now()
|
||
// Try CCR v2 first (GetTeleportEvents — server dispatches Spanner/
|
||
// threadstore). Fall back to session-ingress if it returns null
|
||
// (endpoint not yet deployed, or transient error). Once session-ingress
|
||
// is gone, the fallback becomes a no-op — getSessionLogsViaOAuth will
|
||
// return null too and we fail with "Failed to fetch session logs".
|
||
let logs = await getTeleportEvents(sessionId, accessToken, orgUUID)
|
||
if (logs === null) {
|
||
logForDebugging(
|
||
'[teleport] v2 endpoint returned null, trying session-ingress',
|
||
)
|
||
logs = await getSessionLogsViaOAuth(sessionId, accessToken, orgUUID)
|
||
}
|
||
logForDebugging(
|
||
`[teleport] Session logs fetched in ${Date.now() - logsStartTime}ms`,
|
||
)
|
||
|
||
if (logs === null) {
|
||
throw new Error('Failed to fetch session logs')
|
||
}
|
||
|
||
// Filter to get only transcript messages, excluding sidechain messages
|
||
const filterStartTime = Date.now()
|
||
const messages = logs.filter(
|
||
entry => isTranscriptMessage(entry) && !entry.isSidechain,
|
||
) as Message[]
|
||
logForDebugging(
|
||
`[teleport] Filtered ${logs.length} entries to ${messages.length} messages in ${Date.now() - filterStartTime}ms`,
|
||
)
|
||
|
||
// Extract branch info from session data
|
||
onProgress?.('fetching_branch')
|
||
const branch = sessionData ? getBranchFromSession(sessionData) : undefined
|
||
if (branch) {
|
||
logForDebugging(`[teleport] Found branch: ${branch}`)
|
||
}
|
||
|
||
logForDebugging(
|
||
`[teleport] Total teleportFromSessionsAPI time: ${Date.now() - startTime}ms`,
|
||
)
|
||
|
||
return {
|
||
log: messages,
|
||
branch,
|
||
}
|
||
} catch (error) {
|
||
const err = toError(error)
|
||
|
||
// Handle 404 specifically
|
||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||
logEvent('tengu_teleport_error_session_not_found_404', {
|
||
sessionId:
|
||
sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
throw new TeleportOperationError(
|
||
`${sessionId} not found.`,
|
||
`${sessionId} not found.\n${chalk.dim('Run /status in Claude Code to check your account.')}`,
|
||
)
|
||
}
|
||
|
||
logError(err)
|
||
|
||
throw new Error(`Failed to fetch session from Sessions API: ${err.message}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Response type for polling remote session events (uses SDK events format)
|
||
*/
|
||
export type PollRemoteSessionResponse = {
|
||
newEvents: SDKMessage[]
|
||
lastEventId: string | null
|
||
branch?: string
|
||
sessionStatus?: 'idle' | 'running' | 'requires_action' | 'archived'
|
||
}
|
||
|
||
/**
|
||
* Polls remote session events. Pass the previous response's `lastEventId`
|
||
* as `afterId` to fetch only the delta. Set `skipMetadata` to avoid the
|
||
* per-call GET /v1/sessions/{id} when branch/status aren't needed.
|
||
*/
|
||
export async function pollRemoteSessionEvents(
|
||
sessionId: string,
|
||
afterId: string | null = null,
|
||
opts?: { skipMetadata?: boolean },
|
||
): Promise<PollRemoteSessionResponse> {
|
||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||
if (!accessToken) {
|
||
throw new Error('No access token for polling')
|
||
}
|
||
|
||
const orgUUID = await getOrganizationUUID()
|
||
if (!orgUUID) {
|
||
throw new Error('No org UUID for polling')
|
||
}
|
||
|
||
const headers = {
|
||
...getOAuthHeaders(accessToken),
|
||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||
'x-organization-uuid': orgUUID,
|
||
}
|
||
const eventsUrl = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`
|
||
|
||
type EventsResponse = {
|
||
data: unknown[]
|
||
has_more: boolean
|
||
first_id: string | null
|
||
last_id: string | null
|
||
}
|
||
|
||
// Cap is a safety valve against stuck cursors; steady-state is 0–1 pages.
|
||
const MAX_EVENT_PAGES = 50
|
||
const sdkMessages: SDKMessage[] = []
|
||
let cursor = afterId
|
||
for (let page = 0; page < MAX_EVENT_PAGES; page++) {
|
||
const eventsResponse = await axios.get(eventsUrl, {
|
||
headers,
|
||
params: cursor ? { after_id: cursor } : undefined,
|
||
timeout: 30000,
|
||
})
|
||
|
||
if (eventsResponse.status !== 200) {
|
||
throw new Error(
|
||
`Failed to fetch session events: ${eventsResponse.statusText}`,
|
||
)
|
||
}
|
||
|
||
const eventsData: EventsResponse = eventsResponse.data
|
||
if (!eventsData?.data || !Array.isArray(eventsData.data)) {
|
||
throw new Error('Invalid events response')
|
||
}
|
||
|
||
for (const event of eventsData.data) {
|
||
if (event && typeof event === 'object' && 'type' in event) {
|
||
if (
|
||
event.type === 'env_manager_log' ||
|
||
event.type === 'control_response'
|
||
) {
|
||
continue
|
||
}
|
||
if ('session_id' in event) {
|
||
sdkMessages.push(event as SDKMessage)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!eventsData.last_id) break
|
||
cursor = eventsData.last_id
|
||
if (!eventsData.has_more) break
|
||
}
|
||
|
||
if (opts?.skipMetadata) {
|
||
return { newEvents: sdkMessages, lastEventId: cursor }
|
||
}
|
||
|
||
// Fetch session metadata (branch, status)
|
||
let branch: string | undefined
|
||
let sessionStatus: PollRemoteSessionResponse['sessionStatus']
|
||
try {
|
||
const sessionData = await fetchSession(sessionId)
|
||
branch = getBranchFromSession(sessionData)
|
||
sessionStatus =
|
||
sessionData.session_status as PollRemoteSessionResponse['sessionStatus']
|
||
} catch (e) {
|
||
logForDebugging(
|
||
`teleport: failed to fetch session ${sessionId} metadata: ${e}`,
|
||
{ level: 'debug' },
|
||
)
|
||
}
|
||
|
||
return { newEvents: sdkMessages, lastEventId: cursor, branch, sessionStatus }
|
||
}
|
||
|
||
/**
|
||
* Creates a remote Claude.ai session using the Sessions API.
|
||
*
|
||
* Two source modes:
|
||
* - GitHub (default): backend clones from the repo's origin URL. Requires a
|
||
* GitHub remote + CCR-side GitHub connection. 43% of CLI sessions have an
|
||
* origin remote; far fewer pass the full precondition chain.
|
||
* - Bundle (CCR_FORCE_BUNDLE=1): CLI creates `git bundle --all`, uploads via Files
|
||
* API, passes file_id as seed_bundle_file_id on the session context. CCR
|
||
* downloads it and clones from the bundle. No GitHub dependency — works for
|
||
* local-only repos. Reach: 54% of CLI sessions (anything with .git/).
|
||
* Backend: anthropic#303856.
|
||
*/
|
||
export async function teleportToRemote(options: {
|
||
initialMessage: string | null
|
||
branchName?: string
|
||
title?: string
|
||
/**
|
||
* The description of the session. This is used to generate the title and
|
||
* session branch name (unless they are explicitly provided).
|
||
*/
|
||
description?: string
|
||
model?: string
|
||
permissionMode?: PermissionMode
|
||
ultraplan?: boolean
|
||
signal: AbortSignal
|
||
useDefaultEnvironment?: boolean
|
||
/**
|
||
* Explicit environment_id (e.g. the code_review synthetic env). Bypasses
|
||
* fetchEnvironments; the usual repo-detection → git source still runs so
|
||
* the container gets the repo checked out (orchestrator reads --repo-dir
|
||
* from pwd, it doesn't clone).
|
||
*/
|
||
environmentId?: string
|
||
/**
|
||
* Per-session env vars merged into session_context.environment_variables.
|
||
* Write-only at the API layer (stripped from Get/List responses). When
|
||
* environmentId is set, CLAUDE_CODE_OAUTH_TOKEN is auto-injected from the
|
||
* caller's accessToken so the container's hook can hit inference (the
|
||
* server only passes through what the caller sends; bughunter.go mints
|
||
* its own, user sessions don't get one automatically).
|
||
*/
|
||
environmentVariables?: Record<string, string>
|
||
/**
|
||
* When set with environmentId, creates and uploads a git bundle of the
|
||
* local working tree (createAndUploadGitBundle handles the stash-create
|
||
* for uncommitted changes) and passes it as seed_bundle_file_id. Backend
|
||
* clones from the bundle instead of GitHub — container gets the caller's
|
||
* exact local state. Needs .git/ only, not a GitHub remote.
|
||
*/
|
||
useBundle?: boolean
|
||
/**
|
||
* Called with a user-facing message when the bundle path is attempted but
|
||
* fails. The wrapper stderr.writes it (pre-REPL). Remote-agent callers
|
||
* capture it to include in their throw (in-REPL, Ink-rendered).
|
||
*/
|
||
onBundleFail?: (message: string) => void
|
||
|
||
onCreateFail?: (message: string) => void
|
||
/**
|
||
* When true, disables the git-bundle fallback entirely. Use for flows like
|
||
* autofix where CCR must push to GitHub — a bundle can't do that.
|
||
*/
|
||
skipBundle?: boolean
|
||
/**
|
||
* When set, reuses this branch as the outcome branch instead of generating
|
||
* a new claude/ branch. Sets allow_unrestricted_git_push on the source and
|
||
* reuse_outcome_branches on the session context so the remote pushes to the
|
||
* caller's branch directly.
|
||
*/
|
||
reuseOutcomeBranch?: string
|
||
/**
|
||
* GitHub PR to attach to the session context. Backend uses this to
|
||
* identify the PR associated with this session.
|
||
*/
|
||
githubPr?: { owner: string; repo: string; number: number }
|
||
}): Promise<TeleportToRemoteResponse | null> {
|
||
const { initialMessage, signal } = options
|
||
try {
|
||
// Check authentication
|
||
await checkAndRefreshOAuthTokenIfNeeded()
|
||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||
if (!accessToken) {
|
||
logError(new Error('No access token found for remote session creation'))
|
||
return null
|
||
}
|
||
|
||
// Get organization UUID
|
||
const orgUUID = await getOrganizationUUID()
|
||
if (!orgUUID) {
|
||
logError(
|
||
new Error(
|
||
'Unable to get organization UUID for remote session creation',
|
||
),
|
||
)
|
||
return null
|
||
}
|
||
|
||
// Explicit environmentId short-circuits Haiku title-gen + env selection.
|
||
// Still runs repo detection so the container gets a working directory —
|
||
// the code_review orchestrator reads --repo-dir $(pwd), it doesn't clone
|
||
// (bughunter.go:520 sets a git source too; env-manager does the checkout
|
||
// before the SessionStart hook fires).
|
||
if (options.environmentId) {
|
||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions`
|
||
const headers = {
|
||
...getOAuthHeaders(accessToken),
|
||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||
'x-organization-uuid': orgUUID,
|
||
}
|
||
const envVars = {
|
||
CLAUDE_CODE_OAUTH_TOKEN: accessToken,
|
||
...(options.environmentVariables ?? {}),
|
||
}
|
||
|
||
// Bundle mode: upload local working tree (uncommitted changes via
|
||
// refs/seed/stash), container clones from the bundle. No GitHub.
|
||
// Otherwise: github.com source — caller checked eligibility.
|
||
let gitSource: GitSource | null = null
|
||
let seedBundleFileId: string | null = null
|
||
if (options.useBundle) {
|
||
const bundle = await createAndUploadGitBundle(
|
||
{
|
||
oauthToken: accessToken,
|
||
sessionId: getSessionId(),
|
||
baseUrl: getOauthConfig().BASE_API_URL,
|
||
},
|
||
{ signal },
|
||
)
|
||
if (!bundle.success) {
|
||
const failBundle = bundle as { success: false; error: string; failReason?: string }
|
||
logError(new Error(`Bundle upload failed: ${failBundle.error}`))
|
||
return null
|
||
}
|
||
seedBundleFileId = bundle.fileId
|
||
logEvent('tengu_teleport_bundle_mode', {
|
||
size_bytes: bundle.bundleSizeBytes,
|
||
scope:
|
||
bundle.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
has_wip: bundle.hasWip,
|
||
reason:
|
||
'explicit_env_bundle' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
} else {
|
||
const repoInfo = await detectCurrentRepositoryWithHost()
|
||
if (repoInfo) {
|
||
gitSource = {
|
||
type: 'git_repository',
|
||
url: `https://${repoInfo.host}/${repoInfo.owner}/${repoInfo.name}`,
|
||
revision: options.branchName,
|
||
}
|
||
}
|
||
}
|
||
|
||
const requestBody = {
|
||
title: options.title || options.description || 'Remote task',
|
||
events: [],
|
||
session_context: {
|
||
sources: gitSource ? [gitSource] : [],
|
||
...(seedBundleFileId && { seed_bundle_file_id: seedBundleFileId }),
|
||
outcomes: [],
|
||
environment_variables: envVars,
|
||
},
|
||
environment_id: options.environmentId,
|
||
}
|
||
logForDebugging(
|
||
`[teleportToRemote] explicit env ${options.environmentId}, ${Object.keys(envVars).length} env vars, ${seedBundleFileId ? `bundle=${seedBundleFileId}` : `source=${gitSource?.url ?? 'none'}@${options.branchName ?? 'default'}`}`,
|
||
)
|
||
const response = await axios.post(url, requestBody, { headers, signal })
|
||
if (response.status !== 200 && response.status !== 201) {
|
||
logError(
|
||
new Error(
|
||
`CreateSession ${response.status}: ${jsonStringify(response.data)}`,
|
||
),
|
||
)
|
||
return null
|
||
}
|
||
const sessionData = response.data as SessionResource
|
||
if (!sessionData || typeof sessionData.id !== 'string') {
|
||
logError(
|
||
new Error(
|
||
`No session id in response: ${jsonStringify(response.data)}`,
|
||
),
|
||
)
|
||
return null
|
||
}
|
||
return {
|
||
id: sessionData.id,
|
||
title: sessionData.title || requestBody.title,
|
||
}
|
||
}
|
||
|
||
let gitSource: GitSource | null = null
|
||
let gitOutcome: GitRepositoryOutcome | null = null
|
||
let seedBundleFileId: string | null = null
|
||
|
||
// Source selection ladder: GitHub clone (if CCR can actually pull it) →
|
||
// bundle fallback (if .git exists) → empty sandbox.
|
||
//
|
||
// The preflight is the same code path the container's git-proxy clone
|
||
// will hit (get_github_client_with_user_auth → no_sync_user_token_found).
|
||
// 50% of users who reach the "install GitHub App" step never finish it;
|
||
// without the preflight, every one of them gets a container that 401s
|
||
// on clone. With it, they silently fall back to bundle.
|
||
//
|
||
// CCR_FORCE_BUNDLE=1 skips the preflight entirely — useful for testing
|
||
// or when you know your GitHub auth is busted. Read here (not in the
|
||
// caller) so it works for remote-agent too, not just --remote.
|
||
|
||
const repoInfo = await detectCurrentRepositoryWithHost()
|
||
|
||
// Generate title and branch name for the session. Skip the Haiku call
|
||
// when both title and outcome branch are explicitly provided.
|
||
let sessionTitle: string
|
||
let sessionBranch: string
|
||
if (options.title && options.reuseOutcomeBranch) {
|
||
sessionTitle = options.title
|
||
sessionBranch = options.reuseOutcomeBranch
|
||
} else {
|
||
const generated = await generateTitleAndBranch(
|
||
options.description || initialMessage || 'Background task',
|
||
signal,
|
||
)
|
||
sessionTitle = options.title || generated.title
|
||
sessionBranch = options.reuseOutcomeBranch || generated.branchName
|
||
}
|
||
|
||
// Preflight: does CCR have a token that can clone this repo?
|
||
// Only checked for github.com — GHES needs ghe_configuration_id which
|
||
// we don't have, and GHES users are power users who probably finished
|
||
// setup. For them (and for non-GitHub hosts that parseGitRemote
|
||
// somehow accepted), fall through optimistically; if the backend
|
||
// rejects the host, bundle next time.
|
||
let ghViable = false
|
||
let sourceReason:
|
||
| 'github_preflight_ok'
|
||
| 'ghes_optimistic'
|
||
| 'github_preflight_failed'
|
||
| 'no_github_remote'
|
||
| 'forced_bundle'
|
||
| 'no_git_at_all' = 'no_git_at_all'
|
||
|
||
// gitRoot gates both bundle creation and the gate check itself — no
|
||
// point awaiting GrowthBook when there's nothing to bundle.
|
||
const gitRoot = findGitRoot(getCwd())
|
||
const forceBundle =
|
||
!options.skipBundle && isEnvTruthy(process.env.CCR_FORCE_BUNDLE)
|
||
const bundleSeedGateOn =
|
||
!options.skipBundle &&
|
||
gitRoot !== null &&
|
||
(isEnvTruthy(process.env.CCR_ENABLE_BUNDLE) ||
|
||
(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bundle_seed_enabled')))
|
||
|
||
if (repoInfo && !forceBundle) {
|
||
if (repoInfo.host === 'github.com') {
|
||
ghViable = await checkGithubAppInstalled(
|
||
repoInfo.owner,
|
||
repoInfo.name,
|
||
signal,
|
||
)
|
||
sourceReason = ghViable
|
||
? 'github_preflight_ok'
|
||
: 'github_preflight_failed'
|
||
} else {
|
||
ghViable = true
|
||
sourceReason = 'ghes_optimistic'
|
||
}
|
||
} else if (forceBundle) {
|
||
sourceReason = 'forced_bundle'
|
||
} else if (gitRoot) {
|
||
sourceReason = 'no_github_remote'
|
||
}
|
||
|
||
// Preflight failed but bundle is off — fall through optimistically like
|
||
// pre-preflight behavior. Backend reports the real auth error.
|
||
if (!ghViable && !bundleSeedGateOn && repoInfo) {
|
||
ghViable = true
|
||
}
|
||
|
||
if (ghViable && repoInfo) {
|
||
const { host, owner, name } = repoInfo
|
||
// Resolve the base branch: prefer explicit branchName, fall back to default branch
|
||
const revision =
|
||
options.branchName ?? (await getDefaultBranch()) ?? undefined
|
||
logForDebugging(
|
||
`[teleportToRemote] Git source: ${host}/${owner}/${name}, revision: ${revision ?? 'none'}`,
|
||
)
|
||
gitSource = {
|
||
type: 'git_repository',
|
||
url: `https://${host}/${owner}/${name}`,
|
||
// The revision specifies which ref to checkout as the base branch
|
||
revision,
|
||
...(options.reuseOutcomeBranch && {
|
||
allow_unrestricted_git_push: true,
|
||
}),
|
||
}
|
||
// type: 'github' is used for all GitHub-compatible hosts (github.com and GHE).
|
||
// The CLI can't distinguish GHE from non-GitHub hosts (GitLab, Bitbucket)
|
||
// client-side — the backend validates the URL against configured GHE instances
|
||
// and ignores git_info for unrecognized hosts.
|
||
gitOutcome = {
|
||
type: 'git_repository',
|
||
git_info: {
|
||
type: 'github',
|
||
repo: `${owner}/${name}`,
|
||
branches: [sessionBranch],
|
||
},
|
||
}
|
||
}
|
||
|
||
// Bundle fallback. Only try bundle if GitHub wasn't viable, the gate is
|
||
// on, and there's a .git/ to bundle from. Reaching here with
|
||
// ghViable=false and repoInfo non-null means the preflight failed —
|
||
// .git definitely exists (detectCurrentRepositoryWithHost read the
|
||
// remote from it).
|
||
if (!gitSource && bundleSeedGateOn) {
|
||
logForDebugging(`[teleportToRemote] Bundling (reason: ${sourceReason})`)
|
||
const bundle = await createAndUploadGitBundle(
|
||
{
|
||
oauthToken: accessToken,
|
||
sessionId: getSessionId(),
|
||
baseUrl: getOauthConfig().BASE_API_URL,
|
||
},
|
||
{ signal },
|
||
)
|
||
if (!bundle.success) {
|
||
const failBundle = bundle as { success: false; error: string; failReason?: string }
|
||
logError(new Error(`Bundle upload failed: ${failBundle.error}`))
|
||
// Only steer users to GitHub setup when there's a remote to clone from.
|
||
const setup = repoInfo
|
||
? '. Please setup GitHub on https://claude.ai/code'
|
||
: ''
|
||
let msg: string
|
||
switch (failBundle.failReason) {
|
||
case 'empty_repo':
|
||
msg =
|
||
'Repository has no commits — run `git add . && git commit -m "initial"` then retry'
|
||
break
|
||
case 'too_large':
|
||
msg = `Repo is too large to teleport${setup}`
|
||
break
|
||
case 'git_error':
|
||
msg = `Failed to create git bundle (${failBundle.error})${setup}`
|
||
break
|
||
case undefined:
|
||
msg = `Bundle upload failed: ${failBundle.error}${setup}`
|
||
break
|
||
default: {
|
||
msg = `Bundle upload failed: ${failBundle.error}`
|
||
}
|
||
}
|
||
options.onBundleFail?.(msg)
|
||
return null
|
||
}
|
||
seedBundleFileId = bundle.fileId
|
||
logEvent('tengu_teleport_bundle_mode', {
|
||
size_bytes: bundle.bundleSizeBytes,
|
||
scope:
|
||
bundle.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
has_wip: bundle.hasWip,
|
||
reason:
|
||
sourceReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
}
|
||
|
||
logEvent('tengu_teleport_source_decision', {
|
||
reason:
|
||
sourceReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
path: (gitSource
|
||
? 'github'
|
||
: seedBundleFileId
|
||
? 'bundle'
|
||
: 'empty') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
if (!gitSource && !seedBundleFileId) {
|
||
logForDebugging(
|
||
'[teleportToRemote] No repository detected — session will have an empty sandbox',
|
||
)
|
||
}
|
||
|
||
// Fetch available environments
|
||
let environments = await fetchEnvironments()
|
||
if (!environments || environments.length === 0) {
|
||
logError(new Error('No environments available for session creation'))
|
||
return null
|
||
}
|
||
|
||
logForDebugging(
|
||
`Available environments: ${environments.map(e => `${e.environment_id} (${e.name}, ${e.kind})`).join(', ')}`,
|
||
)
|
||
|
||
// Select environment based on settings, then anthropic_cloud preference, then first available.
|
||
// Prefer anthropic_cloud environments over byoc: anthropic_cloud environments (e.g. "Default")
|
||
// are the standard compute environments with full repo access, whereas byoc environments
|
||
// (e.g. "monorepo") are user-owned compute that may not support the current repository.
|
||
const settings = getSettings_DEPRECATED()
|
||
const defaultEnvironmentId = options.useDefaultEnvironment
|
||
? undefined
|
||
: settings?.remote?.defaultEnvironmentId
|
||
let cloudEnv = environments.find(env => env.kind === 'anthropic_cloud')
|
||
// When the caller opts out of their configured default, do not fall
|
||
// through to a BYOC env that may not support the current repo or the
|
||
// requested permission mode. Retry once for eventual consistency,
|
||
// then fail loudly.
|
||
if (options.useDefaultEnvironment && !cloudEnv) {
|
||
logForDebugging(
|
||
`No anthropic_cloud in env list (${environments.length} envs); retrying fetchEnvironments`,
|
||
)
|
||
const retried = await fetchEnvironments()
|
||
cloudEnv = retried?.find(env => env.kind === 'anthropic_cloud')
|
||
if (!cloudEnv) {
|
||
logError(
|
||
new Error(
|
||
`No anthropic_cloud environment available after retry (got: ${(retried ?? environments).map(e => `${e.name} (${e.kind})`).join(', ')}). Silent byoc fallthrough would launch into a dead env — fail fast instead.`,
|
||
),
|
||
)
|
||
return null
|
||
}
|
||
if (retried) environments = retried
|
||
}
|
||
const selectedEnvironment =
|
||
(defaultEnvironmentId &&
|
||
environments.find(
|
||
env => env.environment_id === defaultEnvironmentId,
|
||
)) ||
|
||
cloudEnv ||
|
||
environments.find(env => env.kind !== 'bridge') ||
|
||
environments[0]
|
||
|
||
if (!selectedEnvironment) {
|
||
logError(new Error('No environments available for session creation'))
|
||
return null
|
||
}
|
||
|
||
if (defaultEnvironmentId) {
|
||
const matchedDefault =
|
||
selectedEnvironment.environment_id === defaultEnvironmentId
|
||
logForDebugging(
|
||
matchedDefault
|
||
? `Using configured default environment: ${defaultEnvironmentId}`
|
||
: `Configured default environment ${defaultEnvironmentId} not found, using first available`,
|
||
)
|
||
}
|
||
|
||
const environmentId = selectedEnvironment.environment_id
|
||
logForDebugging(
|
||
`Selected environment: ${environmentId} (${selectedEnvironment.name}, ${selectedEnvironment.kind})`,
|
||
)
|
||
|
||
// Prepare API request for Sessions API
|
||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions`
|
||
|
||
const headers = {
|
||
...getOAuthHeaders(accessToken),
|
||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||
'x-organization-uuid': orgUUID,
|
||
}
|
||
|
||
const sessionContext = {
|
||
sources: gitSource ? [gitSource] : [],
|
||
...(seedBundleFileId && { seed_bundle_file_id: seedBundleFileId }),
|
||
outcomes: gitOutcome ? [gitOutcome] : [],
|
||
model: options.model ?? getMainLoopModel(),
|
||
...(options.reuseOutcomeBranch && { reuse_outcome_branches: true }),
|
||
...(options.githubPr && { github_pr: options.githubPr }),
|
||
}
|
||
|
||
// CreateCCRSessionPayload has no permission_mode field — a top-level
|
||
// body entry is silently dropped by the proto parser server-side.
|
||
// Instead prepend a set_permission_mode control_request event. Initial
|
||
// events are written to threadstore before the container connects, so
|
||
// the CLI applies the mode before the first user turn — no readiness race.
|
||
const events: Array<{ type: 'event'; data: Record<string, unknown> }> = []
|
||
if (options.permissionMode) {
|
||
events.push({
|
||
type: 'event',
|
||
data: {
|
||
type: 'control_request',
|
||
request_id: `set-mode-${randomUUID()}`,
|
||
request: {
|
||
subtype: 'set_permission_mode',
|
||
mode: options.permissionMode,
|
||
ultraplan: options.ultraplan,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
if (initialMessage) {
|
||
events.push({
|
||
type: 'event',
|
||
data: {
|
||
uuid: randomUUID(),
|
||
session_id: '',
|
||
type: 'user',
|
||
parent_tool_use_id: null,
|
||
message: {
|
||
role: 'user',
|
||
content: initialMessage,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
const requestBody = {
|
||
title: options.ultraplan ? `ultraplan: ${sessionTitle}` : sessionTitle,
|
||
events,
|
||
session_context: sessionContext,
|
||
environment_id: environmentId,
|
||
}
|
||
|
||
logForDebugging(
|
||
`Creating session with payload: ${jsonStringify(requestBody, null, 2)}`,
|
||
)
|
||
|
||
// Make API call
|
||
const response = await axios.post(url, requestBody, { headers, signal, validateStatus: (status) => status < 500 })
|
||
const isSuccess = response.status === 200 || response.status === 201
|
||
|
||
if (!isSuccess) {
|
||
logError(
|
||
new Error(
|
||
`API request failed with status ${response.status}: ${response.statusText}\n\nResponse data: ${jsonStringify(response.data, null, 2)}`,
|
||
),
|
||
)
|
||
|
||
options.onCreateFail?.(`${response.status} ${response.statusText}: ${jsonStringify(response.data)}`);
|
||
return null
|
||
}
|
||
|
||
// Parse response as SessionResource
|
||
const sessionData = response.data as SessionResource
|
||
if (!sessionData || typeof sessionData.id !== 'string') {
|
||
logError(
|
||
new Error(
|
||
`Cannot determine session ID from API response: ${jsonStringify(response.data)}`,
|
||
),
|
||
)
|
||
return null
|
||
}
|
||
|
||
logForDebugging(`Successfully created remote session: ${sessionData.id}`)
|
||
return {
|
||
id: sessionData.id,
|
||
title: sessionData.title || requestBody.title,
|
||
}
|
||
} catch (error) {
|
||
const err = toError(error)
|
||
logError(err)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Best-effort session archive. POST /v1/sessions/{id}/archive has no
|
||
* running-status check (unlike DELETE which 409s on RUNNING), so it works
|
||
* mid-implementation. Archived sessions reject new events (send_events.go),
|
||
* so the remote stops on its next write. 409 (already archived) treated as
|
||
* success. Fire-and-forget; failure leaks a visible session until the
|
||
* reaper collects it.
|
||
*/
|
||
export async function archiveRemoteSession(sessionId: string, timeout = 10_000): Promise<void> {
|
||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||
if (!accessToken) return
|
||
const orgUUID = await getOrganizationUUID()
|
||
if (!orgUUID) return
|
||
const headers = {
|
||
...getOAuthHeaders(accessToken),
|
||
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
||
'x-organization-uuid': orgUUID,
|
||
}
|
||
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`
|
||
try {
|
||
const resp = await axios.post(
|
||
url,
|
||
{},
|
||
{ headers, timeout, validateStatus: s => s < 500 },
|
||
)
|
||
if (resp.status === 200 || resp.status === 409) {
|
||
logForDebugging(`[archiveRemoteSession] archived ${sessionId}`)
|
||
} else {
|
||
logForDebugging(
|
||
`[archiveRemoteSession] ${sessionId} failed ${resp.status}: ${jsonStringify(resp.data)}`,
|
||
)
|
||
}
|
||
} catch (err) {
|
||
logError(err)
|
||
}
|
||
}
|