mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-21 15:55:50 +00:00
feat: /goal命令能力支持,参考codex实现 (#1261)
* feat: /goal命令能力支持,参考codex实现 * fix: 修复promp和提示词不一致的问题 * fix: 修复 goal 功能多项 AI 审查问题 - prompt 中 update 行为描述与运行时不一致(no-op → error) - src/commands/goal/ 使用相对路径导入,改为 src/* 别名 - /goal 命令标记 bridgeSafe 但含交互式对话框,改为 false - useGoalContinuation 中 origin 使用 as unknown as string 强转,改为直接传字符串 - ResumeConversation 路径缺少 goal hydration,补齐恢复逻辑 - onCancel 在非查询状态下误暂停 goal,加 queryGuard 守卫 - resumeGoal 允许从终态恢复,收紧为仅允许 paused 状态 - buildGoalContextBlock 生成畸形 XML 属性,改为合法 budget 属性 * fix: 修复剩余AI审查的问题 * fix: 防止goal状态丢失 * fix: 修复Biome规范错误问题 * fix: 修复部分情况下goal无法启动的问题 * fix: 增加断网后状态默认设置为PAUSE机制、完成暂停-恢复状态切换,且正常进行前端渲染。设置达到max turn后处理逻辑。 * fix: 修复终端异常断开情况,resume续跑;修复用户消息排队信息被goal输出信息覆盖的问题。 * fix: apply biome formatting to pass CI lint check Co-authored-by: Cursor <cursoragent@cursor.com> * fix: skip slash command echo in setUserInputOnProcessing to prevent UI flash Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: moyu <moyu@kingsoft.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
192
src/hooks/useGoalContinuation.ts
Normal file
192
src/hooks/useGoalContinuation.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* useGoalContinuation — React hook that drives the auto-continuation
|
||||
* loop for the `/goal` feature.
|
||||
*
|
||||
* Mounted inside REPL.tsx when feature('GOAL') is enabled. After each
|
||||
* turn completes (queryGuard transitions to idle), checks whether the
|
||||
* active goal should trigger another turn:
|
||||
*
|
||||
* 1. GOAL feature flag enabled
|
||||
* 2. Goal exists and status === 'active'
|
||||
* 3. Query just finished (isLoading transitioned false)
|
||||
* 4. No active local-JSX UI (modal dialog)
|
||||
* 5. Not in plan mode
|
||||
* 6. turnsExecuted < MAX_GOAL_TURNS
|
||||
* 7. No user messages in the queue (user input always takes priority)
|
||||
*
|
||||
* When user messages are queued during a goal turn, the hook always
|
||||
* yields to let them process first. After the user messages are
|
||||
* handled, the next idle will fire the hook again to continue.
|
||||
* This ensures commands like `/goal pause` are never starved by
|
||||
* auto-continuation.
|
||||
*
|
||||
* The hook is intentionally simple: a single useEffect that fires
|
||||
* when `isLoading` flips to false. No timers, no intervals — the
|
||||
* idle→enqueue→process→query→idle cycle is self-sustaining.
|
||||
*/
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import {
|
||||
markGoalMaxTurnsReached,
|
||||
getGoal,
|
||||
incrementGoalTurns,
|
||||
MAX_GOAL_TURNS,
|
||||
} from 'src/services/goal/goalState.js'
|
||||
import { persistCurrentGoal } from 'src/services/goal/goalStorage.js'
|
||||
import {
|
||||
buildBudgetLimitPrompt,
|
||||
buildContinuationPrompt,
|
||||
} from 'src/services/goal/prompts.js'
|
||||
import {
|
||||
enqueue,
|
||||
getCommandQueueSnapshot,
|
||||
} from 'src/utils/messageQueueManager.js'
|
||||
|
||||
function hookLog(msg: string): void {
|
||||
logForDebugging(`[goal] hook: ${msg}`)
|
||||
}
|
||||
|
||||
export type UseGoalContinuationOpts = {
|
||||
isLoading: boolean
|
||||
wasAborted: boolean
|
||||
queuedCommandsLength: number
|
||||
hasActiveLocalJsxUI: boolean
|
||||
isInPlanMode: boolean
|
||||
isQueryActiveNow?: () => boolean
|
||||
onMaxTurnsReached?: () => void
|
||||
onContinuationEnqueued?: (payload: {
|
||||
turn: number
|
||||
objective: string
|
||||
}) => void
|
||||
}
|
||||
|
||||
export function useGoalContinuation(opts: UseGoalContinuationOpts): void {
|
||||
const optsRef = useRef(opts)
|
||||
optsRef.current = opts
|
||||
|
||||
// Track whether we already enqueued for the current idle window.
|
||||
// Reset to false every time isLoading becomes true (new turn starts).
|
||||
const enqueuedRef = useRef(false)
|
||||
// Fire budget_limit prompt exactly once per budget transition.
|
||||
const budgetLimitFiredRef = useRef(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (opts.isLoading) {
|
||||
enqueuedRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid stale-render races: queue processing can reserve QueryGuard in an
|
||||
// earlier effect during the same commit. Read live state before deciding.
|
||||
if (opts.isQueryActiveNow?.()) {
|
||||
hookLog('skip: queryActiveNow=true')
|
||||
return
|
||||
}
|
||||
|
||||
// Codex parity: continuation only after normal completion.
|
||||
// Aborted turns (Ctrl+C / Escape) must not trigger a new turn.
|
||||
if (opts.wasAborted) {
|
||||
hookLog('skip: wasAborted=true')
|
||||
return
|
||||
}
|
||||
|
||||
// Already enqueued for this idle window
|
||||
if (enqueuedRef.current) return
|
||||
|
||||
// User messages always take priority over auto-continuation.
|
||||
// If the user typed something (e.g. `/goal pause`) while a turn was
|
||||
// running, let their message process first. After it finishes, the
|
||||
// next idle cycle will re-evaluate whether to continue.
|
||||
const liveQueueLength = getCommandQueueSnapshot().length
|
||||
if (liveQueueLength > 0) {
|
||||
hookLog('skip: yielding to queued user messages')
|
||||
return
|
||||
}
|
||||
if (opts.hasActiveLocalJsxUI) {
|
||||
hookLog('skip: activeLocalJsxUI')
|
||||
return
|
||||
}
|
||||
if (opts.isInPlanMode) {
|
||||
hookLog('skip: planMode')
|
||||
return
|
||||
}
|
||||
|
||||
const goal = getGoal()
|
||||
if (!goal) {
|
||||
budgetLimitFiredRef.current = false
|
||||
return
|
||||
}
|
||||
if (goal.status === 'active') {
|
||||
budgetLimitFiredRef.current = false
|
||||
}
|
||||
|
||||
// Budget-limited: inject one final steering prompt so the model
|
||||
// knows to stop substantive work and summarise progress.
|
||||
if (goal.status === 'budget_limited' && !budgetLimitFiredRef.current) {
|
||||
budgetLimitFiredRef.current = true
|
||||
enqueuedRef.current = true
|
||||
const prompt = buildBudgetLimitPrompt(goal)
|
||||
logForDebugging(
|
||||
'[goal] hook: budget limit reached, injecting wrap-up prompt',
|
||||
)
|
||||
enqueue({
|
||||
value: prompt,
|
||||
mode: 'prompt',
|
||||
priority: 'now',
|
||||
isMeta: true,
|
||||
origin: 'goal-budget-limit',
|
||||
skipSlashCommands: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Only continue for active goals
|
||||
if (goal.status !== 'active') {
|
||||
hookLog(`skip: status="${goal.status}" (not active)`)
|
||||
return
|
||||
}
|
||||
|
||||
if (goal.turnsExecuted >= MAX_GOAL_TURNS) {
|
||||
const marked = markGoalMaxTurnsReached()
|
||||
if (marked) {
|
||||
persistCurrentGoal()
|
||||
opts.onMaxTurnsReached?.()
|
||||
}
|
||||
logForDebugging(
|
||||
`[goal] hook: MAX_GOAL_TURNS (${MAX_GOAL_TURNS}) reached, stopping`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// All conditions met — enqueue a continuation turn
|
||||
enqueuedRef.current = true
|
||||
|
||||
const turns = incrementGoalTurns()
|
||||
persistCurrentGoal()
|
||||
|
||||
const prompt = buildContinuationPrompt(goal)
|
||||
logForDebugging(
|
||||
`[goal] hook: enqueuing turn ${turns} for "${goal.objective.slice(0, 60)}"`,
|
||||
)
|
||||
|
||||
enqueue({
|
||||
value: prompt,
|
||||
mode: 'prompt',
|
||||
priority: 'now',
|
||||
isMeta: true,
|
||||
origin: 'goal-continuation',
|
||||
skipSlashCommands: true,
|
||||
})
|
||||
opts.onContinuationEnqueued?.({
|
||||
turn: turns,
|
||||
objective: goal.objective,
|
||||
})
|
||||
}, [
|
||||
opts.isLoading,
|
||||
opts.wasAborted,
|
||||
opts.queuedCommandsLength,
|
||||
opts.hasActiveLocalJsxUI,
|
||||
opts.isInPlanMode,
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user