teleport.tsx 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234
  1. import axios from 'axios';
  2. import chalk from 'chalk';
  3. import { randomUUID } from 'crypto';
  4. import React from 'react';
  5. import { getOriginalCwd, getSessionId } from 'src/bootstrap/state.js';
  6. import { checkGate_CACHED_OR_BLOCKING } from 'src/services/analytics/growthbook.js';
  7. import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js';
  8. import { isPolicyAllowed } from 'src/services/policyLimits/index.js';
  9. import { z } from 'zod/v4';
  10. import { getTeleportErrors, TeleportError, type TeleportLocalErrorType } from '../components/TeleportError.js';
  11. import { getOauthConfig } from '../constants/oauth.js';
  12. import type { SDKMessage } from '../entrypoints/agentSdkTypes.js';
  13. import type { Root } from '../ink.js';
  14. import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js';
  15. import { queryHaiku } from '../services/api/claude.js';
  16. import { getSessionLogsViaOAuth, getTeleportEvents } from '../services/api/sessionIngress.js';
  17. import { getOrganizationUUID } from '../services/oauth/client.js';
  18. import { AppStateProvider } from '../state/AppState.js';
  19. import type { Message, SystemMessage } from '../types/message.js';
  20. import type { PermissionMode } from '../types/permissions.js';
  21. import { checkAndRefreshOAuthTokenIfNeeded, getClaudeAIOAuthTokens } from './auth.js';
  22. import { checkGithubAppInstalled } from './background/remote/preconditions.js';
  23. import { deserializeMessages, type TeleportRemoteResponse } from './conversationRecovery.js';
  24. import { getCwd } from './cwd.js';
  25. import { logForDebugging } from './debug.js';
  26. import { detectCurrentRepositoryWithHost, parseGitHubRepository, parseGitRemote } from './detectRepository.js';
  27. import { isEnvTruthy } from './envUtils.js';
  28. import { TeleportOperationError, toError } from './errors.js';
  29. import { execFileNoThrow } from './execFileNoThrow.js';
  30. import { truncateToWidth } from './format.js';
  31. import { findGitRoot, getDefaultBranch, getIsClean, gitExe } from './git.js';
  32. import { safeParseJSON } from './json.js';
  33. import { logError } from './log.js';
  34. import { createSystemMessage, createUserMessage } from './messages.js';
  35. import { getMainLoopModel } from './model/model.js';
  36. import { isTranscriptMessage } from './sessionStorage.js';
  37. import { getSettings_DEPRECATED } from './settings/settings.js';
  38. import { jsonStringify } from './slowOperations.js';
  39. import { asSystemPrompt } from './systemPromptType.js';
  40. import { fetchSession, type GitRepositoryOutcome, type GitSource, getBranchFromSession, getOAuthHeaders, type SessionResource } from './teleport/api.js';
  41. import { fetchEnvironments } from './teleport/environments.js';
  42. import { createAndUploadGitBundle } from './teleport/gitBundle.js';
  43. export type TeleportResult = {
  44. messages: Message[];
  45. branchName: string;
  46. };
  47. export type TeleportProgressStep = 'validating' | 'fetching_logs' | 'fetching_branch' | 'checking_out' | 'done';
  48. export type TeleportProgressCallback = (step: TeleportProgressStep) => void;
  49. /**
  50. * Creates a system message to inform about teleport session resume
  51. * @returns SystemMessage indicating session was resumed from another machine
  52. */
  53. function createTeleportResumeSystemMessage(branchError: Error | null): SystemMessage {
  54. if (branchError === null) {
  55. return createSystemMessage('Session resumed', 'suggestion');
  56. }
  57. const formattedError = branchError instanceof TeleportOperationError ? branchError.formattedMessage : branchError.message;
  58. return createSystemMessage(`Session resumed without branch: ${formattedError}`, 'warning');
  59. }
  60. /**
  61. * Creates a user message to inform the model about teleport session resume
  62. * @returns User message indicating session was resumed from another machine
  63. */
  64. function createTeleportResumeUserMessage() {
  65. return createUserMessage({
  66. content: `This session is being continued from another machine. Application state may have changed. The updated working directory is ${getOriginalCwd()}`,
  67. isMeta: true
  68. });
  69. }
  70. type TeleportToRemoteResponse = {
  71. id: string;
  72. title: string;
  73. };
  74. 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.
  75. 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.
  76. Use sentence case for the title (capitalize only the first word and proper nouns), not Title Case.
  77. The branch name should be clear, concise, and accurately reflect the content of the coding task.
  78. 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.
  79. Return a JSON object with "title" and "branch" fields.
  80. Example 1: {"title": "Fix login button not working on mobile", "branch": "claude/fix-mobile-login-button"}
  81. Example 2: {"title": "Update README with installation instructions", "branch": "claude/update-readme"}
  82. Example 3: {"title": "Improve performance of data processing script", "branch": "claude/improve-data-processing"}
  83. Here is the session description:
  84. <description>{description}</description>
  85. Please generate a title and branch name for this session.`;
  86. type TitleAndBranch = {
  87. title: string;
  88. branchName: string;
  89. };
  90. /**
  91. * Generates a title and branch name for a coding session using Claude Haiku
  92. * @param description The description/prompt for the session
  93. * @returns Promise<TitleAndBranch> The generated title and branch name
  94. */
  95. async function generateTitleAndBranch(description: string, signal: AbortSignal): Promise<TitleAndBranch> {
  96. const fallbackTitle = truncateToWidth(description, 75);
  97. const fallbackBranch = 'claude/task';
  98. try {
  99. const userPrompt = SESSION_TITLE_AND_BRANCH_PROMPT.replace('{description}', description);
  100. const response = await queryHaiku({
  101. systemPrompt: asSystemPrompt([]),
  102. userPrompt,
  103. outputFormat: {
  104. type: 'json_schema',
  105. schema: {
  106. type: 'object',
  107. properties: {
  108. title: {
  109. type: 'string'
  110. },
  111. branch: {
  112. type: 'string'
  113. }
  114. },
  115. required: ['title', 'branch'],
  116. additionalProperties: false
  117. }
  118. },
  119. signal,
  120. options: {
  121. querySource: 'teleport_generate_title',
  122. agents: [],
  123. isNonInteractiveSession: false,
  124. hasAppendSystemPrompt: false,
  125. mcpTools: []
  126. }
  127. });
  128. // Extract text from the response
  129. const content = response.message.content;
  130. if (!Array.isArray(content)) {
  131. return {
  132. title: fallbackTitle,
  133. branchName: fallbackBranch
  134. };
  135. }
  136. const firstBlock = content[0];
  137. if (!firstBlock || typeof firstBlock === 'string' || !('type' in firstBlock) || firstBlock.type !== 'text') {
  138. return {
  139. title: fallbackTitle,
  140. branchName: fallbackBranch
  141. };
  142. }
  143. const parsed = safeParseJSON(('text' in firstBlock ? firstBlock.text : '').trim());
  144. const parseResult = z.object({
  145. title: z.string(),
  146. branch: z.string()
  147. }).safeParse(parsed);
  148. if (parseResult.success) {
  149. return {
  150. title: parseResult.data.title || fallbackTitle,
  151. branchName: parseResult.data.branch || fallbackBranch
  152. };
  153. }
  154. return {
  155. title: fallbackTitle,
  156. branchName: fallbackBranch
  157. };
  158. } catch (error) {
  159. logError(new Error(`Error generating title and branch: ${error}`));
  160. return {
  161. title: fallbackTitle,
  162. branchName: fallbackBranch
  163. };
  164. }
  165. }
  166. /**
  167. * Validates that the git working directory is clean (ignoring untracked files)
  168. * Untracked files are ignored because they won't be lost during branch switching
  169. */
  170. export async function validateGitState(): Promise<void> {
  171. const isClean = await getIsClean({
  172. ignoreUntracked: true
  173. });
  174. if (!isClean) {
  175. logEvent('tengu_teleport_error_git_not_clean', {});
  176. 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'));
  177. throw error;
  178. }
  179. }
  180. /**
  181. * Fetches a specific branch from remote origin
  182. * @param branch The branch to fetch. If not specified, fetches all branches.
  183. */
  184. async function fetchFromOrigin(branch?: string): Promise<void> {
  185. const fetchArgs = branch ? ['fetch', 'origin', `${branch}:${branch}`] : ['fetch', 'origin'];
  186. const {
  187. code: fetchCode,
  188. stderr: fetchStderr
  189. } = await execFileNoThrow(gitExe(), fetchArgs);
  190. if (fetchCode !== 0) {
  191. // If fetching a specific branch fails, it might not exist locally yet
  192. // Try fetching just the ref without mapping to local branch
  193. if (branch && fetchStderr.includes('refspec')) {
  194. logForDebugging(`Specific branch fetch failed, trying to fetch ref: ${branch}`);
  195. const {
  196. code: refFetchCode,
  197. stderr: refFetchStderr
  198. } = await execFileNoThrow(gitExe(), ['fetch', 'origin', branch]);
  199. if (refFetchCode !== 0) {
  200. logError(new Error(`Failed to fetch from remote origin: ${refFetchStderr}`));
  201. }
  202. } else {
  203. logError(new Error(`Failed to fetch from remote origin: ${fetchStderr}`));
  204. }
  205. }
  206. }
  207. /**
  208. * Ensures that the current branch has an upstream set
  209. * If not, sets it to origin/<branchName> if that remote branch exists
  210. */
  211. async function ensureUpstreamIsSet(branchName: string): Promise<void> {
  212. // Check if upstream is already set
  213. const {
  214. code: upstreamCheckCode
  215. } = await execFileNoThrow(gitExe(), ['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`]);
  216. if (upstreamCheckCode === 0) {
  217. // Upstream is already set
  218. logForDebugging(`Branch '${branchName}' already has upstream set`);
  219. return;
  220. }
  221. // Check if origin/<branchName> exists
  222. const {
  223. code: remoteCheckCode
  224. } = await execFileNoThrow(gitExe(), ['rev-parse', '--verify', `origin/${branchName}`]);
  225. if (remoteCheckCode === 0) {
  226. // Remote branch exists, set upstream
  227. logForDebugging(`Setting upstream for '${branchName}' to 'origin/${branchName}'`);
  228. const {
  229. code: setUpstreamCode,
  230. stderr: setUpstreamStderr
  231. } = await execFileNoThrow(gitExe(), ['branch', '--set-upstream-to', `origin/${branchName}`, branchName]);
  232. if (setUpstreamCode !== 0) {
  233. logForDebugging(`Failed to set upstream for '${branchName}': ${setUpstreamStderr}`);
  234. // Don't throw, just log - this is not critical
  235. } else {
  236. logForDebugging(`Successfully set upstream for '${branchName}'`);
  237. }
  238. } else {
  239. logForDebugging(`Remote branch 'origin/${branchName}' does not exist, skipping upstream setup`);
  240. }
  241. }
  242. /**
  243. * Checks out a specific branch
  244. */
  245. async function checkoutBranch(branchName: string): Promise<void> {
  246. // First try to checkout the branch as-is (might be local)
  247. let {
  248. code: checkoutCode,
  249. stderr: checkoutStderr
  250. } = await execFileNoThrow(gitExe(), ['checkout', branchName]);
  251. // If that fails, try to checkout from origin
  252. if (checkoutCode !== 0) {
  253. logForDebugging(`Local checkout failed, trying to checkout from origin: ${checkoutStderr}`);
  254. // Try to checkout the remote branch and create a local tracking branch
  255. const result = await execFileNoThrow(gitExe(), ['checkout', '-b', branchName, '--track', `origin/${branchName}`]);
  256. checkoutCode = result.code;
  257. checkoutStderr = result.stderr;
  258. // If that also fails, try without -b in case the branch exists but isn't checked out
  259. if (checkoutCode !== 0) {
  260. logForDebugging(`Remote checkout with -b failed, trying without -b: ${checkoutStderr}`);
  261. const finalResult = await execFileNoThrow(gitExe(), ['checkout', '--track', `origin/${branchName}`]);
  262. checkoutCode = finalResult.code;
  263. checkoutStderr = finalResult.stderr;
  264. }
  265. }
  266. if (checkoutCode !== 0) {
  267. logEvent('tengu_teleport_error_branch_checkout_failed', {});
  268. throw new TeleportOperationError(`Failed to checkout branch '${branchName}': ${checkoutStderr}`, chalk.red(`Failed to checkout branch '${branchName}'\n`));
  269. }
  270. // After successful checkout, ensure upstream is set
  271. await ensureUpstreamIsSet(branchName);
  272. }
  273. /**
  274. * Gets the current branch name
  275. */
  276. async function getCurrentBranch(): Promise<string> {
  277. const {
  278. stdout: currentBranch
  279. } = await execFileNoThrow(gitExe(), ['branch', '--show-current']);
  280. return currentBranch.trim();
  281. }
  282. /**
  283. * Processes messages for teleport resume, removing incomplete tool_use blocks
  284. * and adding teleport notice messages
  285. * @param messages The conversation messages
  286. * @param error Optional error from branch checkout
  287. * @returns Processed messages ready for resume
  288. */
  289. export function processMessagesForTeleportResume(messages: Message[], error: Error | null): Message[] {
  290. // Shared logic with resume for handling interruped session transcripts
  291. const deserializedMessages = deserializeMessages(messages);
  292. // Add user message about teleport resume (visible to model)
  293. const messagesWithTeleportNotice = [...deserializedMessages, createTeleportResumeUserMessage(), createTeleportResumeSystemMessage(error)];
  294. return messagesWithTeleportNotice;
  295. }
  296. /**
  297. * Checks out the specified branch for a teleported session
  298. * @param branch Optional branch to checkout
  299. * @returns The current branch name and any error that occurred
  300. */
  301. export async function checkOutTeleportedSessionBranch(branch?: string): Promise<{
  302. branchName: string;
  303. branchError: Error | null;
  304. }> {
  305. try {
  306. const currentBranch = await getCurrentBranch();
  307. logForDebugging(`Current branch before teleport: '${currentBranch}'`);
  308. if (branch) {
  309. logForDebugging(`Switching to branch '${branch}'...`);
  310. await fetchFromOrigin(branch);
  311. await checkoutBranch(branch);
  312. const newBranch = await getCurrentBranch();
  313. logForDebugging(`Branch after checkout: '${newBranch}'`);
  314. } else {
  315. logForDebugging('No branch specified, staying on current branch');
  316. }
  317. const branchName = await getCurrentBranch();
  318. return {
  319. branchName,
  320. branchError: null
  321. };
  322. } catch (error) {
  323. const branchName = await getCurrentBranch();
  324. const branchError = toError(error);
  325. return {
  326. branchName,
  327. branchError
  328. };
  329. }
  330. }
  331. /**
  332. * Result of repository validation for teleport
  333. */
  334. export type RepoValidationResult = {
  335. status: 'match' | 'mismatch' | 'not_in_repo' | 'no_repo_required' | 'error';
  336. sessionRepo?: string;
  337. currentRepo?: string | null;
  338. /** Host of the session repo (e.g. "github.com" or "ghe.corp.com") — for display only */
  339. sessionHost?: string;
  340. /** Host of the current repo (e.g. "github.com" or "ghe.corp.com") — for display only */
  341. currentHost?: string;
  342. errorMessage?: string;
  343. };
  344. /**
  345. * Validates that the current repository matches the session's repository.
  346. * Returns a result object instead of throwing, allowing the caller to handle mismatches.
  347. *
  348. * @param sessionData The session resource to validate against
  349. * @returns Validation result with status and repo information
  350. */
  351. export async function validateSessionRepository(sessionData: SessionResource): Promise<RepoValidationResult> {
  352. const currentParsed = await detectCurrentRepositoryWithHost();
  353. const currentRepo = currentParsed ? `${currentParsed.owner}/${currentParsed.name}` : null;
  354. const gitSource = sessionData.session_context.sources.find((source): source is GitSource => source.type === 'git_repository');
  355. if (!gitSource?.url) {
  356. // Session has no repo requirement
  357. logForDebugging(currentRepo ? 'Session has no associated repository, proceeding without validation' : 'Session has no repo requirement and not in git directory, proceeding');
  358. return {
  359. status: 'no_repo_required'
  360. };
  361. }
  362. const sessionParsed = parseGitRemote(gitSource.url);
  363. const sessionRepo = sessionParsed ? `${sessionParsed.owner}/${sessionParsed.name}` : parseGitHubRepository(gitSource.url);
  364. if (!sessionRepo) {
  365. return {
  366. status: 'no_repo_required'
  367. };
  368. }
  369. logForDebugging(`Session is for repository: ${sessionRepo}, current repo: ${currentRepo ?? 'none'}`);
  370. if (!currentRepo) {
  371. // Not in a git repo, but session requires one
  372. return {
  373. status: 'not_in_repo',
  374. sessionRepo,
  375. sessionHost: sessionParsed?.host,
  376. currentRepo: null
  377. };
  378. }
  379. // Compare both owner/repo and host to avoid cross-instance mismatches.
  380. // Strip ports before comparing hosts — SSH remotes omit the port while
  381. // HTTPS remotes may include a non-standard port (e.g. ghe.corp.com:8443),
  382. // which would cause a false mismatch.
  383. const stripPort = (host: string): string => host.replace(/:\d+$/, '');
  384. const repoMatch = currentRepo.toLowerCase() === sessionRepo.toLowerCase();
  385. const hostMatch = !currentParsed || !sessionParsed || stripPort(currentParsed.host.toLowerCase()) === stripPort(sessionParsed.host.toLowerCase());
  386. if (repoMatch && hostMatch) {
  387. return {
  388. status: 'match',
  389. sessionRepo,
  390. currentRepo
  391. };
  392. }
  393. // Repo mismatch — keep sessionRepo/currentRepo as plain "owner/repo" so
  394. // downstream consumers (e.g. getKnownPathsForRepo) can use them as lookup keys.
  395. // Include host information in separate fields for display purposes.
  396. return {
  397. status: 'mismatch',
  398. sessionRepo,
  399. currentRepo,
  400. sessionHost: sessionParsed?.host,
  401. currentHost: currentParsed?.host
  402. };
  403. }
  404. /**
  405. * Handles teleporting from a code session ID.
  406. * Fetches session logs and validates repo.
  407. * @param sessionId The session ID to resume
  408. * @param onProgress Optional callback for progress updates
  409. * @returns The raw session log and branch name
  410. */
  411. export async function teleportResumeCodeSession(sessionId: string, onProgress?: TeleportProgressCallback): Promise<TeleportRemoteResponse> {
  412. if (!isPolicyAllowed('allow_remote_sessions')) {
  413. throw new Error("Remote sessions are disabled by your organization's policy.");
  414. }
  415. logForDebugging(`Resuming code session ID: ${sessionId}`);
  416. try {
  417. const accessToken = getClaudeAIOAuthTokens()?.accessToken;
  418. if (!accessToken) {
  419. logEvent('tengu_teleport_resume_error', {
  420. error_type: 'no_access_token' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  421. });
  422. 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.');
  423. }
  424. // Get organization UUID
  425. const orgUUID = await getOrganizationUUID();
  426. if (!orgUUID) {
  427. logEvent('tengu_teleport_resume_error', {
  428. error_type: 'no_org_uuid' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  429. });
  430. throw new Error('Unable to get organization UUID for constructing session URL');
  431. }
  432. // Fetch and validate repository matches before resuming
  433. onProgress?.('validating');
  434. const sessionData = await fetchSession(sessionId);
  435. const repoValidation = await validateSessionRepository(sessionData);
  436. switch (repoValidation.status) {
  437. case 'match':
  438. case 'no_repo_required':
  439. // Proceed with teleport
  440. break;
  441. case 'not_in_repo':
  442. {
  443. logEvent('tengu_teleport_error_repo_not_in_git_dir_sessions_api', {
  444. sessionId: sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  445. });
  446. // Include host for GHE users so they know which instance the repo is on
  447. const notInRepoDisplay = repoValidation.sessionHost && repoValidation.sessionHost.toLowerCase() !== 'github.com' ? `${repoValidation.sessionHost}/${repoValidation.sessionRepo}` : repoValidation.sessionRepo;
  448. 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`));
  449. }
  450. case 'mismatch':
  451. {
  452. logEvent('tengu_teleport_error_repo_mismatch_sessions_api', {
  453. sessionId: sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  454. });
  455. // Only include host prefix when hosts actually differ to disambiguate
  456. // cross-instance mismatches; for same-host mismatches the host is noise.
  457. const hostsDiffer = repoValidation.sessionHost && repoValidation.currentHost && repoValidation.sessionHost.replace(/:\d+$/, '').toLowerCase() !== repoValidation.currentHost.replace(/:\d+$/, '').toLowerCase();
  458. const sessionDisplay = hostsDiffer ? `${repoValidation.sessionHost}/${repoValidation.sessionRepo}` : repoValidation.sessionRepo;
  459. const currentDisplay = hostsDiffer ? `${repoValidation.currentHost}/${repoValidation.currentRepo}` : repoValidation.currentRepo;
  460. 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`));
  461. }
  462. case 'error':
  463. throw new TeleportOperationError(repoValidation.errorMessage || 'Failed to validate session repository', chalk.red(`Error: ${repoValidation.errorMessage || 'Failed to validate session repository'}\n`));
  464. default:
  465. {
  466. const _exhaustive: never = repoValidation.status;
  467. throw new Error(`Unhandled repo validation status: ${_exhaustive}`);
  468. }
  469. }
  470. return await teleportFromSessionsAPI(sessionId, orgUUID, accessToken, onProgress, sessionData);
  471. } catch (error) {
  472. if (error instanceof TeleportOperationError) {
  473. throw error;
  474. }
  475. const err = toError(error);
  476. logError(err);
  477. logEvent('tengu_teleport_resume_error', {
  478. error_type: 'resume_session_id_catch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  479. });
  480. throw new TeleportOperationError(err.message, chalk.red(`Error: ${err.message}\n`));
  481. }
  482. }
  483. /**
  484. * Helper function to handle teleport prerequisites (authentication and git state)
  485. * Shows TeleportError dialog rendered into the existing root if needed
  486. */
  487. async function handleTeleportPrerequisites(root: Root, errorsToIgnore?: Set<TeleportLocalErrorType>): Promise<void> {
  488. const errors = await getTeleportErrors();
  489. if (errors.size > 0) {
  490. // Log teleport errors detected
  491. logEvent('tengu_teleport_errors_detected', {
  492. error_types: Array.from(errors).join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  493. errors_ignored: Array.from(errorsToIgnore || []).join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  494. });
  495. // Show TeleportError dialog for user interaction
  496. await new Promise<void>(resolve => {
  497. root.render(<AppStateProvider>
  498. <KeybindingSetup>
  499. <TeleportError errorsToIgnore={errorsToIgnore} onComplete={() => {
  500. // Log when errors are resolved
  501. logEvent('tengu_teleport_errors_resolved', {
  502. error_types: Array.from(errors).join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  503. });
  504. void resolve();
  505. }} />
  506. </KeybindingSetup>
  507. </AppStateProvider>);
  508. });
  509. }
  510. }
  511. /**
  512. * Creates a remote Claude.ai session with error handling and UI feedback.
  513. * Shows prerequisite error dialog in the existing root if needed.
  514. * @param root The existing Ink root to render dialogs into
  515. * @param description The description/prompt for the new session (null for no initial prompt)
  516. * @param signal AbortSignal for cancellation
  517. * @param branchName Optional branch name for the remote session to use
  518. * @returns Promise<TeleportToRemoteResponse | null> The created session or null if creation fails
  519. */
  520. export async function teleportToRemoteWithErrorHandling(root: Root, description: string | null, signal: AbortSignal, branchName?: string): Promise<TeleportToRemoteResponse | null> {
  521. const errorsToIgnore = new Set<TeleportLocalErrorType>(['needsGitStash']);
  522. await handleTeleportPrerequisites(root, errorsToIgnore);
  523. return teleportToRemote({
  524. initialMessage: description,
  525. signal,
  526. branchName,
  527. onBundleFail: msg => process.stderr.write(`\n${msg}\n`)
  528. });
  529. }
  530. /**
  531. * Fetches session data from the session ingress API (/v1/session_ingress/)
  532. * Uses session logs instead of SDK events to get the correct message structure
  533. * @param sessionId The session ID to fetch
  534. * @param orgUUID The organization UUID
  535. * @param accessToken The OAuth access token
  536. * @param onProgress Optional callback for progress updates
  537. * @param sessionData Optional session data (used to extract branch info)
  538. * @returns TeleportRemoteResponse with session logs as Message[]
  539. */
  540. export async function teleportFromSessionsAPI(sessionId: string, orgUUID: string, accessToken: string, onProgress?: TeleportProgressCallback, sessionData?: SessionResource): Promise<TeleportRemoteResponse> {
  541. const startTime = Date.now();
  542. try {
  543. // Fetch session logs via session ingress
  544. logForDebugging(`[teleport] Starting fetch for session: ${sessionId}`);
  545. onProgress?.('fetching_logs');
  546. const logsStartTime = Date.now();
  547. // Try CCR v2 first (GetTeleportEvents — server dispatches Spanner/
  548. // threadstore). Fall back to session-ingress if it returns null
  549. // (endpoint not yet deployed, or transient error). Once session-ingress
  550. // is gone, the fallback becomes a no-op — getSessionLogsViaOAuth will
  551. // return null too and we fail with "Failed to fetch session logs".
  552. let logs = await getTeleportEvents(sessionId, accessToken, orgUUID);
  553. if (logs === null) {
  554. logForDebugging('[teleport] v2 endpoint returned null, trying session-ingress');
  555. logs = await getSessionLogsViaOAuth(sessionId, accessToken, orgUUID);
  556. }
  557. logForDebugging(`[teleport] Session logs fetched in ${Date.now() - logsStartTime}ms`);
  558. if (logs === null) {
  559. throw new Error('Failed to fetch session logs');
  560. }
  561. // Filter to get only transcript messages, excluding sidechain messages
  562. const filterStartTime = Date.now();
  563. const messages = logs.filter(entry => isTranscriptMessage(entry) && !entry.isSidechain) as Message[];
  564. logForDebugging(`[teleport] Filtered ${logs.length} entries to ${messages.length} messages in ${Date.now() - filterStartTime}ms`);
  565. // Extract branch info from session data
  566. onProgress?.('fetching_branch');
  567. const branch = sessionData ? getBranchFromSession(sessionData) : undefined;
  568. if (branch) {
  569. logForDebugging(`[teleport] Found branch: ${branch}`);
  570. }
  571. logForDebugging(`[teleport] Total teleportFromSessionsAPI time: ${Date.now() - startTime}ms`);
  572. return {
  573. log: messages,
  574. branch
  575. };
  576. } catch (error) {
  577. const err = toError(error);
  578. // Handle 404 specifically
  579. if (axios.isAxiosError(error) && error.response?.status === 404) {
  580. logEvent('tengu_teleport_error_session_not_found_404', {
  581. sessionId: sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  582. });
  583. throw new TeleportOperationError(`${sessionId} not found.`, `${sessionId} not found.\n${chalk.dim('Run /status in Claude Code to check your account.')}`);
  584. }
  585. logError(err);
  586. throw new Error(`Failed to fetch session from Sessions API: ${err.message}`);
  587. }
  588. }
  589. /**
  590. * Response type for polling remote session events (uses SDK events format)
  591. */
  592. export type PollRemoteSessionResponse = {
  593. newEvents: SDKMessage[];
  594. lastEventId: string | null;
  595. branch?: string;
  596. sessionStatus?: 'idle' | 'running' | 'requires_action' | 'archived';
  597. };
  598. /**
  599. * Polls remote session events. Pass the previous response's `lastEventId`
  600. * as `afterId` to fetch only the delta. Set `skipMetadata` to avoid the
  601. * per-call GET /v1/sessions/{id} when branch/status aren't needed.
  602. */
  603. export async function pollRemoteSessionEvents(sessionId: string, afterId: string | null = null, opts?: {
  604. skipMetadata?: boolean;
  605. }): Promise<PollRemoteSessionResponse> {
  606. const accessToken = getClaudeAIOAuthTokens()?.accessToken;
  607. if (!accessToken) {
  608. throw new Error('No access token for polling');
  609. }
  610. const orgUUID = await getOrganizationUUID();
  611. if (!orgUUID) {
  612. throw new Error('No org UUID for polling');
  613. }
  614. const headers = {
  615. ...getOAuthHeaders(accessToken),
  616. 'anthropic-beta': 'ccr-byoc-2025-07-29',
  617. 'x-organization-uuid': orgUUID
  618. };
  619. const eventsUrl = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`;
  620. type EventsResponse = {
  621. data: unknown[];
  622. has_more: boolean;
  623. first_id: string | null;
  624. last_id: string | null;
  625. };
  626. // Cap is a safety valve against stuck cursors; steady-state is 0–1 pages.
  627. const MAX_EVENT_PAGES = 50;
  628. const sdkMessages: SDKMessage[] = [];
  629. let cursor = afterId;
  630. for (let page = 0; page < MAX_EVENT_PAGES; page++) {
  631. const eventsResponse = await axios.get(eventsUrl, {
  632. headers,
  633. params: cursor ? {
  634. after_id: cursor
  635. } : undefined,
  636. timeout: 30000
  637. });
  638. if (eventsResponse.status !== 200) {
  639. throw new Error(`Failed to fetch session events: ${eventsResponse.statusText}`);
  640. }
  641. const eventsData: EventsResponse = eventsResponse.data;
  642. if (!eventsData?.data || !Array.isArray(eventsData.data)) {
  643. throw new Error('Invalid events response');
  644. }
  645. for (const event of eventsData.data) {
  646. if (event && typeof event === 'object' && 'type' in event) {
  647. if (event.type === 'env_manager_log' || event.type === 'control_response') {
  648. continue;
  649. }
  650. if ('session_id' in event) {
  651. sdkMessages.push(event as SDKMessage);
  652. }
  653. }
  654. }
  655. if (!eventsData.last_id) break;
  656. cursor = eventsData.last_id;
  657. if (!eventsData.has_more) break;
  658. }
  659. if (opts?.skipMetadata) {
  660. return {
  661. newEvents: sdkMessages,
  662. lastEventId: cursor
  663. };
  664. }
  665. // Fetch session metadata (branch, status)
  666. let branch: string | undefined;
  667. let sessionStatus: PollRemoteSessionResponse['sessionStatus'];
  668. try {
  669. const sessionData = await fetchSession(sessionId);
  670. branch = getBranchFromSession(sessionData);
  671. sessionStatus = sessionData.session_status as PollRemoteSessionResponse['sessionStatus'];
  672. } catch (e) {
  673. logForDebugging(`teleport: failed to fetch session ${sessionId} metadata: ${e}`, {
  674. level: 'debug'
  675. });
  676. }
  677. return {
  678. newEvents: sdkMessages,
  679. lastEventId: cursor,
  680. branch,
  681. sessionStatus
  682. };
  683. }
  684. /**
  685. * Creates a remote Claude.ai session using the Sessions API.
  686. *
  687. * Two source modes:
  688. * - GitHub (default): backend clones from the repo's origin URL. Requires a
  689. * GitHub remote + CCR-side GitHub connection. 43% of CLI sessions have an
  690. * origin remote; far fewer pass the full precondition chain.
  691. * - Bundle (CCR_FORCE_BUNDLE=1): CLI creates `git bundle --all`, uploads via Files
  692. * API, passes file_id as seed_bundle_file_id on the session context. CCR
  693. * downloads it and clones from the bundle. No GitHub dependency — works for
  694. * local-only repos. Reach: 54% of CLI sessions (anything with .git/).
  695. * Backend: anthropic#303856.
  696. */
  697. export async function teleportToRemote(options: {
  698. initialMessage: string | null;
  699. branchName?: string;
  700. title?: string;
  701. /**
  702. * The description of the session. This is used to generate the title and
  703. * session branch name (unless they are explicitly provided).
  704. */
  705. description?: string;
  706. model?: string;
  707. permissionMode?: PermissionMode;
  708. ultraplan?: boolean;
  709. signal: AbortSignal;
  710. useDefaultEnvironment?: boolean;
  711. /**
  712. * Explicit environment_id (e.g. the code_review synthetic env). Bypasses
  713. * fetchEnvironments; the usual repo-detection → git source still runs so
  714. * the container gets the repo checked out (orchestrator reads --repo-dir
  715. * from pwd, it doesn't clone).
  716. */
  717. environmentId?: string;
  718. /**
  719. * Per-session env vars merged into session_context.environment_variables.
  720. * Write-only at the API layer (stripped from Get/List responses). When
  721. * environmentId is set, CLAUDE_CODE_OAUTH_TOKEN is auto-injected from the
  722. * caller's accessToken so the container's hook can hit inference (the
  723. * server only passes through what the caller sends; bughunter.go mints
  724. * its own, user sessions don't get one automatically).
  725. */
  726. environmentVariables?: Record<string, string>;
  727. /**
  728. * When set with environmentId, creates and uploads a git bundle of the
  729. * local working tree (createAndUploadGitBundle handles the stash-create
  730. * for uncommitted changes) and passes it as seed_bundle_file_id. Backend
  731. * clones from the bundle instead of GitHub — container gets the caller's
  732. * exact local state. Needs .git/ only, not a GitHub remote.
  733. */
  734. useBundle?: boolean;
  735. /**
  736. * Called with a user-facing message when the bundle path is attempted but
  737. * fails. The wrapper stderr.writes it (pre-REPL). Remote-agent callers
  738. * capture it to include in their throw (in-REPL, Ink-rendered).
  739. */
  740. onBundleFail?: (message: string) => void;
  741. /**
  742. * When true, disables the git-bundle fallback entirely. Use for flows like
  743. * autofix where CCR must push to GitHub — a bundle can't do that.
  744. */
  745. skipBundle?: boolean;
  746. /**
  747. * When set, reuses this branch as the outcome branch instead of generating
  748. * a new claude/ branch. Sets allow_unrestricted_git_push on the source and
  749. * reuse_outcome_branches on the session context so the remote pushes to the
  750. * caller's branch directly.
  751. */
  752. reuseOutcomeBranch?: string;
  753. /**
  754. * GitHub PR to attach to the session context. Backend uses this to
  755. * identify the PR associated with this session.
  756. */
  757. githubPr?: {
  758. owner: string;
  759. repo: string;
  760. number: number;
  761. };
  762. }): Promise<TeleportToRemoteResponse | null> {
  763. const {
  764. initialMessage,
  765. signal
  766. } = options;
  767. try {
  768. // Check authentication
  769. await checkAndRefreshOAuthTokenIfNeeded();
  770. const accessToken = getClaudeAIOAuthTokens()?.accessToken;
  771. if (!accessToken) {
  772. logError(new Error('No access token found for remote session creation'));
  773. return null;
  774. }
  775. // Get organization UUID
  776. const orgUUID = await getOrganizationUUID();
  777. if (!orgUUID) {
  778. logError(new Error('Unable to get organization UUID for remote session creation'));
  779. return null;
  780. }
  781. // Explicit environmentId short-circuits Haiku title-gen + env selection.
  782. // Still runs repo detection so the container gets a working directory —
  783. // the code_review orchestrator reads --repo-dir $(pwd), it doesn't clone
  784. // (bughunter.go:520 sets a git source too; env-manager does the checkout
  785. // before the SessionStart hook fires).
  786. if (options.environmentId) {
  787. const url = `${getOauthConfig().BASE_API_URL}/v1/sessions`;
  788. const headers = {
  789. ...getOAuthHeaders(accessToken),
  790. 'anthropic-beta': 'ccr-byoc-2025-07-29',
  791. 'x-organization-uuid': orgUUID
  792. };
  793. const envVars = {
  794. CLAUDE_CODE_OAUTH_TOKEN: accessToken,
  795. ...(options.environmentVariables ?? {})
  796. };
  797. // Bundle mode: upload local working tree (uncommitted changes via
  798. // refs/seed/stash), container clones from the bundle. No GitHub.
  799. // Otherwise: github.com source — caller checked eligibility.
  800. let gitSource: GitSource | null = null;
  801. let seedBundleFileId: string | null = null;
  802. if (options.useBundle) {
  803. const bundle = await createAndUploadGitBundle({
  804. oauthToken: accessToken,
  805. sessionId: getSessionId(),
  806. baseUrl: getOauthConfig().BASE_API_URL
  807. }, {
  808. signal
  809. });
  810. if (!bundle.success) {
  811. const failedBundle = bundle as { success: false; error: string; failReason?: string };
  812. logError(new Error(`Bundle upload failed: ${failedBundle.error}`));
  813. return null;
  814. }
  815. seedBundleFileId = bundle.fileId;
  816. logEvent('tengu_teleport_bundle_mode', {
  817. size_bytes: bundle.bundleSizeBytes,
  818. scope: bundle.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  819. has_wip: bundle.hasWip,
  820. reason: 'explicit_env_bundle' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  821. });
  822. } else {
  823. const repoInfo = await detectCurrentRepositoryWithHost();
  824. if (repoInfo) {
  825. gitSource = {
  826. type: 'git_repository',
  827. url: `https://${repoInfo.host}/${repoInfo.owner}/${repoInfo.name}`,
  828. revision: options.branchName
  829. };
  830. }
  831. }
  832. const requestBody = {
  833. title: options.title || options.description || 'Remote task',
  834. events: [],
  835. session_context: {
  836. sources: gitSource ? [gitSource] : [],
  837. ...(seedBundleFileId && {
  838. seed_bundle_file_id: seedBundleFileId
  839. }),
  840. outcomes: [],
  841. environment_variables: envVars
  842. },
  843. environment_id: options.environmentId
  844. };
  845. logForDebugging(`[teleportToRemote] explicit env ${options.environmentId}, ${Object.keys(envVars).length} env vars, ${seedBundleFileId ? `bundle=${seedBundleFileId}` : `source=${gitSource?.url ?? 'none'}@${options.branchName ?? 'default'}`}`);
  846. const response = await axios.post(url, requestBody, {
  847. headers,
  848. signal
  849. });
  850. if (response.status !== 200 && response.status !== 201) {
  851. logError(new Error(`CreateSession ${response.status}: ${jsonStringify(response.data)}`));
  852. return null;
  853. }
  854. const sessionData = response.data as SessionResource;
  855. if (!sessionData || typeof sessionData.id !== 'string') {
  856. logError(new Error(`No session id in response: ${jsonStringify(response.data)}`));
  857. return null;
  858. }
  859. return {
  860. id: sessionData.id,
  861. title: sessionData.title || requestBody.title
  862. };
  863. }
  864. let gitSource: GitSource | null = null;
  865. let gitOutcome: GitRepositoryOutcome | null = null;
  866. let seedBundleFileId: string | null = null;
  867. // Source selection ladder: GitHub clone (if CCR can actually pull it) →
  868. // bundle fallback (if .git exists) → empty sandbox.
  869. //
  870. // The preflight is the same code path the container's git-proxy clone
  871. // will hit (get_github_client_with_user_auth → no_sync_user_token_found).
  872. // 50% of users who reach the "install GitHub App" step never finish it;
  873. // without the preflight, every one of them gets a container that 401s
  874. // on clone. With it, they silently fall back to bundle.
  875. //
  876. // CCR_FORCE_BUNDLE=1 skips the preflight entirely — useful for testing
  877. // or when you know your GitHub auth is busted. Read here (not in the
  878. // caller) so it works for remote-agent too, not just --remote.
  879. const repoInfo = await detectCurrentRepositoryWithHost();
  880. // Generate title and branch name for the session. Skip the Haiku call
  881. // when both title and outcome branch are explicitly provided.
  882. let sessionTitle: string;
  883. let sessionBranch: string;
  884. if (options.title && options.reuseOutcomeBranch) {
  885. sessionTitle = options.title;
  886. sessionBranch = options.reuseOutcomeBranch;
  887. } else {
  888. const generated = await generateTitleAndBranch(options.description || initialMessage || 'Background task', signal);
  889. sessionTitle = options.title || generated.title;
  890. sessionBranch = options.reuseOutcomeBranch || generated.branchName;
  891. }
  892. // Preflight: does CCR have a token that can clone this repo?
  893. // Only checked for github.com — GHES needs ghe_configuration_id which
  894. // we don't have, and GHES users are power users who probably finished
  895. // setup. For them (and for non-GitHub hosts that parseGitRemote
  896. // somehow accepted), fall through optimistically; if the backend
  897. // rejects the host, bundle next time.
  898. let ghViable = false;
  899. let sourceReason: 'github_preflight_ok' | 'ghes_optimistic' | 'github_preflight_failed' | 'no_github_remote' | 'forced_bundle' | 'no_git_at_all' = 'no_git_at_all';
  900. // gitRoot gates both bundle creation and the gate check itself — no
  901. // point awaiting GrowthBook when there's nothing to bundle.
  902. const gitRoot = findGitRoot(getCwd());
  903. const forceBundle = !options.skipBundle && isEnvTruthy(process.env.CCR_FORCE_BUNDLE);
  904. const bundleSeedGateOn = !options.skipBundle && gitRoot !== null && (isEnvTruthy(process.env.CCR_ENABLE_BUNDLE) || (await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bundle_seed_enabled')));
  905. if (repoInfo && !forceBundle) {
  906. if (repoInfo.host === 'github.com') {
  907. ghViable = await checkGithubAppInstalled(repoInfo.owner, repoInfo.name, signal);
  908. sourceReason = ghViable ? 'github_preflight_ok' : 'github_preflight_failed';
  909. } else {
  910. ghViable = true;
  911. sourceReason = 'ghes_optimistic';
  912. }
  913. } else if (forceBundle) {
  914. sourceReason = 'forced_bundle';
  915. } else if (gitRoot) {
  916. sourceReason = 'no_github_remote';
  917. }
  918. // Preflight failed but bundle is off — fall through optimistically like
  919. // pre-preflight behavior. Backend reports the real auth error.
  920. if (!ghViable && !bundleSeedGateOn && repoInfo) {
  921. ghViable = true;
  922. }
  923. if (ghViable && repoInfo) {
  924. const {
  925. host,
  926. owner,
  927. name
  928. } = repoInfo;
  929. // Resolve the base branch: prefer explicit branchName, fall back to default branch
  930. const revision = options.branchName ?? (await getDefaultBranch()) ?? undefined;
  931. logForDebugging(`[teleportToRemote] Git source: ${host}/${owner}/${name}, revision: ${revision ?? 'none'}`);
  932. gitSource = {
  933. type: 'git_repository',
  934. url: `https://${host}/${owner}/${name}`,
  935. // The revision specifies which ref to checkout as the base branch
  936. revision,
  937. ...(options.reuseOutcomeBranch && {
  938. allow_unrestricted_git_push: true
  939. })
  940. };
  941. // type: 'github' is used for all GitHub-compatible hosts (github.com and GHE).
  942. // The CLI can't distinguish GHE from non-GitHub hosts (GitLab, Bitbucket)
  943. // client-side — the backend validates the URL against configured GHE instances
  944. // and ignores git_info for unrecognized hosts.
  945. gitOutcome = {
  946. type: 'git_repository',
  947. git_info: {
  948. type: 'github',
  949. repo: `${owner}/${name}`,
  950. branches: [sessionBranch]
  951. }
  952. };
  953. }
  954. // Bundle fallback. Only try bundle if GitHub wasn't viable, the gate is
  955. // on, and there's a .git/ to bundle from. Reaching here with
  956. // ghViable=false and repoInfo non-null means the preflight failed —
  957. // .git definitely exists (detectCurrentRepositoryWithHost read the
  958. // remote from it).
  959. if (!gitSource && bundleSeedGateOn) {
  960. logForDebugging(`[teleportToRemote] Bundling (reason: ${sourceReason})`);
  961. const bundle = await createAndUploadGitBundle({
  962. oauthToken: accessToken,
  963. sessionId: getSessionId(),
  964. baseUrl: getOauthConfig().BASE_API_URL
  965. }, {
  966. signal
  967. });
  968. if (!bundle.success) {
  969. const failedBundle = bundle as { success: false; error: string; failReason?: 'git_error' | 'too_large' | 'empty_repo' };
  970. logError(new Error(`Bundle upload failed: ${failedBundle.error}`));
  971. // Only steer users to GitHub setup when there's a remote to clone from.
  972. const setup = repoInfo ? '. Please setup GitHub on https://claude.ai/code' : '';
  973. let msg: string;
  974. switch (failedBundle.failReason) {
  975. case 'empty_repo':
  976. msg = 'Repository has no commits — run `git add . && git commit -m "initial"` then retry';
  977. break;
  978. case 'too_large':
  979. msg = `Repo is too large to teleport${setup}`;
  980. break;
  981. case 'git_error':
  982. msg = `Failed to create git bundle (${failedBundle.error})${setup}`;
  983. break;
  984. case undefined:
  985. msg = `Bundle upload failed: ${failedBundle.error}${setup}`;
  986. break;
  987. default:
  988. {
  989. const _exhaustive: never = failedBundle.failReason;
  990. void _exhaustive;
  991. msg = `Bundle upload failed: ${failedBundle.error}`;
  992. }
  993. }
  994. options.onBundleFail?.(msg);
  995. return null;
  996. }
  997. seedBundleFileId = bundle.fileId;
  998. logEvent('tengu_teleport_bundle_mode', {
  999. size_bytes: bundle.bundleSizeBytes,
  1000. scope: bundle.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  1001. has_wip: bundle.hasWip,
  1002. reason: sourceReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  1003. });
  1004. }
  1005. logEvent('tengu_teleport_source_decision', {
  1006. reason: sourceReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  1007. path: (gitSource ? 'github' : seedBundleFileId ? 'bundle' : 'empty') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  1008. });
  1009. if (!gitSource && !seedBundleFileId) {
  1010. logForDebugging('[teleportToRemote] No repository detected — session will have an empty sandbox');
  1011. }
  1012. // Fetch available environments
  1013. let environments = await fetchEnvironments();
  1014. if (!environments || environments.length === 0) {
  1015. logError(new Error('No environments available for session creation'));
  1016. return null;
  1017. }
  1018. logForDebugging(`Available environments: ${environments.map(e => `${e.environment_id} (${e.name}, ${e.kind})`).join(', ')}`);
  1019. // Select environment based on settings, then anthropic_cloud preference, then first available.
  1020. // Prefer anthropic_cloud environments over byoc: anthropic_cloud environments (e.g. "Default")
  1021. // are the standard compute environments with full repo access, whereas byoc environments
  1022. // (e.g. "monorepo") are user-owned compute that may not support the current repository.
  1023. const settings = getSettings_DEPRECATED();
  1024. const defaultEnvironmentId = options.useDefaultEnvironment ? undefined : settings?.remote?.defaultEnvironmentId;
  1025. let cloudEnv = environments.find(env => env.kind === 'anthropic_cloud');
  1026. // When the caller opts out of their configured default, do not fall
  1027. // through to a BYOC env that may not support the current repo or the
  1028. // requested permission mode. Retry once for eventual consistency,
  1029. // then fail loudly.
  1030. if (options.useDefaultEnvironment && !cloudEnv) {
  1031. logForDebugging(`No anthropic_cloud in env list (${environments.length} envs); retrying fetchEnvironments`);
  1032. const retried = await fetchEnvironments();
  1033. cloudEnv = retried?.find(env => env.kind === 'anthropic_cloud');
  1034. if (!cloudEnv) {
  1035. 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.`));
  1036. return null;
  1037. }
  1038. if (retried) environments = retried;
  1039. }
  1040. const selectedEnvironment = defaultEnvironmentId && environments.find(env => env.environment_id === defaultEnvironmentId) || cloudEnv || environments.find(env => env.kind !== 'bridge') || environments[0];
  1041. if (!selectedEnvironment) {
  1042. logError(new Error('No environments available for session creation'));
  1043. return null;
  1044. }
  1045. if (defaultEnvironmentId) {
  1046. const matchedDefault = selectedEnvironment.environment_id === defaultEnvironmentId;
  1047. logForDebugging(matchedDefault ? `Using configured default environment: ${defaultEnvironmentId}` : `Configured default environment ${defaultEnvironmentId} not found, using first available`);
  1048. }
  1049. const environmentId = selectedEnvironment.environment_id;
  1050. logForDebugging(`Selected environment: ${environmentId} (${selectedEnvironment.name}, ${selectedEnvironment.kind})`);
  1051. // Prepare API request for Sessions API
  1052. const url = `${getOauthConfig().BASE_API_URL}/v1/sessions`;
  1053. const headers = {
  1054. ...getOAuthHeaders(accessToken),
  1055. 'anthropic-beta': 'ccr-byoc-2025-07-29',
  1056. 'x-organization-uuid': orgUUID
  1057. };
  1058. const sessionContext = {
  1059. sources: gitSource ? [gitSource] : [],
  1060. ...(seedBundleFileId && {
  1061. seed_bundle_file_id: seedBundleFileId
  1062. }),
  1063. outcomes: gitOutcome ? [gitOutcome] : [],
  1064. model: options.model ?? getMainLoopModel(),
  1065. ...(options.reuseOutcomeBranch && {
  1066. reuse_outcome_branches: true
  1067. }),
  1068. ...(options.githubPr && {
  1069. github_pr: options.githubPr
  1070. })
  1071. };
  1072. // CreateCCRSessionPayload has no permission_mode field — a top-level
  1073. // body entry is silently dropped by the proto parser server-side.
  1074. // Instead prepend a set_permission_mode control_request event. Initial
  1075. // events are written to threadstore before the container connects, so
  1076. // the CLI applies the mode before the first user turn — no readiness race.
  1077. const events: Array<{
  1078. type: 'event';
  1079. data: Record<string, unknown>;
  1080. }> = [];
  1081. if (options.permissionMode) {
  1082. events.push({
  1083. type: 'event',
  1084. data: {
  1085. type: 'control_request',
  1086. request_id: `set-mode-${randomUUID()}`,
  1087. request: {
  1088. subtype: 'set_permission_mode',
  1089. mode: options.permissionMode,
  1090. ultraplan: options.ultraplan
  1091. }
  1092. }
  1093. });
  1094. }
  1095. if (initialMessage) {
  1096. events.push({
  1097. type: 'event',
  1098. data: {
  1099. uuid: randomUUID(),
  1100. session_id: '',
  1101. type: 'user',
  1102. parent_tool_use_id: null,
  1103. message: {
  1104. role: 'user',
  1105. content: initialMessage
  1106. }
  1107. }
  1108. });
  1109. }
  1110. const requestBody = {
  1111. title: options.ultraplan ? `ultraplan: ${sessionTitle}` : sessionTitle,
  1112. events,
  1113. session_context: sessionContext,
  1114. environment_id: environmentId
  1115. };
  1116. logForDebugging(`Creating session with payload: ${jsonStringify(requestBody, null, 2)}`);
  1117. // Make API call
  1118. const response = await axios.post(url, requestBody, {
  1119. headers,
  1120. signal
  1121. });
  1122. const isSuccess = response.status === 200 || response.status === 201;
  1123. if (!isSuccess) {
  1124. logError(new Error(`API request failed with status ${response.status}: ${response.statusText}\n\nResponse data: ${jsonStringify(response.data, null, 2)}`));
  1125. return null;
  1126. }
  1127. // Parse response as SessionResource
  1128. const sessionData = response.data as SessionResource;
  1129. if (!sessionData || typeof sessionData.id !== 'string') {
  1130. logError(new Error(`Cannot determine session ID from API response: ${jsonStringify(response.data)}`));
  1131. return null;
  1132. }
  1133. logForDebugging(`Successfully created remote session: ${sessionData.id}`);
  1134. return {
  1135. id: sessionData.id,
  1136. title: sessionData.title || requestBody.title
  1137. };
  1138. } catch (error) {
  1139. const err = toError(error);
  1140. logError(err);
  1141. return null;
  1142. }
  1143. }
  1144. /**
  1145. * Best-effort session archive. POST /v1/sessions/{id}/archive has no
  1146. * running-status check (unlike DELETE which 409s on RUNNING), so it works
  1147. * mid-implementation. Archived sessions reject new events (send_events.go),
  1148. * so the remote stops on its next write. 409 (already archived) treated as
  1149. * success. Fire-and-forget; failure leaks a visible session until the
  1150. * reaper collects it.
  1151. */
  1152. export async function archiveRemoteSession(sessionId: string): Promise<void> {
  1153. const accessToken = getClaudeAIOAuthTokens()?.accessToken;
  1154. if (!accessToken) return;
  1155. const orgUUID = await getOrganizationUUID();
  1156. if (!orgUUID) return;
  1157. const headers = {
  1158. ...getOAuthHeaders(accessToken),
  1159. 'anthropic-beta': 'ccr-byoc-2025-07-29',
  1160. 'x-organization-uuid': orgUUID
  1161. };
  1162. const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`;
  1163. try {
  1164. const resp = await axios.post(url, {}, {
  1165. headers,
  1166. timeout: 10000,
  1167. validateStatus: s => s < 500
  1168. });
  1169. if (resp.status === 200 || resp.status === 409) {
  1170. logForDebugging(`[archiveRemoteSession] archived ${sessionId}`);
  1171. } else {
  1172. logForDebugging(`[archiveRemoteSession] ${sessionId} failed ${resp.status}: ${jsonStringify(resp.data)}`);
  1173. }
  1174. } catch (err) {
  1175. logError(err);
  1176. }
  1177. }