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>
522 lines
14 KiB
TypeScript
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
|
|
}
|