feat: 添加 /goal 命令,支持长时间运行任务的目标管理 (#1222)

* feat: 添加 /goal 命令,支持长时间运行任务的目标管理

从 Codex 项目移植 /goal 命令到 Claude Code,实现:
- Goal 状态管理模块(active/paused/budget_limited/complete)
- /goal 斜杠命令(set/clear/pause/resume/complete)
- Goal 模型工具(get/set/complete)
- Continuation prompt 自动注入系统提示
- Token 用量自动追踪

Co-Authored-By: mimo-v2.5-pro <XiaomiMiMo@claude-code-best.win>

* fix: goal 状态改为 session-scoped,避免多会话泄漏

将 currentGoal 单例替换为 Map<string, GoalState>,按 sessionId 隔离,
遵循 sessionIngress.ts 的模式。所有函数支持可选 sessionId 参数。

Co-Authored-By: mimo-v2.5-pro <XiaomiMiMo@claude-code-best.win>

* fix: 对 goal 的 tokenBudget/tokensUsed 添加数值校验

setGoal 中 tokenBudget 非 finite 或负数时归零;
updateGoalTokens 中 usage 非 finite 或负数时归零。

Co-Authored-By: mimo-v2.5-pro <XiaomiMiMo@claude-code-best.win>

* fix: 暂停期间 goal 时间不再继续计数

新增 pausedAt/accumulatedActiveMs 字段,pauseGoal 累积已活跃时间,
resumeGoal 重置 startTime,计时统一使用 getActiveElapsedMs()。

Co-Authored-By: mimo-v2.5-pro <XiaomiMiMo@claude-code-best.win>

---------

Co-authored-by: mimo-v2.5-pro <XiaomiMiMo@claude-code-best.win>
This commit is contained in:
Fearless
2026-05-17 10:05:46 +08:00
committed by GitHub
parent 48a19b8a0d
commit d66a6f6124
10 changed files with 483 additions and 0 deletions

View File

@@ -167,6 +167,7 @@ import thinkbackPlay from './commands/thinkback-play/index.js'
import permissions from './commands/permissions/index.js'
import plan from './commands/plan/index.js'
import fast from './commands/fast/index.js'
import goal from './commands/goal/index.js'
import passes from './commands/passes/index.js'
import privacySettings from './commands/privacy-settings/index.js'
import hooks from './commands/hooks/index.js'
@@ -316,6 +317,7 @@ const COMMANDS = memoize((): Command[] => [
exit,
fast,
files,
goal,
heapDump,
help,
ide,

66
src/commands/goal/goal.ts Normal file
View File

@@ -0,0 +1,66 @@
import type { LocalCommandCall } from '../../types/command.js'
import {
clearGoal,
completeGoal,
formatGoalStatus,
getGoal,
pauseGoal,
resumeGoal,
setGoal,
} from '../../services/goal/goalState.js'
export const call: LocalCommandCall = async args => {
const trimmed = args.trim()
// No arguments — show current goal status
if (!trimmed) {
return { type: 'text', value: formatGoalStatus() }
}
const lower = trimmed.toLowerCase()
// Control subcommands
if (lower === 'clear') {
const goal = getGoal()
if (!goal) {
return { type: 'text', value: 'No active goal to clear.' }
}
clearGoal()
return { type: 'text', value: 'Goal cleared.' }
}
if (lower === 'pause') {
if (pauseGoal()) {
return { type: 'text', value: 'Goal paused.' }
}
return { type: 'text', value: 'No active goal to pause.' }
}
if (lower === 'resume') {
if (resumeGoal()) {
return { type: 'text', value: 'Goal resumed.' }
}
return { type: 'text', value: 'No paused goal to resume.' }
}
if (lower === 'complete') {
if (completeGoal()) {
return { type: 'text', value: 'Goal marked as complete.' }
}
return { type: 'text', value: 'No active goal to complete.' }
}
// Set a new goal
const existing = getGoal()
if (existing && existing.status === 'active') {
// Replace existing active goal
setGoal(trimmed)
return {
type: 'text',
value: `Goal replaced.\n\n${formatGoalStatus()}`,
}
}
setGoal(trimmed)
return { type: 'text', value: `Goal set.\n\n${formatGoalStatus()}` }
}

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const goal = {
type: 'local',
name: 'goal',
description: 'Set or view the goal for a long-running task',
supportsNonInteractive: true,
argumentHint: '<objective> | clear | pause | resume',
load: () => import('./goal.js'),
} satisfies Command
export default goal

View File

@@ -57,6 +57,7 @@ import {
resolveSystemPromptSections,
} from './systemPromptSections.js'
import { SLEEP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SleepTool/prompt.js'
import { getGoalContinuationPrompt } from '../services/goal/goalState.js'
import { TICK_TAG } from './xml.js'
import { logForDebugging } from '../utils/debug.js'
import { loadMemoryPrompt } from '../memdir/memdir.js'
@@ -505,6 +506,11 @@ ${CYBER_RISK_INSTRUCTION}`,
...(feature('KAIROS') || feature('KAIROS_BRIEF')
? [systemPromptSection('brief', () => getBriefSection())]
: []),
DANGEROUS_uncachedSystemPromptSection(
'goal_continuation',
() => getGoalContinuationPrompt(),
'Goal state changes between turns',
),
]
const resolvedDynamicSections =

View File

@@ -5,6 +5,7 @@ import type {
} from '@anthropic-ai/sdk/resources/index.mjs'
import type { CanUseToolFn } from './hooks/useCanUseTool.js'
import { FallbackTriggeredError } from './services/api/withRetry.js'
import { updateGoalTokens } from './services/goal/goalState.js'
import {
calculateTokenWarningState,
estimateMaxTurnGrowth,
@@ -1265,6 +1266,13 @@ async function* queryLoop(
if (warningInfo) {
yield createCacheWarningMessage(warningInfo)
}
// Update goal token usage
const totalTokens =
usage.input_tokens +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0)
updateGoalTokens(totalTokens)
}
}

View File

@@ -0,0 +1,156 @@
import { getSessionId } from '../../bootstrap/state.js'
export type GoalStatus = 'active' | 'paused' | 'budget_limited' | 'complete'
export type GoalState = {
objective: string
status: GoalStatus
tokenBudget: number | null
tokensUsed: number
startTime: number
pausedAt: number | null
accumulatedActiveMs: number
}
const goals: Map<string, GoalState> = new Map()
export function getGoal(sessionId?: string): GoalState | null {
return goals.get(sessionId ?? getSessionId()) ?? null
}
export function setGoal(
objective: string,
tokenBudget?: number,
sessionId?: string,
): GoalState {
const validBudget =
tokenBudget !== undefined &&
Number.isFinite(tokenBudget) &&
tokenBudget >= 0
? tokenBudget
: null
const state: GoalState = {
objective,
status: 'active',
tokenBudget: validBudget,
tokensUsed: 0,
startTime: Date.now(),
pausedAt: null,
accumulatedActiveMs: 0,
}
goals.set(sessionId ?? getSessionId(), state)
return state
}
export function clearGoal(sessionId?: string): void {
goals.delete(sessionId ?? getSessionId())
}
export function pauseGoal(sessionId?: string): boolean {
const goal = getGoal(sessionId)
if (!goal || goal.status !== 'active') return false
goal.accumulatedActiveMs += Date.now() - goal.startTime
goal.pausedAt = Date.now()
goal.status = 'paused'
return true
}
export function resumeGoal(sessionId?: string): boolean {
const goal = getGoal(sessionId)
if (!goal || goal.status !== 'paused') return false
goal.pausedAt = null
goal.startTime = Date.now()
goal.status = 'active'
return true
}
export function completeGoal(sessionId?: string): boolean {
const goal = getGoal(sessionId)
if (!goal) return false
goal.status = 'complete'
return true
}
export function updateGoalTokens(usage: number, sessionId?: string): void {
const goal = getGoal(sessionId)
if (!goal || goal.status !== 'active') return
const validUsage = Number.isFinite(usage) && usage >= 0 ? usage : 0
goal.tokensUsed += validUsage
if (goal.tokenBudget !== null && goal.tokensUsed >= goal.tokenBudget) {
goal.status = 'budget_limited'
}
}
export function getActiveElapsedMs(goal: GoalState): number {
const ongoing =
goal.status === 'active' && goal.pausedAt === null
? Date.now() - goal.startTime
: 0
return goal.accumulatedActiveMs + ongoing
}
export function getGoalContinuationPrompt(sessionId?: string): string | null {
const goal = getGoal(sessionId)
if (!goal || goal.status !== 'active') return null
const elapsedSeconds = Math.floor(getActiveElapsedMs(goal) / 1000)
const budgetDisplay =
goal.tokenBudget !== null ? `${goal.tokenBudget}` : 'unlimited'
const remainingDisplay =
goal.tokenBudget !== null
? `${Math.max(0, goal.tokenBudget - goal.tokensUsed)}`
: 'unlimited'
return `Continue working toward the active goal.
<objective>
${goal.objective}
</objective>
Budget:
- Time spent: ${elapsedSeconds} seconds
- Tokens used: ${goal.tokensUsed}
- Token budget: ${budgetDisplay}
- Tokens remaining: ${remainingDisplay}
Avoid repeating work that is already done. Choose the next concrete action toward the objective.
Before deciding that the goal is achieved, perform a completion audit:
- Restate the objective as concrete deliverables or success criteria.
- Inspect relevant files, command output, test results, or other real evidence.
- Do not accept proxy signals as completion by themselves.
- Treat uncertainty as not achieved; do more verification or continue the work.
- Only mark the goal achieved when the objective has actually been achieved and no required work remains.
If the objective is achieved, call the goal tool with action "complete" so usage accounting is preserved.`
}
export function formatGoalStatus(sessionId?: string): string {
const goal = getGoal(sessionId)
if (!goal) return 'No active goal.'
const elapsed = Math.floor(getActiveElapsedMs(goal) / 1000)
const minutes = Math.floor(elapsed / 60)
const seconds = elapsed % 60
const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`
const statusLabel: Record<GoalStatus, string> = {
active: 'Active',
paused: 'Paused',
budget_limited: 'Budget Limited',
complete: 'Complete',
}
const lines = [
`Goal: ${goal.objective}`,
`Status: ${statusLabel[goal.status]}`,
`Time: ${timeStr}`,
`Tokens: ${goal.tokensUsed}${goal.tokenBudget !== null ? ` / ${goal.tokenBudget}` : ''}`,
]
return lines.join('\n')
}
export function clearAllGoals(): void {
goals.clear()
}

View File

@@ -87,6 +87,7 @@ import { EnterPlanModeTool } from '@claude-code-best/builtin-tools/tools/EnterPl
import { EnterWorktreeTool } from '@claude-code-best/builtin-tools/tools/EnterWorktreeTool/EnterWorktreeTool.js'
import { ExitWorktreeTool } from '@claude-code-best/builtin-tools/tools/ExitWorktreeTool/ExitWorktreeTool.js'
import { ConfigTool } from '@claude-code-best/builtin-tools/tools/ConfigTool/ConfigTool.js'
import { GoalTool } from '@claude-code-best/builtin-tools/tools/GoalTool/GoalTool.js'
import { LocalMemoryRecallTool } from '@claude-code-best/builtin-tools/tools/LocalMemoryRecallTool/LocalMemoryRecallTool.js'
import { VaultHttpFetchTool } from '@claude-code-best/builtin-tools/tools/VaultHttpFetchTool/VaultHttpFetchTool.js'
import { TaskCreateTool } from '@claude-code-best/builtin-tools/tools/TaskCreateTool/TaskCreateTool.js'
@@ -261,6 +262,7 @@ export function getAllBaseTools(): Tools {
...(RemoteTriggerTool ? [RemoteTriggerTool] : []),
...(MonitorTool ? [MonitorTool] : []),
BriefTool,
GoalTool,
...(SendUserFileTool ? [SendUserFileTool] : []),
...(PushNotificationTool ? [PushNotificationTool] : []),
...(SubscribePRTool ? [SubscribePRTool] : []),