Files
claude-code/src/hooks/usePipeMuteSync.ts
unraid 637c9081f6 feat: integrate 5 feature branches + daemon/job 命令层级化 + 跨平台后台引擎 + TypeScript 错误修复
Squashed merge of:
1. fix/mcp-tsc-errors — 修复上游 MCP 重构后的 tsc 错误和测试失败
2. feat/pipe-mute-disconnect — Pipe IPC 逻辑断开、/lang 命令、mute 状态机
3. feat/stub-recovery-all — 实现全部 stub 恢复 (task 001-012)
4. feat/kairos-activation — KAIROS 激活解除阻塞 + 工具实现
5. codex/openclaw-autonomy-pr — 自治权限系统、运行记录、managed flows

Additional:
6. daemon/job 命令层级化重构 (subcommand 架构)
7. 跨平台后台引擎抽象 (detached/tmux engines)
8. 修复 src/ 中 43 个预存在的 TypeScript 类型错误
9. 修复 langfuse isolated test mock 完整性
10. 修复 CodeRabbit 审查的 Critical/Major/Minor 问题
11. remote-control-server logger 抽象 (测试 stderr 静默化)
12. /simplify 审查修复 (代码复用、质量、效率)
2026-04-14 19:53:36 +08:00

142 lines
4.4 KiB
TypeScript

/**
* usePipeMuteSync — Sync master's UI selection state to slave relay mute flags.
*
* Watches routeMode, selectedPipes, slave client registry, and send-override
* changes. When a slave is deselected or routeMode switches to 'local', sends
* relay_mute. When re-selected, sends relay_unmute. Also maintains the
* master-side muted set for in-flight message filtering.
*
* Feature-gated by UDS_INBOX (conditional import in REPL.tsx).
*/
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { useAppState } from '../state/AppState.js'
import { getPipeIpc } from '../utils/pipeTransport.js'
import {
setMasterMutedPipes,
clearMasterMutedPipes,
hasSendOverride,
clearSendOverrides,
subscribeSendOverride,
getSendOverrideVersion,
} from '../utils/pipeMuteState.js'
import {
getAllSlaveClients,
subscribeToSlaveClientRegistry,
getSlaveClientRegistryVersion,
} from './useMasterMonitor.js'
type UsePipeMuteSyncDeps = {
setToolUseConfirmQueue: (action: React.SetStateAction<Record<string, unknown>[]>) => void
}
export function usePipeMuteSync({
setToolUseConfirmQueue,
}: UsePipeMuteSyncDeps): void {
// Subscribe to individual scalars to avoid object-selector re-render churn
// (AppState.tsx warns against object-returning selectors)
const routeMode = useAppState(
s => (getPipeIpc(s).routeMode as 'selected' | 'local') ?? 'selected',
)
const selectedPipes: string[] = useAppState(
s => (getPipeIpc(s).selectedPipes as string[]) ?? [],
)
// Subscribe to slave client registry changes
const registryVersion = useSyncExternalStore(
subscribeToSlaveClientRegistry,
getSlaveClientRegistryVersion,
getSlaveClientRegistryVersion,
)
// Subscribe to send-override changes so mute recalculates after /send completes
const sendOverrideVersion = useSyncExternalStore(
subscribeSendOverride,
getSendOverrideVersion,
getSendOverrideVersion,
)
const prevMutedRef = useRef<Set<string>>(new Set())
useEffect(() => {
const slaves = getAllSlaveClients()
// Compute which slaves should be muted now
const nextMuted = new Set<string>()
if (routeMode === 'local') {
// All connected slaves muted
for (const name of slaves.keys()) {
if (!hasSendOverride(name)) {
nextMuted.add(name)
}
}
} else {
// routeMode === 'selected': mute slaves NOT in selectedPipes
const selectedSet = new Set(selectedPipes)
for (const name of slaves.keys()) {
if (!selectedSet.has(name) && !hasSendOverride(name)) {
nextMuted.add(name)
}
}
}
// Step 1: Update master-side muted set FIRST (before sending control packets)
setMasterMutedPipes(nextMuted)
const prevMuted = prevMutedRef.current
// Step 2: For newly muted slaves — abort pending permissions, then send relay_mute
for (const name of nextMuted) {
if (!prevMuted.has(name)) {
// Abort pending permission prompts for this slave
setToolUseConfirmQueue((queue: Record<string, unknown>[]) => {
const toAbort = queue.filter(
(item: Record<string, unknown>) => item.pipeName === name,
)
for (const item of toAbort) {
try {
;(item.onAbort as (() => void) | undefined)?.()
} catch {
// onAbort may throw if client disconnected — safe to ignore
}
}
return queue.filter((item: Record<string, unknown>) => item.pipeName !== name)
})
// Send relay_mute to slave
const client = slaves.get(name)
if (client?.connected) {
try {
client.send({ type: 'relay_mute' })
} catch {
// send may fail if socket is closing — non-fatal
}
}
}
}
// Step 3: For newly unmuted slaves — send relay_unmute
for (const name of prevMuted) {
if (!nextMuted.has(name)) {
const client = slaves.get(name)
if (client?.connected) {
try {
client.send({ type: 'relay_unmute' })
} catch {
// non-fatal
}
}
}
}
prevMutedRef.current = nextMuted
}, [routeMode, selectedPipes, registryVersion, sendOverrideVersion, setToolUseConfirmQueue])
// Cleanup on unmount: clear all master-side mute state
useEffect(() => {
return () => {
clearMasterMutedPipes()
clearSendOverrides()
}
}, [])
}