mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
879 lines
36 KiB
TypeScript
879 lines
36 KiB
TypeScript
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 '@anthropic/ink'
|
||
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'
|
||
import { ContentBlockParam } from '@anthropic-ai/sdk/resources'
|
||
|
||
/** 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 => s.replBridgeConnected)
|
||
: false
|
||
const replBridgeOutboundOnly = feature('BRIDGE_MODE')
|
||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||
useAppState(s => s.replBridgeOutboundOnly)
|
||
: false
|
||
const replBridgeInitialName = feature('BRIDGE_MODE')
|
||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||
useAppState(s => s.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'
|
||
)
|
||
const rawContent = fields.content
|
||
let sanitized: string | Array<{ type: string; [key: string]: unknown }> = typeof rawContent === 'string' ? rawContent : rawContent as unknown as Array<{ type: string; [key: string]: unknown }>
|
||
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 */
|
||
if (typeof sanitized === 'string') {
|
||
sanitized = sanitizeInboundWebhookContent(sanitized)
|
||
}
|
||
}
|
||
const content = await resolveAndPrepend(msg, sanitized as string | ContentBlockParam[])
|
||
|
||
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?: string,
|
||
): void {
|
||
if (cancelled) return
|
||
if (outboundOnly) {
|
||
logForDebugging(
|
||
`[bridge:repl] Mirror state=${state}${detail ? ` detail=${detail}` : ''}`,
|
||
)
|
||
// Sync replBridgeConnected so the forwarding effect starts/stops
|
||
// writing as the transport comes up or dies.
|
||
if (state === 'failed') {
|
||
setAppState(prev => {
|
||
if (!prev.replBridgeConnected) return prev
|
||
return { ...prev, replBridgeConnected: false }
|
||
})
|
||
} else if (state === 'ready' || state === 'connected') {
|
||
setAppState(prev => {
|
||
if (prev.replBridgeConnected) return prev
|
||
return { ...prev, replBridgeConnected: true }
|
||
})
|
||
}
|
||
return
|
||
}
|
||
const handle = handleRef.current
|
||
switch (state) {
|
||
case 'ready':
|
||
setAppState(prev => {
|
||
const connectUrl =
|
||
handle && handle.environmentId !== ''
|
||
? buildBridgeConnectUrl(
|
||
handle.environmentId,
|
||
handle.sessionIngressUrl,
|
||
)
|
||
: prev.replBridgeConnectUrl
|
||
const sessionUrl = handle
|
||
? getRemoteSessionUrl(
|
||
handle.bridgeSessionId,
|
||
handle.sessionIngressUrl,
|
||
)
|
||
: prev.replBridgeSessionUrl
|
||
const envId = handle?.environmentId
|
||
const sessionId = handle?.bridgeSessionId
|
||
if (
|
||
prev.replBridgeConnected &&
|
||
!prev.replBridgeSessionActive &&
|
||
!prev.replBridgeReconnecting &&
|
||
prev.replBridgeConnectUrl === connectUrl &&
|
||
prev.replBridgeSessionUrl === sessionUrl &&
|
||
prev.replBridgeEnvironmentId === envId &&
|
||
prev.replBridgeSessionId === sessionId
|
||
) {
|
||
return prev
|
||
}
|
||
return {
|
||
...prev,
|
||
replBridgeConnected: true,
|
||
replBridgeSessionActive: false,
|
||
replBridgeReconnecting: false,
|
||
replBridgeConnectUrl: connectUrl,
|
||
replBridgeSessionUrl: sessionUrl,
|
||
replBridgeEnvironmentId: envId,
|
||
replBridgeSessionId: sessionId,
|
||
replBridgeError: undefined,
|
||
}
|
||
})
|
||
break
|
||
case 'connected': {
|
||
setAppState(prev => {
|
||
if (prev.replBridgeSessionActive) return prev
|
||
return {
|
||
...prev,
|
||
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 = 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.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.agentDefinitions.activeAgents,
|
||
skills,
|
||
plugins: [],
|
||
fastMode: state.fastMode,
|
||
}),
|
||
])
|
||
} catch (err) {
|
||
logForDebugging(
|
||
`[bridge:repl] Failed to send system/init: ${errorMessage(err)}`,
|
||
{ level: 'error' },
|
||
)
|
||
}
|
||
})()
|
||
}
|
||
break
|
||
}
|
||
case 'reconnecting':
|
||
setAppState(prev => {
|
||
if (prev.replBridgeReconnecting) return prev
|
||
return {
|
||
...prev,
|
||
replBridgeReconnecting: true,
|
||
replBridgeSessionActive: false,
|
||
}
|
||
})
|
||
break
|
||
case 'failed':
|
||
// Clear any previous failure dismiss timer
|
||
clearTimeout(failureTimeoutRef.current)
|
||
notifyBridgeFailed(detail)
|
||
setAppState(prev => ({
|
||
...prev,
|
||
replBridgeError: detail,
|
||
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 => {
|
||
if (!prev.replBridgeError) return prev
|
||
return {
|
||
...prev,
|
||
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: SDKControlResponse): void {
|
||
const requestId = msg.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.response
|
||
if (
|
||
inner.subtype === 'success' &&
|
||
inner.response &&
|
||
isBridgePermissionResponse(inner.response)
|
||
) {
|
||
handler(inner.response)
|
||
}
|
||
}
|
||
|
||
const handle = 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 => {
|
||
if (prev.mainLoopModelForSession === resolved) return prev
|
||
return { ...prev, mainLoopModelForSession: resolved }
|
||
})
|
||
},
|
||
onSetMaxThinkingTokens(maxTokens) {
|
||
const enabled = maxTokens !== null
|
||
setAppState(prev => {
|
||
if (prev.thinkingEnabled === enabled) return prev
|
||
return { ...prev, 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 => {
|
||
const current = prev.toolPermissionContext.mode
|
||
if (current === mode) return prev
|
||
const next = transitionPermissionMode(
|
||
current,
|
||
mode,
|
||
prev.toolPermissionContext,
|
||
)
|
||
return {
|
||
...prev,
|
||
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 ? ` env=${handle.environmentId}` : ''}`,
|
||
)
|
||
if (handle) {
|
||
void handle.teardown()
|
||
}
|
||
return
|
||
}
|
||
if (!handle) {
|
||
// 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 => ({
|
||
...prev,
|
||
replBridgeError:
|
||
prev.replBridgeError ?? 'check debug logs for details',
|
||
}))
|
||
failureTimeoutRef.current = setTimeout(() => {
|
||
if (cancelled) return
|
||
failureTimeoutRef.current = undefined
|
||
setAppState(prev => {
|
||
if (!prev.replBridgeError) return prev
|
||
return {
|
||
...prev,
|
||
replBridgeEnabled: false,
|
||
replBridgeError: undefined,
|
||
}
|
||
})
|
||
}, BRIDGE_FAILURE_DISMISS_MS)
|
||
return
|
||
}
|
||
handleRef.current = handle
|
||
setReplBridgeHandle(handle)
|
||
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 => {
|
||
if (
|
||
prev.replBridgeConnected &&
|
||
prev.replBridgeSessionId === handle.bridgeSessionId
|
||
)
|
||
return prev
|
||
return {
|
||
...prev,
|
||
replBridgeConnected: true,
|
||
replBridgeSessionId: handle.bridgeSessionId,
|
||
replBridgeSessionUrl: undefined,
|
||
replBridgeConnectUrl: undefined,
|
||
replBridgeError: undefined,
|
||
}
|
||
})
|
||
logForDebugging(
|
||
`[bridge:repl] Mirror initialized, session=${handle.bridgeSessionId}`,
|
||
)
|
||
} else {
|
||
// Build bridge permission callbacks so the interactive permission
|
||
// handler can race bridge responses against local user interaction.
|
||
const permissionCallbacks: BridgePermissionCallbacks = {
|
||
sendRequest(
|
||
requestId,
|
||
toolName,
|
||
input,
|
||
toolUseId,
|
||
description,
|
||
permissionSuggestions,
|
||
blockedPath,
|
||
) {
|
||
handle.sendControlRequest({
|
||
type: 'control_request',
|
||
request_id: requestId,
|
||
request: {
|
||
subtype: 'can_use_tool',
|
||
tool_name: toolName,
|
||
input,
|
||
tool_use_id: toolUseId,
|
||
description,
|
||
...(permissionSuggestions
|
||
? { permission_suggestions: permissionSuggestions }
|
||
: {}),
|
||
...(blockedPath ? { blocked_path: blockedPath } : {}),
|
||
},
|
||
})
|
||
},
|
||
sendResponse(requestId, response) {
|
||
const payload: Record<string, unknown> = { ...response }
|
||
handle.sendControlResponse({
|
||
type: 'control_response',
|
||
response: {
|
||
subtype: 'success',
|
||
request_id: requestId,
|
||
response: payload,
|
||
},
|
||
})
|
||
},
|
||
cancelRequest(requestId) {
|
||
handle.sendControlCancelRequest(requestId)
|
||
},
|
||
onResponse(requestId, handler) {
|
||
pendingPermissionHandlers.set(requestId, handler)
|
||
return () => {
|
||
pendingPermissionHandlers.delete(requestId)
|
||
}
|
||
},
|
||
}
|
||
setAppState(prev => ({
|
||
...prev,
|
||
replBridgePermissionCallbacks: permissionCallbacks,
|
||
}))
|
||
const url = getRemoteSessionUrl(
|
||
handle.bridgeSessionId,
|
||
handle.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.environmentId !== ''
|
||
const connectUrl = hasEnv
|
||
? buildBridgeConnectUrl(
|
||
handle.environmentId,
|
||
handle.sessionIngressUrl,
|
||
)
|
||
: undefined
|
||
setAppState(prev => {
|
||
if (
|
||
prev.replBridgeConnected &&
|
||
prev.replBridgeSessionUrl === url
|
||
) {
|
||
return prev
|
||
}
|
||
return {
|
||
...prev,
|
||
replBridgeConnected: true,
|
||
replBridgeSessionUrl: url,
|
||
replBridgeConnectUrl: connectUrl ?? prev.replBridgeConnectUrl,
|
||
replBridgeEnvironmentId: handle.environmentId,
|
||
replBridgeSessionId: handle.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 => [
|
||
...prev,
|
||
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.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 => ({
|
||
...prev,
|
||
replBridgeError: errMsg,
|
||
}))
|
||
failureTimeoutRef.current = setTimeout(() => {
|
||
if (cancelled) return
|
||
failureTimeoutRef.current = undefined
|
||
setAppState(prev => {
|
||
if (!prev.replBridgeError) return prev
|
||
return {
|
||
...prev,
|
||
replBridgeEnabled: false,
|
||
replBridgeError: undefined,
|
||
}
|
||
})
|
||
}, BRIDGE_FAILURE_DISMISS_MS)
|
||
if (!outboundOnly) {
|
||
setMessages(prev => [
|
||
...prev,
|
||
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 => {
|
||
if (
|
||
!prev.replBridgeConnected &&
|
||
!prev.replBridgeSessionActive &&
|
||
!prev.replBridgeError
|
||
) {
|
||
return prev
|
||
}
|
||
return {
|
||
...prev,
|
||
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 = handleRef.current
|
||
if (!handle) 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 = messages[i]
|
||
if (
|
||
msg &&
|
||
(msg.type === 'user' ||
|
||
msg.type === 'assistant' ||
|
||
(msg.type === 'system' && msg.subtype === 'local_command'))
|
||
) {
|
||
newMessages.push(msg)
|
||
}
|
||
}
|
||
lastWrittenIndexRef.current = messages.length
|
||
|
||
if (newMessages.length > 0) {
|
||
handle.writeMessages(newMessages)
|
||
}
|
||
}
|
||
}, [messages, replBridgeConnected])
|
||
|
||
const sendBridgeResult = useCallback(() => {
|
||
if (feature('BRIDGE_MODE')) {
|
||
handleRef.current?.sendResult()
|
||
}
|
||
}, [])
|
||
|
||
return { sendBridgeResult }
|
||
}
|