Files
claude-code/src/hooks/useScheduledTasks.ts
claude-code-best c8d08d235b 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 from 637c908 dropped 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 from 637c908 dropped 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>
2026-04-16 20:59:29 +08:00

185 lines
7.3 KiB
TypeScript

import { useEffect, useRef } from 'react'
import { useAppStateStore, useSetAppState } from '../state/AppState.js'
import { isTerminalTaskStatus } from '../Task.js'
import {
findTeammateTaskByAgentId,
injectUserMessageToTeammate,
} 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'
import { WORKLOAD_CRON } from '../utils/workloadContext.js'
type Props = {
isLoading: boolean
/**
* When true, bypasses the isLoading gate so tasks can enqueue while a
* query is streaming rather than deferring to the next 1s check tick
* after the turn ends. Assistant mode no longer forces --proactive
* (#20425) so isLoading drops between turns like a normal REPL — this
* bypass is now a latency nicety, not a starvation fix. The prompt is
* enqueued at 'later' priority either way and drains between turns.
*/
assistantMode?: boolean
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
}
/**
* REPL wrapper for the cron scheduler. Mounts the scheduler once and tears
* it down on unmount. Fired prompts go into the command queue as 'later'
* priority, which the REPL drains via useCommandQueue between turns.
*
* Scheduler core (timer, file watcher, fire logic) lives in cronScheduler.ts
* so SDK/-p mode can share it — see print.ts for the headless wiring.
*/
export function useScheduledTasks({
isLoading,
assistantMode = false,
setMessages,
}: Props): void {
// Latest-value ref so the scheduler's isLoading() getter doesn't capture
// a stale closure. The effect mounts once; isLoading changes every turn.
const isLoadingRef = useRef(isLoading)
isLoadingRef.current = isLoading
const store = useAppStateStore()
const setAppState = useSetAppState()
useEffect(() => {
// Runtime gate checked here (not at the hook call site) so the hook
// stays unconditionally mounted — rules-of-hooks forbid wrapping the
// call in a dynamic condition. getFeatureValue_CACHED_WITH_REFRESH
// reads from disk; the 5-min TTL fires a background refetch but the
// effect won't re-run on value flip (assistantMode is the only dep),
// so this guard alone is launch-grain. The mid-session killswitch is
// the isKilled option below — check() polls it every tick.
if (!isKairosCronEnabled()) return
// System-generated — hidden from queue preview and transcript UI.
// In brief mode, executeForkedSlashCommand runs as a background
// subagent and returns no visible messages. In normal mode,
// isMeta is only propagated for plain-text prompts (via
// processTextPrompt); slash commands like /context:fork do not
// 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 = 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: prompt => {
void enqueueForLead(prompt)
},
// Normal fires receive the full CronTask so we can route by agentId.
onFireTask: task => {
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
}
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())})`,
)
setMessages(prev => [...prev, msg])
enqueuePendingNotification(command)
})()
},
isLoading: () => isLoadingRef.current,
assistantMode,
getJitterConfig: getCronJitterConfig,
isKilled: () => !isKairosCronEnabled(),
})
scheduler.start()
return () => scheduler.stop()
// assistantMode is stable for the session lifetime; store/setAppState are
// stable refs from useSyncExternalStore; setMessages is a stable useCallback.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assistantMode])
}
function formatCronFireTime(d: Date): string {
return d
.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
.replace(/,? at |, /, ' ')
.replace(/ ([AP]M)/, (_, ampm) => ampm.toLowerCase())
}