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>
This commit is contained in:
claude-code-best
2026-04-11 23:22:55 +08:00
committed by GitHub
parent 2fea429dc6
commit 09fc515edb
124 changed files with 10958 additions and 577 deletions

View File

@@ -1,3 +1,219 @@
// Auto-generated stub — replace with real implementation
export const sendToUdsSocket: (target: string, message: string) => Promise<void> = async () => {};
export const listAllLiveSessions: () => Promise<Array<{ kind?: string; sessionId?: string }>> = async () => [];
/**
* UDS Client — connect to peer Claude Code sessions via Unix Domain Sockets.
*
* Peers are discovered by reading the PID-file registry in ~/.claude/sessions/
* (written by concurrentSessions.ts) and checking each entry's
* `messagingSocketPath` field. A peer is "alive" if its PID is running and
* its socket accepts a ping/pong round-trip.
*/
import { createConnection, type Socket } from 'net'
import { readdir, readFile } from 'fs/promises'
import { join } from 'path'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { logForDebugging } from './debug.js'
import { errorMessage, isFsInaccessible } from './errors.js'
import { isProcessRunning } from './genericProcessUtils.js'
import { jsonParse, jsonStringify } from './slowOperations.js'
import type { SessionKind } from './concurrentSessions.js'
import type { UdsMessage } from './udsMessaging.js'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PeerSession = {
pid: number
sessionId?: string
cwd?: string
startedAt?: number
kind?: SessionKind
name?: string
messagingSocketPath?: string
entrypoint?: string
bridgeSessionId?: string | null
alive: boolean
}
// ---------------------------------------------------------------------------
// Session directory
// ---------------------------------------------------------------------------
function getSessionsDir(): string {
return join(getClaudeConfigHomeDir(), 'sessions')
}
// ---------------------------------------------------------------------------
// Discovery
// ---------------------------------------------------------------------------
/**
* List all live sessions from the PID registry, optionally probing their
* UDS sockets for liveness. Sessions whose PID is no longer running are
* excluded (and their stale files cleaned up).
*/
export async function listAllLiveSessions(): Promise<PeerSession[]> {
const dir = getSessionsDir()
let files: string[]
try {
files = await readdir(dir)
} catch (e) {
if (!isFsInaccessible(e)) {
logForDebugging(`[udsClient] readdir failed: ${errorMessage(e)}`)
}
return []
}
const results: PeerSession[] = []
for (const file of files) {
if (!/^\d+\.json$/.test(file)) continue
const pid = parseInt(file.slice(0, -5), 10)
if (!isProcessRunning(pid)) {
// Stale — skip (concurrentSessions handles cleanup)
continue
}
try {
const raw = await readFile(join(dir, file), 'utf8')
const data = jsonParse(raw) as Record<string, unknown>
results.push({
pid,
sessionId: data.sessionId as string | undefined,
cwd: data.cwd as string | undefined,
startedAt: data.startedAt as number | undefined,
kind: data.kind as SessionKind | undefined,
name: data.name as string | undefined,
messagingSocketPath: data.messagingSocketPath as string | undefined,
entrypoint: data.entrypoint as string | undefined,
bridgeSessionId: data.bridgeSessionId as string | null | undefined,
alive: true,
})
} catch {
// Corrupted file — skip
}
}
return results
}
/**
* List peer sessions that have a UDS messaging socket (i.e. can receive
* messages). Excludes the current process.
*/
export async function listPeers(): Promise<PeerSession[]> {
const all = await listAllLiveSessions()
return all.filter(
s => s.pid !== process.pid && s.messagingSocketPath != null,
)
}
// ---------------------------------------------------------------------------
// Connection helpers
// ---------------------------------------------------------------------------
/**
* Probe a UDS socket to check if a server is listening (ping/pong).
* Returns true if the peer responds within the timeout.
*/
export async function isPeerAlive(socketPath: string, timeoutMs = 3000): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const conn = createConnection(socketPath, () => {
const ping: UdsMessage = { type: 'ping', ts: new Date().toISOString() }
conn.write(jsonStringify(ping) + '\n')
})
let resolved = false
const timer = setTimeout(() => {
if (!resolved) {
resolved = true
conn.destroy()
resolve(false)
}
}, timeoutMs)
let buffer = ''
conn.on('data', (chunk) => {
buffer += chunk.toString()
if (buffer.includes('"pong"')) {
if (!resolved) {
resolved = true
clearTimeout(timer)
conn.end()
resolve(true)
}
}
})
conn.on('error', () => {
if (!resolved) {
resolved = true
clearTimeout(timer)
resolve(false)
}
})
})
}
/**
* Send a text message to a peer's UDS socket. This is the high-level helper
* used by SendMessageTool for `uds:<path>` addresses.
*/
export async function sendToUdsSocket(
targetSocketPath: string,
message: string | Record<string, unknown>,
): Promise<void> {
const data = typeof message === 'string' ? message : jsonStringify(message)
const udsMsg: UdsMessage = {
type: 'text',
data,
ts: new Date().toISOString(),
}
// Lazily import to avoid circular dep at module-load time
const { getUdsMessagingSocketPath } = await import('./udsMessaging.js')
udsMsg.from = getUdsMessagingSocketPath()
return new Promise<void>((resolve, reject) => {
const conn = createConnection(targetSocketPath, () => {
conn.write(jsonStringify(udsMsg) + '\n', (err) => {
conn.end()
if (err) reject(err)
else resolve()
})
})
conn.on('error', (err) => {
reject(new Error(`Failed to connect to peer at ${targetSocketPath}: ${errorMessage(err)}`))
})
conn.setTimeout(5000, () => {
conn.destroy(new Error('Connection timed out'))
})
})
}
/**
* Connect to a peer and return the raw socket for bidirectional communication.
* The caller is responsible for managing the connection lifecycle.
*/
export function connectToPeer(socketPath: string): Promise<Socket> {
return new Promise<Socket>((resolve, reject) => {
const conn = createConnection(socketPath, () => {
resolve(conn)
})
conn.on('error', reject)
conn.setTimeout(5000, () => {
conn.destroy(new Error('Connection timed out'))
})
})
}
/**
* Disconnect a previously connected peer socket.
*/
export function disconnectPeer(socket: Socket): void {
if (!socket.destroyed) {
socket.end()
}
}