Files
claude-code/src/utils/pipeRegistry.ts
claude-code-best 09fc515edb feat: 远程群控 (#243)
* 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>
2026-04-11 23:22:55 +08:00

522 lines
14 KiB
TypeScript

/**
* Pipe Registry — central registry for multi-instance pipe coordination.
*
* Manages a shared registry.json that tracks all CLI instances (main + subs).
* Main role is bound to machineId (OS-level stable fingerprint), not to
* instance startup order.
*
* File locking prevents race conditions when multiple instances start
* simultaneously.
*/
import { readFile, writeFile, unlink, mkdir } from 'fs/promises'
import { join } from 'path'
import { createHash } from 'crypto'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { isPipeAlive, getPipesDir } from './pipeTransport.js'
import type { TcpEndpoint } from './pipeTransport.js'
import type { LanAnnounce } from './lanBeacon.js'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PipeRegistryEntry {
id: string
pid: number
machineId: string
startedAt: number
ip: string
mac: string
hostname: string
pipeName: string
tcpPort?: number
lanVisible?: boolean
}
export interface PipeRegistrySub extends PipeRegistryEntry {
subIndex: number
boundToMain: string | null
}
export interface PipeRegistry {
version: number
mainMachineId: string | null
main: PipeRegistryEntry | null
subs: PipeRegistrySub[]
}
export type DetermineRoleResult =
| { role: 'main' }
| { role: 'main-recover' }
| { role: 'sub'; subIndex: number }
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
function getRegistryPath(): string {
return join(getPipesDir(), 'registry.json')
}
function getLockPath(): string {
return join(getPipesDir(), 'registry.lock')
}
// ---------------------------------------------------------------------------
// Machine ID — stable OS-level fingerprint
// ---------------------------------------------------------------------------
let _cachedMachineId: string | null = null
export async function getMachineId(): Promise<string> {
if (_cachedMachineId) return _cachedMachineId
let raw: string | null = null
if (process.platform === 'win32') {
// Windows: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid (async)
try {
const { execFile } =
require('child_process') as typeof import('child_process')
raw = await new Promise<string>((resolve, reject) => {
execFile(
'reg',
[
'query',
'HKLM\\SOFTWARE\\Microsoft\\Cryptography',
'/v',
'MachineGuid',
],
{ timeout: 3000 },
(err, stdout) => (err ? reject(err) : resolve(stdout)),
)
})
const match = raw.match(/MachineGuid\s+REG_SZ\s+(\S+)/)
if (match) {
_cachedMachineId = match[1]!
return _cachedMachineId
}
} catch {}
} else if (process.platform === 'linux') {
// Linux: /etc/machine-id (already async)
try {
raw = await readFile('/etc/machine-id', 'utf8')
raw = raw.trim()
if (raw) {
_cachedMachineId = raw
return _cachedMachineId
}
} catch {}
} else if (process.platform === 'darwin') {
// macOS: IOPlatformSerialNumber (async)
try {
const { execFile } =
require('child_process') as typeof import('child_process')
raw = await new Promise<string>((resolve, reject) => {
execFile(
'bash',
[
'-c',
'ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformSerialNumber',
],
{ timeout: 3000 },
(err, stdout) => (err ? reject(err) : resolve(stdout)),
)
})
const match = raw.match(/"IOPlatformSerialNumber"\s*=\s*"(\S+)"/)
if (match) {
_cachedMachineId = match[1]!
return _cachedMachineId
}
} catch {}
}
// Fallback: hash hostname + MAC addresses
_cachedMachineId = generateFallbackId()
return _cachedMachineId
}
function generateFallbackId(): string {
const os = require('os') as typeof import('os')
const nets = os.networkInterfaces()
const macs: string[] = []
for (const name of Object.keys(nets)) {
for (const net of nets[name] ?? []) {
if (net.mac && net.mac !== '00:00:00:00:00:00') {
macs.push(net.mac)
}
}
}
macs.sort()
const raw = `${os.hostname()}:${macs.join(',')}`
return createHash('sha256').update(raw).digest('hex').slice(0, 32)
}
export function getMacAddress(): string {
const os = require('os') as typeof import('os')
const nets = os.networkInterfaces()
for (const name of Object.keys(nets)) {
for (const net of nets[name] ?? []) {
if (
net.family === 'IPv4' &&
!net.internal &&
net.mac &&
net.mac !== '00:00:00:00:00:00'
) {
return net.mac
}
}
}
return '00:00:00:00:00:00'
}
// ---------------------------------------------------------------------------
// File lock — simple .lock file with timeout
// ---------------------------------------------------------------------------
const LOCK_TIMEOUT_MS = 2000
const LOCK_RETRY_MS = 50
async function acquireLock(): Promise<void> {
await mkdir(getPipesDir(), { recursive: true })
const lockPath = getLockPath()
const deadline = Date.now() + LOCK_TIMEOUT_MS
while (Date.now() < deadline) {
try {
// O_CREAT | O_EXCL — fails if file exists
await writeFile(lockPath, String(process.pid), { flag: 'wx' })
return // Lock acquired
} catch (err: any) {
if (err.code === 'EEXIST') {
// Check if lock is stale (older than LOCK_TIMEOUT_MS)
try {
const content = await readFile(lockPath, 'utf8')
const lockPid = parseInt(content, 10)
if (lockPid && lockPid !== process.pid) {
try {
process.kill(lockPid, 0) // Check if process alive
} catch {
// Process dead — remove stale lock
await unlink(lockPath).catch(() => {})
continue
}
}
} catch {
// Can't read lock file — try to remove
await unlink(lockPath).catch(() => {})
continue
}
await new Promise(r => setTimeout(r, LOCK_RETRY_MS))
} else {
throw err
}
}
}
// Timeout — force remove and retry once
await unlink(getLockPath()).catch(() => {})
await writeFile(lockPath, String(process.pid), { flag: 'wx' }).catch(() => {})
}
async function releaseLock(): Promise<void> {
await unlink(getLockPath()).catch(() => {})
}
// ---------------------------------------------------------------------------
// Registry CRUD
// ---------------------------------------------------------------------------
const EMPTY_REGISTRY: PipeRegistry = {
version: 1,
mainMachineId: null,
main: null,
subs: [],
}
export async function readRegistry(): Promise<PipeRegistry> {
try {
const content = await readFile(getRegistryPath(), 'utf8')
const parsed = JSON.parse(content) as PipeRegistry
if (parsed.version !== 1) return { ...EMPTY_REGISTRY }
return parsed
} catch {
return { ...EMPTY_REGISTRY }
}
}
export async function writeRegistry(registry: PipeRegistry): Promise<void> {
await mkdir(getPipesDir(), { recursive: true })
await writeFile(getRegistryPath(), JSON.stringify(registry, null, 2))
}
// ---------------------------------------------------------------------------
// Role management (all operations are lock-protected)
// ---------------------------------------------------------------------------
export async function determineRole(
machineId: string,
): Promise<DetermineRoleResult> {
await acquireLock()
try {
const registry = await readRegistry()
// Case A: no main registered
if (!registry.mainMachineId || !registry.main) {
return { role: 'main' }
}
// Case B: this machine is the main machine
if (registry.mainMachineId === machineId) {
if (registry.main && (await isPipeAlive(registry.main.pipeName, 1000))) {
// Main instance is alive → this is a same-machine sub
const subIndex = registry.subs.length + 1
return { role: 'sub', subIndex }
}
// Main instance is dead → recover main on same machine
return { role: 'main-recover' }
}
// Case C: different machine
const subIndex = registry.subs.length + 1
return { role: 'sub', subIndex }
} finally {
await releaseLock()
}
}
export async function registerAsMain(entry: PipeRegistryEntry): Promise<void> {
await acquireLock()
try {
const registry = await readRegistry()
registry.mainMachineId = entry.machineId
registry.main = entry
await writeRegistry(registry)
} finally {
await releaseLock()
}
}
export async function registerAsSub(
entry: PipeRegistryEntry,
subIndex: number,
): Promise<void> {
await acquireLock()
try {
const registry = await readRegistry()
// Remove existing entry with same id (re-registration)
registry.subs = registry.subs.filter(s => s.id !== entry.id)
registry.subs.push({
...entry,
subIndex,
boundToMain: registry.main?.id ?? null,
})
await writeRegistry(registry)
} finally {
await releaseLock()
}
}
export async function unregister(id: string): Promise<void> {
await acquireLock()
try {
const registry = await readRegistry()
if (registry.main?.id === id) {
registry.main = null
// Don't clear mainMachineId — same machine can recover
}
registry.subs = registry.subs.filter(s => s.id !== id)
await writeRegistry(registry)
} finally {
await releaseLock()
}
}
export async function revertToIndependent(id: string): Promise<void> {
await acquireLock()
try {
const registry = await readRegistry()
const sub = registry.subs.find(s => s.id === id)
if (sub) {
sub.boundToMain = null
}
await writeRegistry(registry)
} finally {
await releaseLock()
}
}
export async function claimMain(
newMachineId: string,
entry: PipeRegistryEntry,
): Promise<void> {
await acquireLock()
try {
const registry = await readRegistry()
registry.mainMachineId = newMachineId
registry.main = entry
// All existing subs become bound to new main
for (const sub of registry.subs) {
sub.boundToMain = entry.id
}
await writeRegistry(registry)
} finally {
await releaseLock()
}
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export async function isMainAlive(): Promise<boolean> {
const registry = await readRegistry()
if (!registry.main) return false
return isPipeAlive(registry.main.pipeName, 1000)
}
export function isMainMachine(
machineId: string,
registry: PipeRegistry,
): boolean {
return registry.mainMachineId === machineId
}
export async function getAliveSubs(): Promise<PipeRegistrySub[]> {
const registry = await readRegistry()
const results = await Promise.all(
registry.subs.map(sub =>
isPipeAlive(sub.pipeName, 1000).then(alive => (alive ? sub : null)),
),
)
return results.filter((s): s is PipeRegistrySub => s !== null)
}
export async function cleanupStaleEntries(): Promise<void> {
// Phase 1: Probe all entries in parallel WITHOUT holding the lock
const registry = await readRegistry()
const [mainAlive, subResults] = await Promise.all([
registry.main
? isPipeAlive(registry.main.pipeName, 1000)
: Promise.resolve(true),
Promise.all(
registry.subs.map(sub =>
isPipeAlive(sub.pipeName, 1000).then(alive => ({ sub, alive })),
),
),
])
const needsWrite = !mainAlive || subResults.some(r => !r.alive)
if (!needsWrite) return
// Phase 2: Briefly hold lock to apply changes
await acquireLock()
try {
const fresh = await readRegistry()
let changed = false
if (!mainAlive && fresh.main?.pipeName === registry.main?.pipeName) {
fresh.main = null
changed = true
}
const deadNames = new Set(
subResults.filter(r => !r.alive).map(r => r.sub.pipeName),
)
const aliveSubs = fresh.subs.filter(s => !deadNames.has(s.pipeName))
if (aliveSubs.length !== fresh.subs.length) {
fresh.subs = aliveSubs
changed = true
}
if (changed) {
await writeRegistry(fresh)
}
} finally {
await releaseLock()
}
}
// ---------------------------------------------------------------------------
// LAN peer merging
// ---------------------------------------------------------------------------
export type MergedPipeEntry = {
id: string
pipeName: string
role: string
machineId: string
ip: string
hostname: string
alive: boolean
source: 'local' | 'lan'
tcpEndpoint?: TcpEndpoint
}
/**
* Merge local registry entries with LAN beacon-discovered peers.
* Local entries take precedence — LAN peers are only added if not
* already present in the local registry.
*/
export function mergeWithLanPeers(
registry: PipeRegistry,
lanPeers: Map<string, LanAnnounce>,
): MergedPipeEntry[] {
const result: MergedPipeEntry[] = []
const knownPipes = new Set<string>()
// Add main from local registry
if (registry.main) {
knownPipes.add(registry.main.pipeName)
result.push({
id: registry.main.id,
pipeName: registry.main.pipeName,
role: 'main',
machineId: registry.main.machineId,
ip: registry.main.ip,
hostname: registry.main.hostname,
alive: true, // caller should verify
source: 'local',
tcpEndpoint: registry.main.tcpPort
? { host: registry.main.ip, port: registry.main.tcpPort }
: undefined,
})
}
// Add subs from local registry
for (const sub of registry.subs) {
knownPipes.add(sub.pipeName)
result.push({
id: sub.id,
pipeName: sub.pipeName,
role: `sub-${sub.subIndex}`,
machineId: sub.machineId,
ip: sub.ip,
hostname: sub.hostname,
alive: true,
source: 'local',
tcpEndpoint: sub.tcpPort
? { host: sub.ip, port: sub.tcpPort }
: undefined,
})
}
// Add LAN peers not already in local registry
for (const [pipeName, peer] of lanPeers) {
if (knownPipes.has(pipeName)) continue
result.push({
id: `lan-${pipeName}`,
pipeName,
role: peer.role,
machineId: peer.machineId,
ip: peer.ip,
hostname: peer.hostname,
alive: true,
source: 'lan',
tcpEndpoint: { host: peer.ip, port: peer.tcpPort },
})
}
return result
}