mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
@@ -1,3 +1,264 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export const startUdsMessaging: (socketPath: string, options: { isExplicit: boolean }) => Promise<void> = async () => {};
|
||||
export const getDefaultUdsSocketPath: () => string = () => '';
|
||||
/**
|
||||
* UDS Messaging Layer — Unix Domain Socket IPC for Claude Code instances.
|
||||
*
|
||||
* Each session auto-creates a UDS server so peer sessions can send messages.
|
||||
* Protocol: newline-delimited JSON (NDJSON), one message per line.
|
||||
*
|
||||
* Socket path defaults to a tmpdir-based path derived from the session PID,
|
||||
* but can be overridden via --messaging-socket-path.
|
||||
*/
|
||||
|
||||
import { createServer, type Server, type Socket } from 'net'
|
||||
import { mkdir, unlink } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { registerCleanup } from './cleanupRegistry.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
import { errorMessage } from './errors.js'
|
||||
import { attachNdjsonFramer } from './ndjsonFramer.js'
|
||||
import { jsonParse, jsonStringify } from './slowOperations.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type UdsMessageType =
|
||||
| 'text'
|
||||
| 'notification'
|
||||
| 'query'
|
||||
| 'response'
|
||||
| 'ping'
|
||||
| 'pong'
|
||||
|
||||
export type UdsMessage = {
|
||||
/** Discriminator */
|
||||
type: UdsMessageType
|
||||
/** Payload text / JSON content */
|
||||
data?: string
|
||||
/** Sender socket path (so the receiver can reply) */
|
||||
from?: string
|
||||
/** ISO timestamp */
|
||||
ts?: string
|
||||
/** Optional metadata */
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type UdsInboxEntry = {
|
||||
id: string
|
||||
message: UdsMessage
|
||||
receivedAt: number
|
||||
status: 'pending' | 'processed'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let server: Server | null = null
|
||||
let socketPath: string | null = null
|
||||
let onEnqueueCb: (() => void) | null = null
|
||||
const clients = new Set<Socket>()
|
||||
const inbox: UdsInboxEntry[] = []
|
||||
let nextId = 1
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — socket path helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Default socket path based on PID, placed in a tmpdir subdirectory so it
|
||||
* survives across config-home changes and avoids polluting ~/.claude.
|
||||
*/
|
||||
export function getDefaultUdsSocketPath(): string {
|
||||
return join(tmpdir(), 'claude-code-socks', `${process.pid}.sock`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the socket path of the currently running server, or undefined
|
||||
* if the server has not been started.
|
||||
*/
|
||||
export function getUdsMessagingSocketPath(): string | undefined {
|
||||
return socketPath ?? undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a callback invoked whenever a message is enqueued into the inbox.
|
||||
* Used by the print/SDK query loop to kick off processing.
|
||||
*/
|
||||
export function setOnEnqueue(cb: (() => void) | null): void {
|
||||
onEnqueueCb = cb
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all pending inbox messages, marking them processed.
|
||||
*/
|
||||
export function drainInbox(): UdsInboxEntry[] {
|
||||
const pending = inbox.filter(e => e.status === 'pending')
|
||||
for (const entry of pending) {
|
||||
entry.status = 'processed'
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start the UDS messaging server on the given socket path.
|
||||
*
|
||||
* Exports `CLAUDE_CODE_MESSAGING_SOCKET` into `process.env` so child
|
||||
* processes (hooks, spawned agents) can discover and connect back.
|
||||
*/
|
||||
export async function startUdsMessaging(
|
||||
path: string,
|
||||
opts?: { isExplicit?: boolean },
|
||||
): Promise<void> {
|
||||
if (server) {
|
||||
logForDebugging('[udsMessaging] server already running, skipping start')
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
|
||||
// Clean up stale socket file
|
||||
try {
|
||||
await unlink(path)
|
||||
} catch {
|
||||
// ENOENT is fine
|
||||
}
|
||||
|
||||
socketPath = path
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const srv = createServer(socket => {
|
||||
clients.add(socket)
|
||||
logForDebugging(
|
||||
`[udsMessaging] client connected (total: ${clients.size})`,
|
||||
)
|
||||
|
||||
attachNdjsonFramer<UdsMessage>(
|
||||
socket,
|
||||
msg => {
|
||||
// Handle ping with automatic pong
|
||||
if (msg.type === 'ping') {
|
||||
const pong: UdsMessage = {
|
||||
type: 'pong',
|
||||
from: socketPath ?? undefined,
|
||||
ts: new Date().toISOString(),
|
||||
}
|
||||
if (!socket.destroyed) {
|
||||
socket.write(jsonStringify(pong) + '\n')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Enqueue into inbox
|
||||
const entry: UdsInboxEntry = {
|
||||
id: `uds-${nextId++}`,
|
||||
message: msg,
|
||||
receivedAt: Date.now(),
|
||||
status: 'pending',
|
||||
}
|
||||
inbox.push(entry)
|
||||
logForDebugging(
|
||||
`[udsMessaging] enqueued message type=${msg.type} from=${msg.from ?? 'unknown'}`,
|
||||
)
|
||||
onEnqueueCb?.()
|
||||
},
|
||||
text => jsonParse(text) as UdsMessage,
|
||||
)
|
||||
|
||||
socket.on('close', () => {
|
||||
clients.delete(socket)
|
||||
})
|
||||
|
||||
socket.on('error', err => {
|
||||
clients.delete(socket)
|
||||
logForDebugging(`[udsMessaging] client error: ${errorMessage(err)}`)
|
||||
})
|
||||
})
|
||||
|
||||
srv.on('error', reject)
|
||||
|
||||
srv.listen(path, () => {
|
||||
server = srv
|
||||
// Export so child processes can discover the socket
|
||||
process.env.CLAUDE_CODE_MESSAGING_SOCKET = path
|
||||
logForDebugging(
|
||||
`[udsMessaging] server listening on ${path}${opts?.isExplicit ? ' (explicit)' : ''}`,
|
||||
)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
// Register cleanup so the socket file is removed on exit
|
||||
registerCleanup(async () => {
|
||||
await stopUdsMessaging()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the UDS messaging server and clean up the socket file.
|
||||
*/
|
||||
export async function stopUdsMessaging(): Promise<void> {
|
||||
if (!server) return
|
||||
|
||||
// Close all connected clients
|
||||
for (const socket of clients) {
|
||||
socket.destroy()
|
||||
}
|
||||
clients.clear()
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server!.close(() => resolve())
|
||||
})
|
||||
server = null
|
||||
|
||||
// Remove socket file
|
||||
if (socketPath) {
|
||||
try {
|
||||
await unlink(socketPath)
|
||||
} catch {
|
||||
// Already gone
|
||||
}
|
||||
delete process.env.CLAUDE_CODE_MESSAGING_SOCKET
|
||||
logForDebugging(
|
||||
`[udsMessaging] server stopped, socket removed: ${socketPath}`,
|
||||
)
|
||||
socketPath = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a UDS message to a specific socket path (outbound — used when this
|
||||
* session wants to push a message to a peer's server).
|
||||
*/
|
||||
export async function sendUdsMessage(
|
||||
targetSocketPath: string,
|
||||
message: UdsMessage,
|
||||
): Promise<void> {
|
||||
const { createConnection } = await import('net')
|
||||
message.from = message.from ?? socketPath ?? undefined
|
||||
message.ts = message.ts ?? new Date().toISOString()
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const conn = createConnection(targetSocketPath, () => {
|
||||
conn.write(jsonStringify(message) + '\n', err => {
|
||||
conn.end()
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
conn.on('error', reject)
|
||||
// Timeout so we don't hang on unreachable sockets
|
||||
conn.setTimeout(5000, () => {
|
||||
conn.destroy(new Error('Connection timed out'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user