mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user