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

@@ -0,0 +1,165 @@
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'
// Mock dgram before importing LanBeacon
const mockSocket = {
on: mock(() => mockSocket),
bind: mock((port: number, cb: () => void) => cb()),
addMembership: mock(() => {}),
setMulticastInterface: mock(() => {}),
setMulticastTTL: mock(() => {}),
setBroadcast: mock(() => {}),
dropMembership: mock(() => {}),
send: mock(() => {}),
close: mock(() => {}),
}
mock.module('dgram', () => ({
createSocket: () => mockSocket,
}))
const { LanBeacon } = await import('../lanBeacon.js')
type MockCall = [string, ...unknown[]]
function getMessageHandler(): ((msg: Buffer, rinfo: { address: string; port: number }) => void) | undefined {
const calls = mockSocket.on.mock.calls as unknown as MockCall[]
const call = calls.find(c => c[0] === 'message')
return call?.[1] as ((msg: Buffer, rinfo: { address: string; port: number }) => void) | undefined
}
describe('LanBeacon', () => {
let beacon: InstanceType<typeof LanBeacon>
const announceData = {
pipeName: 'cli-test1234',
machineId: 'machine-abc',
hostname: 'test-host',
ip: '192.168.1.10',
tcpPort: 7100,
role: 'main' as const,
}
beforeEach(() => {
mockSocket.on.mockClear()
mockSocket.bind.mockClear()
mockSocket.send.mockClear()
mockSocket.close.mockClear()
mockSocket.addMembership.mockClear()
mockSocket.dropMembership.mockClear()
beacon = new LanBeacon(announceData)
})
afterEach(() => {
beacon.stop()
})
test('start initializes socket and sends first announce', () => {
beacon.start()
expect(mockSocket.bind).toHaveBeenCalledTimes(1)
expect(mockSocket.addMembership).toHaveBeenCalledWith(
'224.0.71.67',
'192.168.1.10',
)
expect(mockSocket.setMulticastTTL).toHaveBeenCalledWith(1)
// First announce sent immediately
expect(mockSocket.send).toHaveBeenCalled()
})
test('getPeers returns empty map initially', () => {
beacon.start()
expect(beacon.getPeers().size).toBe(0)
})
test('stop closes socket and clears peers', () => {
beacon.start()
beacon.stop()
expect(mockSocket.close).toHaveBeenCalled()
})
test('processes incoming announce from different peer', () => {
beacon.start()
const messageHandler = getMessageHandler()
if (!messageHandler) return
const peerAnnounce = JSON.stringify({
proto: 'claude-pipe-v1',
pipeName: 'cli-peer5678',
machineId: 'machine-xyz',
hostname: 'peer-host',
ip: '192.168.1.20',
tcpPort: 7102,
role: 'sub',
ts: Date.now(),
})
let discoveredPeer: any = null
beacon.on('peer-discovered', (peer: any) => {
discoveredPeer = peer
})
messageHandler(Buffer.from(peerAnnounce), {
address: '192.168.1.20',
port: 7101,
})
expect(beacon.getPeers().size).toBe(1)
expect(beacon.getPeers().has('cli-peer5678')).toBe(true)
expect(discoveredPeer).not.toBeNull()
expect(discoveredPeer.pipeName).toBe('cli-peer5678')
})
test('ignores self-announces', () => {
beacon.start()
const messageHandler = getMessageHandler()
if (!messageHandler) return
const selfAnnounce = JSON.stringify({
proto: 'claude-pipe-v1',
pipeName: 'cli-test1234', // same as our pipeName
machineId: 'machine-abc',
hostname: 'test-host',
ip: '192.168.1.10',
tcpPort: 7100,
role: 'main',
ts: Date.now(),
})
messageHandler(Buffer.from(selfAnnounce), {
address: '192.168.1.10',
port: 7101,
})
expect(beacon.getPeers().size).toBe(0)
})
test('ignores non-claude-pipe protocol messages', () => {
beacon.start()
const messageHandler = getMessageHandler()
if (!messageHandler) return
const foreignMessage = JSON.stringify({
proto: 'something-else',
pipeName: 'cli-foreign',
})
messageHandler(Buffer.from(foreignMessage), {
address: '192.168.1.30',
port: 7101,
})
expect(beacon.getPeers().size).toBe(0)
})
test('updateAnnounce changes role', () => {
beacon.updateAnnounce({ role: 'sub' })
beacon.start()
// The send call should include the updated role
const sendCalls = mockSocket.send.mock.calls as unknown as [Buffer, ...unknown[]][]
const sendCall = sendCalls[0]
if (sendCall) {
const payload = JSON.parse(sendCall[0].toString())
expect(payload.role).toBe('sub')
}
})
})

View File

