mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
Feat/integrate lint preview (#285)
* feat: 适配 zed acp 协议 * docs: 完善 acp 文档 * feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎 Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct detectMimeFromBase64 to decode raw bytes from base64 Cherry-picked from origin/lint/preview (ee36954). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构 Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes. - 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层 - 修复 --daemon-worker=kind 等号格式解析 - 修复 daemon/bg fast path 缺少 setShellIfWindows() - 修复 checkPathExists 用 existsSync 替代 execSync('dir') - 7 个 spawn 站点迁移到 CliLaunchSpec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,6 @@ export function useAwaySummary(
|
||||
'tengu_sedge_lantern',
|
||||
false,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature('AWAY_SUMMARY')) return
|
||||
if (!gbEnabled) return
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
type PipeIpcSlaveState,
|
||||
} from '../utils/pipeTransport.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import {
|
||||
isMasterPipeMuted,
|
||||
hasSendOverride,
|
||||
removeSendOverride,
|
||||
} from '../utils/pipeMuteState.js'
|
||||
|
||||
/** Session history entry for pipe IPC monitoring. */
|
||||
export type SessionEntry = {
|
||||
@@ -113,6 +118,28 @@ function isMonitoredPipeEntryType(type: string): boolean {
|
||||
return MONITORED_PIPE_ENTRY_TYPES.includes(type)
|
||||
}
|
||||
|
||||
/** Business message types that should be dropped when a slave is muted. */
|
||||
const MUTED_DROPPABLE_TYPES = new Set([
|
||||
'prompt_ack',
|
||||
'stream',
|
||||
'tool_start',
|
||||
'tool_result',
|
||||
'done',
|
||||
'error',
|
||||
'permission_request',
|
||||
'permission_cancel',
|
||||
])
|
||||
|
||||
/**
|
||||
* Centralized mute check used by both attachPipeEntryEmitter and
|
||||
* useMasterMonitor's inline handler — keeps the two gates in sync.
|
||||
*/
|
||||
export function shouldDropMutedMessage(slaveName: string, msgType: string): boolean {
|
||||
if (hasSendOverride(slaveName)) return false
|
||||
if (!isMasterPipeMuted(slaveName)) return false
|
||||
return MUTED_DROPPABLE_TYPES.has(msgType)
|
||||
}
|
||||
|
||||
function pipeMessageToSessionEntry(
|
||||
slaveName: string,
|
||||
msg: PipeMessage,
|
||||
@@ -153,6 +180,35 @@ function attachPipeEntryEmitter(name: string, client: PipeClient): void {
|
||||
if (typeof client.on !== 'function') return
|
||||
const handler = (msg: PipeMessage) => {
|
||||
if (!isMonitoredPipeEntryType(msg.type)) return
|
||||
|
||||
// Mute gate: drop business messages from muted slaves
|
||||
if (shouldDropMutedMessage(name, msg.type)) {
|
||||
// Auto-deny permission_request to prevent slave deadlock
|
||||
if (msg.type === 'permission_request') {
|
||||
try {
|
||||
const payload = JSON.parse(msg.data ?? '{}')
|
||||
if (payload.requestId) {
|
||||
client.send({
|
||||
type: 'permission_response',
|
||||
data: JSON.stringify({
|
||||
requestId: payload.requestId,
|
||||
behavior: 'deny',
|
||||
feedback: 'Permission auto-denied: pipe is logically disconnected.',
|
||||
}),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Malformed payload — safe to ignore
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Clear /send override when slave turn completes
|
||||
if ((msg.type === 'done' || msg.type === 'error') && hasSendOverride(name)) {
|
||||
removeSendOverride(name)
|
||||
}
|
||||
|
||||
emitPipeEntry(name, pipeMessageToSessionEntry(name, msg))
|
||||
}
|
||||
_pipeEntryHandlers.set(name, handler)
|
||||
@@ -166,14 +222,14 @@ function emitSlaveClientRegistryChanged(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToSlaveClientRegistry(listener: () => void): () => void {
|
||||
export function subscribeToSlaveClientRegistry(listener: () => void): () => void {
|
||||
_slaveClientRegistryListeners.add(listener)
|
||||
return () => {
|
||||
_slaveClientRegistryListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function getSlaveClientRegistryVersion(): number {
|
||||
export function getSlaveClientRegistryVersion(): number {
|
||||
return _slaveClientRegistryVersion
|
||||
}
|
||||
|
||||
@@ -248,13 +304,23 @@ export function useMasterMonitor(): void {
|
||||
|
||||
for (const [slaveName, client] of _slaveClients.entries()) {
|
||||
const handler = (msg: PipeMessage) => {
|
||||
const entry = pipeMessageToSessionEntry(slaveName, msg)
|
||||
|
||||
// Only record relevant message types
|
||||
if (!isMonitoredPipeEntryType(msg.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mute gate (second gate, same helper as attachPipeEntryEmitter)
|
||||
if (shouldDropMutedMessage(slaveName, msg.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear /send override when slave turn completes
|
||||
if ((msg.type === 'done' || msg.type === 'error') && hasSendOverride(slaveName)) {
|
||||
removeSendOverride(slaveName)
|
||||
}
|
||||
|
||||
const entry = pipeMessageToSessionEntry(slaveName, msg)
|
||||
|
||||
setAppState(prev => {
|
||||
const slave = getPipeIpc(prev).slaves[slaveName]
|
||||
if (!slave) return prev
|
||||
@@ -294,6 +360,8 @@ export function useMasterMonitor(): void {
|
||||
// Handle slave disconnect
|
||||
const onDisconnect = () => {
|
||||
logForDebugging(`[MasterMonitor] Slave "${slaveName}" disconnected`)
|
||||
// Clear any lingering /send override before removing client
|
||||
removeSendOverride(slaveName)
|
||||
removeSlaveClient(slaveName)
|
||||
setAppState(prev => {
|
||||
const { [slaveName]: _removed, ...remainingSlaves } =
|
||||
|
||||
@@ -246,6 +246,15 @@ function registerMessageHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
// Handle relay mute/unmute from master
|
||||
server.onMessage((msg: PipeMessage, _reply) => {
|
||||
if (msg.type === 'relay_mute') {
|
||||
pp().setRelayMuted(true)
|
||||
} else if (msg.type === 'relay_unmute') {
|
||||
pp().setRelayMuted(false)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle detach
|
||||
server.onMessage((msg: PipeMessage, _reply) => {
|
||||
if (msg.type !== 'detach') return
|
||||
|
||||
141
src/hooks/usePipeMuteSync.ts
Normal file
141
src/hooks/usePipeMuteSync.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export function usePipePermissionForward({
|
||||
input: payload.input,
|
||||
toolUseContext,
|
||||
toolUseID: `pipe:${payload.requestId}`,
|
||||
pipeName,
|
||||
permissionResult: payload.permissionResult,
|
||||
permissionPromptStartTimeMs:
|
||||
payload.permissionPromptStartTimeMs,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* `getPipeRelay()` singleton set by usePipeIpc's attach handler.
|
||||
*/
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { getPipeRelay } from '../utils/pipePermissionRelay.js'
|
||||
import { getPipeRelay, isRelayMuted } from '../utils/pipePermissionRelay.js'
|
||||
import type { PipeMessage } from '../utils/pipeTransport.js'
|
||||
|
||||
export type PipeRelayHandle = {
|
||||
@@ -29,6 +29,9 @@ export function usePipeRelay(): PipeRelayHandle {
|
||||
if (typeof relay !== 'function') {
|
||||
return false
|
||||
}
|
||||
if (isRelayMuted()) {
|
||||
return false
|
||||
}
|
||||
relay(message)
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
|
||||
import { isKairosCronEnabled } from '@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { getCwd } from '../utils/cwd.js'
|
||||
import { getCronJitterConfig } from '../utils/cronJitterConfig.js'
|
||||
import { createCronScheduler } from '../utils/cronScheduler.js'
|
||||
import { removeCronTasks } from '../utils/cronTasks.js'
|
||||
import { createAutonomyQueuedPrompt } from '../utils/autonomyRuns.js'
|
||||
import { markAutonomyRunFailed } from '../utils/autonomyRuns.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
|
||||
import { createScheduledTaskFireMessage } from '../utils/messages.js'
|
||||
@@ -68,50 +71,92 @@ export function useScheduledTasks({
|
||||
// forward isMeta, so their messages remain visible in the
|
||||
// transcript. This is acceptable since normal mode is not the
|
||||
// primary use case for scheduled tasks.
|
||||
const enqueueForLead = (prompt: string) =>
|
||||
enqueuePendingNotification({
|
||||
value: prompt,
|
||||
mode: 'prompt',
|
||||
priority: 'later',
|
||||
isMeta: true,
|
||||
// Threaded through to cc_workload= in the billing-header
|
||||
// attribution block so the API can serve cron-initiated requests
|
||||
// at lower QoS when capacity is tight. No human is actively
|
||||
// waiting on this response.
|
||||
const enqueueForLead = async (prompt: string) => {
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: getCwd(),
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
enqueuePendingNotification(command)
|
||||
}
|
||||
|
||||
const scheduler = createCronScheduler({
|
||||
// Missed-task surfacing (onFire fallback). Teammate crons are always
|
||||
// session-only (durable:false) so they never appear in the missed list,
|
||||
// which is populated from disk at scheduler startup — this path only
|
||||
// handles team-lead durable crons.
|
||||
onFire: enqueueForLead,
|
||||
onFire: prompt => {
|
||||
void enqueueForLead(prompt)
|
||||
},
|
||||
// Normal fires receive the full CronTask so we can route by agentId.
|
||||
onFireTask: task => {
|
||||
if (task.agentId) {
|
||||
const teammate = findTeammateTaskByAgentId(
|
||||
task.agentId,
|
||||
store.getState().tasks,
|
||||
)
|
||||
if (teammate && !isTerminalTaskStatus(teammate.status)) {
|
||||
injectUserMessageToTeammate(teammate.id, task.prompt, setAppState)
|
||||
void (async () => {
|
||||
if (task.agentId) {
|
||||
const teammate = findTeammateTaskByAgentId(
|
||||
task.agentId,
|
||||
store.getState().tasks,
|
||||
)
|
||||
if (teammate && !isTerminalTaskStatus(teammate.status)) {
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: getCwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
const injected = injectUserMessageToTeammate(
|
||||
teammate.id,
|
||||
command.value as string,
|
||||
{
|
||||
autonomyRunId: command.autonomy?.runId,
|
||||
origin: command.origin,
|
||||
},
|
||||
setAppState,
|
||||
)
|
||||
if (!injected && command.autonomy?.runId) {
|
||||
await markAutonomyRunFailed(
|
||||
command.autonomy.runId,
|
||||
`Teammate ${task.agentId} exited before the scheduled message could be delivered.`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Teammate is gone — clean up the orphaned cron so it doesn't keep
|
||||
// firing into nowhere every tick. One-shots would auto-delete on
|
||||
// fire anyway, but recurring crons would loop until auto-expiry.
|
||||
logForDebugging(
|
||||
`[ScheduledTasks] teammate ${task.agentId} gone, removing orphaned cron ${task.id}`,
|
||||
)
|
||||
void removeCronTasks([task.id])
|
||||
return
|
||||
}
|
||||
// Teammate is gone — clean up the orphaned cron so it doesn't keep
|
||||
// firing into nowhere every tick. One-shots would auto-delete on
|
||||
// fire anyway, but recurring crons would loop until auto-expiry.
|
||||
logForDebugging(
|
||||
`[ScheduledTasks] teammate ${task.agentId} gone, removing orphaned cron ${task.id}`,
|
||||
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: getCwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
|
||||
const msg = createScheduledTaskFireMessage(
|
||||
`Running scheduled task (${formatCronFireTime(new Date())})`,
|
||||
)
|
||||
void removeCronTasks([task.id])
|
||||
return
|
||||
}
|
||||
const msg = createScheduledTaskFireMessage(
|
||||
`Running scheduled task (${formatCronFireTime(new Date())})`,
|
||||
)
|
||||
setMessages(prev => [...prev, msg])
|
||||
enqueueForLead(task.prompt)
|
||||
setMessages(prev => [...prev, msg])
|
||||
enqueuePendingNotification(command)
|
||||
})()
|
||||
},
|
||||
isLoading: () => isLoadingRef.current,
|
||||
assistantMode,
|
||||
|
||||
Reference in New Issue
Block a user