feat: 远程群控 (#243)

* feat: restore pipe IPC, LAN pipes, monitor tool, and PR-package features

Core IPC system (UDS_INBOX):
- PipeServer/PipeClient with UDS + TCP dual transport, NDJSON protocol
- PipeRegistry: machineId-based role assignment, file locking
- Master/slave attach, prompt relay, permission forwarding
- Heartbeat lifecycle with parallel isPipeAlive probes
- Commands: /pipes, /attach, /detach, /send, /claim-main, /pipe-status

LAN Pipes (LAN_PIPES):
- UDP multicast beacon (224.0.71.67:7101) for zero-config LAN discovery
- PipeServer TCP listener, PipeClient TCP connect mode
- Heartbeat auto-attaches LAN peers via TCP
- Cross-machine attach allowed regardless of role
- /pipes shows [LAN] peers with role + hostname/IP
- SendMessageTool supports tcp: scheme with user consent

Architecture — extracted hooks from REPL.tsx (~830 lines → ~20 lines):
- usePipeIpc: lifecycle (bootstrap, handlers, heartbeat, cleanup)
- usePipeRelay: slave→master message relay via module singleton
- usePipePermissionForward: permission request/cancel forwarding
- usePipeRouter: selected pipe input routing with role+IP labels
- Shared ndjsonFramer.ts replaces 3 duplicate NDJSON parsers

Key fixes applied during development:
- Multicast binds to correct LAN interface (not WSL/Docker)
- Beacon ref stored as module singleton (not Zustand state mutation)
- Heartbeat preserves LAN peers in discoveredPipes and selectedPipes
- Disconnect handler calls removeSlaveClient (fixes listener leak)
- cleanupStaleEntries probes without lock, writes briefly under lock
- getMachineId uses async execFile (not blocking execSync)
- globalThis.__pipeSendToMaster replaced with setPipeRelay singleton
- M key only toggles route mode when selector panel is expanded
- User prompt displayed in message list on pipe broadcast
- Broadcast notifications show [role] + hostname/IP for LAN peers

Other restored features:
- Monitor tool: /monitor command, MonitorTool, MonitorMcpTask lifecycle
- Daemon supervisor and remoteControlServer command
- Tools: SnipTool, SleepTool, ListPeersTool, SendUserFileTool,
  WebBrowserTool, WorkflowTool, and 10+ stub→implementation rewrites
- Feature flags: UDS_INBOX, LAN_PIPES, MONITOR_TOOL, FORK_SUBAGENT,
  KAIROS, COORDINATOR_MODE, WORKFLOW_SCRIPTS, HISTORY_SNIP

Tests: 2190 pass / 0 fail (15 new: lanBeacon 7, peerAddress 8)

* fix: resolve merge conflicts and fix all tsc/test errors after main merge

- Export ToolResultBlockParam from Tool.ts (14 tool files fixed)
- Migrate ink imports from ../../ink.js to @anthropic/ink (7 files)
- Fix toolUseID → toolUseId typo in monitor.ts and MonitorTool.tsx
- Add fallback values for string|undefined type errors (8 locations)
- Fix AppState type in assistant.ts, add NewInstallWizard stubs
- Fix ParsedRepository.repo → .name in subscribe-pr.ts
- Fix AgentId/string type mismatch in BackgroundTasksDialog.tsx
- Fix PipeRelayFn return type in pipePermissionRelay.ts
- Use PipeMessage type in usePipeRelay.ts
- Fix lanBeacon.test.ts mock type assertions
- Create missing MouseActionEvent class for ink package
- Use ansi: color format instead of bare "green"/"red"
- Resolve theme.permission access via getTheme()

Result: 0 tsc errors, 2496 tests pass, 0 fail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 恢复 /poor 的说明

---------

Co-authored-by: unraid <local@unraid.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-11 23:22:55 +08:00
committed by GitHub
parent 2fea429dc6
commit 09fc515edb
124 changed files with 10958 additions and 577 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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