mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
移除未使用的导入(getSubscriptionType、isEnvDefinedFalsy、 getClaudeConfigHomeDir 等)、未使用的常量(ACCENT_COLOR、 NAME_MATCH_BONUS、CLIPBOARD_THRESHOLD)和死函数 (getOpus41Option、pasteViaClipboard), 为未使用参数添加 _ 前缀。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
521 lines
14 KiB
TypeScript
521 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 { 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
|
|
}
|