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:
@@ -355,6 +355,9 @@ 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 useGoalContinuation: typeof import('../hooks/useGoalContinuation.js').useGoalContinuation | null = feature('GOAL')
|
||||
? require('../hooks/useGoalContinuation.js').useGoalContinuation
|
||||
: null;
|
||||
const useMasterMonitor = feature('UDS_INBOX')
|
||||
? require('../hooks/useMasterMonitor.js').useMasterMonitor
|
||||
: () => undefined;
|
||||
@@ -1133,6 +1136,12 @@ export function REPL({
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
// Track whether the last turn was user-aborted (Ctrl+C / Escape).
|
||||
// When true, useGoalContinuation skips the continuation enqueue so
|
||||
// interrupted turns don't spin into an unstoppable loop. Reset to
|
||||
// false at the start of the next user-initiated turn.
|
||||
const [wasAborted, setWasAborted] = useState(false);
|
||||
|
||||
// Ref for the bridge result callback — set after useReplBridge initializes,
|
||||
// read in the onQuery finally block to notify mobile clients that a turn ended.
|
||||
const sendBridgeResultRef = useRef<() => void>(() => {});
|
||||
@@ -2220,6 +2229,16 @@ export function REPL({
|
||||
// cached name and write it to the wrong transcript on first message.
|
||||
clearSessionMetadata();
|
||||
restoreSessionMetadata(log);
|
||||
|
||||
// Hydrate goal state from the resumed session's transcript
|
||||
if (feature('GOAL') && log.goal) {
|
||||
const { hydrateGoalFromTranscript } =
|
||||
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js');
|
||||
const goalsMap = new Map<UUID, import('../types/logs.js').GoalState>();
|
||||
goalsMap.set(sessionId as UUID, log.goal);
|
||||
hydrateGoalFromTranscript(goalsMap, sessionId as UUID);
|
||||
}
|
||||
|
||||
// Resumed sessions shouldn't re-title from mid-conversation context
|
||||
// (same reasoning as the useRef seed), and the previous session's
|
||||
// Haiku title shouldn't carry over.
|
||||
@@ -2523,6 +2542,24 @@ export function REPL({
|
||||
proactiveModule?.pauseProactive();
|
||||
}
|
||||
|
||||
// Ctrl+C during an active goal turn pauses the goal so the
|
||||
// continuation loop stops. The user can /goal resume to continue later.
|
||||
// Guard: only pause when a query is actually in flight. onCancel() is
|
||||
// also called from the restore/edit flow (idle), and pausing then would
|
||||
// incorrectly stop the next continuation.
|
||||
if (feature('GOAL') && queryGuard.getSnapshot()) {
|
||||
const { getGoal, pauseGoal } =
|
||||
require('../services/goal/goalState.js') as typeof import('../services/goal/goalState.js');
|
||||
const { persistCurrentGoal } =
|
||||
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js');
|
||||
const currentGoal = getGoal();
|
||||
if (currentGoal?.status === 'active') {
|
||||
pauseGoal();
|
||||
persistCurrentGoal();
|
||||
}
|
||||
}
|
||||
setWasAborted(true);
|
||||
|
||||
queryGuard.forceEnd();
|
||||
skipIdleCheckRef.current = false;
|
||||
|
||||
@@ -3162,6 +3199,43 @@ export function REPL({
|
||||
proactiveModule?.setContextBlocked(false);
|
||||
}
|
||||
}
|
||||
// Auto-pause active /goal when the turn failed due to connectivity.
|
||||
// Continuing immediately after network failures usually burns turns
|
||||
// without progress and can rapidly hit max-turn guards.
|
||||
if (
|
||||
feature('GOAL') &&
|
||||
newMessage.type === 'assistant' &&
|
||||
'isApiErrorMessage' in newMessage &&
|
||||
newMessage.isApiErrorMessage
|
||||
) {
|
||||
const assistantText =
|
||||
getContentText((newMessage.message?.content ?? '') as string | ContentBlockParam[]) ?? '';
|
||||
const lowerText = assistantText.toLowerCase();
|
||||
const isConnectivityFailure =
|
||||
lowerText.includes('connection error') ||
|
||||
lowerText.includes('fetch failed') ||
|
||||
lowerText.includes('network error') ||
|
||||
lowerText.includes('enotfound') ||
|
||||
lowerText.includes('econnreset') ||
|
||||
lowerText.includes('etimedout');
|
||||
|
||||
if (isConnectivityFailure) {
|
||||
const { getGoal, pauseGoal } =
|
||||
require('../services/goal/goalState.js') as typeof import('../services/goal/goalState.js');
|
||||
const { persistCurrentGoal } =
|
||||
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js');
|
||||
const currentGoal = getGoal();
|
||||
if (currentGoal?.status === 'active') {
|
||||
pauseGoal();
|
||||
persistCurrentGoal();
|
||||
addNotification({
|
||||
key: 'goal-auto-paused-connectivity-error',
|
||||
text: 'Detected connection error. Active goal was auto-paused. Run /goal resume after network recovers.',
|
||||
priority: 'immediate',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Relay assistant response to master when in slave mode.
|
||||
if (feature('UDS_INBOX') && newMessage.type === 'assistant') {
|
||||
// Extract text from content blocks (API format)
|
||||
@@ -3550,6 +3624,7 @@ export function REPL({
|
||||
|
||||
try {
|
||||
pipeReturnHadErrorRef.current = false;
|
||||
setWasAborted(false);
|
||||
// isLoading is derived from queryGuard — tryStart() above already
|
||||
// transitioned dispatching→running, so no setter call needed here.
|
||||
resetTimingRefs();
|
||||
@@ -3607,6 +3682,7 @@ export function REPL({
|
||||
// running→idle. Returns false if a newer query owns the guard
|
||||
// (cancel+resubmit race where the stale finally fires as a microtask).
|
||||
if (queryGuard.end(thisGeneration)) {
|
||||
setWasAborted(abortController.signal.aborted);
|
||||
setLastQueryCompletionTime(Date.now());
|
||||
skipIdleCheckRef.current = false;
|
||||
// Always reset loading state in finally - this ensures cleanup even
|
||||
@@ -3960,6 +4036,7 @@ export function REPL({
|
||||
doneOptions?: {
|
||||
display?: CommandResultDisplay;
|
||||
metaMessages?: string[];
|
||||
displayArgs?: string;
|
||||
},
|
||||
): void => {
|
||||
doneWasCalled = true;
|
||||
@@ -3983,8 +4060,9 @@ export function REPL({
|
||||
// doesn't change model context). Outside fullscreen the
|
||||
// transcript entry stays so scrollback shows what ran.
|
||||
if (!isFullscreenEnvEnabled()) {
|
||||
const breadcrumbArgs = doneOptions?.displayArgs ?? commandArgs;
|
||||
newMessages.push(
|
||||
createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), commandArgs)),
|
||||
createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), breadcrumbArgs)),
|
||||
createCommandInputMessage(
|
||||
`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}</${LOCAL_COMMAND_STDOUT_TAG}>`,
|
||||
),
|
||||
@@ -5015,6 +5093,34 @@ export function REPL({
|
||||
onQueueTick: (command: QueuedCommand) => enqueue(command),
|
||||
});
|
||||
|
||||
// Goal auto-continuation: enqueue a steering prompt when idle + active goal
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useGoalContinuation?.({
|
||||
isLoading: isLoading || initialMessage !== null,
|
||||
wasAborted,
|
||||
queuedCommandsLength: queuedCommands.length,
|
||||
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
|
||||
isInPlanMode: toolPermissionContext.mode === 'plan',
|
||||
isQueryActiveNow: queryGuard.getSnapshot,
|
||||
onContinuationEnqueued: ({ turn, objective }) => {
|
||||
const visibleGoalTurnInput = `Goal auto-continue (${turn}/1): continue advancing "${objective}".`;
|
||||
setMessages(oldMessages => [
|
||||
...oldMessages,
|
||||
createUserMessage({
|
||||
content: visibleGoalTurnInput,
|
||||
isVisibleInTranscriptOnly: true,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onMaxTurnsReached: () => {
|
||||
addNotification({
|
||||
key: 'goal-max-turns-reached',
|
||||
text: 'Goal reached max continuation turns (1). Run /goal continue to reset turn counter and continue.',
|
||||
priority: 'immediate',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!proactiveActive) {
|
||||
notifyAutomationStateChanged(null);
|
||||
@@ -5832,7 +5938,6 @@ export function REPL({
|
||||
!hasRunningTeammates &&
|
||||
isBriefOnly &&
|
||||
!viewedAgentTask && <BriefIdleStatus />}
|
||||
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
|
||||
</>
|
||||
}
|
||||
bottom={
|
||||
@@ -5845,6 +5950,7 @@ export function REPL({
|
||||
<CompanionSprite />
|
||||
) : null}
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
|
||||
{permissionStickyFooter}
|
||||
{/* Immediate local-jsx commands (/btw, /sandbox, /assistant,
|
||||
/issue) render here, NOT inside scrollable. They stay mounted
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import type { UUID } from 'crypto';
|
||||
import { dirname } from 'path';
|
||||
import React from 'react';
|
||||
import { useTerminalSize } from 'src/hooks/useTerminalSize.js';
|
||||
@@ -296,6 +297,14 @@ export function ResumeConversation({
|
||||
}
|
||||
}
|
||||
|
||||
if (feature('GOAL') && result.goal && result.sessionId) {
|
||||
const { hydrateGoalFromTranscript } =
|
||||
require('src/services/goal/goalStorage.js') as typeof import('src/services/goal/goalStorage.js');
|
||||
const goalsMap = new Map<UUID, import('src/types/logs.js').GoalState>();
|
||||
goalsMap.set(result.sessionId, result.goal);
|
||||
hydrateGoalFromTranscript(goalsMap, result.sessionId);
|
||||
}
|
||||
|
||||
if (feature('CONTEXT_COLLAPSE')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user