@@ -1,5 +1,12 @@
import { describe, expect, test } from "bun:test";
import { tmpdir } from "os";
import { resolve } from "path";
import {
getFsImplementation,
setFsImplementation,
setOriginalFsImplementation,
type FsOperations,
} from "../fsOperations";
import {
containsPathTraversal,
expandPath,
@@ -176,24 +183,67 @@ describe("toRelativePath", () => {
describe("getDirectoryForPath", () => {
test("returns the path itself when given an existing directory", () => {
// The src directory is guaranteed to exist in this repo
const dir = resolve(process.cwd(), "src");
const result = getDirectoryForPath(dir);
expect(result).toBe(dir);
setOriginalFsImplementation();
const dir = resolve(tmpdir(), "ccb-existing-dir");
const baseFs = getFsImplementation();
setFsImplementation({
...baseFs,
statSync: ((path: string) => {
if (path === dir) {
return { isDirectory: () => true } as any;
}
return baseFs.statSync(path);
}) as FsOperations["statSync"],
});
try {
const result = getDirectoryForPath(dir);
expect(result).toBe(dir);
} finally {
setOriginalFsImplementation();
}
});
test("returns parent directory for a known file", () => {
// package.json is at the repo root
const file = resolve(process.cwd(), "package.json");
const expectedParent = process.cwd();
const result = getDirectoryForPath(file);
expect(result).toBe(expectedParent);
setOriginalFsImplementation();
const expectedParent = resolve(tmpdir(), "ccb-file-parent");
const file = resolve(expectedParent, "sample.txt");
const baseFs = getFsImplementation();
setFsImplementation({
...baseFs,
statSync: ((path: string) => {
if (path === file) {
return { isDirectory: () => false } as any;
}
return baseFs.statSync(path);
}) as FsOperations["statSync"],
});
try {
const result = getDirectoryForPath(file);
expect(result).toBe(expectedParent);
} finally {
setOriginalFsImplementation();
}
});
test("returns parent directory for a non-existent path", () => {
const nonExistent = resolve(process.cwd(), "does-not-exist-xyz123.ts");
const expectedParent = process.cwd();
const result = getDirectoryForPath(nonExistent);
expect(result).toBe(expectedParent);
setOriginalFsImplementation();
const expectedParent = resolve(tmpdir(), "ccb-missing-parent");
const nonExistent = resolve(expectedParent, "does-not-exist-xyz123.ts");
const baseFs = getFsImplementation();
setFsImplementation({
...baseFs,
statSync: ((path: string) => {
if (path === nonExistent) {
throw new Error("ENOENT");
}
return baseFs.statSync(path);
}) as FsOperations["statSync"],
});
try {
const result = getDirectoryForPath(nonExistent);
expect(result).toBe(expectedParent);
} finally {
setOriginalFsImplementation();
}
});
});

View File

@@ -0,0 +1,60 @@
import { describe, test, expect } from 'bun:test'
import { parseAddress, parseTcpTarget } from '../peerAddress.js'
describe('parseAddress', () => {
test('uds: scheme', () => {
expect(parseAddress('uds:/tmp/test.sock')).toEqual({
scheme: 'uds',
target: '/tmp/test.sock',
})
})
test('bridge: scheme', () => {
expect(parseAddress('bridge:session-123')).toEqual({
scheme: 'bridge',
target: 'session-123',
})
})
test('tcp: scheme', () => {
expect(parseAddress('tcp:192.168.1.20:7100')).toEqual({
scheme: 'tcp',
target: '192.168.1.20:7100',
})
})
test('bare path routes to uds', () => {
expect(parseAddress('/var/run/test.sock')).toEqual({
scheme: 'uds',
target: '/var/run/test.sock',
})
})
test('other falls through', () => {
expect(parseAddress('teammate-name')).toEqual({
scheme: 'other',
target: 'teammate-name',
})
})
})
describe('parseTcpTarget', () => {
test('valid host:port', () => {
expect(parseTcpTarget('192.168.1.20:7100')).toEqual({
host: '192.168.1.20',
port: 7100,
})
})
test('hostname:port', () => {
expect(parseTcpTarget('my-host:8080')).toEqual({
host: 'my-host',
port: 8080,
})
})
test('invalid format returns null', () => {
expect(parseTcpTarget('no-port')).toBeNull()
expect(parseTcpTarget('')).toBeNull()
})
})

View File

@@ -0,0 +1,76 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
clearPendingPipePermissions,
resolvePipePermissionResponse,
tryRelayPipePermissionRequest,
setPipeRelay,
} from '../pipePermissionRelay.js'
afterEach(() => {
setPipeRelay(null)
clearPendingPipePermissions()
})
function makeToolUseConfirm(overrides: Record<string, unknown> = {}) {
return {
assistantMessage: { message: { id: 'msg-1' } },
tool: { name: 'Bash' },
description: 'Run command',
input: { command: 'echo hello' },
toolUseID: 'tool-1',
permissionResult: { behavior: 'ask', message: 'Approve?' },
permissionPromptStartTimeMs: 1,
...overrides,
} as any
}
describe('pipe permission relay', () => {
test('serializes permission requests through the active pipe sender', () => {
const sent: any[] = []
setPipeRelay((message: any) => {
sent.push(message)
})
const requestId = tryRelayPipePermissionRequest(
makeToolUseConfirm(),
() => {},
)
expect(requestId).toBeString()
expect(sent).toHaveLength(1)
expect(sent[0].type).toBe('permission_request')
const payload = JSON.parse(sent[0].data)
expect(payload.requestId).toBe(requestId)
expect(payload.toolName).toBe('Bash')
expect(payload.input).toEqual({ command: 'echo hello' })
})
test('dispatches permission responses to the pending request handler', () => {
setPipeRelay(() => {})
const seen: any[] = []
const requestId = tryRelayPipePermissionRequest(
makeToolUseConfirm(),
payload => {
seen.push(payload)
},
)
expect(requestId).toBeString()
const resolved = resolvePipePermissionResponse({
requestId: requestId!,
behavior: 'allow',
updatedInput: { command: 'echo ok' },
permissionUpdates: [],
})
expect(resolved).toBe(true)
expect(seen).toEqual([
{
requestId,
behavior: 'allow',
updatedInput: { command: 'echo ok' },
permissionUpdates: [],
},
])
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test'
import {
getPipeDisplayRole,
isPipeControlled,
type PipeIpcState,
} from '../pipeTransport.js'
function makePipeState(overrides: Partial<PipeIpcState> = {}): PipeIpcState {
return {
role: 'main',
subIndex: null,
displayRole: 'main',
serverName: 'cli-main',
attachedBy: null,
localIp: null,
hostname: null,
machineId: null,
mac: null,
statusVisible: false,
selectorOpen: false,
selectedPipes: [],
routeMode: 'selected',
slaves: {},
discoveredPipes: [],
...overrides,
}
}
describe('pipe transport role helpers', () => {
test('keeps controlled subs on their sub-N display role', () => {
const state = makePipeState({
role: 'sub',
subIndex: 2,
displayRole: 'slave',
attachedBy: 'cli-master',
})
expect(isPipeControlled(state)).toBe(true)
expect(getPipeDisplayRole(state)).toBe('sub-2')
})
test('preserves master and main display roles', () => {
expect(getPipeDisplayRole(makePipeState())).toBe('main')
expect(
getPipeDisplayRole(
makePipeState({
role: 'master',
displayRole: 'main',
}),
),
).toBe('master')
})
})

View File

@@ -1,4 +1,26 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, mock, test } from "bun:test";
mock.module("src/ink/stringWidth.js", () => ({
stringWidth: (str: string) => {
let width = 0;
for (const char of str) {
const code = char.codePointAt(0)!;
if (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3000 && code <= 0x303f) ||
(code >= 0xff01 && code <= 0xff60) ||
(code >= 0xf900 && code <= 0xfaff)
) {
width += 2;
} else if (code >= 0x1f300 && code <= 0x1faff) {
width += 2;
} else if (code > 0) {
width += 1;
}
}
return width;
},
}));
import {
truncatePathMiddle,
truncateToWidth,

View File

@@ -1434,6 +1434,7 @@ export async function shouldShowClaudeMdExternalIncludesWarning(): Promise<boole
*/
export function isMemoryFilePath(filePath: string): boolean {
const name = basename(filePath)
const normalizedPath = normalizePathForComparison(filePath)
// CLAUDE.md or CLAUDE.local.md anywhere
if (name === 'CLAUDE.md' || name === 'CLAUDE.local.md') {
@@ -1443,7 +1444,7 @@ export function isMemoryFilePath(filePath: string): boolean {
// .md files in .claude/rules/ directories
if (
name.endsWith('.md') &&
filePath.includes(`${sep}.claude${sep}rules${sep}`)
normalizedPath.includes('/.claude/rules/')
) {
return true
}

View File

@@ -566,11 +566,13 @@ export function normalizePathForComparison(filePath: string): string {
// Use path.normalize() to clean up redundant separators and resolve . and ..
let normalized = normalize(filePath)
// On Windows, normalize for case-insensitive comparison:
// - Convert forward slashes to backslashes (path.normalize only does this on actual Windows)
// - Convert to lowercase (Windows paths are case-insensitive)
// Convert separators to a stable slash form so comparison behavior stays
// consistent across platforms and in tests that use POSIX-style fixtures.
normalized = normalized.replace(/\\/g, '/')
// On Windows, normalize case for case-insensitive comparison.
if (getPlatform() === 'windows') {
normalized = normalized.replace(/\//g, '\\').toLowerCase()
normalized = normalized.toLowerCase()
}
return normalized

205
src/utils/lanBeacon.ts Normal file
View File

@@ -0,0 +1,205 @@
/**
* LAN Beacon — UDP multicast peer discovery for Pipes system.
*
* Uses multicast group 224.0.71.67 ("CC" = Claude Code ASCII) on port 7101
* to announce and discover CLI instances on the local network.
*
* Feature-gated by LAN_PIPES.
*/
import { createSocket, type Socket as DgramSocket } from 'dgram'
import { EventEmitter } from 'events'
import { logError } from './log.js'
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MULTICAST_GROUP = '224.0.71.67'
const MULTICAST_PORT = 7101
const ANNOUNCE_INTERVAL_MS = 3000
const PEER_TIMEOUT_MS = 15000
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type LanAnnounce = {
proto: 'claude-pipe-v1'
pipeName: string
machineId: string
hostname: string
ip: string
tcpPort: number
role: 'main' | 'sub'
ts: number
}
// ---------------------------------------------------------------------------
// LanBeacon
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Module-level singleton — avoids (state as any)._lanBeacon hack
// ---------------------------------------------------------------------------
let _lanBeaconInstance: LanBeacon | null = null
export function getLanBeacon(): LanBeacon | null {
return _lanBeaconInstance
}
export function setLanBeacon(instance: LanBeacon | null): void {
_lanBeaconInstance = instance
}
// ---------------------------------------------------------------------------
// LanBeacon class
// ---------------------------------------------------------------------------
export class LanBeacon extends EventEmitter {
private socket: DgramSocket | null = null
private announceTimer: ReturnType<typeof setInterval> | null = null
private cleanupTimer: ReturnType<typeof setInterval> | null = null
private peers: Map<string, LanAnnounce> = new Map()
private announce: LanAnnounce
constructor(announce: Omit<LanAnnounce, 'proto' | 'ts'>) {
super()
this.announce = {
...announce,
proto: 'claude-pipe-v1',
ts: Date.now(),
}
}
/**
* Start broadcasting announcements and listening for peers.
*/
start(): void {
if (this.socket) return
try {
this.socket = createSocket({ type: 'udp4', reuseAddr: true })
this.socket.on('error', err => {
logError(err)
// Non-fatal — multicast may not be supported on this network
})
this.socket.on('message', (buf, rinfo) => {
try {
const msg = JSON.parse(buf.toString()) as LanAnnounce
if (msg.proto !== 'claude-pipe-v1') return
if (msg.pipeName === this.announce.pipeName) return // ignore self
const isNew = !this.peers.has(msg.pipeName)
this.peers.set(msg.pipeName, { ...msg, ts: Date.now() })
if (isNew) {
this.emit('peer-discovered', msg)
}
} catch {
// Malformed packet — ignore
}
})
this.socket.bind(MULTICAST_PORT, () => {
try {
// Specify the local LAN interface for multicast membership.
// Without this, Windows may bind to a WSL/Docker virtual adapter
// and multicast packets never reach the real LAN.
const localIp = this.announce.ip
this.socket!.addMembership(MULTICAST_GROUP, localIp)
this.socket!.setMulticastInterface(localIp)
this.socket!.setMulticastTTL(1) // link-local only
this.socket!.setBroadcast(true)
} catch (err) {
logError(err as Error)
}
// Start announce + cleanup timers after socket is fully bound
this.announceTimer = setInterval(
() => this.sendAnnounce(),
ANNOUNCE_INTERVAL_MS,
)
// Send first announce immediately
this.sendAnnounce()
// Periodic cleanup of stale peers
this.cleanupTimer = setInterval(
() => this.cleanupStalePeers(),
PEER_TIMEOUT_MS / 2,
)
})
} catch (err) {
logError(err as Error)
}
}
/**
* Stop broadcasting and close the socket.
*/
stop(): void {
if (this.announceTimer) {
clearInterval(this.announceTimer)
this.announceTimer = null
}
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer)
this.cleanupTimer = null
}
if (this.socket) {
try {
this.socket.dropMembership(MULTICAST_GROUP)
} catch {
// May fail if socket already closed
}
this.socket.close()
this.socket = null
}
this.peers.clear()
}
/**
* Get all currently known peers (excluding self).
*/
getPeers(): Map<string, LanAnnounce> {
return new Map(this.peers)
}
/**
* Update the announce data (e.g., when role changes).
*/
updateAnnounce(partial: Partial<Omit<LanAnnounce, 'proto' | 'ts'>>): void {
this.announce = { ...this.announce, ...partial }
}
private sendAnnounce(): void {
if (!this.socket) return
try {
const payload = Buffer.from(
JSON.stringify({ ...this.announce, ts: Date.now() }),
)
this.socket.send(
payload,
0,
payload.length,
MULTICAST_PORT,
MULTICAST_GROUP,
)
} catch {
// Send failure — non-fatal
}
}
private cleanupStalePeers(): void {
const now = Date.now()
for (const [name, peer] of this.peers) {
if (now - peer.ts > PEER_TIMEOUT_MS) {
this.peers.delete(name)
this.emit('peer-lost', name)
}
}
}
}

39
src/utils/ndjsonFramer.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Shared NDJSON (Newline-Delimited JSON) socket framing.
*
* Accumulates incoming data chunks, splits on newlines, and emits
* parsed JSON objects. Used by both pipeTransport (UDS+TCP) and
* udsMessaging to avoid duplicating the same buffer logic.
*/
import type { Socket } from 'net'
/**
* Attach an NDJSON framer to a socket. Calls `onMessage` for each
* complete JSON line received. Malformed lines are silently skipped.
*
* @param parse - Optional custom JSON parser (defaults to JSON.parse).
* Useful when the caller uses a wrapped parser like jsonParse
* from slowOperations.
*/
export function attachNdjsonFramer<T = unknown>(
socket: Socket,
onMessage: (msg: T) => void,
parse: (text: string) => T = text => JSON.parse(text) as T,
): void {
let buffer = ''
socket.on('data', (chunk: Buffer) => {
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try {
onMessage(parse(line))
} catch {
// Malformed JSON — skip
}
}
})
}

View File

@@ -1,5 +1,5 @@
import { homedir } from 'os'
import { dirname, isAbsolute, join, normalize, relative, resolve } from 'path'
import { dirname, isAbsolute, join, normalize, posix, relative, resolve } from 'path'
import { getCwd } from './cwd.js'
import { getFsImplementation } from './fsOperations.js'
import { getPlatform } from './platform.js'
@@ -49,9 +49,15 @@ export function expandPath(path: string, baseDir?: string): string {
throw new Error('Path contains null bytes')
}
const isSyntheticPosixPath = (value: string): boolean =>
value.includes('/') && !value.includes('\\') && !/^[A-Za-z]:/.test(value)
// Handle empty or whitespace-only paths
const trimmedPath = path.trim()
if (!trimmedPath) {
if (getPlatform() === 'windows' && isSyntheticPosixPath(actualBaseDir)) {
return posix.normalize(actualBaseDir).normalize('NFC')
}
return normalize(actualBaseDir).normalize('NFC')
}
@@ -77,10 +83,21 @@ export function expandPath(path: string, baseDir?: string): string {
// Handle absolute paths
if (isAbsolute(processedPath)) {
if (getPlatform() === 'windows' && isSyntheticPosixPath(processedPath)) {
return posix.normalize(processedPath).normalize('NFC')
}
return normalize(processedPath).normalize('NFC')
}
// Handle relative paths
if (
getPlatform() === 'windows' &&
isSyntheticPosixPath(actualBaseDir) &&
!/^[A-Za-z]:/.test(processedPath) &&
!processedPath.startsWith('\\\\')
) {
return posix.resolve(actualBaseDir, processedPath).normalize('NFC')
}
return resolve(actualBaseDir, processedPath).normalize('NFC')
}

View File

@@ -6,11 +6,12 @@
/** Parse a URI-style address into scheme + target. */
export function parseAddress(to: string): {
scheme: 'uds' | 'bridge' | 'other'
scheme: 'uds' | 'bridge' | 'tcp' | 'other'
target: string
} {
if (to.startsWith('uds:')) return { scheme: 'uds', target: to.slice(4) }
if (to.startsWith('bridge:')) return { scheme: 'bridge', target: to.slice(7) }
if (to.startsWith('tcp:')) return { scheme: 'tcp', target: to.slice(4) }
// Legacy: old-code UDS senders emit bare socket paths in from=; route them
// through the UDS branch so replies aren't silently dropped into teammate
// routing. (No bare-session-ID fallback — bridge messaging is new enough
@@ -19,3 +20,14 @@ export function parseAddress(to: string): {
if (to.startsWith('/')) return { scheme: 'uds', target: to }
return { scheme: 'other', target: to }
}
/** Parse a tcp: target string into host and port. */
export function parseTcpTarget(
target: string,
): { host: string; port: number } | null {
const match = target.match(/^([^:]+):(\d+)$/)
if (!match) return null
const port = parseInt(match[2]!, 10)
if (port < 1 || port > 65535) return null
return { host: match[1]!, port }
}

View File

@@ -0,0 +1,156 @@
import { randomUUID } from 'crypto'
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'
import type {
PipeMessage,
PipePermissionRequestPayload,
PipePermissionResponsePayload,
} from './pipeTransport.js'
import type { PermissionUpdate } from './permissions/PermissionUpdateSchema.js'
type PendingPipePermission = {
onResponse: (payload: PipePermissionResponsePayload) => void
}
const pendingPipePermissions = new Map<string, PendingPipePermission>()
// Module-level singleton for the relay function to master.
// Replaces the old (globalThis as any).__pipeSendToMaster pattern.
type PipeRelayFn = (message: PipeMessage) => void
let _pipeRelay: PipeRelayFn | null = null
export function setPipeRelay(fn: PipeRelayFn | null): void {
_pipeRelay = fn
}
export function getPipeRelay(): PipeRelayFn | null {
return _pipeRelay
}
function getPipeSender():
| ((message: PipeMessage) => void)
| null {
return _pipeRelay ?? null
}
export function tryRelayPipePermissionRequest(
toolUseConfirm: ToolUseConfirm,
onResponse: (payload: PipePermissionResponsePayload) => void,
): string | null {
const send = getPipeSender()
if (!send) return null
const requestId = randomUUID()
const payload: PipePermissionRequestPayload = {
requestId,
toolName: toolUseConfirm.tool.name,
toolUseID: toolUseConfirm.toolUseID,
description: toolUseConfirm.description,
input: toolUseConfirm.input as Record<string, unknown>,
permissionResult: toolUseConfirm.permissionResult,
permissionPromptStartTimeMs: toolUseConfirm.permissionPromptStartTimeMs,
}
pendingPipePermissions.set(requestId, { onResponse })
send({ type: 'permission_request', data: JSON.stringify(payload) })
return requestId
}
export function resolvePipePermissionResponse(
payload: PipePermissionResponsePayload,
): boolean {
const pending = pendingPipePermissions.get(payload.requestId)
if (!pending) return false
pendingPipePermissions.delete(payload.requestId)
pending.onResponse(payload)
return true
}
export function cancelPipePermissionRequest(
requestId: string,
reason?: string,
): boolean {
const pending = pendingPipePermissions.get(requestId)
if (!pending) return false
pendingPipePermissions.delete(requestId)
pending.onResponse({
requestId,
behavior: 'deny',
feedback: reason ?? 'Permission request was cancelled by main.',
})
return true
}
export function forgetPipePermissionRequest(
requestId: string | null | undefined,
): void {
if (!requestId) return
pendingPipePermissions.delete(requestId)
}
export function notifyPipePermissionCancel(
requestId: string | null | undefined,
reason?: string,
): void {
if (!requestId) return
const send = getPipeSender()
if (!send) return
send({
type: 'permission_cancel',
data: JSON.stringify({ requestId, reason }),
})
}
export function clearPendingPipePermissions(
reason = 'Pipe permission relay was disconnected.',
): void {
for (const requestId of [...pendingPipePermissions.keys()]) {
cancelPipePermissionRequest(requestId, reason)
}
}
export function makePipePermissionResponsePayload(
requestId: string,
behavior: 'allow',
updatedInput: Record<string, unknown>,
permissionUpdates: PermissionUpdate[],
feedback?: string,
contentBlocks?: ContentBlockParam[],
): PipePermissionResponsePayload
export function makePipePermissionResponsePayload(
requestId: string,
behavior: 'deny',
feedback?: string,
contentBlocks?: ContentBlockParam[],
): PipePermissionResponsePayload
export function makePipePermissionResponsePayload(
requestId: string,
behavior: 'allow' | 'deny',
updatedInputOrFeedback?: Record<string, unknown> | string,
permissionUpdatesOrContentBlocks?: PermissionUpdate[] | ContentBlockParam[],
feedback?: string,
contentBlocks?: ContentBlockParam[],
): PipePermissionResponsePayload {
if (behavior === 'allow') {
return {
requestId,
behavior,
updatedInput:
(updatedInputOrFeedback as Record<string, unknown> | undefined) ?? {},
permissionUpdates:
(permissionUpdatesOrContentBlocks as PermissionUpdate[] | undefined) ??
[],
feedback,
contentBlocks,
}
}
return {
requestId,
behavior,
feedback: updatedInputOrFeedback as string | undefined,
contentBlocks: permissionUpdatesOrContentBlocks as
| ContentBlockParam[]
| undefined,
}
}

521
src/utils/pipeRegistry.ts Normal file
View File

@@ -0,0 +1,521 @@
/**
* 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
}

719
src/utils/pipeTransport.ts Normal file
View File

@@ -0,0 +1,719 @@
/**
* Named Pipe Transport - Unix domain socket IPC for CLI terminals
*
* Supports two modes:
* 1. Standalone: Two independent terminals chat via pipes
* 2. Master-Slave bridge: Master CLI attaches to Slave CLI, forwarding
* prompts and receiving streamed AI output back.
*
* Each CLI auto-creates a PipeServer at:
* ~/.claude/pipes/{session-short-id}.sock
*
* Protocol: newline-delimited JSON (NDJSON), one message per line.
*/
import { createServer, createConnection, type Server, type Socket } from 'net'
import { mkdir, unlink, readdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { EventEmitter } from 'events'
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
import type { PermissionDecision } from '../types/permissions.js'
import type { PermissionUpdate } from './permissions/PermissionUpdateSchema.js'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { logError } from './log.js'
import { attachNdjsonFramer } from './ndjsonFramer.js'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Message types exchanged over the pipe.
*
* Basic: ping, pong
* Control: attach_request, attach_accept, attach_reject, detach
* Data (M→S): prompt — master sends user input to slave
* Data (S→M): stream — slave streams AI output fragments
* tool_start — slave notifies tool execution start
* tool_result — slave notifies tool result
* done — slave signals turn complete
* error — either side reports an error
* Legacy: chat, cmd, result, exit — kept for backward compat
*/
export type PipeMessageType =
// Basic
| 'ping'
| 'pong'
// Control flow (master-slave bridge)
| 'attach_request'
| 'attach_accept'
| 'attach_reject'
| 'detach'
// Data flow (master → slave)
| 'prompt'
// Data flow (slave → master)
| 'prompt_ack'
| 'stream'
| 'tool_start'
| 'tool_result'
| 'done'
| 'error'
| 'permission_request'
| 'permission_response'
| 'permission_cancel'
// Legacy (standalone chat demo)
| 'chat'
| 'cmd'
| 'result'
| 'exit'
export type PipeMessage = {
/** Discriminator */
type: PipeMessageType
/** Payload (text, command output, prompt, stream fragment, etc.) */
data?: string
/** Sender pipe name */
from?: string
/** ISO timestamp */
ts?: string
/** Additional metadata (tool name, error details, etc.) */
meta?: Record<string, unknown>
}
export type PipePermissionRequestPayload = {
requestId: string
toolName: string
toolUseID: string
description: string
input: Record<string, unknown>
permissionResult: PermissionDecision
permissionPromptStartTimeMs: number
}
export type PipePermissionResponsePayload =
| {
requestId: string
behavior: 'allow'
updatedInput?: Record<string, unknown>
permissionUpdates?: PermissionUpdate[]
feedback?: string
contentBlocks?: ContentBlockParam[]
}
| {
requestId: string
behavior: 'deny'
feedback?: string
contentBlocks?: ContentBlockParam[]
}
export type PipePermissionCancelPayload = {
requestId: string
reason?: string
}
export type PipeMessageHandler = (
msg: PipeMessage,
reply: (msg: PipeMessage) => void,
) => void
// ---------------------------------------------------------------------------
// TCP transport types
// ---------------------------------------------------------------------------
export type PipeTransportMode = 'uds' | 'tcp'
export type TcpEndpoint = { host: string; port: number }
export type PipeServerOptions = {
enableTcp?: boolean
tcpPort?: number // 0 = random port
}
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
export function getPipesDir(): string {
return join(getClaudeConfigHomeDir(), 'pipes')
}
export function getPipePath(name: string): string {
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_')
if (process.platform === 'win32') {
return `\\\\.\\pipe\\claude-code-${safeName}`
}
return join(getPipesDir(), `${safeName}.sock`)
}
async function ensurePipesDir(): Promise<void> {
await mkdir(getPipesDir(), { recursive: true })
}
// ---------------------------------------------------------------------------
// Server (listener side)
// ---------------------------------------------------------------------------
export class PipeServer extends EventEmitter {
private server: Server | null = null
private tcpServer: Server | null = null
private clients: Set<Socket> = new Set()
private handlers: PipeMessageHandler[] = []
private _tcpAddress: TcpEndpoint | null = null
readonly name: string
readonly socketPath: string
constructor(name: string) {
super()
this.name = name
this.socketPath = getPipePath(name)
}
/** TCP endpoint if TCP is enabled, null otherwise. */
get tcpAddress(): TcpEndpoint | null {
return this._tcpAddress
}
/**
* Shared handler for both UDS and TCP sockets.
*/
private setupSocket(socket: Socket): void {
this.clients.add(socket)
this.emit('connection', socket)
attachNdjsonFramer<PipeMessage>(socket, msg => {
this.emit('message', msg)
const reply = (replyMsg: PipeMessage) => {
replyMsg.from = replyMsg.from ?? this.name
replyMsg.ts = replyMsg.ts ?? new Date().toISOString()
if (!socket.destroyed) {
socket.write(JSON.stringify(replyMsg) + '\n')
}
}
for (const handler of this.handlers) {
handler(msg, reply)
}
})
socket.on('close', () => {
this.clients.delete(socket)
this.emit('disconnect', socket)
})
socket.on('error', err => {
this.clients.delete(socket)
logError(err)
})
}
/**
* Start listening for incoming connections.
* @param options - Optional TCP configuration for LAN mode.
*/
async start(options?: PipeServerOptions): Promise<void> {
await ensurePipesDir()
// Clean up stale socket file (Unix only)
if (process.platform !== 'win32') {
try {
await unlink(this.socketPath)
} catch {
// File doesn't exist — fine
}
}
// Start UDS/Named Pipe server
await new Promise<void>((resolve, reject) => {
this.server = createServer(socket => this.setupSocket(socket))
this.server.on('error', reject)
this.server.listen(this.socketPath, () => {
// On Windows, Named Pipes don't exist in the filesystem.
// Write a registry file so listPipes() can discover this server.
if (process.platform === 'win32') {
const regFile = join(getPipesDir(), `${this.name}.pipe`)
const { hostname } = require('os') as typeof import('os')
void writeFile(
regFile,
JSON.stringify({
pid: process.pid,
ts: Date.now(),
ip: getLocalIp(),
hostname: hostname(),
}),
).catch(() => {})
}
resolve()
})
})
// Optionally start TCP server for LAN connectivity
if (options?.enableTcp) {
await this.startTcpServer(options.tcpPort ?? 0)
}
}
/**
* Start TCP listener for LAN peers.
*/
private async startTcpServer(port: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.tcpServer = createServer(socket => this.setupSocket(socket))
this.tcpServer.on('error', reject)
this.tcpServer.listen(port, '0.0.0.0', () => {
const addr = this.tcpServer!.address()
if (addr && typeof addr === 'object') {
this._tcpAddress = { host: '0.0.0.0', port: addr.port }
}
resolve()
})
})
}
/**
* Register a handler for incoming messages.
*/
onMessage(handler: PipeMessageHandler): void {
this.handlers.push(handler)
}
/**
* Broadcast a message to all connected clients.
*/
broadcast(msg: PipeMessage): void {
msg.from = msg.from ?? this.name
msg.ts = msg.ts ?? new Date().toISOString()
const line = JSON.stringify(msg) + '\n'
for (const client of this.clients) {
if (!client.destroyed) {
client.write(line)
}
}
}
/**
* Send to a specific socket (used for directed replies in attach flow).
*/
sendTo(socket: Socket, msg: PipeMessage): void {
msg.from = msg.from ?? this.name
msg.ts = msg.ts ?? new Date().toISOString()
if (!socket.destroyed) {
socket.write(JSON.stringify(msg) + '\n')
}
}
get connectionCount(): number {
return this.clients.size
}
async close(): Promise<void> {
for (const client of this.clients) {
client.destroy()
}
this.clients.clear()
// Close TCP server if running
if (this.tcpServer) {
await new Promise<void>(resolve => {
this.tcpServer!.close(() => {
this.tcpServer = null
this._tcpAddress = null
resolve()
})
})
}
return new Promise(resolve => {
if (!this.server) {
resolve()
return
}
this.server.close(() => {
this.server = null
if (process.platform === 'win32') {
// Remove the registry file
const regFile = join(getPipesDir(), `${this.name}.pipe`)
void unlink(regFile).catch(() => {})
} else {
void unlink(this.socketPath).catch(() => {})
}
resolve()
})
})
}
}
// ---------------------------------------------------------------------------
// Client (connector side)
// ---------------------------------------------------------------------------
export class PipeClient extends EventEmitter {
private socket: Socket | null = null
private handlers: PipeMessageHandler[] = []
readonly targetName: string
readonly senderName: string
readonly socketPath: string
private tcpEndpoint: TcpEndpoint | null
constructor(
targetName: string,
senderName?: string,
tcpEndpoint?: TcpEndpoint,
) {
super()
this.targetName = targetName
this.senderName = senderName ?? `client-${process.pid}`
this.socketPath = getPipePath(targetName)
this.tcpEndpoint = tcpEndpoint ?? null
}
/**
* Connect to a pipe server (UDS or TCP).
* When tcpEndpoint was provided in constructor, connects over TCP.
* Otherwise uses UDS with retry for socket file existence.
*/
async connect(timeoutMs: number = 5000): Promise<void> {
if (this.tcpEndpoint) {
return this.connectTcp(timeoutMs)
}
return this.connectUds(timeoutMs)
}
private async connectTcp(timeoutMs: number): Promise<void> {
const { host, port } = this.tcpEndpoint!
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new Error(
`TCP connection to "${this.targetName}" at ${host}:${port} timed out after ${timeoutMs}ms`,
),
)
}, timeoutMs)
const socket = createConnection({ host, port }, () => {
clearTimeout(timer)
this.socket = socket
this.setupSocketListeners(socket)
this.emit('connected')
resolve()
})
socket.on('error', err => {
clearTimeout(timer)
socket.destroy()
reject(err)
})
})
}
private async connectUds(timeoutMs: number): Promise<void> {
const { access } = await import('fs/promises')
const deadline = Date.now() + timeoutMs
const retryDelayMs = 300
// Wait for socket file to exist (Unix only)
if (process.platform !== 'win32') {
while (Date.now() < deadline) {
try {
await access(this.socketPath)
break
} catch {
if (Date.now() + retryDelayMs >= deadline) {
throw new Error(
`Pipe "${this.targetName}" not found at ${this.socketPath}. Is the server running?`,
)
}
await new Promise(r => setTimeout(r, retryDelayMs))
}
}
}
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => {
reject(
new Error(
`Connection to pipe "${this.targetName}" timed out after ${timeoutMs}ms`,
),
)
},
Math.max(deadline - Date.now(), 1000),
)
const socket = createConnection({ path: this.socketPath }, () => {
clearTimeout(timer)
this.socket = socket
this.setupSocketListeners(socket)
this.emit('connected')
resolve()
})
socket.on('error', err => {
clearTimeout(timer)
socket.destroy()
reject(err)
})
})
}
private setupSocketListeners(socket: Socket): void {
attachNdjsonFramer<PipeMessage>(socket, msg => {
this.emit('message', msg)
const reply = (replyMsg: PipeMessage) => this.send(replyMsg)
for (const handler of this.handlers) {
handler(msg, reply)
}
})
socket.on('close', () => {
this.emit('disconnect')
})
socket.on('error', err => {
logError(err)
})
}
onMessage(handler: PipeMessageHandler): void {
this.handlers.push(handler)
}
send(msg: PipeMessage): void {
if (!this.socket || this.socket.destroyed) {
throw new Error(`Not connected to pipe "${this.targetName}"`)
}
msg.from = msg.from ?? this.senderName
msg.ts = msg.ts ?? new Date().toISOString()
this.socket.write(JSON.stringify(msg) + '\n')
}
disconnect(): void {
if (this.socket) {
this.socket.destroy()
this.socket = null
}
}
get connected(): boolean {
return this.socket !== null && !this.socket.destroyed
}
}
// ---------------------------------------------------------------------------
// Convenience factory functions
// ---------------------------------------------------------------------------
export async function createPipeServer(
name: string,
options?: PipeServerOptions,
): Promise<PipeServer> {
const server = new PipeServer(name)
await server.start(options)
return server
}
export async function connectToPipe(
targetName: string,
senderName?: string,
timeoutMs?: number,
tcpEndpoint?: TcpEndpoint,
): Promise<PipeClient> {
const client = new PipeClient(targetName, senderName, tcpEndpoint)
await client.connect(timeoutMs)
return client
}
/**
* List all registered pipe names (fast — file scan only, no network probe).
* Use isPipeAlive() separately to check liveness.
*/
export async function listPipes(): Promise<string[]> {
try {
await ensurePipesDir()
const files = await readdir(getPipesDir())
const ext = process.platform === 'win32' ? '.pipe' : '.sock'
return files
.filter(f => f.endsWith(ext))
.map(f => f.replace(new RegExp(`\\${ext}$`), ''))
} catch {
return []
}
}
/**
* List only alive pipes (probes each one — slower, use sparingly).
* Automatically cleans up stale registry files.
*/
export async function listAlivePipes(): Promise<string[]> {
const names = await listPipes()
const ext = process.platform === 'win32' ? '.pipe' : '.sock'
const alive: string[] = []
for (const name of names) {
if (await isPipeAlive(name, 1000)) {
alive.push(name)
} else {
const staleFile = join(getPipesDir(), `${name}${ext}`)
void unlink(staleFile).catch(() => {})
}
}
return alive
}
/**
* Probe whether a pipe server is alive by sending a ping.
*/
export async function isPipeAlive(
name: string,
timeoutMs: number = 2000,
): Promise<boolean> {
try {
const client = new PipeClient(name, '_probe')
await client.connect(timeoutMs)
return new Promise(resolve => {
const timer = setTimeout(() => {
client.disconnect()
resolve(false)
}, timeoutMs)
client.onMessage(msg => {
if (msg.type === 'pong') {
clearTimeout(timer)
client.disconnect()
resolve(true)
}
})
client.send({ type: 'ping' })
})
} catch {
return false
}
}
// ─── PipeIpc AppState extension ──────────────────────────────────────
// AppState.pipeIpc is added at runtime when feature('PIPE_IPC') is on.
// These types and the default accessor ensure safe access from hooks
// and commands without modifying the original AppStateStore.
export type PipeIpcSlaveState = {
name: string
connectedAt: string
status: 'idle' | 'busy' | 'error'
lastActivityAt?: string
lastSummary?: string
lastEventType?:
| 'prompt'
| 'prompt_ack'
| 'stream'
| 'tool_start'
| 'tool_result'
| 'done'
| 'error'
unreadCount?: number
history: Array<{
type: string
content: string
from: string
timestamp: string
meta?: Record<string, unknown>
}>
}
export type PipeIpcState = {
role: 'main' | 'sub' | 'master' | 'slave'
/** Sub instance sequence number (1-based), null for main */
subIndex: number | null
/** Display name shown in UI. Controlled subs still display as "sub-N". */
displayRole: string
serverName: string | null
attachedBy: string | null
/** Local IP address for registry display and machine identity metadata */
localIp: string | null
/** Host info for registry display and machine identity metadata */
hostname: string | null
/** OS-level stable machine fingerprint */
machineId: string | null
/** Primary NIC MAC address */
mac: string | null
/** Show pipe status line in footer (set by /pipes command) */
statusVisible: boolean
/** Selector panel expanded (toggled by /pipes command) */
selectorOpen: boolean
/** Pipes selected for message broadcast (toggled via /pipes or status panel) */
selectedPipes: string[]
/** Current routing mode for normal prompts. `local` preserves selections but talks to main. */
routeMode: 'selected' | 'local'
slaves: Record<string, PipeIpcSlaveState>
/** Discovered pipe entries from registry (populated by /pipes) */
discoveredPipes: Array<{
id: string
pipeName: string
role: string
machineId: string
ip: string
hostname: string
alive: boolean
}>
}
const DEFAULT_PIPE_IPC: PipeIpcState = {
role: 'main',
subIndex: null,
displayRole: 'main',
serverName: null,
attachedBy: null,
localIp: null,
hostname: null,
machineId: null,
mac: null,
statusVisible: false,
selectorOpen: false,
selectedPipes: [],
routeMode: 'selected',
slaves: {},
discoveredPipes: [],
}
export function isPipeControlled(pipeIpc: PipeIpcState): boolean {
return Boolean(pipeIpc.attachedBy)
}
export function getPipeDisplayRole(pipeIpc: PipeIpcState): string {
if (pipeIpc.role === 'master') {
return 'master'
}
if (pipeIpc.subIndex != null) {
return `sub-${pipeIpc.subIndex}`
}
return 'main'
}
/**
* Get the local (non-loopback) IPv4 address for registry metadata.
*/
export function getLocalIp(): string {
try {
const { networkInterfaces } = require('os') as typeof import('os')
const nets = networkInterfaces()
for (const name of Object.keys(nets)) {
for (const net of nets[name] ?? []) {
if (net.family === 'IPv4' && !net.internal) {
return net.address
}
}
}
} catch {}
return '127.0.0.1'
}
/**
* Safely read pipeIpc from AppState, returning the default if not yet initialized.
* This avoids crashes when the state hasn't been extended by the PIPE_IPC bootstrap.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getPipeIpc(state: any): PipeIpcState {
return state?.pipeIpc ?? DEFAULT_PIPE_IPC
}

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()
}
}

View File

@@ -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'))
})
})
}

View File

@@ -8,7 +8,7 @@
*/
import { homedir as osHomedir } from 'os'
import { join } from 'path'
import { join, posix } from 'path'
type EnvLike = Record<string, string | undefined>
@@ -24,6 +24,13 @@ function resolveOptions(options?: XDGOptions): { env: EnvLike; home: string } {
}
}
function joinPortable(base: string, ...parts: string[]): string {
if (base.includes('/') && !base.includes('\\') && !/^[A-Za-z]:/.test(base)) {
return posix.join(base, ...parts)
}
return join(base, ...parts)
}
/**
* Get XDG state home directory
* Default: ~/.local/state
@@ -31,7 +38,7 @@ function resolveOptions(options?: XDGOptions): { env: EnvLike; home: string } {
*/
export function getXDGStateHome(options?: XDGOptions): string {
const { env, home } = resolveOptions(options)
return env.XDG_STATE_HOME ?? join(home, '.local', 'state')
return env.XDG_STATE_HOME ?? joinPortable(home, '.local', 'state')
}
/**
@@ -41,7 +48,7 @@ export function getXDGStateHome(options?: XDGOptions): string {
*/
export function getXDGCacheHome(options?: XDGOptions): string {
const { env, home } = resolveOptions(options)
return env.XDG_CACHE_HOME ?? join(home, '.cache')
return env.XDG_CACHE_HOME ?? joinPortable(home, '.cache')
}
/**
@@ -51,7 +58,7 @@ export function getXDGCacheHome(options?: XDGOptions): string {
*/
export function getXDGDataHome(options?: XDGOptions): string {
const { env, home } = resolveOptions(options)
return env.XDG_DATA_HOME ?? join(home, '.local', 'share')
return env.XDG_DATA_HOME ?? joinPortable(home, '.local', 'share')
}
/**
@@ -61,5 +68,5 @@ export function getXDGDataHome(options?: XDGOptions): string {
*/
export function getUserBinDir(options?: XDGOptions): string {
const { home } = resolveOptions(options)
return join(home, '.local', 'bin')
return joinPortable(home, '.local', 'bin')
}