Files
claude-code/src/hooks/useReplBridge.tsx
unraid f2e9af4927 feat: harden autonomy lifecycle, OOM bounds, and provider-boundary finalization
This PR consolidates a coordinated batch of fixes around autonomy run/flow lifecycle, scheduled task deduplication, provider-boundary state finalization, and matching memory-bound treatments for adjacent long-running subsystems (REPL fullscreen scrollback, skill-search/skill-learning runtime activation). All changes were developed and reviewed together because they touched the same lifecycle invariants and were uncovered by the same long-running session reproductions.

## Lifecycle correctness

- Queued autonomy prompts are not injected unless the persisted run was successfully claimed; queued run claiming is now terminal-safe so a once-consumed/cancelled/failed run can not slip back into `queued`.
- Autonomy run/flow finalization happens on completion, provider error, generator close, and cancellation — not just the happy path. New `src/__tests__/queryAutonomyProviderBoundary.test.ts` covers these provider-boundary transitions.
- `requestManagedAutonomyFlowCancel` and `resumeManagedAutonomyFlowPrompt` carry `rootDir` and `currentDir` explicitly across detached async boundaries (proactive-tick, cron, daemon restart) instead of inferring from process state.
- Active runs/flows are protected from janitor pruning so a running step can not be garbage-collected mid-flight (`src/utils/autonomyAuthority.ts`).
- Heartbeat parser now ignores fenced code blocks; the two-phase commit window for autonomy state transitions is documented in `docs/internals/autonomy-jira.md`.

## Ownership and dedup

- `src/utils/autonomyRuns.ts`: ownership stamping (run id + rootDir carried end-to-end), source-based dedup against active runs.
- `src/hooks/useScheduledTasks.ts`: scheduled ticks deduplicate against runs already active on the same source label.
- `src/utils/processUserInput/processSlashCommand.tsx`: forked slash commands now thread the autonomy `runId` so completion finalizers can find the originating run for deferred completion.
- New `src/utils/autonomyQueueLifecycle.ts` and tests collect the queue-side lifecycle invariants in one place.

## Memory bounds (related, same review pass)

- `src/screens/REPL.tsx`: caps fullscreen scrollback after the compact boundary and updates trailing progress rows in place. Long-running fullscreen sessions could otherwise retain thousands of post-compaction messages and duplicate progress rows, keeping Ink trees alive long after their useful context had moved on.
- `src/services/skillSearch/*` and `src/services/skillLearning/*`: runtime activation is strictly opt-in via existing env toggles; session caches are capped so long-running processes can not grow them forever. Build presence is preserved so operators can still discover and opt into the slash commands.

## CI / test contract

- `tests/integration/dependency-overrides.test.ts`: smoke test no longer drives Mermaid's browser renderer; it validates the package-resolution contract directly so CI does not regress on unrelated browser timing.
- New `tests/integration/autonomy-lifecycle-user-flow.test.ts`: end-to-end CLI subprocess flow exercising `status --deep`, `flows`, `flow <id>`, `flow resume`, `flow cancel` against persisted state.
- `src/entrypoints/cli.tsx`: `claude autonomy …` routes through an entrypoint fast path that reuses the slash-command formatter without booting the full interactive CLI. Stdout is flushed before forced exit so coverage subprocesses do not terminate with empty stdout.
- `packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts`: stabilized to prevent audit flake under coverage.

## Tests added

- `src/__tests__/queryAutonomyProviderBoundary.test.ts`
- `src/hooks/__tests__/useScheduledTasks.test.ts`
- `src/utils/__tests__/autonomyAuthority.test.ts`
- `src/utils/__tests__/autonomyFlows.test.ts` (extended)
- `src/utils/__tests__/autonomyPersistence.test.ts` (extended)
- `src/utils/__tests__/autonomyQueueLifecycle.test.ts`
- `src/utils/__tests__/autonomyRuns.test.ts` (extended)
- `src/utils/processUserInput/__tests__/processSlashCommand.test.ts`
- `tests/integration/autonomy-lifecycle-user-flow.test.ts`

## Docs

- `docs/agent/sur-loop-scheduled-oom.md`: System Understanding Report covering the scheduled/loop OOM problem, the call graphs investigated, and the lifecycle invariants this PR establishes.
- `docs/agent/sur-skill-overflow-bugs.md`: SUR for the related skill-overflow context.
- `docs/internals/autonomy-jira.md`: documents the two-phase commit window and ownership stamping invariants.
- `docs/memory-leak-audit.md`: audit notes covering the REPL/scrollback and skill-search bounds.

## Invariants this PR establishes

1. Queued autonomy prompts are not injected unless the persisted run was successfully claimed.
2. Terminal run/flow states are terminal — completion, failure, and cancellation all finalize state regardless of which provider/error path triggered them.
3. Autonomy run/flow `rootDir` is carried explicitly across detached async boundaries instead of inferred from a shared singleton.
4. State-only CLI subcommands (`autonomy status|runs|flows|flow …`) bypass full interactive bootstrap so they do not hold unrelated handles open.
5. REPL fullscreen scrollback and skill-search/skill-learning session caches are explicitly bounded.

## Validation

```bash
bun run typecheck
CI=true GITHUB_ACTIONS=true bun test            # 3996 pass / 0 fail across 305 files
bun test src/__tests__/queryAutonomyProviderBoundary.test.ts \
         src/hooks/__tests__/useScheduledTasks.test.ts \
         src/utils/__tests__/autonomy{Runs,Flows,Authority,QueueLifecycle,Persistence}.test.ts \
         src/utils/processUserInput/__tests__/processSlashCommand.test.ts \
         tests/integration/autonomy-lifecycle-user-flow.test.ts
```

## Origin

This PR is the consolidated, upstream-targeted version of two fork-side review PRs (fix/loop-scheduled-autonomy-oom and fix/autonomy-lifecycle). The fork-side review history is preserved at https://github.com/amDosion/claude-code-bast/pull/7 . The fork's own internal `chore: keep fork current with upstream` sync commits and the `docs: update contributors` automation are intentionally not included in this PR.

The autonomy CLI handler `rootDir` threading that the fork added (78f64d8a, 98d04ddb) is intentionally omitted here because upstream `a2cfaf91` (fix: 修复 RemoteTriggerTool 和 autonomy 测试的全量运行失败) already performed the equivalent change with an additional `currentDir` option. Keeping the upstream version avoids regressing that improvement.
2026-04-29 14:04:27 +08:00

1052 lines
42 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 replBridgeEnabled = feature('BRIDGE_MODE')
?
useAppState(s => s.replBridgeEnabled)
: false
const replBridgeConnected = feature('BRIDGE_MODE')
?
useAppState(s => s.replBridgeConnected)
: false
const replBridgeSessionActive = feature('BRIDGE_MODE')
?
useAppState(s => s.replBridgeSessionActive)
: false
const replBridgeOutboundOnly = feature('BRIDGE_MODE')
?
useAppState(s => s.replBridgeOutboundOnly)
: false
const replBridgeInitialName = feature('BRIDGE_MODE')
?
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
}
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 }
}