mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05: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:
@@ -507,6 +507,8 @@ export async function loadConversationForResume(
|
||||
prRepository?: string
|
||||
// Full path to the session file (for cross-directory resume)
|
||||
fullPath?: string
|
||||
// Goal state for hydration on resume
|
||||
goal?: import('../types/logs.js').GoalState
|
||||
} | null> {
|
||||
try {
|
||||
let log: LogOption | null = null
|
||||
@@ -618,6 +620,8 @@ export async function loadConversationForResume(
|
||||
prRepository: log?.prRepository,
|
||||
// Include full path for cross-directory resume
|
||||
fullPath: log?.fullPath,
|
||||
// Goal state for hydration on resume
|
||||
goal: log?.goal,
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
|
||||
@@ -740,6 +740,7 @@ async function getMessagesForSlashCommand(
|
||||
metaMessages?: string[];
|
||||
nextInput?: string;
|
||||
submitNextInput?: boolean;
|
||||
displayArgs?: string;
|
||||
},
|
||||
) => {
|
||||
doneWasCalled = true;
|
||||
@@ -773,20 +774,22 @@ async function getMessagesForSlashCommand(
|
||||
const skipTranscript =
|
||||
isFullscreenEnvEnabled() && typeof result === 'string' && result.endsWith(' dismissed');
|
||||
|
||||
const breadcrumbArgs = options?.displayArgs ?? args;
|
||||
|
||||
void resolve({
|
||||
messages:
|
||||
options?.display === 'system'
|
||||
? skipTranscript
|
||||
? metaMessages
|
||||
: [
|
||||
createCommandInputMessage(formatCommandInput(command, args)),
|
||||
createCommandInputMessage(formatCommandInput(command, breadcrumbArgs)),
|
||||
createCommandInputMessage(`<local-command-stdout>${result}</local-command-stdout>`),
|
||||
...metaMessages,
|
||||
]
|
||||
: [
|
||||
createUserMessage({
|
||||
content: prepareUserContent({
|
||||
inputString: formatCommandInput(command, args),
|
||||
inputString: formatCommandInput(command, breadcrumbArgs),
|
||||
precedingInputBlocks,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -146,9 +146,10 @@ export async function processUserInput({
|
||||
}): Promise<ProcessUserInputBaseResult> {
|
||||
const inputString = typeof input === 'string' ? input : null
|
||||
// Immediately show the user input prompt while we are still processing the input.
|
||||
// Skip for isMeta (system-generated prompts like scheduled tasks) — those
|
||||
// should run invisibly.
|
||||
if (mode === 'prompt' && inputString !== null && !isMeta) {
|
||||
// Skip for isMeta (system-generated prompts like scheduled tasks) and slash
|
||||
// commands (they produce their own system message echo via createCommandInputMessage).
|
||||
const isSlashInput = inputString?.startsWith('/') && !skipSlashCommands
|
||||
if (mode === 'prompt' && inputString !== null && !isMeta && !isSlashInput) {
|
||||
setUserInputOnProcessing?.(inputString)
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,7 @@ type ResumeLoadResult = {
|
||||
prNumber?: number
|
||||
prUrl?: string
|
||||
prRepository?: string
|
||||
goal?: import('../types/logs.js').GoalState
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -471,6 +472,18 @@ export async function processResumedConversation(
|
||||
opts.forkSession ? { ...result, worktreeSession: undefined } : result,
|
||||
)
|
||||
|
||||
if (feature('GOAL') && result.goal) {
|
||||
const { hydrateGoalFromTranscript } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('../services/goal/goalStorage.js') as typeof import('../services/goal/goalStorage.js')
|
||||
const goalsMap = new Map<UUID, import('../types/logs.js').GoalState>()
|
||||
const sid = (opts.sessionIdOverride ??
|
||||
result.sessionId ??
|
||||
getSessionId()) as UUID
|
||||
goalsMap.set(sid, result.goal)
|
||||
hydrateGoalFromTranscript(goalsMap, sid)
|
||||
}
|
||||
|
||||
if (!opts.forkSession) {
|
||||
// Cd back into the worktree the session was in when it last exited.
|
||||
// Done after restoreSessionMetadata (which caches the worktree state
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type ContextCollapseSnapshotEntry,
|
||||
type Entry,
|
||||
type FileHistorySnapshotMessage,
|
||||
type GoalState,
|
||||
type LogOption,
|
||||
type PersistedWorktreeSession,
|
||||
type SerializedMessage,
|
||||
@@ -542,6 +543,7 @@ class Project {
|
||||
currentSessionLastPrompt: string | undefined
|
||||
currentSessionAgentSetting: string | undefined
|
||||
currentSessionMode: 'coordinator' | 'normal' | undefined
|
||||
currentSessionGoal: GoalState | undefined
|
||||
// Tri-state: undefined = never touched (don't write), null = exited worktree,
|
||||
// object = currently in worktree. reAppendSessionMetadata writes null so
|
||||
// --resume knows the session exited (vs. crashed while inside).
|
||||
@@ -827,6 +829,14 @@ class Project {
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
if (this.currentSessionGoal) {
|
||||
appendEntryToFile(this.sessionFile, {
|
||||
type: 'goal',
|
||||
sessionId,
|
||||
state: this.currentSessionGoal,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
if (this.currentSessionWorktree !== undefined) {
|
||||
appendEntryToFile(this.sessionFile, {
|
||||
type: 'worktree-state',
|
||||
@@ -1226,6 +1236,10 @@ class Project {
|
||||
} else if (entry.type === 'marble-origami-snapshot') {
|
||||
// Always append. Last-wins on restore — later entries supersede.
|
||||
void this.enqueueWrite(sessionFile, entry)
|
||||
} else if (entry.type === 'goal') {
|
||||
void this.enqueueWrite(sessionFile, entry)
|
||||
} else if (entry.type === 'goal-cleared') {
|
||||
void this.enqueueWrite(sessionFile, entry)
|
||||
} else {
|
||||
const messageSet = await getSessionMessages(sessionId)
|
||||
if (entry.type === 'queue-operation') {
|
||||
@@ -2723,6 +2737,48 @@ export async function saveTag(sessionId: UUID, tag: string, fullPath?: string) {
|
||||
logEvent('tengu_session_tagged', {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a goal-state checkpoint to the JSONL transcript. Called by
|
||||
* src/services/goal/goalStorage.ts on every mutation. The latest entry
|
||||
* wins on read; older entries are harmlessly ignored.
|
||||
*
|
||||
* Cached on Project so reAppendSessionMetadata can keep the goal alive
|
||||
* past compaction's tail-read window.
|
||||
*/
|
||||
export function saveGoal(
|
||||
sessionId: UUID,
|
||||
state: GoalState,
|
||||
fullPath?: string,
|
||||
): void {
|
||||
const resolvedPath = fullPath ?? getTranscriptPathForSession(sessionId)
|
||||
appendEntryToFile(resolvedPath, {
|
||||
type: 'goal',
|
||||
sessionId,
|
||||
state,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
if (sessionId === getSessionId()) {
|
||||
getProject().currentSessionGoal = state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a "goal cleared" tombstone so a future --resume cannot
|
||||
* resurrect the goal from a prior `goal` entry. Also drops the
|
||||
* in-memory cache for the current session.
|
||||
*/
|
||||
export function clearGoalEntry(sessionId: UUID, fullPath?: string): void {
|
||||
const resolvedPath = fullPath ?? getTranscriptPathForSession(sessionId)
|
||||
appendEntryToFile(resolvedPath, {
|
||||
type: 'goal-cleared',
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
if (sessionId === getSessionId()) {
|
||||
getProject().currentSessionGoal = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a session to a GitHub pull request.
|
||||
* This stores the PR number, URL, and repository for tracking and navigation.
|
||||
@@ -2791,6 +2847,7 @@ export function restoreSessionMetadata(meta: {
|
||||
prNumber?: number
|
||||
prUrl?: string
|
||||
prRepository?: string
|
||||
goal?: GoalState
|
||||
}): void {
|
||||
const project = getProject()
|
||||
// ??= so --name (cacheSessionTitle) wins over the resumed
|
||||
@@ -2807,6 +2864,7 @@ export function restoreSessionMetadata(meta: {
|
||||
project.currentSessionPrNumber = meta.prNumber
|
||||
if (meta.prUrl) project.currentSessionPrUrl = meta.prUrl
|
||||
if (meta.prRepository) project.currentSessionPrRepository = meta.prRepository
|
||||
if (meta.goal) project.currentSessionGoal = meta.goal
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2823,6 +2881,7 @@ export function clearSessionMetadata(): void {
|
||||
project.currentSessionLastPrompt = undefined
|
||||
project.currentSessionAgentSetting = undefined
|
||||
project.currentSessionMode = undefined
|
||||
project.currentSessionGoal = undefined
|
||||
project.currentSessionWorktree = undefined
|
||||
project.currentSessionPrNumber = undefined
|
||||
project.currentSessionPrUrl = undefined
|
||||
@@ -2997,6 +3056,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
||||
prRepositories,
|
||||
modes,
|
||||
worktreeStates,
|
||||
goals,
|
||||
fileHistorySnapshots,
|
||||
attributionSnapshots,
|
||||
contentReplacements,
|
||||
@@ -3006,7 +3066,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
||||
} = await loadTranscriptFile(sessionFile)
|
||||
|
||||
if (messages.size === 0) {
|
||||
return log
|
||||
const fallbackGoal = log.sessionId
|
||||
? goals.get(log.sessionId as UUID)
|
||||
: undefined
|
||||
return fallbackGoal ? { ...log, goal: fallbackGoal } : log
|
||||
}
|
||||
|
||||
// Find the most recent user/assistant leaf message from the transcript
|
||||
@@ -3017,7 +3080,10 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
||||
(msg.type === 'user' || msg.type === 'assistant'),
|
||||
)
|
||||
if (!mostRecentLeaf) {
|
||||
return log
|
||||
const fallbackGoal = log.sessionId
|
||||
? goals.get(log.sessionId as UUID)
|
||||
: undefined
|
||||
return fallbackGoal ? { ...log, goal: fallbackGoal } : log
|
||||
}
|
||||
|
||||
// Build the conversation chain from this leaf
|
||||
@@ -3043,6 +3109,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
|
||||
sessionId && worktreeStates.has(sessionId)
|
||||
? worktreeStates.get(sessionId)
|
||||
: log.worktreeSession,
|
||||
goal: sessionId ? goals.get(sessionId) : log.goal,
|
||||
prNumber: sessionId ? prNumbers.get(sessionId) : log.prNumber,
|
||||
prUrl: sessionId ? prUrls.get(sessionId) : log.prUrl,
|
||||
prRepository: sessionId
|
||||
@@ -3144,6 +3211,8 @@ const METADATA_TYPE_MARKERS = [
|
||||
'"type":"agent-setting"',
|
||||
'"type":"mode"',
|
||||
'"type":"worktree-state"',
|
||||
'"type":"goal"',
|
||||
'"type":"goal-cleared"',
|
||||
'"type":"pr-link"',
|
||||
]
|
||||
const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m))
|
||||
@@ -3510,6 +3579,7 @@ export async function loadTranscriptFile(
|
||||
prRepositories: Map<UUID, string>
|
||||
modes: Map<UUID, string>
|
||||
worktreeStates: Map<UUID, PersistedWorktreeSession | null>
|
||||
goals: Map<UUID, GoalState>
|
||||
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
|
||||
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
|
||||
contentReplacements: Map<UUID, ContentReplacementRecord[]>
|
||||
@@ -3530,6 +3600,7 @@ export async function loadTranscriptFile(
|
||||
const prRepositories = new Map<UUID, string>()
|
||||
const modes = new Map<UUID, string>()
|
||||
const worktreeStates = new Map<UUID, PersistedWorktreeSession | null>()
|
||||
const goals = new Map<UUID, GoalState>()
|
||||
const fileHistorySnapshots = new Map<UUID, FileHistorySnapshotMessage>()
|
||||
const attributionSnapshots = new Map<UUID, AttributionSnapshotMessage>()
|
||||
const contentReplacements = new Map<UUID, ContentReplacementRecord[]>()
|
||||
@@ -3628,6 +3699,10 @@ export async function loadTranscriptFile(
|
||||
modes.set(entry.sessionId, entry.mode)
|
||||
} else if (entry.type === 'worktree-state' && entry.sessionId) {
|
||||
worktreeStates.set(entry.sessionId, entry.worktreeSession)
|
||||
} else if (entry.type === 'goal' && entry.sessionId) {
|
||||
goals.set(entry.sessionId, entry.state)
|
||||
} else if (entry.type === 'goal-cleared' && entry.sessionId) {
|
||||
goals.delete(entry.sessionId)
|
||||
} else if (entry.type === 'pr-link' && entry.sessionId) {
|
||||
prNumbers.set(entry.sessionId, entry.prNumber)
|
||||
prUrls.set(entry.sessionId, entry.prUrl)
|
||||
@@ -3696,6 +3771,10 @@ export async function loadTranscriptFile(
|
||||
modes.set(entry.sessionId, entry.mode)
|
||||
} else if (entry.type === 'worktree-state' && entry.sessionId) {
|
||||
worktreeStates.set(entry.sessionId, entry.worktreeSession)
|
||||
} else if (entry.type === 'goal' && entry.sessionId) {
|
||||
goals.set(entry.sessionId, entry.state)
|
||||
} else if (entry.type === 'goal-cleared' && entry.sessionId) {
|
||||
goals.delete(entry.sessionId)
|
||||
} else if (entry.type === 'pr-link' && entry.sessionId) {
|
||||
prNumbers.set(entry.sessionId, entry.prNumber)
|
||||
prUrls.set(entry.sessionId, entry.prUrl)
|
||||
@@ -3827,6 +3906,7 @@ export async function loadTranscriptFile(
|
||||
prRepositories,
|
||||
modes,
|
||||
worktreeStates,
|
||||
goals,
|
||||
fileHistorySnapshots,
|
||||
attributionSnapshots,
|
||||
contentReplacements,
|
||||
@@ -3847,6 +3927,7 @@ async function loadSessionFile(sessionId: UUID): Promise<{
|
||||
tags: Map<UUID, string>
|
||||
agentSettings: Map<UUID, string>
|
||||
worktreeStates: Map<UUID, PersistedWorktreeSession | null>
|
||||
goals: Map<UUID, GoalState>
|
||||
fileHistorySnapshots: Map<UUID, FileHistorySnapshotMessage>
|
||||
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
|
||||
contentReplacements: Map<UUID, ContentReplacementRecord[]>
|
||||
@@ -3905,6 +3986,7 @@ export async function getLastSessionLog(
|
||||
fileHistorySnapshots,
|
||||
attributionSnapshots,
|
||||
contentReplacements,
|
||||
goals,
|
||||
contextCollapseCommits,
|
||||
contextCollapseSnapshot,
|
||||
} = await loadSessionFile(sessionId)
|
||||
@@ -3946,6 +4028,7 @@ export async function getLastSessionLog(
|
||||
contentReplacements.get(sessionId) ?? [],
|
||||
),
|
||||
worktreeSession: worktreeStates.get(sessionId),
|
||||
goal: goals.get(sessionId),
|
||||
contextCollapseCommits: contextCollapseCommits.filter(
|
||||
e => e.sessionId === sessionId,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user