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

@@ -258,6 +258,7 @@ import { useManagePlugins } from '../hooks/useManagePlugins.js';
import { Messages } from '../components/Messages.js';
import { TaskListV2 } from '../components/TaskListV2.js';
import { TeammateViewHeader } from '../components/TeammateViewHeader.js';
import { getPipeDisplayRole, getPipeIpc, isPipeControlled } from '../utils/pipeTransport.js';
import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js';
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js';
import type { MCPServerConnection } from '../services/mcp/types.js';
@@ -332,6 +333,22 @@ const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
const useProactive =
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null;
const useMasterMonitor = feature('UDS_INBOX')
? require('../hooks/useMasterMonitor.js').useMasterMonitor
: () => undefined;
const useSlaveNotifications = feature('UDS_INBOX')
? require('../hooks/useSlaveNotifications.js').useSlaveNotifications
: () => undefined;
const usePipeIpc = feature('UDS_INBOX') ? require('../hooks/usePipeIpc.js').usePipeIpc : () => undefined;
const usePipeRelay = feature('UDS_INBOX')
? require('../hooks/usePipeRelay.js').usePipeRelay
: () => ({ relayPipeMessage: () => false, pipeReturnHadErrorRef: { current: false } });
const usePipePermissionForward = feature('UDS_INBOX')
? require('../hooks/usePipePermissionForward.js').usePipePermissionForward
: () => undefined;
const usePipeRouter = feature('UDS_INBOX')
? require('../hooks/usePipeRouter.js').usePipeRouter
: () => ({ routeToSelectedPipes: () => false });
/* eslint-enable @typescript-eslint/no-require-imports */
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js';
@@ -823,8 +840,7 @@ export function REPL({
);
const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
const disableMessageActions = feature('MESSAGE_ACTIONS')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), [])
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), [])
: false;
// Log REPL mount/unmount lifecycle
@@ -1478,7 +1494,6 @@ export function REPL({
messages.length,
);
if (feature('AWAY_SUMMARY')) {
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAwaySummary(messages, setMessages, isLoading);
}
const [cursor, setCursor] = useState<MessageActionsState | null>(null);
@@ -1515,8 +1530,7 @@ export function REPL({
// the branch is dead-code-eliminated in non-KAIROS builds (same pattern
// as useUnseenDivider above).
const { maybeLoadOlder } = feature('KAIROS')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAssistantHistory({
? useAssistantHistory({
config: remoteSessionConfig,
setMessages,
scrollRef,
@@ -3091,6 +3105,34 @@ export function REPL({
proactiveModule?.setContextBlocked(false);
}
}
// Relay assistant response to master when in slave mode.
if (feature('UDS_INBOX') && newMessage.type === 'assistant') {
// Extract text from content blocks (API format)
const msg = newMessage.message as any;
const contentBlocks = msg?.content ?? (newMessage as any).content ?? [];
const textParts: string[] = [];
if (Array.isArray(contentBlocks)) {
for (const block of contentBlocks) {
if (typeof block === 'string') {
textParts.push(block);
} else if (block?.type === 'text' && block.text) {
textParts.push(block.text);
}
}
} else if (typeof contentBlocks === 'string') {
textParts.push(contentBlocks);
}
const text = textParts.join('\n').trim();
if ('isApiErrorMessage' in newMessage && newMessage.isApiErrorMessage) {
pipeReturnHadErrorRef.current = true;
relayPipeMessage({
type: 'error',
data: text || 'Slave request failed',
});
} else if (text) {
relayPipeMessage({ type: 'stream', data: text });
}
}
},
newContent => {
// setResponseLength handles updating both responseLengthRef (for
@@ -3320,6 +3362,16 @@ export function REPL({
queryCheckpoint('query_end');
if (feature('UDS_INBOX')) {
if (abortController.signal.aborted) {
pipeReturnHadErrorRef.current = true;
relayPipeMessage({
type: 'error',
data: 'Slave request was interrupted before completion.',
});
}
}
// Capture ant-only API metrics before resetLoadingState clears the ref.
// For multi-request turns (tool use loops), compute P50 across all requests.
if (process.env.USER_TYPE === 'ant' && apiMetricsRef.current.length > 0) {
@@ -3431,6 +3483,7 @@ export function REPL({
}
try {
pipeReturnHadErrorRef.current = false;
// isLoading is derived from queryGuard — tryStart() above already
// transitioned dispatching→running, so no setter call needed here.
resetTimingRefs();
@@ -3463,15 +3516,26 @@ export function REPL({
}
}
await onQueryImpl(
latestMessages,
newMessages,
abortController,
shouldQuery,
additionalAllowedTools,
mainLoopModelParam,
effort,
);
try {
await onQueryImpl(
latestMessages,
newMessages,
abortController,
shouldQuery,
additionalAllowedTools,
mainLoopModelParam,
effort,
);
} catch (error) {
if (feature('UDS_INBOX')) {
pipeReturnHadErrorRef.current = true;
relayPipeMessage({
type: 'error',
data: error instanceof Error ? error.message : String(error),
});
}
throw error;
}
} finally {
// queryGuard.end() atomically checks generation and transitions
// running→idle. Returns false if a newer query owns the guard
@@ -3486,6 +3550,13 @@ export function REPL({
await mrOnTurnComplete(messagesRef.current, abortController.signal.aborted);
if (feature('UDS_INBOX') && !pipeReturnHadErrorRef.current) {
relayPipeMessage({
type: 'done',
data: '',
});
}
// Notify bridge clients that the turn is complete so mobile apps
// can stop the spark animation and show post-turn UI.
sendBridgeResultRef.current();
@@ -3747,6 +3818,27 @@ export function REPL({
proactiveModule?.resumeProactive();
}
// Route user input to selected pipe targets (extracted to usePipeRouter)
if (routeToSelectedPipes(input)) {
// Show the user's prompt in the message list so they can see what was sent
const userMessage = createUserMessage({ content: input });
setMessages(prev => [...prev, userMessage]);
if (!options?.fromKeybinding) {
addToHistory({
display: prependModeCharacterToInput(input, inputMode),
pastedContents,
});
}
setInputValue('');
helpers.setCursorOffset(0);
helpers.clearBuffer();
setPastedContents({});
setInputMode('prompt');
setIDESelection(undefined);
return;
}
// Handle immediate commands - these bypass the queue and execute right away
// even while Claude is processing. Commands opt-in via `immediate: true`.
// Commands triggered via keybindings are always treated as immediate.
@@ -4739,10 +4831,11 @@ export function REPL({
[onQuery, mainLoopModel, store],
);
const { relayPipeMessage, pipeReturnHadErrorRef } = usePipeRelay();
// Voice input integration (VOICE_MODE builds only)
const voice = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef })
? useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef })
: {
stripTrailing: () => 0,
handleKeyEvent: () => {},
@@ -4758,6 +4851,15 @@ export function REPL({
});
useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt });
useMasterMonitor();
useSlaveNotifications();
const pipeIpcState = useAppState(s => getPipeIpc(s as any));
usePipePermissionForward({ store, tools, setMessages, setToolUseConfirmQueue, getToolUseContext, mainLoopModel });
// Pipe IPC lifecycle — extracted to usePipeIpc hook
usePipeIpc({ store, handleIncomingPrompt });
const { routeToSelectedPipes } = usePipeRouter({ store, setAppState, addNotification });
// Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
if (feature('AGENT_TRIGGERS')) {
@@ -4768,7 +4870,6 @@ export function REPL({
// useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic
// condition would break rules-of-hooks.
const assistantMode = store.getState().kairosEnabled;
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useScheduledTasks!({ isLoading, assistantMode, setMessages });
}
@@ -4779,29 +4880,28 @@ export function REPL({
if (process.env.USER_TYPE === 'ant') {
// Tasks mode: watch for tasks and auto-process them
// eslint-disable-next-line react-hooks/rules-of-hooks
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
useTaskListWatcher({
taskListId,
isLoading,
onSubmitTask: handleIncomingPrompt,
});
// Loop mode: auto-tick when enabled (via /job command)
// eslint-disable-next-line react-hooks/rules-of-hooks
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
useProactive?.({
// Suppress ticks while an initial message is pending — the initial
// message will be processed asynchronously and a premature tick would
// race with it, causing concurrent-query enqueue of expanded skill text.
isLoading: isLoading || initialMessage !== null,
queuedCommandsLength: queuedCommands.length,
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
isInPlanMode: toolPermissionContext.mode === 'plan',
onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { isMeta: true }),
onQueueTick: (prompt: string) => enqueue({ mode: 'prompt', value: prompt, isMeta: true }),
});
}
// Proactive mode: auto-tick when enabled (via /proactive command)
// Moved out of USER_TYPE === 'ant' block so external users can use it.
// eslint-disable-next-line react-hooks/rules-of-hooks
useProactive?.({
// Suppress ticks while an initial message is pending — the initial
// message will be processed asynchronously and a premature tick would
// race with it, causing concurrent-query enqueue of expanded skill text.
isLoading: isLoading || initialMessage !== null,
queuedCommandsLength: queuedCommands.length,
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
isInPlanMode: toolPermissionContext.mode === 'plan',
onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { isMeta: true }),
onQueueTick: (prompt: string) => enqueue({ mode: 'prompt', value: prompt, isMeta: true }),
});
// Abort the current operation when a 'now' priority message arrives
// (e.g. from a chat UI client via UDS).
useEffect(() => {
@@ -5119,8 +5219,15 @@ export function REPL({
// Handle shift+down for teammate navigation and background task management.
// Guard onOpenBackgroundTasks when a local-jsx dialog (e.g. /mcp) is open —
// otherwise Shift+Down stacks BackgroundTasksDialog on top and deadlocks input.
// Third case: Shift+Down toggles the pipe IPC selector panel when pipes are active.
useBackgroundTaskNavigation({
onOpenBackgroundTasks: isShowingLocalJSXCommand ? undefined : () => setShowBashesDialog(true),
onTogglePipeSelector: () => {
setAppState((prev: any) => {
const pIpc = prev.pipeIpc ?? {};
return { ...prev, pipeIpc: { ...pIpc, selectorOpen: !pIpc.selectorOpen } };
});
},
});
// Auto-exit viewing mode when teammate completes or errors
useTeammateViewAutoExit();
@@ -5375,12 +5482,12 @@ export function REPL({
// /config, /theme, /diff, ...) both go here now.
const toolJsxCentered = isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true;
const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null;
// <AlternateScreen> at the root: everything below is inside its
// <Box height={rows}>. Handlers/contexts are zero-height so ScrollBox's
// flexGrow in FullscreenLayout resolves against this Box. The transcript
// early return above wraps its virtual-scroll branch the same way; only
// the 30-cap dump branch stays unwrapped for native terminal scrollback.
const mainReturn = (
<KeybindingSetup>
<AnimatedTerminalTitle
@@ -5413,7 +5520,7 @@ export function REPL({
isFullscreenEnvEnabled() &&
(centeredModal != null || !focusedInputDialog || focusedInputDialog === 'tool-permission')
}
onScroll={centeredModal || toolPermissionOverlay || viewedAgentTask ? undefined : composedOnScroll}
onScroll={composedOnScroll}
/>
{feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? (
<MessageActionsKeybindings handlers={messageActionHandlers} isActive={cursor !== null} />