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,6 @@
import { feature } from 'bun:bundle'
import * as React from 'react'
import { memo, type ReactNode, useMemo, useRef } from 'react'
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'
import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js'
import { useSetPromptOverlay } from '../../context/promptOverlayContext.js'
@@ -8,14 +8,16 @@ import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'
import type { IDESelection } from '../../hooks/useIdeSelection.js'
import { useSettings } from '../../hooks/useSettings.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '@anthropic/ink'
import { Box, Text, useInput } from '@anthropic/ink'
import type { MCPServerConnection } from '../../services/mcp/types.js'
import { useAppState } from '../../state/AppState.js'
import { useRegisterOverlay } from '../../context/overlayContext.js'
import { useAppState, useSetAppState } from '../../state/AppState.js'
import type { ToolPermissionContext } from '../../Tool.js'
import type { Message } from '../../types/message.js'
import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js'
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
import { getPipeDisplayRole, isPipeControlled } from '../../utils/pipeTransport.js'
import { isUndercover } from '../../utils/undercover.js'
import {
CoordinatorTaskPanel,
@@ -28,49 +30,48 @@ import {
} from '../StatusLine.js'
import { Notifications } from './Notifications.js'
import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'
import {
PromptInputFooterSuggestions,
type SuggestionItem,
} from './PromptInputFooterSuggestions.js'
// Inline pipe status is shown only after /pipes sets pipeIpc.statusVisible.
import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js'
import { PromptInputHelpMenu } from './PromptInputHelpMenu.js'
type Props = {
apiKeyStatus: VerificationStatus
debug: boolean
apiKeyStatus: VerificationStatus;
debug: boolean;
exitMessage: {
show: boolean
key?: string
}
vimMode: VimMode | undefined
mode: PromptInputMode
autoUpdaterResult: AutoUpdaterResult | null
isAutoUpdating: boolean
verbose: boolean
onAutoUpdaterResult: (result: AutoUpdaterResult) => void
onChangeIsUpdating: (isUpdating: boolean) => void
suggestions: SuggestionItem[]
selectedSuggestion: number
maxColumnWidth?: number
toolPermissionContext: ToolPermissionContext
helpOpen: boolean
suppressHint: boolean
isLoading: boolean
tasksSelected: boolean
teamsSelected: boolean
bridgeSelected: boolean
tmuxSelected: boolean
teammateFooterIndex?: number
ideSelection: IDESelection | undefined
mcpClients?: MCPServerConnection[]
isPasting?: boolean
isInputWrapped?: boolean
messages: Message[]
isSearching: boolean
historyQuery: string
setHistoryQuery: (query: string) => void
historyFailedMatch: boolean
onOpenTasksDialog?: (taskId?: string) => void
}
show: boolean;
key?: string;
};
vimMode: VimMode | undefined;
mode: PromptInputMode;
autoUpdaterResult: AutoUpdaterResult | null;
isAutoUpdating: boolean;
verbose: boolean;
onAutoUpdaterResult: (result: AutoUpdaterResult) => void;
onChangeIsUpdating: (isUpdating: boolean) => void;
suggestions: SuggestionItem[];
selectedSuggestion: number;
maxColumnWidth?: number;
toolPermissionContext: ToolPermissionContext;
helpOpen: boolean;
suppressHint: boolean;
isLoading: boolean;
tasksSelected: boolean;
teamsSelected: boolean;
bridgeSelected: boolean;
tmuxSelected: boolean;
teammateFooterIndex?: number;
ideSelection: IDESelection | undefined;
mcpClients?: MCPServerConnection[];
isPasting?: boolean;
isInputWrapped?: boolean;
messages: Message[];
isSearching: boolean;
historyQuery: string;
setHistoryQuery: (query: string) => void;
historyFailedMatch: boolean;
onOpenTasksDialog?: (taskId?: string) => void;
};
function PromptInputFooter({
apiKeyStatus,
@@ -106,43 +107,35 @@ function PromptInputFooter({
historyFailedMatch,
onOpenTasksDialog,
}: Props): ReactNode {
const settings = useSettings()
const { columns, rows } = useTerminalSize()
const messagesRef = useRef(messages)
messagesRef.current = messages
const lastAssistantMessageId = useMemo(
() => getLastAssistantMessageId(messages),
[messages],
)
const isNarrow = columns < 80
const settings = useSettings();
const { columns, rows } = useTerminalSize();
const messagesRef = useRef(messages);
messagesRef.current = messages;
const lastAssistantMessageId = useMemo(() => getLastAssistantMessageId(messages), [messages]);
const isNarrow = columns < 80;
// In fullscreen the bottom slot is flexShrink:0, so every row here is a row
// stolen from the ScrollBox. Drop the optional StatusLine first. Non-fullscreen
// has terminal scrollback to absorb overflow, so we never hide StatusLine there.
const isFullscreen = isFullscreenEnvEnabled()
const isShort = isFullscreen && rows < 24
const isFullscreen = isFullscreenEnvEnabled();
const isShort = isFullscreen && rows < 24;
// Pill highlights when tasks is the active footer item AND no specific
// agent row is selected. When coordinatorTaskIndex >= 0 the pointer has
// moved into CoordinatorTaskPanel, so the pill should un-highlight.
// coordinatorTaskCount === 0 covers the bash-only case (no agent rows
// exist, pill is the only selectable item).
const coordinatorTaskCount = useCoordinatorTaskCount()
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex)
const pillSelected =
tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0)
const coordinatorTaskCount = useCoordinatorTaskCount();
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex);
const pillSelected = tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0);
// Hide `? for shortcuts` if the user has a custom status line, or during ctrl-r
const suppressHint =
suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching
const suppressHint = suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching;
// Fullscreen: portal data to FullscreenLayout — see promptOverlayContext.tsx
const overlayData = useMemo(
() =>
isFullscreen && suggestions.length
? { suggestions, selectedSuggestion, maxColumnWidth }
: null,
() => (isFullscreen && suggestions.length ? { suggestions, selectedSuggestion, maxColumnWidth } : null),
[isFullscreen, suggestions, selectedSuggestion, maxColumnWidth],
)
useSetPromptOverlay(overlayData)
);
useSetPromptOverlay(overlayData);
if (suggestions.length && !isFullscreen) {
return (
@@ -153,13 +146,11 @@ function PromptInputFooter({
maxColumnWidth={maxColumnWidth}
/>
</Box>
)
);
}
if (helpOpen) {
return (
<PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />
)
return <PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />;
}
return (
@@ -171,17 +162,10 @@ function PromptInputFooter({
gap={isNarrow ? 0 : 1}
>
<Box flexDirection="column" flexShrink={isNarrow ? 0 : 1}>
{mode === 'prompt' &&
!isShort &&
!exitMessage.show &&
!isPasting &&
statusLineShouldDisplay(settings) && (
<StatusLine
messagesRef={messagesRef}
lastAssistantMessageId={lastAssistantMessageId}
vimMode={vimMode}
/>
)}
{mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && (
<StatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} vimMode={vimMode} />
)}
<PipeStatusInline />
<PromptInputFooterLeftSide
exitMessage={exitMessage}
vimMode={vimMode}
@@ -218,62 +202,215 @@ function PromptInputFooter({
isNarrow={isNarrow}
/>
)}
{process.env.USER_TYPE === 'ant' && isUndercover() && (
<Text dimColor>undercover</Text>
)}
{process.env.USER_TYPE === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
</Box>
</Box>
{process.env.USER_TYPE === 'ant' && <CoordinatorTaskPanel />}
</>
)
);
}
export default memo(PromptInputFooter)
export default memo(PromptInputFooter);
type BridgeStatusProps = {
bridgeSelected: boolean
}
bridgeSelected: boolean;
};
function BridgeStatusIndicator({
bridgeSelected,
}: BridgeStatusProps): React.ReactNode {
if (!feature('BRIDGE_MODE')) return null
function BridgeStatusIndicator({ bridgeSelected }: BridgeStatusProps): React.ReactNode {
if (!feature('BRIDGE_MODE')) return null;
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const enabled = useAppState(s => s.replBridgeEnabled)
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const connected = useAppState(s => s.replBridgeConnected)
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const sessionActive = useAppState(s => s.replBridgeSessionActive)
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const reconnecting = useAppState(s => s.replBridgeReconnecting)
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const explicit = useAppState(s => s.replBridgeExplicit)
const enabled = useAppState(s => s.replBridgeEnabled);
const connected = useAppState(s => s.replBridgeConnected);
const sessionActive = useAppState(s => s.replBridgeSessionActive);
const reconnecting = useAppState(s => s.replBridgeReconnecting);
const explicit = useAppState(s => s.replBridgeExplicit);
// Failed state is surfaced via notification (useReplBridge), not a footer pill.
if (!isBridgeEnabled() || !enabled) return null
if (!isBridgeEnabled() || !enabled) return null;
const status = getBridgeStatus({
error: undefined,
connected,
sessionActive,
reconnecting,
})
});
// For implicit (config-driven) remote, only show the reconnecting state
if (!explicit && status.label !== 'Remote Control reconnecting') {
return null
return null;
}
return (
<Text
color={bridgeSelected ? 'background' : status.color}
inverse={bridgeSelected}
wrap="truncate"
>
<Text color={bridgeSelected ? 'background' : status.color} inverse={bridgeSelected} wrap="truncate">
{status.label}
{bridgeSelected && <Text dimColor> · Enter to view</Text>}
</Text>
)
);
}
/**
* Inline pipe status panel with interactive checkbox selection.
*
* Shows after /pipes sets statusVisible. Displays:
* - Header: own pipe info (collapsed mode)
* - Ctrl+P: toggle expanded mode with sub list + checkboxes
* - Expanded: ↑↓ to move cursor, Space to toggle, Enter/Esc to collapse
*
* Only uses AppState + Ink — no heavy external imports.
*/
function PipeStatusInline(): React.ReactNode {
if (!feature('UDS_INBOX')) return null;
// All hooks must be called before any conditional return to maintain
// consistent hook count across renders (React rules of hooks).
const pipeIpc = useAppState(s => (s as any).pipeIpc);
const setAppState = useSetAppState();
const [cursorIndex, setCursorIndex] = useState(0);
const isVisible = !!pipeIpc?.statusVisible && !!pipeIpc?.serverName;
const selectorOpen: boolean = !!pipeIpc?.selectorOpen;
const slaves = pipeIpc?.slaves ?? {};
const slaveNames = Object.keys(slaves);
const discovered: Array<{ pipeName: string; role: string; ip: string; hostname: string }> =
pipeIpc?.discoveredPipes ?? [];
const allPipes = [...new Set([...slaveNames, ...discovered.map(d => d.pipeName)])].filter(
n => n !== pipeIpc?.serverName,
);
const selectedPipes: string[] = pipeIpc?.selectedPipes ?? [];
const displayRole = pipeIpc ? getPipeDisplayRole(pipeIpc) : 'main';
const routeMode: 'selected' | 'local' = pipeIpc?.routeMode ?? 'selected';
const selectedRouteActive = routeMode !== 'local' && selectedPipes.length > 0;
const setRouteMode = (mode: 'selected' | 'local') => {
setAppState((prev: any) => {
const pIpc = prev.pipeIpc ?? {};
return { ...prev, pipeIpc: { ...pIpc, routeMode: mode } };
});
};
// Register as modal overlay when selector is open.
// This sets isModalOverlayActive=true in PromptInput → TextInput focus=false
// → TextInput's useInput is deactivated → ↑↓ no longer trigger history navigation.
// Same mechanism used by BackgroundTasksDialog, FuzzyPicker, etc.
useRegisterOverlay('pipe-selector', isVisible && selectorOpen);
// Keyboard handler — must be called every render (hooks rules).
// ↑↓ navigate list, Space toggles selection, ←/→ or m switches route mode, Enter/Esc close selector.
// No conflict with history nav: useRegisterOverlay above disables TextInput when open.
useInput((_input, key) => {
if (!isVisible) return;
// When collapsed: only ←/→ arrow keys toggle route mode (no overlay,
// so printable keys like 'm' would leak into the TextInput).
// When expanded: ←/→ and 'm' all work (overlay blocks TextInput).
if (selectedPipes.length > 0) {
const arrowToggle = key.leftArrow || key.rightArrow;
const mToggle = selectorOpen && _input.toLowerCase() === 'm';
if (arrowToggle || mToggle) {
setRouteMode(routeMode === 'local' ? 'selected' : 'local');
return;
}
}
if (!selectorOpen) return;
if (key.downArrow) {
setCursorIndex(i => Math.min(i + 1, allPipes.length - 1));
} else if (key.upArrow) {
setCursorIndex(i => Math.max(i - 1, 0));
} else if (_input === ' ') {
const pipeName = allPipes[cursorIndex];
if (pipeName) {
setAppState((prev: any) => {
const pIpc = prev.pipeIpc ?? {};
const sel: string[] = pIpc.selectedPipes ?? [];
const newSel = sel.includes(pipeName) ? sel.filter((n: string) => n !== pipeName) : [...sel, pipeName];
return { ...prev, pipeIpc: { ...pIpc, selectedPipes: newSel } };
});
}
} else if (key.return || key.escape) {
setAppState((prev: any) => {
const pIpc = prev.pipeIpc ?? {};
return { ...prev, pipeIpc: { ...pIpc, selectorOpen: false } };
});
}
});
// Early return AFTER all hooks
if (!isVisible) return null;
if (!selectorOpen) {
return (
<Box height={1} gap={1}>
<Text dimColor>pipe:</Text>
<Text bold>{pipeIpc.serverName}</Text>
<Text dimColor>({displayRole})</Text>
{pipeIpc.localIp && <Text dimColor>{pipeIpc.localIp}</Text>}
{allPipes.length > 0 && (
<Text color={selectedRouteActive ? 'success' : undefined} dimColor={selectedPipes.length === 0}>
{selectedPipes.length}/{allPipes.length} selected
</Text>
)}
{pipeIpc && isPipeControlled(pipeIpc) && pipeIpc.attachedBy && (
<Text color="warning">
{'→ '}
{pipeIpc.attachedBy}
</Text>
)}
{allPipes.length > 0 && (
<Text color={selectedRouteActive ? 'success' : undefined} dimColor={!selectedRouteActive}>
{selectedPipes.length > 0
? `${routeMode === 'local' ? 'local main' : 'selected pipes only'} · ←/→ switch · Shift+↓ edit`
: 'local main · Shift+↓ select'}
</Text>
)}
</Box>
);
}
// Expanded mode: header + pipe list with checkboxes
return (
<Box flexDirection="column">
<Box height={1} gap={1}>
<Text dimColor>pipe:</Text>
<Text bold>{pipeIpc.serverName}</Text>
<Text dimColor>({displayRole})</Text>
{pipeIpc.localIp && <Text dimColor>{pipeIpc.localIp}</Text>}
<Text color="warning"> move Space select / or m route Enter/Esc close Shift+ toggle</Text>
</Box>
<Box height={1} paddingLeft={2}>
<Text dimColor>
{selectedPipes.length > 0
? `当前普通 prompt 走 ${routeMode === 'local' ? '本地 main' : '已选 sub'};切换不会清空选择`
: '当前未选择 pipe普通 prompt 会在本地 main 对话执行'}
</Text>
</Box>
{allPipes.map((name, idx) => {
const isSelected = selectedPipes.includes(name);
const isCursor = idx === cursorIndex;
const isConnected = !!slaves[name];
const disc = discovered.find(d => d.pipeName === name);
const label = disc ? `${disc.role} ${disc.hostname}/${disc.ip}` : '';
return (
<Box key={name} height={1} paddingLeft={2}>
<Text
inverse={isCursor}
color={isSelected ? 'success' : isConnected ? undefined : 'error'}
dimColor={!isConnected && !isCursor}
>
{isSelected ? '☑' : '☐'} {name}
{isConnected ? '' : ' [offline]'}
{label ? ` (${label})` : ''}
</Text>
</Box>
);
})}
{allPipes.length === 0 && (
<Box height={1} paddingLeft={2}>
<Text dimColor>No other pipes found. Start another instance.</Text>
</Box>
)}
</Box>
);
}