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

@@ -1,6 +1,7 @@
import type { ClickEvent } from './click-event.js'
import type { FocusEvent } from './focus-event.js'
import type { KeyboardEvent } from './keyboard-event.js'
import type { MouseActionEvent } from './mouse-action-event.js'
import type { PasteEvent } from './paste-event.js'
import type { ResizeEvent } from './resize-event.js'
@@ -9,6 +10,7 @@ type FocusEventHandler = (event: FocusEvent) => void
type PasteEventHandler = (event: PasteEvent) => void
type ResizeEventHandler = (event: ResizeEvent) => void
type ClickEventHandler = (event: ClickEvent) => void
type MouseActionEventHandler = (event: MouseActionEvent) => void
type HoverEventHandler = () => void
/**
@@ -33,6 +35,9 @@ export type EventHandlerProps = {
onResize?: ResizeEventHandler
onClick?: ClickEventHandler
onMouseDown?: MouseActionEventHandler
onMouseUp?: MouseActionEventHandler
onMouseDrag?: MouseActionEventHandler
onMouseEnter?: HoverEventHandler
onMouseLeave?: HoverEventHandler
}
@@ -51,6 +56,9 @@ export const HANDLER_FOR_EVENT: Record<
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
resize: { bubble: 'onResize' },
click: { bubble: 'onClick' },
mousedown: { bubble: 'onMouseDown' },
mouseup: { bubble: 'onMouseUp' },
mousedrag: { bubble: 'onMouseDrag' },
}
/**
@@ -68,6 +76,9 @@ export const EVENT_HANDLER_PROPS = new Set<string>([
'onPasteCapture',
'onResize',
'onClick',
'onMouseDown',
'onMouseUp',
'onMouseDrag',
'onMouseEnter',
'onMouseLeave',
])

View File

@@ -0,0 +1,44 @@
import { Event } from './event.js'
import type { EventTarget } from './terminal-event.js'
/**
* Mouse action event (mousedown, mouseup, mousedrag).
* Bubbles from the deepest hit node up through parentNode.
*/
export class MouseActionEvent extends Event {
/** Action type */
readonly type: 'mousedown' | 'mouseup' | 'mousedrag'
/** 0-indexed screen column */
readonly col: number
/** 0-indexed screen row */
readonly row: number
/** Mouse button number */
readonly button: number
/**
* Column relative to the current handler's Box.
* Recomputed before each handler fires.
*/
localCol = 0
/** Row relative to the current handler's Box. */
localRow = 0
constructor(
type: 'mousedown' | 'mouseup' | 'mousedrag',
col: number,
row: number,
button: number,
) {
super()
this.type = type
this.col = col
this.row = row
this.button = button
}
/** Recompute local coords relative to the target Box. */
prepareForTarget(target: EventTarget): void {
const dom = target as unknown as { yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number } }
this.localCol = this.col - (dom.yogaNode?.getComputedLeft?.() ?? 0)
this.localRow = this.row - (dom.yogaNode?.getComputedTop?.() ?? 0)
}
}

View File

@@ -1,6 +1,7 @@
import type { DOMElement } from './dom.js'
import { ClickEvent } from './events/click-event.js'
import type { EventHandlerProps } from './events/event-handlers.js'
import { MouseActionEvent } from './events/mouse-action-event.js'
import { nodeCache } from './node-cache.js'
/**
@@ -128,3 +129,43 @@ export function dispatchHover(
}
}
}
export function dispatchMouseAction(
root: DOMElement,
col: number,
row: number,
button: number,
type: 'mousedown' | 'mouseup' | 'mousedrag',
targetOverride?: DOMElement,
): DOMElement | null {
let target: DOMElement | undefined =
targetOverride ?? hitTest(root, col, row) ?? undefined
if (!target) return null
const propName =
type === 'mousedown'
? 'onMouseDown'
: type === 'mouseup'
? 'onMouseUp'
: 'onMouseDrag'
const event = new MouseActionEvent(type, col, row, button)
let handledBy: DOMElement | null = null
while (target) {
const handler = target._eventHandlers?.[propName] as
| ((event: MouseActionEvent) => void)
| undefined
if (handler) {
handledBy ??= target
event.prepareForTarget(target)
handler(event)
if (event.didStopImmediatePropagation()) {
return handledBy
}
}
target = target.parentNode as DOMElement | undefined
}
return handledBy
}