Files
orcs-code/src/hooks/useReplBridge.tsx
Anandan 462a985d7e Remove embedded source map directives from tracked sources (#329)
Inline base64 source maps had been checked into tracked src files. This strips those comments from the repository without changing runtime behavior or adding ongoing guardrails, per the requested one-time cleanup scope.

Constraint: Keep this change limited to tracked source cleanup only
Rejected: Add CI/source verification guard | user requested one-time cleanup only
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If these directives reappear, fix the producing transform instead of reintroducing repo-side cleanup code
Tested: rg -n "sourceMappingURL" ., bun run smoke, bun run verify:privacy, bun run test:provider, npm run test:provider-recommendation
Not-tested: bun run typecheck (repository has many pre-existing unrelated failures)

Co-authored-by: anandh8x <test@example.com>
2026-04-04 21:19:27 +08:00

723 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { feature } from 'bun:bundle';
import React, { useCallback, useEffect, useRef } from 'react';
import { setMainLoopModelOverride } from '../bootstrap/state.js';
import { type BridgePermissionCallbacks, type BridgePermissionResponse, isBridgePermissionResponse } from '../bridge/bridgePermissionCallbacks.js';
import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js';
import { extractInboundMessageFields } from '../bridge/inboundMessages.js';
import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js';
import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js';
import type { Command } from '../commands.js';
import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js';
import { getRemoteSessionUrl } from '../constants/product.js';
import { useNotifications } from '../context/notifications.js';
import type { PermissionMode, SDKMessage } from '../entrypoints/agentSdkTypes.js';
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js';
import { Text } from '../ink.js';
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.js';
import type { Message } from '../types/message.js';
import { getCwd } from '../utils/cwd.js';
import { logForDebugging } from '../utils/debug.js';
import { errorMessage } from '../utils/errors.js';
import { enqueue } from '../utils/messageQueueManager.js';
import { buildSystemInitMessage } from '../utils/messages/systemInit.js';
import { createBridgeStatusMessage, createSystemMessage } from '../utils/messages.js';
import { getAutoModeUnavailableNotification, getAutoModeUnavailableReason, isAutoModeGateEnabled, isBypassPermissionsModeDisabled, transitionPermissionMode } from '../utils/permissions/permissionSetup.js';
import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js';
/** How long after a failure before replBridgeEnabled is auto-cleared (stops retries). */
export const BRIDGE_FAILURE_DISMISS_MS = 10_000;
/**
* Max consecutive initReplBridge failures before the hook stops re-attempting
* for the session lifetime. Guards against paths that flip replBridgeEnabled
* back on after auto-disable (settings sync, /remote-control, config tool)
* when the underlying OAuth is unrecoverable — each re-attempt is another
* guaranteed 401 against POST /v1/environments/bridge. Datadog 2026-03-08:
* top stuck client generated 2,879 × 401/day alone (17% of all 401s on the
* route).
*/
const MAX_CONSECUTIVE_INIT_FAILURES = 3;
/**
* Hook that initializes an always-on bridge connection in the background
* and writes new user/assistant messages to the bridge session.
*
* Silently skips if bridge is not enabled or user is not OAuth-authenticated.
*
* Watches AppState.replBridgeEnabled — when toggled off (via /config or footer),
* the bridge is torn down. When toggled back on, it re-initializes.
*
* Inbound messages from claude.ai are injected into the REPL via queuedCommands.
*/
export function useReplBridge(messages: Message[], setMessages: (action: React.SetStateAction<Message[]>) => void, abortControllerRef: React.RefObject<AbortController | null>, commands: readonly Command[], mainLoopModel: string): {
sendBridgeResult: () => void;
} {
const handleRef = useRef<ReplBridgeHandle | null>(null);
const teardownPromiseRef = useRef<Promise<void> | undefined>(undefined);
const lastWrittenIndexRef = useRef(0);
// Tracks UUIDs already flushed as initial messages. Persists across
// bridge reconnections so Bridge #2+ only sends new messages — sending
// duplicate UUIDs causes the server to kill the WebSocket.
const flushedUUIDsRef = useRef(new Set<string>());
const failureTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Persists across effect re-runs (unlike the effect's local state). Reset
// only on successful init. Hits MAX_CONSECUTIVE_INIT_FAILURES → fuse blown
// for the session, regardless of replBridgeEnabled re-toggling.
const consecutiveFailuresRef = useRef(0);
const setAppState = useSetAppState();
const commandsRef = useRef(commands);
commandsRef.current = commands;
const mainLoopModelRef = useRef(mainLoopModel);
mainLoopModelRef.current = mainLoopModel;
const messagesRef = useRef(messages);
messagesRef.current = messages;
const store = useAppStateStore();
const {
addNotification
} = useNotifications();
const replBridgeEnabled = feature('BRIDGE_MODE') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s => s.replBridgeEnabled) : false;
const replBridgeConnected = feature('BRIDGE_MODE') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s_0 => s_0.replBridgeConnected) : false;
const replBridgeOutboundOnly = feature('BRIDGE_MODE') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s_1 => s_1.replBridgeOutboundOnly) : false;
const replBridgeInitialName = feature('BRIDGE_MODE') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s_2 => s_2.replBridgeInitialName) : undefined;
// Initialize/teardown bridge when enabled state changes.
// Passes current messages as initialMessages so the remote session
// starts with the existing conversation context (e.g. from /bridge).
useEffect(() => {
// feature() check must use positive pattern for dead code elimination —
// negative pattern (if (!feature(...)) return) does NOT eliminate
// dynamic imports below.
if (feature('BRIDGE_MODE')) {
if (!replBridgeEnabled) return;
const outboundOnly = replBridgeOutboundOnly;
function notifyBridgeFailed(detail?: string): void {
if (outboundOnly) return;
addNotification({
key: 'bridge-failed',
jsx: <>
<Text color="error">Remote Control failed</Text>
{detail && <Text dimColor> · {detail}</Text>}
</>,
priority: 'immediate'
});
}
if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_INIT_FAILURES) {
logForDebugging(`[bridge:repl] Hook: ${consecutiveFailuresRef.current} consecutive init failures, not retrying this session`);
// Clear replBridgeEnabled so /remote-control doesn't mistakenly show
// BridgeDisconnectDialog for a bridge that never connected.
const fuseHint = 'disabled after repeated failures · restart to retry';
notifyBridgeFailed(fuseHint);
setAppState(prev => {
if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) return prev;
return {
...prev,
replBridgeError: fuseHint,
replBridgeEnabled: false
};
});
return;
}
let cancelled = false;
// Capture messages.length now so we don't re-send initial messages
// through writeMessages after the bridge connects.
const initialMessageCount = messages.length;
void (async () => {
try {
// Wait for any in-progress teardown to complete before registering
// a new environment. Without this, the deregister HTTP call from
// the previous teardown races with the new register call, and the
// server may tear down the freshly-created environment.
if (teardownPromiseRef.current) {
logForDebugging('[bridge:repl] Hook: waiting for previous teardown to complete before re-init');
await teardownPromiseRef.current;
teardownPromiseRef.current = undefined;
logForDebugging('[bridge:repl] Hook: previous teardown complete, proceeding with re-init');
}
if (cancelled) return;
// Dynamic import so the module is tree-shaken in external builds
const {
initReplBridge
} = await import('../bridge/initReplBridge.js');
const {
shouldShowAppUpgradeMessage
} = await import('../bridge/envLessBridgeConfig.js');
// Assistant mode: perpetual bridge session — claude.ai shows one
// continuous conversation across CLI restarts instead of a new
// session per invocation. initBridgeCore reads bridge-pointer.json
// (the same crash-recovery file #20735 added) and reuses its
// {environmentId, sessionId} via reuseEnvironmentId +
// api.reconnectSession(). Teardown skips archive/deregister/
// pointer-clear so the session survives clean exits, not just
// crashes. Non-assistant bridges clear the pointer on teardown
// (crash-recovery only).
let perpetual = false;
if (feature('KAIROS')) {
const {
isAssistantMode
} = await import('../assistant/index.js');
perpetual = isAssistantMode();
}
// When a user message arrives from claude.ai, inject it into the REPL.
// Preserves the original UUID so that when the message is forwarded
// back to CCR, it matches the original — avoiding duplicate messages.
//
// Async because file_attachments (if present) need a network fetch +
// disk write before we enqueue with the @path prefix. Caller doesn't
// await — messages with attachments just land in the queue slightly
// later, which is fine (web messages aren't rapid-fire).
async function handleInboundMessage(msg: SDKMessage): Promise<void> {
try {
const fields = extractInboundMessageFields(msg);
if (!fields) return;
const {
uuid
} = fields;
// Dynamic import keeps the bridge code out of non-BRIDGE_MODE builds.
const {
resolveAndPrepend
} = await import('../bridge/inboundAttachments.js');
let sanitized = fields.content;
if (feature('KAIROS_GITHUB_WEBHOOKS')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const {
sanitizeInboundWebhookContent
} = require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js');
/* eslint-enable @typescript-eslint/no-require-imports */
sanitized = sanitizeInboundWebhookContent(fields.content);
}
const content = await resolveAndPrepend(msg, sanitized);
const preview = typeof content === 'string' ? content.slice(0, 80) : `[${content.length} content blocks]`;
logForDebugging(`[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`);
enqueue({
value: content,
mode: 'prompt' as const,
uuid,
// skipSlashCommands stays true as defense-in-depth —
// processUserInputBase overrides it internally when bridgeOrigin
// is set AND the resolved command passes isBridgeSafeCommand.
// This keeps exit-word suppression and immediate-command blocks
// intact for any code path that checks skipSlashCommands directly.
skipSlashCommands: true,
bridgeOrigin: true
});
} catch (e) {
logForDebugging(`[bridge:repl] handleInboundMessage failed: ${e}`, {
level: 'error'
});
}
}
// State change callback — maps bridge lifecycle events to AppState.
function handleStateChange(state: BridgeState, detail_0?: string): void {
if (cancelled) return;
if (outboundOnly) {
logForDebugging(`[bridge:repl] Mirror state=${state}${detail_0 ? ` detail=${detail_0}` : ''}`);
// Sync replBridgeConnected so the forwarding effect starts/stops
// writing as the transport comes up or dies.
if (state === 'failed') {
setAppState(prev_3 => {
if (!prev_3.replBridgeConnected) return prev_3;
return {
...prev_3,
replBridgeConnected: false
};
});
} else if (state === 'ready' || state === 'connected') {
setAppState(prev_4 => {
if (prev_4.replBridgeConnected) return prev_4;
return {
...prev_4,
replBridgeConnected: true
};
});
}
return;
}
const handle = handleRef.current;
switch (state) {
case 'ready':
setAppState(prev_9 => {
const connectUrl = handle && handle.environmentId !== '' ? buildBridgeConnectUrl(handle.environmentId, handle.sessionIngressUrl) : prev_9.replBridgeConnectUrl;
const sessionUrl = handle ? getRemoteSessionUrl(handle.bridgeSessionId, handle.sessionIngressUrl) : prev_9.replBridgeSessionUrl;
const envId = handle?.environmentId;
const sessionId = handle?.bridgeSessionId;
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) {
return prev_9;
}
return {
...prev_9,
replBridgeConnected: true,
replBridgeSessionActive: false,
replBridgeReconnecting: false,
replBridgeConnectUrl: connectUrl,
replBridgeSessionUrl: sessionUrl,
replBridgeEnvironmentId: envId,
replBridgeSessionId: sessionId,
replBridgeError: undefined
};
});
break;
case 'connected':
{
setAppState(prev_8 => {
if (prev_8.replBridgeSessionActive) return prev_8;
return {
...prev_8,
replBridgeConnected: true,
replBridgeSessionActive: true,
replBridgeReconnecting: false,
replBridgeError: undefined
};
});
// Send system/init so remote clients (web/iOS/Android) get
// session metadata. REPL uses query() directly — never hits
// QueryEngine's SDKMessage layer — so this is the only path
// to put system/init on the REPL-bridge wire. Skills load is
// async (memoized, cheap after REPL startup); fire-and-forget
// so the connected-state transition isn't blocked.
if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', false)) {
void (async () => {
try {
const skills = await getSlashCommandToolSkills(getCwd());
if (cancelled) return;
const state_0 = store.getState();
handleRef.current?.writeSdkMessages([buildSystemInitMessage({
// tools/mcpClients/plugins redacted for REPL-bridge:
// MCP-prefixed tool names and server names leak which
// integrations the user has wired up; plugin paths leak
// raw filesystem paths (username, project structure).
// CCR v2 persists SDK messages to Spanner — users who
// tap "Connect from phone" may not expect these on
// Anthropic's servers. QueryEngine (SDK) still emits
// full lists — SDK consumers expect full telemetry.
tools: [],
mcpClients: [],
model: mainLoopModelRef.current,
permissionMode: state_0.toolPermissionContext.mode as PermissionMode,
// TODO: avoid the cast
// Remote clients can only invoke bridge-safe commands —
// advertising unsafe ones (local-jsx, unallowed local)
// would let mobile/web attempt them and hit errors.
commands: commandsRef.current.filter(isBridgeSafeCommand),
agents: state_0.agentDefinitions.activeAgents,
skills,
plugins: [],
fastMode: state_0.fastMode
})]);
} catch (err_0) {
logForDebugging(`[bridge:repl] Failed to send system/init: ${errorMessage(err_0)}`, {
level: 'error'
});
}
})();
}
break;
}
case 'reconnecting':
setAppState(prev_7 => {
if (prev_7.replBridgeReconnecting) return prev_7;
return {
...prev_7,
replBridgeReconnecting: true,
replBridgeSessionActive: false
};
});
break;
case 'failed':
// Clear any previous failure dismiss timer
clearTimeout(failureTimeoutRef.current);
notifyBridgeFailed(detail_0);
setAppState(prev_5 => ({
...prev_5,
replBridgeError: detail_0,
replBridgeReconnecting: false,
replBridgeSessionActive: false,
replBridgeConnected: false
}));
// Auto-disable after timeout so the hook stops retrying.
failureTimeoutRef.current = setTimeout(() => {
if (cancelled) return;
failureTimeoutRef.current = undefined;
setAppState(prev_6 => {
if (!prev_6.replBridgeError) return prev_6;
return {
...prev_6,
replBridgeEnabled: false,
replBridgeError: undefined
};
});
}, BRIDGE_FAILURE_DISMISS_MS);
break;
}
}
// Map of pending bridge permission response handlers, keyed by request_id.
// Each entry is an onResponse handler waiting for CCR to reply.
const pendingPermissionHandlers = new Map<string, (response: BridgePermissionResponse) => void>();
// Dispatch incoming control_response messages to registered handlers
function handlePermissionResponse(msg_0: SDKControlResponse): void {
const requestId = msg_0.response?.request_id;
if (!requestId) return;
const handler = pendingPermissionHandlers.get(requestId);
if (!handler) {
logForDebugging(`[bridge:repl] No handler for control_response request_id=${requestId}`);
return;
}
pendingPermissionHandlers.delete(requestId);
// Extract the permission decision from the control_response payload
const inner = msg_0.response;
if (inner.subtype === 'success' && inner.response && isBridgePermissionResponse(inner.response)) {
handler(inner.response);
}
}
const handle_0 = await initReplBridge({
outboundOnly,
tags: outboundOnly ? ['ccr-mirror'] : undefined,
onInboundMessage: handleInboundMessage,
onPermissionResponse: handlePermissionResponse,
onInterrupt() {
abortControllerRef.current?.abort();
},
onSetModel(model) {
const resolved = model === 'default' ? null : model ?? null;
setMainLoopModelOverride(resolved);
setAppState(prev_10 => {
if (prev_10.mainLoopModelForSession === resolved) return prev_10;
return {
...prev_10,
mainLoopModelForSession: resolved
};
});
},
onSetMaxThinkingTokens(maxTokens) {
const enabled = maxTokens !== null;
setAppState(prev_11 => {
if (prev_11.thinkingEnabled === enabled) return prev_11;
return {
...prev_11,
thinkingEnabled: enabled
};
});
},
onSetPermissionMode(mode) {
// Policy guards MUST fire before transitionPermissionMode —
// its internal auto-gate check is a defensive throw (with a
// setAutoModeActive(true) side-effect BEFORE the throw) rather
// than a graceful reject. Letting that throw escape would:
// (1) leave STATE.autoModeActive=true while the mode is
// unchanged (3-way invariant violation per src/CLAUDE.md)
// (2) fail to send a control_response → server kills WS
// These mirror print.ts handleSetPermissionMode; the bridge
// can't import the checks directly (bootstrap-isolation), so
// it relies on this verdict to emit the error response.
if (mode === 'bypassPermissions') {
if (isBypassPermissionsModeDisabled()) {
return {
ok: false,
error: 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration'
};
}
if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) {
return {
ok: false,
error: 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions'
};
}
}
if (feature('TRANSCRIPT_CLASSIFIER') && mode === 'auto' && !isAutoModeGateEnabled()) {
const reason = getAutoModeUnavailableReason();
return {
ok: false,
error: reason ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` : 'Cannot set permission mode to auto'
};
}
// Guards passed — apply via the centralized transition so
// prePlanMode stashing and auto-mode state sync all fire.
setAppState(prev_12 => {
const current = prev_12.toolPermissionContext.mode;
if (current === mode) return prev_12;
const next = transitionPermissionMode(current, mode, prev_12.toolPermissionContext);
return {
...prev_12,
toolPermissionContext: {
...next,
mode
}
};
});
// Recheck queued permission prompts now that mode changed.
setImmediate(() => {
getLeaderToolUseConfirmQueue()?.(currentQueue => {
currentQueue.forEach(item => {
void item.recheckPermission();
});
return currentQueue;
});
});
return {
ok: true
};
},
onStateChange: handleStateChange,
initialMessages: messages.length > 0 ? messages : undefined,
getMessages: () => messagesRef.current,
previouslyFlushedUUIDs: flushedUUIDsRef.current,
initialName: replBridgeInitialName,
perpetual
});
if (cancelled) {
// Effect was cancelled while initReplBridge was in flight.
// Tear down the handle to avoid leaking resources (poll loop,
// WebSocket, registered environment, cleanup callback).
logForDebugging(`[bridge:repl] Hook: init cancelled during flight, tearing down${handle_0 ? ` env=${handle_0.environmentId}` : ''}`);
if (handle_0) {
void handle_0.teardown();
}
return;
}
if (!handle_0) {
// initReplBridge returned null — a precondition failed. For most
// cases (no_oauth, policy_denied, etc.) onStateChange('failed')
// already fired with a specific hint. The GrowthBook-gate-off case
// is intentionally silent — not a failure, just not rolled out.
consecutiveFailuresRef.current++;
logForDebugging(`[bridge:repl] Init returned null (precondition or session creation failed); consecutive failures: ${consecutiveFailuresRef.current}`);
clearTimeout(failureTimeoutRef.current);
setAppState(prev_13 => ({
...prev_13,
replBridgeError: prev_13.replBridgeError ?? 'check debug logs for details'
}));
failureTimeoutRef.current = setTimeout(() => {
if (cancelled) return;
failureTimeoutRef.current = undefined;
setAppState(prev_14 => {
if (!prev_14.replBridgeError) return prev_14;
return {
...prev_14,
replBridgeEnabled: false,
replBridgeError: undefined
};
});
}, BRIDGE_FAILURE_DISMISS_MS);
return;
}
handleRef.current = handle_0;
setReplBridgeHandle(handle_0);
consecutiveFailuresRef.current = 0;
// Skip initial messages in the forwarding effect — they were
// already loaded as session events during creation.
lastWrittenIndexRef.current = initialMessageCount;
if (outboundOnly) {
setAppState(prev_15 => {
if (prev_15.replBridgeConnected && prev_15.replBridgeSessionId === handle_0.bridgeSessionId) return prev_15;
return {
...prev_15,
replBridgeConnected: true,
replBridgeSessionId: handle_0.bridgeSessionId,
replBridgeSessionUrl: undefined,
replBridgeConnectUrl: undefined,
replBridgeError: undefined
};
});
logForDebugging(`[bridge:repl] Mirror initialized, session=${handle_0.bridgeSessionId}`);
} else {
// Build bridge permission callbacks so the interactive permission
// handler can race bridge responses against local user interaction.
const permissionCallbacks: BridgePermissionCallbacks = {
sendRequest(requestId_0, toolName, input, toolUseId, description, permissionSuggestions, blockedPath) {
handle_0.sendControlRequest({
type: 'control_request',
request_id: requestId_0,
request: {
subtype: 'can_use_tool',
tool_name: toolName,
input,
tool_use_id: toolUseId,
description,
...(permissionSuggestions ? {
permission_suggestions: permissionSuggestions
} : {}),
...(blockedPath ? {
blocked_path: blockedPath
} : {})
}
});
},
sendResponse(requestId_1, response) {
const payload: Record<string, unknown> = {
...response
};
handle_0.sendControlResponse({
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId_1,
response: payload
}
});
},
cancelRequest(requestId_2) {
handle_0.sendControlCancelRequest(requestId_2);
},
onResponse(requestId_3, handler_0) {
pendingPermissionHandlers.set(requestId_3, handler_0);
return () => {
pendingPermissionHandlers.delete(requestId_3);
};
}
};
setAppState(prev_16 => ({
...prev_16,
replBridgePermissionCallbacks: permissionCallbacks
}));
const url = getRemoteSessionUrl(handle_0.bridgeSessionId, handle_0.sessionIngressUrl);
// environmentId === '' signals the v2 env-less path. buildBridgeConnectUrl
// builds an env-specific connect URL, which doesn't exist without an env.
const hasEnv = handle_0.environmentId !== '';
const connectUrl_0 = hasEnv ? buildBridgeConnectUrl(handle_0.environmentId, handle_0.sessionIngressUrl) : undefined;
setAppState(prev_17 => {
if (prev_17.replBridgeConnected && prev_17.replBridgeSessionUrl === url) {
return prev_17;
}
return {
...prev_17,
replBridgeConnected: true,
replBridgeSessionUrl: url,
replBridgeConnectUrl: connectUrl_0 ?? prev_17.replBridgeConnectUrl,
replBridgeEnvironmentId: handle_0.environmentId,
replBridgeSessionId: handle_0.bridgeSessionId,
replBridgeError: undefined
};
});
// Show bridge status with URL in the transcript. perpetual (KAIROS
// assistant mode) falls back to v1 at initReplBridge.ts — skip the
// v2-only upgrade nudge for them. Own try/catch so a cosmetic
// GrowthBook hiccup doesn't hit the outer init-failure handler.
const upgradeNudge = !perpetual ? await shouldShowAppUpgradeMessage().catch(() => false) : false;
if (cancelled) return;
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)]);
logForDebugging(`[bridge:repl] Hook initialized, session=${handle_0.bridgeSessionId}`);
}
} catch (err) {
// Never crash the REPL — surface the error in the UI.
// Check cancelled first (symmetry with the !handle path at line ~386):
// if initReplBridge threw during rapid toggle-off (in-flight network
// error), don't count that toward the fuse or spam a stale error
// into the UI. Also fixes pre-existing spurious setAppState/
// setMessages on cancelled throws.
if (cancelled) return;
consecutiveFailuresRef.current++;
const errMsg = errorMessage(err);
logForDebugging(`[bridge:repl] Init failed: ${errMsg}; consecutive failures: ${consecutiveFailuresRef.current}`);
clearTimeout(failureTimeoutRef.current);
notifyBridgeFailed(errMsg);
setAppState(prev_0 => ({
...prev_0,
replBridgeError: errMsg
}));
failureTimeoutRef.current = setTimeout(() => {
if (cancelled) return;
failureTimeoutRef.current = undefined;
setAppState(prev_1 => {
if (!prev_1.replBridgeError) return prev_1;
return {
...prev_1,
replBridgeEnabled: false,
replBridgeError: undefined
};
});
}, BRIDGE_FAILURE_DISMISS_MS);
if (!outboundOnly) {
setMessages(prev_2 => [...prev_2, createSystemMessage(`Remote Control failed to connect: ${errMsg}`, 'warning')]);
}
}
})();
return () => {
cancelled = true;
clearTimeout(failureTimeoutRef.current);
failureTimeoutRef.current = undefined;
if (handleRef.current) {
logForDebugging(`[bridge:repl] Hook cleanup: starting teardown for env=${handleRef.current.environmentId} session=${handleRef.current.bridgeSessionId}`);
teardownPromiseRef.current = handleRef.current.teardown();
handleRef.current = null;
setReplBridgeHandle(null);
}
setAppState(prev_19 => {
if (!prev_19.replBridgeConnected && !prev_19.replBridgeSessionActive && !prev_19.replBridgeError) {
return prev_19;
}
return {
...prev_19,
replBridgeConnected: false,
replBridgeSessionActive: false,
replBridgeReconnecting: false,
replBridgeConnectUrl: undefined,
replBridgeSessionUrl: undefined,
replBridgeEnvironmentId: undefined,
replBridgeSessionId: undefined,
replBridgeError: undefined,
replBridgePermissionCallbacks: undefined
};
});
lastWrittenIndexRef.current = 0;
};
}
}, [replBridgeEnabled, replBridgeOutboundOnly, setAppState, setMessages, addNotification]);
// Write new messages as they appear.
// Also re-runs when replBridgeConnected changes (bridge finishes init),
// so any messages that arrived before the bridge was ready get written.
useEffect(() => {
// Positive feature() guard — see first useEffect comment
if (feature('BRIDGE_MODE')) {
if (!replBridgeConnected) return;
const handle_1 = handleRef.current;
if (!handle_1) return;
// Clamp the index in case messages were compacted (array shortened).
// After compaction the ref could exceed messages.length, and without
// clamping no new messages would be forwarded.
if (lastWrittenIndexRef.current > messages.length) {
logForDebugging(`[bridge:repl] Compaction detected: lastWrittenIndex=${lastWrittenIndexRef.current} > messages.length=${messages.length}, clamping`);
}
const startIndex = Math.min(lastWrittenIndexRef.current, messages.length);
// Collect new messages since last write
const newMessages: Message[] = [];
for (let i = startIndex; i < messages.length; i++) {
const msg_1 = messages[i];
if (msg_1 && (msg_1.type === 'user' || msg_1.type === 'assistant' || msg_1.type === 'system' && msg_1.subtype === 'local_command')) {
newMessages.push(msg_1);
}
}
lastWrittenIndexRef.current = messages.length;
if (newMessages.length > 0) {
handle_1.writeMessages(newMessages);
}
}
}, [messages, replBridgeConnected]);
const sendBridgeResult = useCallback(() => {
if (feature('BRIDGE_MODE')) {
handleRef.current?.sendResult();
}
}, []);
return {
sendBridgeResult
};
}