mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +00:00
* feat: restore pipe IPC, LAN pipes, monitor tool, and PR-package features Core IPC system (UDS_INBOX): - PipeServer/PipeClient with UDS + TCP dual transport, NDJSON protocol - PipeRegistry: machineId-based role assignment, file locking - Master/slave attach, prompt relay, permission forwarding - Heartbeat lifecycle with parallel isPipeAlive probes - Commands: /pipes, /attach, /detach, /send, /claim-main, /pipe-status LAN Pipes (LAN_PIPES): - UDP multicast beacon (224.0.71.67:7101) for zero-config LAN discovery - PipeServer TCP listener, PipeClient TCP connect mode - Heartbeat auto-attaches LAN peers via TCP - Cross-machine attach allowed regardless of role - /pipes shows [LAN] peers with role + hostname/IP - SendMessageTool supports tcp: scheme with user consent Architecture — extracted hooks from REPL.tsx (~830 lines → ~20 lines): - usePipeIpc: lifecycle (bootstrap, handlers, heartbeat, cleanup) - usePipeRelay: slave→master message relay via module singleton - usePipePermissionForward: permission request/cancel forwarding - usePipeRouter: selected pipe input routing with role+IP labels - Shared ndjsonFramer.ts replaces 3 duplicate NDJSON parsers Key fixes applied during development: - Multicast binds to correct LAN interface (not WSL/Docker) - Beacon ref stored as module singleton (not Zustand state mutation) - Heartbeat preserves LAN peers in discoveredPipes and selectedPipes - Disconnect handler calls removeSlaveClient (fixes listener leak) - cleanupStaleEntries probes without lock, writes briefly under lock - getMachineId uses async execFile (not blocking execSync) - globalThis.__pipeSendToMaster replaced with setPipeRelay singleton - M key only toggles route mode when selector panel is expanded - User prompt displayed in message list on pipe broadcast - Broadcast notifications show [role] + hostname/IP for LAN peers Other restored features: - Monitor tool: /monitor command, MonitorTool, MonitorMcpTask lifecycle - Daemon supervisor and remoteControlServer command - Tools: SnipTool, SleepTool, ListPeersTool, SendUserFileTool, WebBrowserTool, WorkflowTool, and 10+ stub→implementation rewrites - Feature flags: UDS_INBOX, LAN_PIPES, MONITOR_TOOL, FORK_SUBAGENT, KAIROS, COORDINATOR_MODE, WORKFLOW_SCRIPTS, HISTORY_SNIP Tests: 2190 pass / 0 fail (15 new: lanBeacon 7, peerAddress 8) * fix: resolve merge conflicts and fix all tsc/test errors after main merge - Export ToolResultBlockParam from Tool.ts (14 tool files fixed) - Migrate ink imports from ../../ink.js to @anthropic/ink (7 files) - Fix toolUseID → toolUseId typo in monitor.ts and MonitorTool.tsx - Add fallback values for string|undefined type errors (8 locations) - Fix AppState type in assistant.ts, add NewInstallWizard stubs - Fix ParsedRepository.repo → .name in subscribe-pr.ts - Fix AgentId/string type mismatch in BackgroundTasksDialog.tsx - Fix PipeRelayFn return type in pipePermissionRelay.ts - Use PipeMessage type in usePipeRelay.ts - Fix lanBeacon.test.ts mock type assertions - Create missing MouseActionEvent class for ink package - Use ansi: color format instead of bare "green"/"red" - Resolve theme.permission access via getTheme() Result: 0 tsc errors, 2496 tests pass, 0 fail Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 恢复 /poor 的说明 --------- Co-authored-by: unraid <local@unraid.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
206 lines
5.7 KiB
TypeScript
206 lines
5.7 KiB
TypeScript
/**
|
|
* LAN Beacon — UDP multicast peer discovery for Pipes system.
|
|
*
|
|
* Uses multicast group 224.0.71.67 ("CC" = Claude Code ASCII) on port 7101
|
|
* to announce and discover CLI instances on the local network.
|
|
*
|
|
* Feature-gated by LAN_PIPES.
|
|
*/
|
|
|
|
import { createSocket, type Socket as DgramSocket } from 'dgram'
|
|
import { EventEmitter } from 'events'
|
|
import { logError } from './log.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MULTICAST_GROUP = '224.0.71.67'
|
|
const MULTICAST_PORT = 7101
|
|
const ANNOUNCE_INTERVAL_MS = 3000
|
|
const PEER_TIMEOUT_MS = 15000
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type LanAnnounce = {
|
|
proto: 'claude-pipe-v1'
|
|
pipeName: string
|
|
machineId: string
|
|
hostname: string
|
|
ip: string
|
|
tcpPort: number
|
|
role: 'main' | 'sub'
|
|
ts: number
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LanBeacon
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Module-level singleton — avoids (state as any)._lanBeacon hack
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let _lanBeaconInstance: LanBeacon | null = null
|
|
|
|
export function getLanBeacon(): LanBeacon | null {
|
|
return _lanBeaconInstance
|
|
}
|
|
|
|
export function setLanBeacon(instance: LanBeacon | null): void {
|
|
_lanBeaconInstance = instance
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LanBeacon class
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class LanBeacon extends EventEmitter {
|
|
private socket: DgramSocket | null = null
|
|
private announceTimer: ReturnType<typeof setInterval> | null = null
|
|
private cleanupTimer: ReturnType<typeof setInterval> | null = null
|
|
private peers: Map<string, LanAnnounce> = new Map()
|
|
private announce: LanAnnounce
|
|
|
|
constructor(announce: Omit<LanAnnounce, 'proto' | 'ts'>) {
|
|
super()
|
|
this.announce = {
|
|
...announce,
|
|
proto: 'claude-pipe-v1',
|
|
ts: Date.now(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start broadcasting announcements and listening for peers.
|
|
*/
|
|
start(): void {
|
|
if (this.socket) return
|
|
|
|
try {
|
|
this.socket = createSocket({ type: 'udp4', reuseAddr: true })
|
|
|
|
this.socket.on('error', err => {
|
|
logError(err)
|
|
// Non-fatal — multicast may not be supported on this network
|
|
})
|
|
|
|
this.socket.on('message', (buf, rinfo) => {
|
|
try {
|
|
const msg = JSON.parse(buf.toString()) as LanAnnounce
|
|
if (msg.proto !== 'claude-pipe-v1') return
|
|
if (msg.pipeName === this.announce.pipeName) return // ignore self
|
|
|
|
const isNew = !this.peers.has(msg.pipeName)
|
|
this.peers.set(msg.pipeName, { ...msg, ts: Date.now() })
|
|
|
|
if (isNew) {
|
|
this.emit('peer-discovered', msg)
|
|
}
|
|
} catch {
|
|
// Malformed packet — ignore
|
|
}
|
|
})
|
|
|
|
this.socket.bind(MULTICAST_PORT, () => {
|
|
try {
|
|
// Specify the local LAN interface for multicast membership.
|
|
// Without this, Windows may bind to a WSL/Docker virtual adapter
|
|
// and multicast packets never reach the real LAN.
|
|
const localIp = this.announce.ip
|
|
this.socket!.addMembership(MULTICAST_GROUP, localIp)
|
|
this.socket!.setMulticastInterface(localIp)
|
|
this.socket!.setMulticastTTL(1) // link-local only
|
|
this.socket!.setBroadcast(true)
|
|
} catch (err) {
|
|
logError(err as Error)
|
|
}
|
|
|
|
// Start announce + cleanup timers after socket is fully bound
|
|
this.announceTimer = setInterval(
|
|
() => this.sendAnnounce(),
|
|
ANNOUNCE_INTERVAL_MS,
|
|
)
|
|
// Send first announce immediately
|
|
this.sendAnnounce()
|
|
|
|
// Periodic cleanup of stale peers
|
|
this.cleanupTimer = setInterval(
|
|
() => this.cleanupStalePeers(),
|
|
PEER_TIMEOUT_MS / 2,
|
|
)
|
|
})
|
|
} catch (err) {
|
|
logError(err as Error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop broadcasting and close the socket.
|
|
*/
|
|
stop(): void {
|
|
if (this.announceTimer) {
|
|
clearInterval(this.announceTimer)
|
|
this.announceTimer = null
|
|
}
|
|
if (this.cleanupTimer) {
|
|
clearInterval(this.cleanupTimer)
|
|
this.cleanupTimer = null
|
|
}
|
|
if (this.socket) {
|
|
try {
|
|
this.socket.dropMembership(MULTICAST_GROUP)
|
|
} catch {
|
|
// May fail if socket already closed
|
|
}
|
|
this.socket.close()
|
|
this.socket = null
|
|
}
|
|
this.peers.clear()
|
|
}
|
|
|
|
/**
|
|
* Get all currently known peers (excluding self).
|
|
*/
|
|
getPeers(): Map<string, LanAnnounce> {
|
|
return new Map(this.peers)
|
|
}
|
|
|
|
/**
|
|
* Update the announce data (e.g., when role changes).
|
|
*/
|
|
updateAnnounce(partial: Partial<Omit<LanAnnounce, 'proto' | 'ts'>>): void {
|
|
this.announce = { ...this.announce, ...partial }
|
|
}
|
|
|
|
private sendAnnounce(): void {
|
|
if (!this.socket) return
|
|
try {
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ ...this.announce, ts: Date.now() }),
|
|
)
|
|
this.socket.send(
|
|
payload,
|
|
0,
|
|
payload.length,
|
|
MULTICAST_PORT,
|
|
MULTICAST_GROUP,
|
|
)
|
|
} catch {
|
|
// Send failure — non-fatal
|
|
}
|
|
}
|
|
|
|
private cleanupStalePeers(): void {
|
|
const now = Date.now()
|
|
for (const [name, peer] of this.peers) {
|
|
if (now - peer.ts > PEER_TIMEOUT_MS) {
|
|
this.peers.delete(name)
|
|
this.emit('peer-lost', name)
|
|
}
|
|
}
|
|
}
|
|
}
|