- 新增 docs/design/tool-search-design-guide.md,涵盖架构、搜索算法、执行管道、演进历史 - 禁用 getTurnZeroSearchExtraToolsPrefetch,消除用户输入时的频繁弹窗 - inter-turn 发现机制保持不变 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
919 lines
41 KiB
TypeScript
919 lines
41 KiB
TypeScript
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 };
|
||
}
|