mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
- /teleport: 从 claude.ai 恢复会话 - /recap: 生成会话摘要 - /break-cache: 提示缓存管理(once/always/off/status) - /env: 环境信息展示(含密钥脱敏) - /tui: 无闪烁 TUI 模式管理 - /onboarding: 引导流程 - /perf-issue: 性能问题诊断 - /debug-tool-call: 工具调用调试 - /usage: 用量统计(合并 /cost 和 /stats 别名) Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
1350 lines
52 KiB
TypeScript
1350 lines
52 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 };
|
||
/**
|
||
* Identifies which command/flow originated this teleport. CCR backend
|
||
* uses this for routing/observability. Known values: 'autofix_pr',
|
||
* 'ultrareview', 'ultraplan'. Pass-through field — not interpreted
|
||
* client-side; if backend doesn't recognize it, it's silently ignored.
|
||
*/
|
||
source?: string;
|
||
}): 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 }),
|
||
...(options.source && { source: options.source }),
|
||
};
|
||
|
||
// 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);
|
||
}
|
||
}
|