claude-code-best/src/hooks/useReplBridge.tsx
claude-code-best 2cf18c4c49 docs: 添加 ToolSearch 设计指南 + 禁用 turn-zero 工具推荐弹窗
- 新增 docs/design/tool-search-design-guide.md,涵盖架构、搜索算法、执行管道、演进历史
- 禁用 getTurnZeroSearchExtraToolsPrefetch,消除用户输入时的频繁弹窗
- inter-turn 发现机制保持不变

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
2026-05-09 16:45:56 +08:00

919 lines
41 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 { type FSWatcher, watch } from 'fs';
import React, { useCallback, useEffect, useRef } from 'react';
import { setMainLoopModelOverride } from '../bootstrap/state.js';
import {
type BridgePermissionCallbacks,
type BridgePermissionResponse,
parseBridgePermissionResponse,
} from '../bridge/bridgePermissionCallbacks.js';
import { handleRemoteInterrupt } from '../bridge/remoteInterruptHandling.js';
import { isTranscriptResetResultReady, shouldDeferBridgeResult } from '../bridge/bridgeResultScheduling.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 { buildTaskStateMessage, getTaskStateSnapshotKey } from '../utils/taskStateMessage.js';
import {
getAutoModeUnavailableNotification,
getAutoModeUnavailableReason,
isAutoModeGateEnabled,
isBypassPermissionsModeDisabled,
transitionPermissionMode,
} from '../utils/permissions/permissionSetup.js';
import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js';
import { getTaskListId, getTasksDir, listTasks, onTasksUpdated } from '../utils/tasks.js';
import { ContentBlockParam } from '@anthropic-ai/sdk/resources';
const TASK_STATE_DEBOUNCE_MS = 50;
const TASK_STATE_POLL_MS = 5000;
/** 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);
const pendingResultAfterFlushRef = useRef(false);
const transcriptResetPendingRef = useRef(false);
// 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 replBridgeEnabledRaw = useAppState(s => s.replBridgeEnabled);
const replBridgeEnabled = feature('BRIDGE_MODE') ? replBridgeEnabledRaw : false;
const replBridgeConnectedRaw = useAppState(s => s.replBridgeConnected);
const replBridgeConnected = feature('BRIDGE_MODE') ? replBridgeConnectedRaw : false;
const replBridgeSessionActiveRaw = useAppState(s => s.replBridgeSessionActive);
const replBridgeSessionActive = feature('BRIDGE_MODE') ? replBridgeSessionActiveRaw : false;
const replBridgeOutboundOnlyRaw = useAppState(s => s.replBridgeOutboundOnly);
const replBridgeOutboundOnly = feature('BRIDGE_MODE') ? replBridgeOutboundOnlyRaw : false;
const replBridgeInitialNameRaw = useAppState(s => s.replBridgeInitialName);
const replBridgeInitialName = feature('BRIDGE_MODE') ? replBridgeInitialNameRaw : 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': {
const wasSessionActive = store.getState().replBridgeSessionActive;
setAppState(prev => {
if (prev.replBridgeSessionActive) return prev;
return {
...prev,
replBridgeConnected: true,
replBridgeSessionActive: true,
replBridgeReconnecting: false,
replBridgeError: undefined,
};
});
// Notify model about newly available bridge-dependent tools
if (!wasSessionActive) {
setMessages(prev => [
...prev,
createSystemMessage(
'Remote Control 已连接。现在可以使用 PushNotification、SendUserFile、Brief 工具,请使用 SearchExtraTools 搜索发现。',
'info',
),
]);
}
// 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;
}
const parsed = parseBridgePermissionResponse(msg);
if (!parsed) {
logForDebugging(`[bridge:repl] Ignoring unrecognized control_response request_id=${requestId}`);
return;
}
pendingPermissionHandlers.delete(requestId);
handler(parsed);
}
const rawHandle = await initReplBridge({
outboundOnly,
tags: outboundOnly ? ['ccr-mirror'] : undefined,
onInboundMessage: handleInboundMessage,
onPermissionResponse: handlePermissionResponse,
onInterrupt() {
handleRemoteInterrupt(abortControllerRef.current);
},
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,
});
const handle = rawHandle
? {
...rawHandle,
markTranscriptReset() {
transcriptResetPendingRef.current = true;
pendingResultAfterFlushRef.current = false;
lastWrittenIndexRef.current = 0;
},
}
: null;
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;
pendingResultAfterFlushRef.current = false;
transcriptResetPendingRef.current = false;
};
}
}, [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);
transcriptResetPendingRef.current = false;
}
if (
pendingResultAfterFlushRef.current &&
isTranscriptResetResultReady(transcriptResetPendingRef.current, messages.length)
) {
transcriptResetPendingRef.current = false;
pendingResultAfterFlushRef.current = false;
handle.sendResult();
return;
}
if (pendingResultAfterFlushRef.current && !transcriptResetPendingRef.current) {
pendingResultAfterFlushRef.current = false;
handle.sendResult();
}
}
}, [messages, replBridgeConnected]);
useEffect(() => {
if (feature('BRIDGE_MODE')) {
if (!replBridgeSessionActive || replBridgeOutboundOnly) return;
let cancelled = false;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let pollTimer: ReturnType<typeof setInterval> | undefined;
let watcher: FSWatcher | null = null;
let watchedDir: string | null = null;
let lastPublishedSnapshotKey: string | null = null;
let lastPublishedHandle: ReplBridgeHandle | null = null;
const rewatch = (dir: string): void => {
if (dir === watchedDir && watcher !== null) return;
watcher?.close();
watcher = null;
watchedDir = dir;
try {
watcher = watch(dir, schedulePublish);
watcher.unref();
} catch {
// Writers ensure the directory exists; if it does not yet, the
// poll timer and in-process task signal still converge the snapshot.
}
};
const publishTaskState = async (): Promise<void> => {
const handle = handleRef.current;
if (!handle) return;
const taskListId = getTaskListId();
rewatch(getTasksDir(taskListId));
try {
const tasks = await listTasks(taskListId);
if (cancelled || handleRef.current !== handle) return;
const snapshotKey = getTaskStateSnapshotKey(taskListId, tasks);
if (snapshotKey === lastPublishedSnapshotKey && handle === lastPublishedHandle) {
return;
}
handle.writeSdkMessages([buildTaskStateMessage(taskListId, tasks)]);
lastPublishedSnapshotKey = snapshotKey;
lastPublishedHandle = handle;
} catch (err) {
logForDebugging(`[bridge:repl] Failed to publish task_state: ${errorMessage(err)}`, { level: 'error' });
}
};
const schedulePublish = (): void => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = undefined;
void publishTaskState();
}, TASK_STATE_DEBOUNCE_MS);
debounceTimer.unref?.();
};
void publishTaskState();
const unsubscribe = onTasksUpdated(schedulePublish);
pollTimer = setInterval(() => {
void publishTaskState();
}, TASK_STATE_POLL_MS);
pollTimer.unref?.();
return () => {
cancelled = true;
unsubscribe();
if (debounceTimer) clearTimeout(debounceTimer);
if (pollTimer) clearInterval(pollTimer);
watcher?.close();
};
}
}, [replBridgeSessionActive, replBridgeOutboundOnly]);
const sendBridgeResult = useCallback(() => {
if (feature('BRIDGE_MODE')) {
const handle = handleRef.current;
if (!handle) {
pendingResultAfterFlushRef.current = true;
return;
}
if (isTranscriptResetResultReady(transcriptResetPendingRef.current, messagesRef.current.length)) {
transcriptResetPendingRef.current = false;
pendingResultAfterFlushRef.current = false;
handle.sendResult();
return;
}
// Message mirroring happens in a separate effect. When the turn completes
// before that effect flushes the latest transcript rows, hold the result
// so remote state transitions after the final mirrored messages instead
// of bouncing back to "running" on local slash commands like /clear.
if (
transcriptResetPendingRef.current ||
shouldDeferBridgeResult({
hasHandle: true,
isConnected: replBridgeConnected,
lastWrittenIndex: lastWrittenIndexRef.current,
messageCount: messagesRef.current.length,
})
) {
pendingResultAfterFlushRef.current = true;
return;
}
handle.sendResult();
}
}, [replBridgeConnected]);
return { sendBridgeResult };
}