mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45: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:
165
src/utils/__tests__/lanBeacon.test.ts
Normal file
165
src/utils/__tests__/lanBeacon.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
60
src/utils/__tests__/peerAddress.test.ts
Normal file
60
src/utils/__tests__/peerAddress.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
76
src/utils/__tests__/pipePermissionRelay.test.ts
Normal file
76
src/utils/__tests__/pipePermissionRelay.test.ts
Normal 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: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
53
src/utils/__tests__/pipeTransport.test.ts
Normal file
53
src/utils/__tests__/pipeTransport.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
205
src/utils/lanBeacon.ts
Normal 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
39
src/utils/ndjsonFramer.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
156
src/utils/pipePermissionRelay.ts
Normal file
156
src/utils/pipePermissionRelay.ts
Normal 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
521
src/utils/pipeRegistry.ts
Normal 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
719
src/utils/pipeTransport.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user