| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234 |
- 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 '../ink.js';
- 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 content = response.message.content;
- if (!Array.isArray(content)) {
- return {
- title: fallbackTitle,
- branchName: fallbackBranch
- };
- }
- const firstBlock = content[0];
- if (!firstBlock || typeof firstBlock === 'string' || !('type' in firstBlock) || firstBlock.type !== 'text') {
- return {
- title: fallbackTitle,
- branchName: fallbackBranch
- };
- }
- const parsed = safeParseJSON(('text' in firstBlock ? 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;
- /**
- * 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 failedBundle = bundle as { success: false; error: string; failReason?: string };
- logError(new Error(`Bundle upload failed: ${failedBundle.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 failedBundle = bundle as { success: false; error: string; failReason?: 'git_error' | 'too_large' | 'empty_repo' };
- logError(new Error(`Bundle upload failed: ${failedBundle.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 (failedBundle.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 (${failedBundle.error})${setup}`;
- break;
- case undefined:
- msg = `Bundle upload failed: ${failedBundle.error}${setup}`;
- break;
- default:
- {
- const _exhaustive: never = failedBundle.failReason;
- void _exhaustive;
- msg = `Bundle upload failed: ${failedBundle.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
- });
- 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)}`));
- 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): 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: 10000,
- 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);
- }
- }
|