mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +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,
|
||||
|
||||
Reference in New Issue
Block a user