mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
将 lazy require() 调用全部替换为静态 import,解决构建产物中 模块加载时序问题导致的 'undefined is not an object' 错误。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
621 lines
18 KiB
TypeScript
621 lines
18 KiB
TypeScript
/**
|
|
* usePipeIpc — Pipe IPC lifecycle hook.
|
|
*
|
|
* Extracted from REPL.tsx's 575-line inline useEffect. Manages:
|
|
* 1. Server creation (UDS + optional TCP for LAN)
|
|
* 2. LAN beacon startup
|
|
* 3. Message handlers (ping, attach, prompt, permission, detach)
|
|
* 4. Heartbeat loop (main: auto-attach + cleanup; sub: detect main alive)
|
|
* 5. Cleanup on unmount
|
|
*
|
|
* Feature-gated by UDS_INBOX. LAN extensions gated by LAN_PIPES.
|
|
*/
|
|
import { feature } from 'bun:bundle'
|
|
import { useEffect } from 'react'
|
|
import * as pt from '../utils/pipeTransport.js'
|
|
import * as pr from '../utils/pipeRegistry.js'
|
|
import * as mm from './useMasterMonitor.js'
|
|
import { getSessionId as _getSessionId } from '../bootstrap/state.js'
|
|
import * as lb from '../utils/lanBeacon.js'
|
|
import * as pp from '../utils/pipePermissionRelay.js'
|
|
import * as osm from 'os'
|
|
import type {
|
|
PipeMessage,
|
|
PipeServer,
|
|
PipeIpcState,
|
|
} from '../utils/pipeTransport.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type StoreApi = {
|
|
getState: () => any
|
|
setState: (updater: (prev: any) => any) => void
|
|
}
|
|
|
|
export type UsePipeIpcOptions = {
|
|
store: StoreApi
|
|
handleIncomingPrompt: (content: string) => boolean
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: remove a dead slave from registry + state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function removeDeadSlave(slaveName: string, store: StoreApi): void {
|
|
mm.removeSlaveClient(slaveName)
|
|
store.setState((prev: any) => {
|
|
const pipeIpc = pt.getPipeIpc(prev)
|
|
const { [slaveName]: _removed, ...remainingSlaves } = pipeIpc.slaves
|
|
return {
|
|
...prev,
|
|
pipeIpc: {
|
|
...pipeIpc,
|
|
role: Object.keys(remainingSlaves).length > 0 ? 'master' : 'main',
|
|
displayRole:
|
|
Object.keys(remainingSlaves).length > 0 ? 'master' : 'main',
|
|
slaves: remainingSlaves,
|
|
selectedPipes: (pipeIpc.selectedPipes ?? []).filter(
|
|
(name: string) => name !== slaveName,
|
|
),
|
|
discoveredPipes: (pipeIpc.discoveredPipes ?? []).filter(
|
|
(pipe: { pipeName: string }) => pipe.pipeName !== slaveName,
|
|
),
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: refresh discovered pipes (local subs + LAN peers)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function refreshDiscoveredPipes(
|
|
pipeName: string,
|
|
aliveSubs: Array<{
|
|
id: string
|
|
pipeName: string
|
|
subIndex: number
|
|
machineId: string
|
|
ip: string
|
|
hostname: string
|
|
}>,
|
|
store: StoreApi,
|
|
): void {
|
|
const freshDiscovered = aliveSubs
|
|
.filter(sub => sub.pipeName !== pipeName)
|
|
.map(sub => ({
|
|
id: sub.id,
|
|
pipeName: sub.pipeName,
|
|
role: `sub-${sub.subIndex}`,
|
|
machineId: sub.machineId,
|
|
ip: sub.ip,
|
|
hostname: sub.hostname,
|
|
alive: true,
|
|
}))
|
|
|
|
// Include LAN beacon peers so they aren't wiped out by heartbeat
|
|
let lanDiscovered: typeof freshDiscovered = []
|
|
if (feature('LAN_PIPES')) {
|
|
const beacon = lb.getLanBeacon()
|
|
if (beacon) {
|
|
const localNames = new Set(freshDiscovered.map(p => p.pipeName))
|
|
localNames.add(pipeName)
|
|
for (const [pName, peer] of beacon.getPeers()) {
|
|
if (!localNames.has(pName)) {
|
|
lanDiscovered.push({
|
|
id: `lan-${pName}`,
|
|
pipeName: pName,
|
|
role: peer.role,
|
|
machineId: peer.machineId,
|
|
ip: peer.ip,
|
|
hostname: peer.hostname,
|
|
alive: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const allDiscovered = [...freshDiscovered, ...lanDiscovered]
|
|
|
|
// Only update state if the list actually changed
|
|
const prev = pt.getPipeIpc(store.getState())
|
|
const prevNames = (prev.discoveredPipes ?? [])
|
|
.map((p: any) => p.pipeName)
|
|
.join(',')
|
|
const newNames = allDiscovered.map(p => p.pipeName).join(',')
|
|
if (prevNames === newNames) return
|
|
|
|
store.setState((prev: any) => {
|
|
const pipeIpc = pt.getPipeIpc(prev)
|
|
const aliveNames = new Set(allDiscovered.map(pipe => pipe.pipeName))
|
|
return {
|
|
...prev,
|
|
pipeIpc: {
|
|
...pipeIpc,
|
|
discoveredPipes: allDiscovered,
|
|
selectedPipes: (pipeIpc.selectedPipes ?? []).filter((name: string) =>
|
|
aliveNames.has(name),
|
|
),
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase: Register message handlers on server
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function registerMessageHandlers(
|
|
server: PipeServer,
|
|
pipeName: string,
|
|
machineId: string,
|
|
store: StoreApi,
|
|
handleIncomingPrompt: (content: string) => boolean,
|
|
): void {
|
|
// Auto-reply pings for health checks
|
|
server.onMessage((msg: PipeMessage, reply) => {
|
|
if (msg.type === 'ping') reply({ type: 'pong' })
|
|
})
|
|
|
|
// Handle attach requests
|
|
server.onMessage((msg: PipeMessage, reply) => {
|
|
if (msg.type !== 'attach_request') return
|
|
const state = store.getState()
|
|
const currentPipeState = pt.getPipeIpc(state)
|
|
if (pt.isPipeControlled(currentPipeState)) {
|
|
reply({ type: 'attach_reject', data: 'Already controlled' })
|
|
return
|
|
}
|
|
// Allow LAN peers (different machineId) to attach regardless of role.
|
|
const isLanPeer = msg.meta?.machineId && msg.meta.machineId !== machineId
|
|
if (!isLanPeer && currentPipeState.role !== 'sub') {
|
|
reply({
|
|
type: 'attach_reject',
|
|
data: 'Only sub sessions can be attached.',
|
|
})
|
|
return
|
|
}
|
|
reply({ type: 'attach_accept' })
|
|
|
|
const clients = Array.from((server as any).clients as Set<any>)
|
|
const masterSocket = clients[clients.length - 1]
|
|
pp.setPipeRelay((relayMsg: any) => {
|
|
if (masterSocket && !masterSocket.destroyed) {
|
|
relayMsg.from = relayMsg.from ?? pipeName
|
|
relayMsg.ts = relayMsg.ts ?? new Date().toISOString()
|
|
masterSocket.write(JSON.stringify(relayMsg) + '\n')
|
|
}
|
|
})
|
|
|
|
store.setState((prev: any) => ({
|
|
...prev,
|
|
pipeIpc: {
|
|
...pt.getPipeIpc(prev),
|
|
role: 'sub',
|
|
displayRole: pt.getPipeDisplayRole(pt.getPipeIpc(prev)),
|
|
attachedBy: msg.from ?? 'unknown',
|
|
},
|
|
}))
|
|
})
|
|
|
|
// Handle prompts from master
|
|
server.onMessage((msg: PipeMessage, reply) => {
|
|
if (msg.type === 'prompt' && msg.data) {
|
|
const accepted = handleIncomingPrompt(msg.data)
|
|
if (accepted) {
|
|
reply({ type: 'prompt_ack', data: 'accepted' })
|
|
} else {
|
|
reply({
|
|
type: 'error',
|
|
data: 'Slave is busy and could not accept the prompt.',
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
// Handle permission decisions from master
|
|
server.onMessage((msg: PipeMessage, _reply) => {
|
|
if (msg.type !== 'permission_response' && msg.type !== 'permission_cancel')
|
|
return
|
|
const { resolvePipePermissionResponse, cancelPipePermissionRequest } = pp
|
|
|
|
try {
|
|
const payload = msg.data ? JSON.parse(msg.data) : undefined
|
|
if (!payload?.requestId) return
|
|
if (msg.type === 'permission_response') {
|
|
resolvePipePermissionResponse(payload)
|
|
} else {
|
|
cancelPipePermissionRequest(payload.requestId, payload.reason)
|
|
}
|
|
} catch {
|
|
// Malformed — ignore
|
|
}
|
|
})
|
|
|
|
// Handle relay mute/unmute from master
|
|
server.onMessage((msg: PipeMessage, _reply) => {
|
|
if (msg.type === 'relay_mute') {
|
|
pp.setRelayMuted(true)
|
|
} else if (msg.type === 'relay_unmute') {
|
|
pp.setRelayMuted(false)
|
|
}
|
|
})
|
|
|
|
// Handle detach
|
|
server.onMessage((msg: PipeMessage, _reply) => {
|
|
if (msg.type !== 'detach') return
|
|
const { clearPendingPipePermissions } = pp
|
|
clearPendingPipePermissions('Pipe detached before permission was resolved.')
|
|
pp.setPipeRelay(null)
|
|
store.setState((prev: any) => ({
|
|
...prev,
|
|
pipeIpc: (() => {
|
|
const pipeIpc = pt.getPipeIpc(prev)
|
|
const nextRole = pipeIpc.subIndex != null ? 'sub' : 'main'
|
|
const nextPipeState = { ...pipeIpc, role: nextRole, attachedBy: null }
|
|
return {
|
|
...nextPipeState,
|
|
displayRole: pt.getPipeDisplayRole(nextPipeState as PipeIpcState),
|
|
}
|
|
})(),
|
|
}))
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase: Heartbeat
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function runMainHeartbeat(
|
|
pipeName: string,
|
|
machineId: string,
|
|
store: StoreApi,
|
|
disposed: { current: boolean },
|
|
): void {
|
|
void (async () => {
|
|
try {
|
|
await pr.cleanupStaleEntries()
|
|
const aliveSubs = await pr.getAliveSubs()
|
|
refreshDiscoveredPipes(pipeName, aliveSubs, store)
|
|
|
|
const connectedSlaves = mm.getAllSlaveClients()
|
|
const aliveSubNames = new Set(aliveSubs.map(sub => sub.pipeName))
|
|
|
|
// Build unified attach target list: local subs + LAN peers
|
|
type AttachTarget = {
|
|
pipeName: string
|
|
tcpEndpoint?: { host: string; port: number }
|
|
}
|
|
const attachTargets: AttachTarget[] = aliveSubs.map(sub => ({
|
|
pipeName: sub.pipeName,
|
|
}))
|
|
|
|
// Add LAN peers as attach targets
|
|
if (feature('LAN_PIPES')) {
|
|
const beacon = lb.getLanBeacon()
|
|
if (beacon) {
|
|
const localNames = new Set(attachTargets.map(t => t.pipeName))
|
|
localNames.add(pipeName)
|
|
for (const [pName, peer] of beacon.getPeers()) {
|
|
if (!localNames.has(pName)) {
|
|
attachTargets.push({
|
|
pipeName: pName,
|
|
tcpEndpoint: { host: peer.ip, port: peer.tcpPort },
|
|
})
|
|
aliveSubNames.add(pName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const currentPipeState = pt.getPipeIpc(store.getState())
|
|
|
|
for (const target of attachTargets) {
|
|
if (target.pipeName === pipeName) continue
|
|
if (connectedSlaves.has(target.pipeName)) continue
|
|
|
|
try {
|
|
const myName = currentPipeState.serverName ?? pipeName
|
|
const client = await pt.connectToPipe(
|
|
target.pipeName,
|
|
myName,
|
|
3000,
|
|
target.tcpEndpoint,
|
|
)
|
|
|
|
const attached = await new Promise<boolean>(resolve => {
|
|
const timeout = setTimeout(() => {
|
|
client.disconnect()
|
|
resolve(false)
|
|
}, 3000)
|
|
|
|
client.onMessage((msg: any) => {
|
|
if (msg.type === 'attach_accept') {
|
|
clearTimeout(timeout)
|
|
resolve(true)
|
|
} else if (msg.type === 'attach_reject') {
|
|
clearTimeout(timeout)
|
|
client.disconnect()
|
|
resolve(false)
|
|
}
|
|
})
|
|
|
|
client.send({
|
|
type: 'attach_request',
|
|
meta: { machineId },
|
|
})
|
|
})
|
|
|
|
if (attached && !disposed.current) {
|
|
mm.addSlaveClient(target.pipeName, client)
|
|
|
|
client.on('disconnect', () => {
|
|
removeDeadSlave(target.pipeName, store)
|
|
})
|
|
|
|
store.setState((prev: any) => ({
|
|
...prev,
|
|
pipeIpc: {
|
|
...pt.getPipeIpc(prev),
|
|
role: 'master',
|
|
displayRole: 'master',
|
|
slaves: {
|
|
...pt.getPipeIpc(prev).slaves,
|
|
[target.pipeName]: {
|
|
name: target.pipeName,
|
|
connectedAt: new Date().toISOString(),
|
|
status: 'idle',
|
|
unreadCount: 0,
|
|
history: [],
|
|
},
|
|
},
|
|
},
|
|
}))
|
|
}
|
|
} catch {
|
|
// Connection failed — skip this cycle
|
|
}
|
|
}
|
|
|
|
// Clean up slaves that are no longer alive
|
|
let lanPeerNames: Set<string> | null = null
|
|
if (feature('LAN_PIPES')) {
|
|
const beacon = lb.getLanBeacon()
|
|
if (beacon) {
|
|
lanPeerNames = new Set(beacon.getPeers().keys())
|
|
}
|
|
}
|
|
for (const [slaveName, client] of connectedSlaves.entries()) {
|
|
const inLocalRegistry = aliveSubNames.has(slaveName)
|
|
const inLanBeacon = lanPeerNames?.has(slaveName) ?? false
|
|
if (!client.connected || (!inLocalRegistry && !inLanBeacon)) {
|
|
removeDeadSlave(slaveName, store)
|
|
}
|
|
}
|
|
} catch {
|
|
// Heartbeat cycle error — non-fatal
|
|
}
|
|
})()
|
|
}
|
|
|
|
function runSubHeartbeat(
|
|
pipeName: string,
|
|
machineId: string,
|
|
entry: any,
|
|
store: StoreApi,
|
|
disposed: { current: boolean },
|
|
): void {
|
|
void (async () => {
|
|
try {
|
|
const mainAlive = await pr.isMainAlive()
|
|
if (!mainAlive && !disposed.current) {
|
|
const registry = await pr.readRegistry()
|
|
const isSameMachine = pr.isMainMachine(machineId, registry)
|
|
|
|
if (isSameMachine) {
|
|
await pr.registerAsMain(entry)
|
|
} else {
|
|
await pr.revertToIndependent(pipeName)
|
|
}
|
|
|
|
store.setState((prev: any) => ({
|
|
...prev,
|
|
pipeIpc: {
|
|
...pt.getPipeIpc(prev),
|
|
role: 'main',
|
|
subIndex: null,
|
|
displayRole: 'main',
|
|
attachedBy: null,
|
|
},
|
|
}))
|
|
pp.setPipeRelay(null)
|
|
}
|
|
} catch {
|
|
// Heartbeat check error — non-fatal
|
|
}
|
|
})()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main hook
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function usePipeIpc({
|
|
store,
|
|
handleIncomingPrompt,
|
|
}: UsePipeIpcOptions): void {
|
|
if (!feature('UDS_INBOX')) return
|
|
|
|
useEffect(() => {
|
|
const sessionId = _getSessionId()
|
|
if (!sessionId) return
|
|
const pipeName = `cli-${sessionId.slice(0, 8)}`
|
|
const disposed = { current: false }
|
|
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
let heartbeatBusy = false
|
|
let pipeServer: PipeServer | null = null
|
|
|
|
void (async () => {
|
|
try {
|
|
// --- Phase 1: Role determination ---
|
|
const machId = await pr.getMachineId()
|
|
const mac = pr.getMacAddress()
|
|
const localIp = pt.getLocalIp()
|
|
const host = osm.hostname()
|
|
const roleResult = await pr.determineRole(machId)
|
|
|
|
const entry = {
|
|
id: pipeName,
|
|
pid: process.pid,
|
|
machineId: machId,
|
|
startedAt: Date.now(),
|
|
ip: localIp,
|
|
mac,
|
|
hostname: host,
|
|
pipeName,
|
|
}
|
|
|
|
let initialRole: 'main' | 'sub' = 'main'
|
|
let subIndex: number | null = null
|
|
let displayRole = 'main'
|
|
|
|
if (roleResult.role === 'main' || roleResult.role === 'main-recover') {
|
|
await pr.registerAsMain(entry)
|
|
} else {
|
|
subIndex = roleResult.subIndex
|
|
await pr.registerAsSub(entry, subIndex)
|
|
initialRole = 'sub'
|
|
displayRole = `sub-${subIndex}`
|
|
}
|
|
|
|
// --- Phase 2: Server creation ---
|
|
const server = await pt.createPipeServer(
|
|
pipeName,
|
|
feature('LAN_PIPES') ? { enableTcp: true, tcpPort: 0 } : undefined,
|
|
)
|
|
pipeServer = server
|
|
if (disposed.current) {
|
|
await server.close()
|
|
await pr.unregister(pipeName)
|
|
return
|
|
}
|
|
|
|
// --- Phase 3: LAN beacon ---
|
|
if (feature('LAN_PIPES') && server.tcpAddress) {
|
|
const beacon = new (lb.LanBeacon)({
|
|
pipeName,
|
|
machineId: machId,
|
|
hostname: host,
|
|
ip: localIp,
|
|
tcpPort: server.tcpAddress.port,
|
|
role: initialRole,
|
|
})
|
|
beacon.start()
|
|
lb.setLanBeacon(beacon)
|
|
|
|
const entryWithTcp = {
|
|
...entry,
|
|
tcpPort: server.tcpAddress.port,
|
|
lanVisible: true,
|
|
}
|
|
if (initialRole === 'main') {
|
|
await pr.registerAsMain(entryWithTcp)
|
|
} else if (subIndex != null) {
|
|
await pr.registerAsSub(entryWithTcp, subIndex)
|
|
}
|
|
}
|
|
|
|
// Update store
|
|
store.setState((prev: any) => ({
|
|
...prev,
|
|
pipeIpc: {
|
|
...pt.getPipeIpc(prev),
|
|
serverName: pipeName,
|
|
role: initialRole,
|
|
subIndex,
|
|
displayRole,
|
|
localIp,
|
|
hostname: host,
|
|
machineId: machId,
|
|
mac,
|
|
},
|
|
}))
|
|
|
|
// --- Phase 4: Message handlers ---
|
|
registerMessageHandlers(
|
|
server,
|
|
pipeName,
|
|
machId,
|
|
store,
|
|
handleIncomingPrompt,
|
|
)
|
|
|
|
// --- Phase 5: Heartbeat ---
|
|
const HEARTBEAT_INTERVAL_MS = 5000
|
|
|
|
heartbeatTimer = setInterval(() => {
|
|
if (disposed.current || heartbeatBusy) return
|
|
heartbeatBusy = true
|
|
|
|
const currentPipeState = pt.getPipeIpc(store.getState())
|
|
|
|
if (
|
|
currentPipeState.role === 'main' ||
|
|
currentPipeState.role === 'master'
|
|
) {
|
|
runMainHeartbeat(pipeName, machId, store, disposed)
|
|
} else if (currentPipeState.role === 'sub') {
|
|
runSubHeartbeat(pipeName, machId, entry, store, disposed)
|
|
}
|
|
|
|
// Reset busy flag after a short delay to allow the async work to settle
|
|
setTimeout(() => {
|
|
heartbeatBusy = false
|
|
}, 4000)
|
|
}, HEARTBEAT_INTERVAL_MS)
|
|
} catch {
|
|
// PipeServer creation failed — non-fatal
|
|
}
|
|
})()
|
|
|
|
// --- Phase 6: Cleanup ---
|
|
return () => {
|
|
disposed.current = true
|
|
if (heartbeatTimer) {
|
|
clearInterval(heartbeatTimer)
|
|
heartbeatTimer = null
|
|
}
|
|
|
|
// Send detach to all slaves
|
|
const allClients = mm.getAllSlaveClients()
|
|
for (const [name, client] of allClients.entries()) {
|
|
try {
|
|
client.send({ type: 'detach' })
|
|
} catch {}
|
|
client.disconnect()
|
|
removeDeadSlave(name, store)
|
|
}
|
|
|
|
// Stop LAN beacon
|
|
const beacon = lb.getLanBeacon()
|
|
if (beacon) {
|
|
try {
|
|
beacon.stop()
|
|
} catch {}
|
|
lb.setLanBeacon(null)
|
|
}
|
|
|
|
// Unregister + close server
|
|
pr.unregister(pipeName).catch(() => {})
|
|
if (pipeServer) {
|
|
void pipeServer.close().catch(() => {})
|
|
pipeServer = null
|
|
}
|
|
pp.setPipeRelay(null)
|
|
}
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
}
|