useReplBridge.tsx 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. import { feature } from 'bun:bundle';
  2. import React, { useCallback, useEffect, useRef } from 'react';
  3. import { setMainLoopModelOverride } from '../bootstrap/state.js';
  4. import { type BridgePermissionCallbacks, type BridgePermissionResponse, isBridgePermissionResponse } from '../bridge/bridgePermissionCallbacks.js';
  5. import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js';
  6. import { extractInboundMessageFields } from '../bridge/inboundMessages.js';
  7. import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js';
  8. import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js';
  9. import type { Command } from '../commands.js';
  10. import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js';
  11. import { getRemoteSessionUrl } from '../constants/product.js';
  12. import { useNotifications } from '../context/notifications.js';
  13. import type { PermissionMode, SDKMessage } from '../entrypoints/agentSdkTypes.js';
  14. import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js';
  15. import { Text } from '../ink.js';
  16. import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
  17. import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.js';
  18. import type { Message } from '../types/message.js';
  19. import { getCwd } from '../utils/cwd.js';
  20. import { logForDebugging } from '../utils/debug.js';
  21. import { errorMessage } from '../utils/errors.js';
  22. import { enqueue } from '../utils/messageQueueManager.js';
  23. import { buildSystemInitMessage } from '../utils/messages/systemInit.js';
  24. import { createBridgeStatusMessage, createSystemMessage } from '../utils/messages.js';
  25. import { getAutoModeUnavailableNotification, getAutoModeUnavailableReason, isAutoModeGateEnabled, isBypassPermissionsModeDisabled, transitionPermissionMode } from '../utils/permissions/permissionSetup.js';
  26. import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js';
  27. /** How long after a failure before replBridgeEnabled is auto-cleared (stops retries). */
  28. export const BRIDGE_FAILURE_DISMISS_MS = 10_000;
  29. /**
  30. * Max consecutive initReplBridge failures before the hook stops re-attempting
  31. * for the session lifetime. Guards against paths that flip replBridgeEnabled
  32. * back on after auto-disable (settings sync, /remote-control, config tool)
  33. * when the underlying OAuth is unrecoverable — each re-attempt is another
  34. * guaranteed 401 against POST /v1/environments/bridge. Datadog 2026-03-08:
  35. * top stuck client generated 2,879 × 401/day alone (17% of all 401s on the
  36. * route).
  37. */
  38. const MAX_CONSECUTIVE_INIT_FAILURES = 3;
  39. /**
  40. * Hook that initializes an always-on bridge connection in the background
  41. * and writes new user/assistant messages to the bridge session.
  42. *
  43. * Silently skips if bridge is not enabled or user is not OAuth-authenticated.
  44. *
  45. * Watches AppState.replBridgeEnabled — when toggled off (via /config or footer),
  46. * the bridge is torn down. When toggled back on, it re-initializes.
  47. *
  48. * Inbound messages from claude.ai are injected into the REPL via queuedCommands.
  49. */
  50. export function useReplBridge(messages: Message[], setMessages: (action: React.SetStateAction<Message[]>) => void, abortControllerRef: React.RefObject<AbortController | null>, commands: readonly Command[], mainLoopModel: string): {
  51. sendBridgeResult: () => void;
  52. } {
  53. const handleRef = useRef<ReplBridgeHandle | null>(null);
  54. const teardownPromiseRef = useRef<Promise<void> | undefined>(undefined);
  55. const lastWrittenIndexRef = useRef(0);
  56. // Tracks UUIDs already flushed as initial messages. Persists across
  57. // bridge reconnections so Bridge #2+ only sends new messages — sending
  58. // duplicate UUIDs causes the server to kill the WebSocket.
  59. const flushedUUIDsRef = useRef(new Set<string>());
  60. const failureTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  61. // Persists across effect re-runs (unlike the effect's local state). Reset
  62. // only on successful init. Hits MAX_CONSECUTIVE_INIT_FAILURES → fuse blown
  63. // for the session, regardless of replBridgeEnabled re-toggling.
  64. const consecutiveFailuresRef = useRef(0);
  65. const setAppState = useSetAppState();
  66. const commandsRef = useRef(commands);
  67. commandsRef.current = commands;
  68. const mainLoopModelRef = useRef(mainLoopModel);
  69. mainLoopModelRef.current = mainLoopModel;
  70. const messagesRef = useRef(messages);
  71. messagesRef.current = messages;
  72. const store = useAppStateStore();
  73. const {
  74. addNotification
  75. } = useNotifications();
  76. const replBridgeEnabled = feature('BRIDGE_MODE') ?
  77. // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
  78. useAppState(s => s.replBridgeEnabled) : false;
  79. const replBridgeConnected = feature('BRIDGE_MODE') ?
  80. // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
  81. useAppState(s_0 => s_0.replBridgeConnected) : false;
  82. const replBridgeOutboundOnly = feature('BRIDGE_MODE') ?
  83. // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
  84. useAppState(s_1 => s_1.replBridgeOutboundOnly) : false;
  85. const replBridgeInitialName = feature('BRIDGE_MODE') ?
  86. // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
  87. useAppState(s_2 => s_2.replBridgeInitialName) : undefined;
  88. // Initialize/teardown bridge when enabled state changes.
  89. // Passes current messages as initialMessages so the remote session
  90. // starts with the existing conversation context (e.g. from /bridge).
  91. useEffect(() => {
  92. // feature() check must use positive pattern for dead code elimination —
  93. // negative pattern (if (!feature(...)) return) does NOT eliminate
  94. // dynamic imports below.
  95. if (feature('BRIDGE_MODE')) {
  96. if (!replBridgeEnabled) return;
  97. const outboundOnly = replBridgeOutboundOnly;
  98. function notifyBridgeFailed(detail?: string): void {
  99. if (outboundOnly) return;
  100. addNotification({
  101. key: 'bridge-failed',
  102. jsx: <>
  103. <Text color="error">Remote Control failed</Text>
  104. {detail && <Text dimColor> · {detail}</Text>}
  105. </>,
  106. priority: 'immediate'
  107. });
  108. }
  109. if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_INIT_FAILURES) {
  110. logForDebugging(`[bridge:repl] Hook: ${consecutiveFailuresRef.current} consecutive init failures, not retrying this session`);
  111. // Clear replBridgeEnabled so /remote-control doesn't mistakenly show
  112. // BridgeDisconnectDialog for a bridge that never connected.
  113. const fuseHint = 'disabled after repeated failures · restart to retry';
  114. notifyBridgeFailed(fuseHint);
  115. setAppState(prev => {
  116. if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) return prev;
  117. return {
  118. ...prev,
  119. replBridgeError: fuseHint,
  120. replBridgeEnabled: false
  121. };
  122. });
  123. return;
  124. }
  125. let cancelled = false;
  126. // Capture messages.length now so we don't re-send initial messages
  127. // through writeMessages after the bridge connects.
  128. const initialMessageCount = messages.length;
  129. void (async () => {
  130. try {
  131. // Wait for any in-progress teardown to complete before registering
  132. // a new environment. Without this, the deregister HTTP call from
  133. // the previous teardown races with the new register call, and the
  134. // server may tear down the freshly-created environment.
  135. if (teardownPromiseRef.current) {
  136. logForDebugging('[bridge:repl] Hook: waiting for previous teardown to complete before re-init');
  137. await teardownPromiseRef.current;
  138. teardownPromiseRef.current = undefined;
  139. logForDebugging('[bridge:repl] Hook: previous teardown complete, proceeding with re-init');
  140. }
  141. if (cancelled) return;
  142. // Dynamic import so the module is tree-shaken in external builds
  143. const {
  144. initReplBridge
  145. } = await import('../bridge/initReplBridge.js');
  146. const {
  147. shouldShowAppUpgradeMessage
  148. } = await import('../bridge/envLessBridgeConfig.js');
  149. // Assistant mode: perpetual bridge session — claude.ai shows one
  150. // continuous conversation across CLI restarts instead of a new
  151. // session per invocation. initBridgeCore reads bridge-pointer.json
  152. // (the same crash-recovery file #20735 added) and reuses its
  153. // {environmentId, sessionId} via reuseEnvironmentId +
  154. // api.reconnectSession(). Teardown skips archive/deregister/
  155. // pointer-clear so the session survives clean exits, not just
  156. // crashes. Non-assistant bridges clear the pointer on teardown
  157. // (crash-recovery only).
  158. let perpetual = false;
  159. if (feature('KAIROS')) {
  160. const {
  161. isAssistantMode
  162. } = await import('../assistant/index.js');
  163. perpetual = isAssistantMode();
  164. }
  165. // When a user message arrives from claude.ai, inject it into the REPL.
  166. // Preserves the original UUID so that when the message is forwarded
  167. // back to CCR, it matches the original — avoiding duplicate messages.
  168. //
  169. // Async because file_attachments (if present) need a network fetch +
  170. // disk write before we enqueue with the @path prefix. Caller doesn't
  171. // await — messages with attachments just land in the queue slightly
  172. // later, which is fine (web messages aren't rapid-fire).
  173. async function handleInboundMessage(msg: SDKMessage): Promise<void> {
  174. try {
  175. const fields = extractInboundMessageFields(msg);
  176. if (!fields) return;
  177. const {
  178. uuid
  179. } = fields;
  180. // Dynamic import keeps the bridge code out of non-BRIDGE_MODE builds.
  181. const {
  182. resolveAndPrepend
  183. } = await import('../bridge/inboundAttachments.js');
  184. let sanitized = fields.content;
  185. if (feature('KAIROS_GITHUB_WEBHOOKS')) {
  186. /* eslint-disable @typescript-eslint/no-require-imports */
  187. const {
  188. sanitizeInboundWebhookContent
  189. } = require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js');
  190. /* eslint-enable @typescript-eslint/no-require-imports */
  191. sanitized = sanitizeInboundWebhookContent(fields.content);
  192. }
  193. const content = await resolveAndPrepend(msg, sanitized);
  194. const preview = typeof content === 'string' ? content.slice(0, 80) : `[${content.length} content blocks]`;
  195. logForDebugging(`[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`);
  196. enqueue({
  197. value: content,
  198. mode: 'prompt' as const,
  199. uuid,
  200. // skipSlashCommands stays true as defense-in-depth —
  201. // processUserInputBase overrides it internally when bridgeOrigin
  202. // is set AND the resolved command passes isBridgeSafeCommand.
  203. // This keeps exit-word suppression and immediate-command blocks
  204. // intact for any code path that checks skipSlashCommands directly.
  205. skipSlashCommands: true,
  206. bridgeOrigin: true
  207. });
  208. } catch (e) {
  209. logForDebugging(`[bridge:repl] handleInboundMessage failed: ${e}`, {
  210. level: 'error'
  211. });
  212. }
  213. }
  214. // State change callback — maps bridge lifecycle events to AppState.
  215. function handleStateChange(state: BridgeState, detail_0?: string): void {
  216. if (cancelled) return;
  217. if (outboundOnly) {
  218. logForDebugging(`[bridge:repl] Mirror state=${state}${detail_0 ? ` detail=${detail_0}` : ''}`);
  219. // Sync replBridgeConnected so the forwarding effect starts/stops
  220. // writing as the transport comes up or dies.
  221. if (state === 'failed') {
  222. setAppState(prev_3 => {
  223. if (!prev_3.replBridgeConnected) return prev_3;
  224. return {
  225. ...prev_3,
  226. replBridgeConnected: false
  227. };
  228. });
  229. } else if (state === 'ready' || state === 'connected') {
  230. setAppState(prev_4 => {
  231. if (prev_4.replBridgeConnected) return prev_4;
  232. return {
  233. ...prev_4,
  234. replBridgeConnected: true
  235. };
  236. });
  237. }
  238. return;
  239. }
  240. const handle = handleRef.current;
  241. switch (state) {
  242. case 'ready':
  243. setAppState(prev_9 => {
  244. const connectUrl = handle && handle.environmentId !== '' ? buildBridgeConnectUrl(handle.environmentId, handle.sessionIngressUrl) : prev_9.replBridgeConnectUrl;
  245. const sessionUrl = handle ? getRemoteSessionUrl(handle.bridgeSessionId, handle.sessionIngressUrl) : prev_9.replBridgeSessionUrl;
  246. const envId = handle?.environmentId;
  247. const sessionId = handle?.bridgeSessionId;
  248. if (prev_9.replBridgeConnected && !prev_9.replBridgeSessionActive && !prev_9.replBridgeReconnecting && prev_9.replBridgeConnectUrl === connectUrl && prev_9.replBridgeSessionUrl === sessionUrl && prev_9.replBridgeEnvironmentId === envId && prev_9.replBridgeSessionId === sessionId) {
  249. return prev_9;
  250. }
  251. return {
  252. ...prev_9,
  253. replBridgeConnected: true,
  254. replBridgeSessionActive: false,
  255. replBridgeReconnecting: false,
  256. replBridgeConnectUrl: connectUrl,
  257. replBridgeSessionUrl: sessionUrl,
  258. replBridgeEnvironmentId: envId,
  259. replBridgeSessionId: sessionId,
  260. replBridgeError: undefined
  261. };
  262. });
  263. break;
  264. case 'connected':
  265. {
  266. setAppState(prev_8 => {
  267. if (prev_8.replBridgeSessionActive) return prev_8;
  268. return {
  269. ...prev_8,
  270. replBridgeConnected: true,
  271. replBridgeSessionActive: true,
  272. replBridgeReconnecting: false,
  273. replBridgeError: undefined
  274. };
  275. });
  276. // Send system/init so remote clients (web/iOS/Android) get
  277. // session metadata. REPL uses query() directly — never hits
  278. // QueryEngine's SDKMessage layer — so this is the only path
  279. // to put system/init on the REPL-bridge wire. Skills load is
  280. // async (memoized, cheap after REPL startup); fire-and-forget
  281. // so the connected-state transition isn't blocked.
  282. if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', false)) {
  283. void (async () => {
  284. try {
  285. const skills = await getSlashCommandToolSkills(getCwd());
  286. if (cancelled) return;
  287. const state_0 = store.getState();
  288. handleRef.current?.writeSdkMessages([buildSystemInitMessage({
  289. // tools/mcpClients/plugins redacted for REPL-bridge:
  290. // MCP-prefixed tool names and server names leak which
  291. // integrations the user has wired up; plugin paths leak
  292. // raw filesystem paths (username, project structure).
  293. // CCR v2 persists SDK messages to Spanner — users who
  294. // tap "Connect from phone" may not expect these on
  295. // Anthropic's servers. QueryEngine (SDK) still emits
  296. // full lists — SDK consumers expect full telemetry.
  297. tools: [],
  298. mcpClients: [],
  299. model: mainLoopModelRef.current,
  300. permissionMode: state_0.toolPermissionContext.mode as PermissionMode,
  301. // TODO: avoid the cast
  302. // Remote clients can only invoke bridge-safe commands —
  303. // advertising unsafe ones (local-jsx, unallowed local)
  304. // would let mobile/web attempt them and hit errors.
  305. commands: commandsRef.current.filter(isBridgeSafeCommand),
  306. agents: state_0.agentDefinitions.activeAgents,
  307. skills,
  308. plugins: [],
  309. fastMode: state_0.fastMode
  310. })]);
  311. } catch (err_0) {
  312. logForDebugging(`[bridge:repl] Failed to send system/init: ${errorMessage(err_0)}`, {
  313. level: 'error'
  314. });
  315. }
  316. })();
  317. }
  318. break;
  319. }
  320. case 'reconnecting':
  321. setAppState(prev_7 => {
  322. if (prev_7.replBridgeReconnecting) return prev_7;
  323. return {
  324. ...prev_7,
  325. replBridgeReconnecting: true,
  326. replBridgeSessionActive: false
  327. };
  328. });
  329. break;
  330. case 'failed':
  331. // Clear any previous failure dismiss timer
  332. clearTimeout(failureTimeoutRef.current);
  333. notifyBridgeFailed(detail_0);
  334. setAppState(prev_5 => ({
  335. ...prev_5,
  336. replBridgeError: detail_0,
  337. replBridgeReconnecting: false,
  338. replBridgeSessionActive: false,
  339. replBridgeConnected: false
  340. }));
  341. // Auto-disable after timeout so the hook stops retrying.
  342. failureTimeoutRef.current = setTimeout(() => {
  343. if (cancelled) return;
  344. failureTimeoutRef.current = undefined;
  345. setAppState(prev_6 => {
  346. if (!prev_6.replBridgeError) return prev_6;
  347. return {
  348. ...prev_6,
  349. replBridgeEnabled: false,
  350. replBridgeError: undefined
  351. };
  352. });
  353. }, BRIDGE_FAILURE_DISMISS_MS);
  354. break;
  355. }
  356. }
  357. // Map of pending bridge permission response handlers, keyed by request_id.
  358. // Each entry is an onResponse handler waiting for CCR to reply.
  359. const pendingPermissionHandlers = new Map<string, (response: BridgePermissionResponse) => void>();
  360. // Dispatch incoming control_response messages to registered handlers
  361. function handlePermissionResponse(msg_0: SDKControlResponse): void {
  362. const requestId = (msg_0 as any).response?.request_id;
  363. if (!requestId) return;
  364. const handler = pendingPermissionHandlers.get(requestId);
  365. if (!handler) {
  366. logForDebugging(`[bridge:repl] No handler for control_response request_id=${requestId}`);
  367. return;
  368. }
  369. pendingPermissionHandlers.delete(requestId);
  370. // Extract the permission decision from the control_response payload
  371. const inner = (msg_0 as any).response;
  372. if (inner.subtype === 'success' && inner.response && isBridgePermissionResponse(inner.response)) {
  373. handler(inner.response);
  374. }
  375. }
  376. const handle_0 = await initReplBridge({
  377. outboundOnly,
  378. tags: outboundOnly ? ['ccr-mirror'] : undefined,
  379. onInboundMessage: handleInboundMessage,
  380. onPermissionResponse: handlePermissionResponse,
  381. onInterrupt() {
  382. abortControllerRef.current?.abort();
  383. },
  384. onSetModel(model) {
  385. const resolved = model === 'default' ? null : model ?? null;
  386. setMainLoopModelOverride(resolved);
  387. setAppState(prev_10 => {
  388. if (prev_10.mainLoopModelForSession === resolved) return prev_10;
  389. return {
  390. ...prev_10,
  391. mainLoopModelForSession: resolved
  392. };
  393. });
  394. },
  395. onSetMaxThinkingTokens(maxTokens) {
  396. const enabled = maxTokens !== null;
  397. setAppState(prev_11 => {
  398. if (prev_11.thinkingEnabled === enabled) return prev_11;
  399. return {
  400. ...prev_11,
  401. thinkingEnabled: enabled
  402. };
  403. });
  404. },
  405. onSetPermissionMode(mode) {
  406. // Policy guards MUST fire before transitionPermissionMode —
  407. // its internal auto-gate check is a defensive throw (with a
  408. // setAutoModeActive(true) side-effect BEFORE the throw) rather
  409. // than a graceful reject. Letting that throw escape would:
  410. // (1) leave STATE.autoModeActive=true while the mode is
  411. // unchanged (3-way invariant violation per src/CLAUDE.md)
  412. // (2) fail to send a control_response → server kills WS
  413. // These mirror print.ts handleSetPermissionMode; the bridge
  414. // can't import the checks directly (bootstrap-isolation), so
  415. // it relies on this verdict to emit the error response.
  416. if (mode === 'bypassPermissions') {
  417. if (isBypassPermissionsModeDisabled()) {
  418. return {
  419. ok: false,
  420. error: 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration'
  421. };
  422. }
  423. if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) {
  424. return {
  425. ok: false,
  426. error: 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions'
  427. };
  428. }
  429. }
  430. if (feature('TRANSCRIPT_CLASSIFIER') && mode === 'auto' && !isAutoModeGateEnabled()) {
  431. const reason = getAutoModeUnavailableReason();
  432. return {
  433. ok: false,
  434. error: reason ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` : 'Cannot set permission mode to auto'
  435. };
  436. }
  437. // Guards passed — apply via the centralized transition so
  438. // prePlanMode stashing and auto-mode state sync all fire.
  439. setAppState(prev_12 => {
  440. const current = prev_12.toolPermissionContext.mode;
  441. if (current === mode) return prev_12;
  442. const next = transitionPermissionMode(current, mode, prev_12.toolPermissionContext);
  443. return {
  444. ...prev_12,
  445. toolPermissionContext: {
  446. ...next,
  447. mode
  448. }
  449. };
  450. });
  451. // Recheck queued permission prompts now that mode changed.
  452. setImmediate(() => {
  453. getLeaderToolUseConfirmQueue()?.(currentQueue => {
  454. currentQueue.forEach(item => {
  455. void item.recheckPermission();
  456. });
  457. return currentQueue;
  458. });
  459. });
  460. return {
  461. ok: true
  462. };
  463. },
  464. onStateChange: handleStateChange,
  465. initialMessages: messages.length > 0 ? messages : undefined,
  466. getMessages: () => messagesRef.current,
  467. previouslyFlushedUUIDs: flushedUUIDsRef.current,
  468. initialName: replBridgeInitialName,
  469. perpetual
  470. });
  471. if (cancelled) {
  472. // Effect was cancelled while initReplBridge was in flight.
  473. // Tear down the handle to avoid leaking resources (poll loop,
  474. // WebSocket, registered environment, cleanup callback).
  475. logForDebugging(`[bridge:repl] Hook: init cancelled during flight, tearing down${handle_0 ? ` env=${handle_0.environmentId}` : ''}`);
  476. if (handle_0) {
  477. void handle_0.teardown();
  478. }
  479. return;
  480. }
  481. if (!handle_0) {
  482. // initReplBridge returned null — a precondition failed. For most
  483. // cases (no_oauth, policy_denied, etc.) onStateChange('failed')
  484. // already fired with a specific hint. The GrowthBook-gate-off case
  485. // is intentionally silent — not a failure, just not rolled out.
  486. consecutiveFailuresRef.current++;
  487. logForDebugging(`[bridge:repl] Init returned null (precondition or session creation failed); consecutive failures: ${consecutiveFailuresRef.current}`);
  488. clearTimeout(failureTimeoutRef.current);
  489. setAppState(prev_13 => ({
  490. ...prev_13,
  491. replBridgeError: prev_13.replBridgeError ?? 'check debug logs for details'
  492. }));
  493. failureTimeoutRef.current = setTimeout(() => {
  494. if (cancelled) return;
  495. failureTimeoutRef.current = undefined;
  496. setAppState(prev_14 => {
  497. if (!prev_14.replBridgeError) return prev_14;
  498. return {
  499. ...prev_14,
  500. replBridgeEnabled: false,
  501. replBridgeError: undefined
  502. };
  503. });
  504. }, BRIDGE_FAILURE_DISMISS_MS);
  505. return;
  506. }
  507. handleRef.current = handle_0;
  508. setReplBridgeHandle(handle_0);
  509. consecutiveFailuresRef.current = 0;
  510. // Skip initial messages in the forwarding effect — they were
  511. // already loaded as session events during creation.
  512. lastWrittenIndexRef.current = initialMessageCount;
  513. if (outboundOnly) {
  514. setAppState(prev_15 => {
  515. if (prev_15.replBridgeConnected && prev_15.replBridgeSessionId === handle_0.bridgeSessionId) return prev_15;
  516. return {
  517. ...prev_15,
  518. replBridgeConnected: true,
  519. replBridgeSessionId: handle_0.bridgeSessionId,
  520. replBridgeSessionUrl: undefined,
  521. replBridgeConnectUrl: undefined,
  522. replBridgeError: undefined
  523. };
  524. });
  525. logForDebugging(`[bridge:repl] Mirror initialized, session=${handle_0.bridgeSessionId}`);
  526. } else {
  527. // Build bridge permission callbacks so the interactive permission
  528. // handler can race bridge responses against local user interaction.
  529. const permissionCallbacks: BridgePermissionCallbacks = {
  530. sendRequest(requestId_0, toolName, input, toolUseId, description, permissionSuggestions, blockedPath) {
  531. handle_0.sendControlRequest({
  532. type: 'control_request',
  533. request_id: requestId_0,
  534. request: {
  535. subtype: 'can_use_tool',
  536. tool_name: toolName,
  537. input,
  538. tool_use_id: toolUseId,
  539. description,
  540. ...(permissionSuggestions ? {
  541. permission_suggestions: permissionSuggestions
  542. } : {}),
  543. ...(blockedPath ? {
  544. blocked_path: blockedPath
  545. } : {})
  546. }
  547. });
  548. },
  549. sendResponse(requestId_1, response) {
  550. const payload: Record<string, unknown> = {
  551. ...response
  552. };
  553. handle_0.sendControlResponse({
  554. type: 'control_response',
  555. response: {
  556. subtype: 'success',
  557. request_id: requestId_1,
  558. response: payload
  559. }
  560. });
  561. },
  562. cancelRequest(requestId_2) {
  563. handle_0.sendControlCancelRequest(requestId_2);
  564. },
  565. onResponse(requestId_3, handler_0) {
  566. pendingPermissionHandlers.set(requestId_3, handler_0);
  567. return () => {
  568. pendingPermissionHandlers.delete(requestId_3);
  569. };
  570. }
  571. };
  572. setAppState(prev_16 => ({
  573. ...prev_16,
  574. replBridgePermissionCallbacks: permissionCallbacks
  575. }));
  576. const url = getRemoteSessionUrl(handle_0.bridgeSessionId, handle_0.sessionIngressUrl);
  577. // environmentId === '' signals the v2 env-less path. buildBridgeConnectUrl
  578. // builds an env-specific connect URL, which doesn't exist without an env.
  579. const hasEnv = handle_0.environmentId !== '';
  580. const connectUrl_0 = hasEnv ? buildBridgeConnectUrl(handle_0.environmentId, handle_0.sessionIngressUrl) : undefined;
  581. setAppState(prev_17 => {
  582. if (prev_17.replBridgeConnected && prev_17.replBridgeSessionUrl === url) {
  583. return prev_17;
  584. }
  585. return {
  586. ...prev_17,
  587. replBridgeConnected: true,
  588. replBridgeSessionUrl: url,
  589. replBridgeConnectUrl: connectUrl_0 ?? prev_17.replBridgeConnectUrl,
  590. replBridgeEnvironmentId: handle_0.environmentId,
  591. replBridgeSessionId: handle_0.bridgeSessionId,
  592. replBridgeError: undefined
  593. };
  594. });
  595. // Show bridge status with URL in the transcript. perpetual (KAIROS
  596. // assistant mode) falls back to v1 at initReplBridge.ts — skip the
  597. // v2-only upgrade nudge for them. Own try/catch so a cosmetic
  598. // GrowthBook hiccup doesn't hit the outer init-failure handler.
  599. const upgradeNudge = !perpetual ? await shouldShowAppUpgradeMessage().catch(() => false) : false;
  600. if (cancelled) return;
  601. setMessages(prev_18 => [...prev_18, createBridgeStatusMessage(url, upgradeNudge ? 'Please upgrade to the latest version of the Claude mobile app to see your Remote Control sessions.' : undefined)]);
  602. logForDebugging(`[bridge:repl] Hook initialized, session=${handle_0.bridgeSessionId}`);
  603. }
  604. } catch (err) {
  605. // Never crash the REPL — surface the error in the UI.
  606. // Check cancelled first (symmetry with the !handle path at line ~386):
  607. // if initReplBridge threw during rapid toggle-off (in-flight network
  608. // error), don't count that toward the fuse or spam a stale error
  609. // into the UI. Also fixes pre-existing spurious setAppState/
  610. // setMessages on cancelled throws.
  611. if (cancelled) return;
  612. consecutiveFailuresRef.current++;
  613. const errMsg = errorMessage(err);
  614. logForDebugging(`[bridge:repl] Init failed: ${errMsg}; consecutive failures: ${consecutiveFailuresRef.current}`);
  615. clearTimeout(failureTimeoutRef.current);
  616. notifyBridgeFailed(errMsg);
  617. setAppState(prev_0 => ({
  618. ...prev_0,
  619. replBridgeError: errMsg
  620. }));
  621. failureTimeoutRef.current = setTimeout(() => {
  622. if (cancelled) return;
  623. failureTimeoutRef.current = undefined;
  624. setAppState(prev_1 => {
  625. if (!prev_1.replBridgeError) return prev_1;
  626. return {
  627. ...prev_1,
  628. replBridgeEnabled: false,
  629. replBridgeError: undefined
  630. };
  631. });
  632. }, BRIDGE_FAILURE_DISMISS_MS);
  633. if (!outboundOnly) {
  634. setMessages(prev_2 => [...prev_2, createSystemMessage(`Remote Control failed to connect: ${errMsg}`, 'warning')]);
  635. }
  636. }
  637. })();
  638. return () => {
  639. cancelled = true;
  640. clearTimeout(failureTimeoutRef.current);
  641. failureTimeoutRef.current = undefined;
  642. if (handleRef.current) {
  643. logForDebugging(`[bridge:repl] Hook cleanup: starting teardown for env=${handleRef.current.environmentId} session=${handleRef.current.bridgeSessionId}`);
  644. teardownPromiseRef.current = handleRef.current.teardown();
  645. handleRef.current = null;
  646. setReplBridgeHandle(null);
  647. }
  648. setAppState(prev_19 => {
  649. if (!prev_19.replBridgeConnected && !prev_19.replBridgeSessionActive && !prev_19.replBridgeError) {
  650. return prev_19;
  651. }
  652. return {
  653. ...prev_19,
  654. replBridgeConnected: false,
  655. replBridgeSessionActive: false,
  656. replBridgeReconnecting: false,
  657. replBridgeConnectUrl: undefined,
  658. replBridgeSessionUrl: undefined,
  659. replBridgeEnvironmentId: undefined,
  660. replBridgeSessionId: undefined,
  661. replBridgeError: undefined,
  662. replBridgePermissionCallbacks: undefined
  663. };
  664. });
  665. lastWrittenIndexRef.current = 0;
  666. };
  667. }
  668. }, [replBridgeEnabled, replBridgeOutboundOnly, setAppState, setMessages, addNotification]);
  669. // Write new messages as they appear.
  670. // Also re-runs when replBridgeConnected changes (bridge finishes init),
  671. // so any messages that arrived before the bridge was ready get written.
  672. useEffect(() => {
  673. // Positive feature() guard — see first useEffect comment
  674. if (feature('BRIDGE_MODE')) {
  675. if (!replBridgeConnected) return;
  676. const handle_1 = handleRef.current;
  677. if (!handle_1) return;
  678. // Clamp the index in case messages were compacted (array shortened).
  679. // After compaction the ref could exceed messages.length, and without
  680. // clamping no new messages would be forwarded.
  681. if (lastWrittenIndexRef.current > messages.length) {
  682. logForDebugging(`[bridge:repl] Compaction detected: lastWrittenIndex=${lastWrittenIndexRef.current} > messages.length=${messages.length}, clamping`);
  683. }
  684. const startIndex = Math.min(lastWrittenIndexRef.current, messages.length);
  685. // Collect new messages since last write
  686. const newMessages: Message[] = [];
  687. for (let i = startIndex; i < messages.length; i++) {
  688. const msg_1 = messages[i];
  689. if (msg_1 && (msg_1.type === 'user' || msg_1.type === 'assistant' || msg_1.type === 'system' && msg_1.subtype === 'local_command')) {
  690. newMessages.push(msg_1);
  691. }
  692. }
  693. lastWrittenIndexRef.current = messages.length;
  694. if (newMessages.length > 0) {
  695. handle_1.writeMessages(newMessages);
  696. }
  697. }
  698. }, [messages, replBridgeConnected]);
  699. const sendBridgeResult = useCallback(() => {
  700. if (feature('BRIDGE_MODE')) {
  701. handleRef.current?.sendResult();
  702. }
  703. }, []);
  704. return {
  705. sendBridgeResult
  706. };
  707. }