本章总览
REPL Remote Control 由 initReplBridge.ts(约 569 行)做 bootstrap 门控与标题推导,再委托 replBridge.ts 的 initBridgeCore(约 2400 行)完成环境注册、会话创建、work poll、传输挂载与 teardown。replBridgeTransport.ts 把 v1 HybridTransport 与 v2 SSE+CCRClient 统一为 ReplBridgeTransport 接口;replBridgeHandle.ts 暴露进程级单例句柄供斜杠命令与工具调用。本章要求你能从 /remote-control 或 useReplBridge 自动启动,反查到 OAuth 刷新、perpetual 指针恢复与传输 swap 的完整链路。
学完本章你应该能
- 说明 initReplBridge 与 initBridgeCore 的职责分界及 bundle 隔离动机
- 解释 ReplBridgeHandle 与 BridgeCoreHandle 的方法语义
- 描述 createV1ReplTransport 与 createV2ReplTransport 的读写路径差异
- 理解 getReplBridgeHandle 与 updateSessionBridgeId 的并发去重
- 能在 replBridge 中定位 poll 错误退避与 reconnect-in-place 策略
核心概念(先读懂这些)
initReplBridge 不 import initBridgeCore 的同文件
initReplBridge.ts 动态 import replBridge.js 的 initBridgeCore,自身可安全被 print.ts SDK 路径加载;文件头注释写明:getCurrentSessionTitle 经 sessionStorage 牵出整个 commands 树,故 daemon 应只 import 不含 sessionStorage 的 core 文件。
BridgeCoreParams 显式注入一切 bootstrap 状态
initBridgeCore 不读 bootstrap/state:dir、branch、gitRepoUrl、title、createSession、archiveSession、toSDKMessages、onAuth401 均由 wrapper 填入。Agent SDK daemon(PR 4)与 REPL 共用同一 core,仅参数来源不同。
v2 写路径不经 SSETransport.write
replBridgeTransport.ts 注释强调:CCR v2 写出走 CCRClient.writeEvent → SerialBatchEventUploader,SSETransport.write() 的 URL 形状面向 Session-Ingress,不能用于 v2。读侧仍用 SSE;getLastSequenceNum 在 transport swap 时携带,避免服务端从 seq 0 重放全量历史。
建议学习步骤
- 阅读源码块 A:InitBridgeOptions 与 initReplBridge 门控
- 阅读源码块 B:ReplBridgeHandle 与 BridgeCoreParams
- 阅读源码块 C:initBridgeCore 注册与环境创建
- 阅读源码块 D:ReplBridgeTransport 类型与 v1 适配器
- 阅读源码块 E:createV2ReplTransport 注册与 JWT
- 阅读源码块 F:replBridgeHandle 全局指针
- 对照 useReplBridge.tsx 的 setReplBridgeHandle 时机
常见误区
注意
不要把 replBridge.ts 与 remoteBridgeCore.ts 的 env-less 路径混淆
注意
perpetual 模式只复用 source:repl 的 bridgePointer,standalone 指针不可复用
注意
toSDKMessages 未注入时调用 writeMessages 会 throw
注意
v1 getLastSequenceNum 恒为 0,seq 去重逻辑对 v1 为 no-op
在架构中的位置
Remote Control 在 REPL 内的典型启动路径:
用户 /remote-control 或 GrowthBook 自动桥接
→ useReplBridge useEffect / print.ts enableRemoteControl
→ initReplBridge(options)
→ [gate] isBridgeEnabledBlocking / OAuth / policy / 版本
→ initBridgeCore(params) 或 initEnvLessBridgeCore(params)
→ createBridgeSession + registerBridgeEnvironment
→ poll loop 收到 work → 挂载 ReplBridgeTransport
→ writeMessages / handleIngressMessage 双向同步
→ teardown → archiveSession + clearBridgePointer
initReplBridge 负责「能否启动」与「标题/metadata」;initBridgeCore 负责「如何维持连接」。斜杠命令、BriefTool 等通过 getReplBridgeHandle() 调用 sendControlRequest、writeSdkMessages,不必经过 React 树。
与 bridgeMain.ts 的区别:后者是独立进程 claude remote-control,用 sessionRunner spawn 子 CLI,同一套 bridgeApi 但无 Ink REPL 消息数组。
initReplBridge 门控链
initReplBridge 在调用 core 前串行执行多层防护,失败时 onStateChange?.('failed', detail) 并返回 null:
| 步骤 | 检查 | 用户可见 hint |
|---|---|---|
| 1 | isBridgeEnabledBlocking() | 功能未开放 |
| 2 | getBridgeAccessToken() | /login |
| 3 | isPolicyAllowed('allow_remote_control') | 组织策略禁用 |
| 2a-2c | 跨进程 OAuth 死亡计数、主动 refresh、过期且不可刷新 | /login |
setCseShimGate(isCseShimEnabled) 在入口设置 session ID 兼容 shim,daemon 路径可跳过。
标题推导优先级:initialName → sessionStorage /rename → initialMessages 最后一条人类 user → remote-control-{slug}。hasExplicitTitle 阻止 count-1/3 自动覆盖;onUserMessage 回调在第 1、3 条有效 user 消息时 PATCH 标题(Haiku generateSessionTitle)。
v1/v2 分支:isEnvLessBridgeEnabled() 为真时走 initEnvLessBridgeCore(见 remote-bridge-core 章);否则 initBridgeCore 并分别做 checkBridgeMinVersion / checkEnvLessBridgeMinVersion。
源码引用: src/bridge/initReplBridge.ts · 第 75–108 行(共 570 行)
75| export type InitBridgeOptions = {
76| onInboundMessage?: (msg: SDKMessage) => void | Promise<void>
77| onPermissionResponse?: (response: SDKControlResponse) => void
78| onInterrupt?: () => void
79| onSetModel?: (model: string | undefined) => void
80| onSetMaxThinkingTokens?: (maxTokens: number | null) => void
81| onSetPermissionMode?: (
82| mode: PermissionMode,
83| ) => { ok: true } | { ok: false; error: string }
84| onStateChange?: (state: BridgeState, detail?: string) => void
85| initialMessages?: Message[]
86| // Explicit session name from `/remote-control <name>`. When set, overrides
87| // the title derived from the conversation or /rename.
88| initialName?: string
89| // Fresh view of the full conversation at call time. Used by onUserMessage's
90| // count-3 derivation to call generateSessionTitle over the full conversation.
91| // Optional — print.ts's SDK enableRemoteControl path has no REPL message
92| // array; count-3 falls back to the single message text when absent.
93| getMessages?: () => Message[]
94| // UUIDs already flushed in a prior bridge session. Messages with these
95| // UUIDs are excluded from the initial flush to avoid poisoning the
96| // server (duplicate UUIDs across sessions cause the WS to be killed).
97| // Mutated in place — newly flushed UUIDs are added after each flush.
98| previouslyFlushedUUIDs?: Set<string>
99| /** See BridgeCoreParams.perpetual. */
100| perpetual?: boolean
101| /**
102| * When true, the bridge only forwards events outbound (no SSE inbound
103| * stream). Used by CCR mirror mode — local sessions visible on claude.ai
104| * without enabling inbound control.
105| */
106| outboundOnly?: boolean
107| tags?: string[]
108| }
源码引用: src/bridge/initReplBridge.ts · 第 110–162 行(共 570 行)
110| export async function initReplBridge(
111| options?: InitBridgeOptions,
112| ): Promise<ReplBridgeHandle | null> {
113| const {
114| onInboundMessage,
115| onPermissionResponse,
116| onInterrupt,
117| onSetModel,
118| onSetMaxThinkingTokens,
119| onSetPermissionMode,
120| onStateChange,
121| initialMessages,
122| getMessages,
123| previouslyFlushedUUIDs,
124| initialName,
125| perpetual,
126| outboundOnly,
127| tags,
128| } = options ?? {}
129|
130| // Wire the cse_ shim kill switch so toCompatSessionId respects the
131| // GrowthBook gate. Daemon/SDK paths skip this — shim defaults to active.
132| setCseShimGate(isCseShimEnabled)
133|
134| // 1. Runtime gate
135| if (!(await isBridgeEnabledBlocking())) {
136| logBridgeSkip('not_enabled', '[bridge:repl] Skipping: bridge not enabled')
137| return null
138| }
139|
140| // 1b. Minimum version check — deferred to after the v1/v2 branch below,
141| // since each implementation has its own floor (tengu_bridge_min_version
142| // for v1, tengu_bridge_repl_v2_config.min_version for v2).
143|
144| // 2. Check OAuth — must be signed in with claude.ai. Runs before the
145| // policy check so console-auth users get the actionable "/login" hint
146| // instead of a misleading policy error from a stale/wrong-org cache.
147| if (!getBridgeAccessToken()) {
148| logBridgeSkip('no_oauth', '[bridge:repl] Skipping: no OAuth tokens')
149| onStateChange?.('failed', '/login')
150| return null
151| }
152|
153| // 3. Check organization policy — remote control may be disabled
154| await waitForPolicyLimitsToLoad()
155| if (!isPolicyAllowed('allow_remote_control')) {
156| logBridgeSkip(
157| 'policy_denied',
158| '[bridge:repl] Skipping: allow_remote_control policy not allowed',
159| )
160| onStateChange?.('failed', "disabled by your organization's policy")
161| return null
162| }
源码引用: src/bridge/initReplBridge.ts · 第 243–300 行(共 570 行)
243| // 4. Compute baseUrl — needed by both v1 (env-based) and v2 (env-less)
244| // paths. Hoisted above the v2 gate so both can use it.
245| const baseUrl = getBridgeBaseUrl()
246|
247| // 5. Derive session title. Precedence: explicit initialName → /rename
248| // (session storage) → last meaningful user message → generated slug.
249| // Cosmetic only (claude.ai session list); the model never sees it.
250| // Two flags: `hasExplicitTitle` (initialName or /rename — never auto-
251| // overwrite) vs. `hasTitle` (any title, including auto-derived — blocks
252| // the count-1 re-derivation but not count-3). The onUserMessage callback
253| // (wired to both v1 and v2 below) derives from the 1st prompt and again
254| // from the 3rd so mobile/web show a title that reflects more context.
255| // The slug fallback (e.g. "remote-control-graceful-unicorn") makes
256| // auto-started sessions distinguishable in the claude.ai list before the
257| // first prompt.
258| let title = `remote-control-${generateShortWordSlug()}`
259| let hasTitle = false
260| let hasExplicitTitle = false
261| if (initialName) {
262| title = initialName
263| hasTitle = true
264| hasExplicitTitle = true
265| } else {
266| const sessionId = getSessionId()
267| const customTitle = sessionId
268| ? getCurrentSessionTitle(sessionId)
269| : undefined
270| if (customTitle) {
271| title = customTitle
272| hasTitle = true
273| hasExplicitTitle = true
274| } else if (initialMessages && initialMessages.length > 0) {
275| // Find the last user message that has meaningful content. Skip meta
276| // (nudges), tool results, compact summaries ("This session is being
277| // continued…"), non-human origins (task notifications, channel pushes),
278| // and synthetic interrupts ([Request interrupted by user]) — none are
279| // human-authored. Same filter as extractTitleText + isSyntheticMessage.
280| for (let i = initialMessages.length - 1; i >= 0; i--) {
281| const msg = initialMessages[i]!
282| if (
283| msg.type !== 'user' ||
284| msg.isMeta ||
285| msg.toolUseResult ||
286| msg.isCompactSummary ||
287| (msg.origin && msg.origin.kind !== 'human') ||
288| isSyntheticMessage(msg)
289| )
290| continue
291| const rawContent = getContentText(msg.message.content)
292| if (!rawContent) continue
293| const derived = deriveTitle(rawContent)
294| if (!derived) continue
295| title = derived
296| hasTitle = true
297| break
298| }
299| }
300| }
ReplBridgeHandle 与 BridgeCoreParams
ReplBridgeHandle(replBridge.ts 导出)是 REPL 与 claude.ai 交互的稳定面向对象 API:
| 方法 | 用途 |
|---|---|
writeMessages(Message[]) | 过滤 eligible 消息后转 SDKMessage 写出 |
writeSdkMessages(SDKMessage[]) | daemon/SDK 直写,不经 Message 映射 |
sendControlRequest/Response/Cancel | 与 web 端 permission、interrupt 协议对齐 |
sendResult() | 回合结束信号 |
teardown() | 关闭 transport、archive、清指针 |
BridgeCoreParams 扩展了 createSession/archiveSession 注入、perpetual 崩溃恢复、initialSSESequenceNum 跨进程 seq 延续、outboundOnly、onUserMessage 标题策略等。注释明确:createSession 注入是因为 createSession.ts 懒加载 auth/model 在 bun outfile 下仍会内联整个 REPL 树。
BridgeState 四态:ready → connected → reconnecting → failed,经 onStateChange 驱动 useReplBridge UI 指示器。
源码引用: src/bridge/replBridge.ts · 第 70–81 行(共 2407 行)
70| export type ReplBridgeHandle = {
71| bridgeSessionId: string
72| environmentId: string
73| sessionIngressUrl: string
74| writeMessages(messages: Message[]): void
75| writeSdkMessages(messages: SDKMessage[]): void
76| sendControlRequest(request: SDKControlRequest): void
77| sendControlResponse(response: SDKControlResponse): void
78| sendControlCancelRequest(requestId: string): void
79| sendResult(): void
80| teardown(): Promise<void>
81| }
源码引用: src/bridge/replBridge.ts · 第 91–150 行(共 2407 行)
91| export type BridgeCoreParams = {
92| dir: string
93| machineName: string
94| branch: string
95| gitRepoUrl: string | null
96| title: string
97| baseUrl: string
98| sessionIngressUrl: string
99| /**
100| * Opaque string sent as metadata.worker_type. Use BridgeWorkerType for
101| * the two CLI-originated values; daemon callers may send any string the
102| * backend recognizes (it's just a filter key on the web side).
103| */
104| workerType: string
105| getAccessToken: () => string | undefined
106| /**
107| * POST /v1/sessions. Injected because `createSession.ts` lazy-loads
108| * `auth.ts`/`model.ts`/`oauth/client.ts` and `bun --outfile` inlines
109| * dynamic imports — the lazy-load doesn't help, the whole REPL tree ends
110| * up in the Agent SDK bundle.
111| *
112| * REPL wrapper passes `createBridgeSession` from `createSession.ts`.
113| * Daemon wrapper passes `createBridgeSessionLean` from `sessionApi.ts`
114| * (HTTP-only, orgUUID+model supplied by the daemon caller).
115| *
116| * Receives `gitRepoUrl`+`branch` so the REPL wrapper can build the git
117| * source/outcome for claude.ai's session card. Daemon ignores them.
118| */
119| createSession: (opts: {
120| environmentId: string
121| title: string
122| gitRepoUrl: string | null
123| branch: string
124| signal: AbortSignal
125| }) => Promise<string | null>
126| /**
127| * POST /v1/sessions/{id}/archive. Same injection rationale. Best-effort;
128| * the callback MUST NOT throw.
129| */
130| archiveSession: (sessionId: string) => Promise<void>
131| /**
132| * Invoked on reconnect-after-env-lost to refresh the title. REPL wrapper
133| * reads session storage (picks up /rename); daemon returns the static
134| * title. Defaults to () => title.
135| */
136| getCurrentTitle?: () => string
137| /**
138| * Converts internal Message[] → SDKMessage[] for writeMessages() and the
139| * initial-flush/drain paths. REPL wrapper passes the real toSDKMessages
140| * from utils/messages/mappers.ts. Daemon callers that only use
141| * writeSdkMessages() and pass no initialMessages can omit this — those
142| * code paths are unreachable.
143| *
144| * Injected rather than imported because mappers.ts transitively pulls in
145| * src/commands.ts via messages.ts → api.ts → prompts.ts, dragging the
146| * entire command registry + React tree into the Agent SDK bundle.
147| */
148| toSDKMessages?: (messages: Message[]) => SDKMessage[]
149| /**
150| * OAuth 401 refresh handler passed to createBridgeApiClient. REPL wrapper
源码引用: src/bridge/replBridge.ts · 第 83–84 行(共 2407 行)
83| export type BridgeState = 'ready' | 'connected' | 'reconnecting' | 'failed'
84|
initBridgeCore 生命周期概要
initBridgeCore 是 env-based 路径的核心状态机(约 2400 行),关键阶段如下:
注册:createBridgeApiClient + optional wrapApiForFaultInjection(ant)→ registerBridgeEnvironment。reuseEnvironmentId 来自 perpetual bridgePointer(仅 source:repl)。注册失败且存在 stale pointer 时 clearBridgePointer。
会话:createSession({ environmentId, title, gitRepoUrl, branch }) 返回 bridge session id;写入 bridgePointer 供崩溃恢复。支持 tryReconnectInPlace:环境 ID 匹配时对已有 session 调 reconnectSession,避免重复创建。
初始 flush:eligible 历史消息经 HTTP 批量 POST;期间 FlushGate 排队新消息(bridge-messaging 章)。previouslyFlushedUUIDs 防止跨 session 重复 UUID 毒化服务端 WS。
Poll 循环:pollForWork → acknowledgeWork → onWorkReceived 建 transport。Poll 连续失败时指数退避(2s→60s cap,15min give-up)。系统休眠检测阈值 = 2× connCapMs。
Transport:按 feature 选 v1 Hybrid 或 v2;swap 时保存 getLastSequenceNum()、比较 droppedBatchCount 检测静默丢批。
Teardown:archiveSession、clearBridgePointer(非 perpetual)、registerCleanup 钩子。
源码引用: src/bridge/replBridge.ts · 第 251–259 行(共 2407 行)
251| /**
252| * Bootstrap-free core: env registration → session creation → poll loop →
253| * ingress WS → teardown. Reads nothing from bootstrap/state or
254| * sessionStorage — all context comes from params. Caller (initReplBridge
255| * below, or a daemon in PR 4) has already passed entitlement gates and
256| * gathered git/auth/title.
257| *
258| * Returns null on registration or session-creation failure.
259| */
源码引用: src/bridge/replBridge.ts · 第 260–296 行(共 2407 行)
260| export async function initBridgeCore(
261| params: BridgeCoreParams,
262| ): Promise<BridgeCoreHandle | null> {
263| const {
264| dir,
265| machineName,
266| branch,
267| gitRepoUrl,
268| title,
269| baseUrl,
270| sessionIngressUrl,
271| workerType,
272| getAccessToken,
273| createSession,
274| archiveSession,
275| getCurrentTitle = () => title,
276| toSDKMessages = () => {
277| throw new Error(
278| 'BridgeCoreParams.toSDKMessages not provided. Pass it if you use writeMessages() or initialMessages — daemon callers that only use writeSdkMessages() never hit this path.',
279| )
280| },
281| onAuth401,
282| getPollIntervalConfig = () => DEFAULT_POLL_CONFIG,
283| initialHistoryCap = 200,
284| initialMessages,
285| previouslyFlushedUUIDs,
286| onInboundMessage,
287| onPermissionResponse,
288| onInterrupt,
289| onSetModel,
290| onSetMaxThinkingTokens,
291| onSetPermissionMode,
292| onStateChange,
293| onUserMessage,
294| perpetual,
295| initialSSESequenceNum = 0,
296| } = params
源码引用: src/bridge/replBridge.ts · 第 318–370 行(共 2407 行)
318| // 5. Register bridge environment
319| const rawApi = createBridgeApiClient({
320| baseUrl,
321| getAccessToken,
322| runnerVersion: MACRO.VERSION,
323| onDebug: logForDebugging,
324| onAuth401,
325| getTrustedDeviceToken,
326| })
327| // Ant-only: interpose so /bridge-kick can inject poll/register/heartbeat
328| // failures. Zero cost in external builds (rawApi passes through unchanged).
329| const api =
330| process.env.USER_TYPE === 'ant' ? wrapApiForFaultInjection(rawApi) : rawApi
331|
332| const bridgeConfig: BridgeConfig = {
333| dir,
334| machineName,
335| branch,
336| gitRepoUrl,
337| maxSessions: 1,
338| spawnMode: 'single-session',
339| verbose: false,
340| sandbox: false,
341| bridgeId: randomUUID(),
342| workerType,
343| environmentId: randomUUID(),
344| reuseEnvironmentId: prior?.environmentId,
345| apiBaseUrl: baseUrl,
346| sessionIngressUrl,
347| }
348|
349| let environmentId: string
350| let environmentSecret: string
351| try {
352| const reg = await api.registerBridgeEnvironment(bridgeConfig)
353| environmentId = reg.environment_id
354| environmentSecret = reg.environment_secret
355| } catch (err) {
356| logBridgeSkip(
357| 'registration_failed',
358| `[bridge:repl] Environment registration failed: ${errorMessage(err)}`,
359| )
360| // Stale pointer may be the cause (expired/deleted env) — clear it so
361| // the next start doesn't retry the same dead ID.
362| if (prior) {
363| await clearBridgePointer(dir)
364| }
365| onStateChange?.('failed', errorMessage(err))
366| return null
367| }
368|
369| logForDebugging(`[bridge:repl] Environment registered: ${environmentId}`)
370| logForDiagnosticsNoPII('info', 'bridge_repl_env_registered')
源码引用: src/bridge/replBridge.ts · 第 244–246 行(共 2407 行)
244| const POLL_ERROR_INITIAL_DELAY_MS = 2_000
245| const POLL_ERROR_MAX_DELAY_MS = 60_000
246| const POLL_ERROR_GIVE_UP_MS = 15 * 60 * 1000
ReplBridgeTransport 抽象
ReplBridgeTransport 把 replBridge 对底层传输的依赖收敛为单一接口,便于 v1/v2 互换而不改 2000+ 行核心逻辑。
读路径:setOnData 收到 JSON 行 → handleIngressMessage;setOnClose 触发重连;connect() 启动。
写路径:write / writeBatch 发送 StdoutMessage 形状事件;v2 在 flush() 里 drain 队列再 close。
可观测性:getStateLabel、isConnectedStatus、droppedBatchCount(v1 Hybrid 在 maxConsecutiveFailures 时递增)。
v2 专属:reportState(requires_action 指示 web 等待权限)、reportMetadata、reportDelivery(CCR 处理时间戳列)。
createV1ReplTransport:对 HybridTransport 的薄包装,getLastSequenceNum 固定返回 0。
createV2ReplTransport:异步 registerWorker,JWT 的 session_id claim 与 worker role 由服务端校验;OAuth token 不能直接用于 v2 端点(与 v1 故意用 OAuth 相反)。
源码引用: src/bridge/replBridgeTransport.ts · 第 11–70 行(共 371 行)
11| /**
12| * Transport abstraction for replBridge. Covers exactly the surface that
13| * replBridge.ts uses against HybridTransport so the v1/v2 choice is
14| * confined to the construction site.
15| *
16| * - v1: HybridTransport (WS reads + POST writes to Session-Ingress)
17| * - v2: SSETransport (reads) + CCRClient (writes to CCR v2 /worker/*)
18| *
19| * The v2 write path goes through CCRClient.writeEvent → SerialBatchEventUploader,
20| * NOT through SSETransport.write() — SSETransport.write() targets the
21| * Session-Ingress POST URL shape, which is wrong for CCR v2.
22| */
23| export type ReplBridgeTransport = {
24| write(message: StdoutMessage): Promise<void>
25| writeBatch(messages: StdoutMessage[]): Promise<void>
26| close(): void
27| isConnectedStatus(): boolean
28| getStateLabel(): string
29| setOnData(callback: (data: string) => void): void
30| setOnClose(callback: (closeCode?: number) => void): void
31| setOnConnect(callback: () => void): void
32| connect(): void
33| /**
34| * High-water mark of the underlying read stream's event sequence numbers.
35| * replBridge reads this before swapping transports so the new one can
36| * resume from where the old one left off (otherwise the server replays
37| * the entire session history from seq 0).
38| *
39| * v1 returns 0 — Session-Ingress WS doesn't use SSE sequence numbers;
40| * replay-on-reconnect is handled by the server-side message cursor.
41| */
42| getLastSequenceNum(): number
43| /**
44| * Monotonic count of batches dropped via maxConsecutiveFailures.
45| * Snapshot before writeBatch() and compare after to detect silent drops
46| * (writeBatch() resolves normally even when batches were dropped).
47| * v2 returns 0 — the v2 write path doesn't set maxConsecutiveFailures.
48| */
49| readonly droppedBatchCount: number
50| /**
51| * PUT /worker state (v2 only; v1 is a no-op). `requires_action` tells
52| * the backend a permission prompt is pending — claude.ai shows the
53| * "waiting for input" indicator. REPL/daemon callers don't need this
54| * (user watches the REPL locally); multi-session worker callers do.
55| */
56| reportState(state: SessionState): void
57| /** PUT /worker external_metadata (v2 only; v1 is a no-op). */
58| reportMetadata(metadata: Record<string, unknown>): void
59| /**
60| * POST /worker/events/{id}/delivery (v2 only; v1 is a no-op). Populates
61| * CCR's processing_at/processed_at columns. `received` is auto-fired by
62| * CCRClient on every SSE frame and is not exposed here.
63| */
64| reportDelivery(eventId: string, status: 'processing' | 'processed'): void
65| /**
66| * Drain the write queue before close() (v2 only; v1 resolves
67| * immediately — HybridTransport POSTs are already awaited per-write).
68| */
69| flush(): Promise<void>
70| }
源码引用: src/bridge/replBridgeTransport.ts · 第 78–103 行(共 371 行)
78| export function createV1ReplTransport(
79| hybrid: HybridTransport,
80| ): ReplBridgeTransport {
81| return {
82| write: msg => hybrid.write(msg),
83| writeBatch: msgs => hybrid.writeBatch(msgs),
84| close: () => hybrid.close(),
85| isConnectedStatus: () => hybrid.isConnectedStatus(),
86| getStateLabel: () => hybrid.getStateLabel(),
87| setOnData: cb => hybrid.setOnData(cb),
88| setOnClose: cb => hybrid.setOnClose(cb),
89| setOnConnect: cb => hybrid.setOnConnect(cb),
90| connect: () => void hybrid.connect(),
91| // v1 Session-Ingress WS doesn't use SSE sequence numbers; replay
92| // semantics are different. Always return 0 so the seq-num carryover
93| // logic in replBridge is a no-op for v1.
94| getLastSequenceNum: () => 0,
95| get droppedBatchCount() {
96| return hybrid.droppedBatchCount
97| },
98| reportState: () => {},
99| reportMetadata: () => {},
100| reportDelivery: () => {},
101| flush: () => Promise.resolve(),
102| }
103| }
源码引用: src/bridge/replBridgeTransport.ts · 第 105–118 行(共 371 行)
105| /**
106| * v2 adapter: wrap SSETransport (reads) + CCRClient (writes, heartbeat,
107| * state, delivery tracking).
108| *
109| * Auth: v2 endpoints validate the JWT's session_id claim (register_worker.go:32)
110| * and worker role (environment_auth.py:856). OAuth tokens have neither.
111| * This is the inverse of the v1 replBridge path, which deliberately uses OAuth.
112| * The JWT is refreshed when the poll loop re-dispatches work — the caller
113| * invokes createV2ReplTransport again with the fresh token.
114| *
115| * Registration happens here (not in the caller) so the entire v2 handshake
116| * is one async step. registerWorker failure propagates — replBridge will
117| * catch it and stay on the poll loop.
118| */
全局句柄 replBridgeHandle
replBridgeHandle.ts 维护进程级 ReplBridgeHandle | null:
- setReplBridgeHandle(h):init 完成时由 useReplBridge 设置;teardown 时清 null。
- getReplBridgeHandle():工具、斜杠命令读取。
- getSelfBridgeCompatId():
toCompatSessionId(h.bridgeSessionId)写入 session 记录,供 peerSessions 本地优先去重。
设计理由与 bridgeDebug.ts 相同:句柄闭包捕获创建时的 sessionId 与 getAccessToken,外部自行拼 token 可能导致 staging/prod 漂移。
updateSessionBridgeId 在 set/clear 时异步调用,失败静默 catch——不阻塞主路径。
源码引用: src/bridge/replBridgeHandle.ts · 第 5–36 行(共 37 行)
5| /**
6| * Global pointer to the active REPL bridge handle, so callers outside
7| * useReplBridge's React tree (tools, slash commands) can invoke handle methods
8| * like subscribePR. Same one-bridge-per-process justification as bridgeDebug.ts
9| * — the handle's closure captures the sessionId and getAccessToken that created
10| * the session, and re-deriving those independently (BriefTool/upload.ts pattern)
11| * risks staging/prod token divergence.
12| *
13| * Set from useReplBridge.tsx when init completes; cleared on teardown.
14| */
15|
16| let handle: ReplBridgeHandle | null = null
17|
18| export function setReplBridgeHandle(h: ReplBridgeHandle | null): void {
19| handle = h
20| // Publish (or clear) our bridge session ID in the session record so other
21| // local peers can dedup us out of their bridge list — local is preferred.
22| void updateSessionBridgeId(getSelfBridgeCompatId() ?? null).catch(() => {})
23| }
24|
25| export function getReplBridgeHandle(): ReplBridgeHandle | null {
26| return handle
27| }
28|
29| /**
30| * Our own bridge session ID in the session_* compat format the API returns
31| * in /v1/sessions responses — or undefined if bridge isn't connected.
32| */
33| export function getSelfBridgeCompatId(): string | undefined {
34| const h = getReplBridgeHandle()
35| return h ? toCompatSessionId(h.bridgeSessionId) : undefined
36| }
与 commands / hooks 的衔接
commands.ts 中 BRIDGE_SAFE_COMMANDS 与 isBridgeSafeCommand 决定手机端能否触发本地斜杠:local-jsx(如 /model)被阻断,prompt 可展开,local 须在白名单(compact、clear、cost 等)。
/remote-control 命令(commands/bridge/)调用 initReplBridge,传入 initialMessages、onInboundMessage 等回调,把 web 输入注入 REPL 队列。
useReplBridge(hooks)在 mount 时 dynamic import initReplBridge,连接 onSetModel、onSetPermissionMode 等到 AppState;失败次数达 MAX_CONSECUTIVE_INIT_FAILURES 后停止重试。
print.ts SDK enableRemoteControl 路径无完整 messages 数组,count-3 标题推导退化为单条文本;仍可 outboundOnly 镜像会话到 claude.ai。
源码引用: src/bridge/initReplBridge.ts · 第 1–14 行(共 570 行)
1| /**
2| * REPL-specific wrapper around initBridgeCore. Owns the parts that read
3| * bootstrap state — gates, cwd, session ID, git context, OAuth, title
4| * derivation — then delegates to the bootstrap-free core.
5| *
6| * Split out of replBridge.ts because the sessionStorage import
7| * (getCurrentSessionTitle) transitively pulls in src/commands.ts → the
8| * entire slash command + React component tree (~1300 modules). Keeping
9| * initBridgeCore in a file that doesn't touch sessionStorage lets
10| * daemonBridge.ts import the core without bloating the Agent SDK bundle.
11| *
12| * Called via dynamic import by useReplBridge (auto-start) and print.ts
13| * (SDK -p mode via query.enableRemoteControl).
14| */
源码引用: src/bridge/replBridge.ts · 第 1–39 行(共 2407 行)
1| // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
2| import { randomUUID } from 'crypto'
3| import {
4| createBridgeApiClient,
5| BridgeFatalError,
6| isExpiredErrorType,
7| isSuppressible403,
8| } from './bridgeApi.js'
9| import type { BridgeConfig, BridgeApiClient } from './types.js'
10| import { logForDebugging } from '../utils/debug.js'
11| import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
12| import {
13| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
14| logEvent,
15| } from '../services/analytics/index.js'
16| import { registerCleanup } from '../utils/cleanupRegistry.js'
17| import {
18| handleIngressMessage,
19| handleServerControlRequest,
20| makeResultMessage,
21| isEligibleBridgeMessage,
22| extractTitleText,
23| BoundedUUIDSet,
24| } from './bridgeMessaging.js'
25| import {
26| decodeWorkSecret,
27| buildSdkUrl,
28| buildCCRv2SdkUrl,
29| sameSessionId,
30| } from './workSecret.js'
31| import { toCompatSessionId, toInfraSessionId } from './sessionIdCompat.js'
32| import { updateSessionBridgeId } from '../utils/concurrentSessions.js'
33| import { getTrustedDeviceToken } from './trustedDevice.js'
34| import { HybridTransport } from '../cli/transports/HybridTransport.js'
35| import {
36| type ReplBridgeTransport,
37| createV1ReplTransport,
38| createV2ReplTransport,
39| } from './replBridgeTransport.js'
调试清单
| 现象 | 优先查 |
|---|---|
| 桥从不启动 | initReplBridge 各 logBridgeSkip 原因;GrowthBook bridge flags |
| 连上后立刻 failed | registerBridgeEnvironment 4xx;BridgeFatalError.errorType |
| 手机消息重复 | recentInboundUUIDs / SSE seq 未携带 |
| 历史 flush 后乱序 | FlushGate 是否在 initial flush 期间 enqueue |
| 401 风暴 | checkAndRefreshOAuthTokenIfNeeded;bridgeOauthDeadExpiresAt 计数 |
| v2 切换失败 | createV2ReplTransport registerWorker;trusted device 头 |
日志前缀:[bridge:repl]、[bridge:api]、[bridge:transport]。Analytics:tengu_bridge_repl_env_registered、tengu_bridge_message_received 等。
本章小结与延伸
repl-bridge = REPL 侧远程控制的发动机。下一章 bridge-messaging,读 ingress 解析、出站过滤与 FlushGate 排队。 继续学习: