Skip to content

Commit d66a6f6

Browse files
Fearless743mimo-v2.5-pro
andauthored
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>
1 parent 48a19b8 commit d66a6f6

10 files changed

Lines changed: 483 additions & 0 deletions

File tree

packages/builtin-tools/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export { AskUserQuestionTool } from './tools/AskUserQuestionTool/AskUserQuestion
1212
export { BashTool } from './tools/BashTool/BashTool.js'
1313
export { BriefTool } from './tools/BriefTool/BriefTool.js'
1414
export { ConfigTool } from './tools/ConfigTool/ConfigTool.js'
15+
export { GoalTool } from './tools/GoalTool/GoalTool.js'
1516
export { EnterPlanModeTool } from './tools/EnterPlanModeTool/EnterPlanModeTool.js'
1617
export { EnterWorktreeTool } from './tools/EnterWorktreeTool/EnterWorktreeTool.js'
1718
export { ExitPlanModeV2Tool } from './tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { z } from 'zod/v4'
2+
import { buildTool, type ToolDef } from 'src/Tool.js'
3+
import { lazySchema } from 'src/utils/lazySchema.js'
4+
import {
5+
completeGoal,
6+
formatGoalStatus,
7+
getActiveElapsedMs,
8+
getGoal,
9+
setGoal,
10+
} from 'src/services/goal/goalState.js'
11+
import { DESCRIPTION, generatePrompt } from './prompt.js'
12+
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
13+
14+
const inputSchema = lazySchema(() =>
15+
z.strictObject({
16+
action: z
17+
.enum(['get', 'set', 'complete'])
18+
.describe('The action to perform on the goal.'),
19+
objective: z
20+
.string()
21+
.optional()
22+
.describe('The goal objective. Required for "set" action.'),
23+
message: z
24+
.string()
25+
.optional()
26+
.describe('Completion message for "complete" action.'),
27+
}),
28+
)
29+
type InputSchema = ReturnType<typeof inputSchema>
30+
31+
const outputSchema = lazySchema(() =>
32+
z.object({
33+
success: z.boolean(),
34+
action: z.string(),
35+
goal: z
36+
.object({
37+
objective: z.string(),
38+
status: z.string(),
39+
tokensUsed: z.number(),
40+
tokenBudget: z.number().nullable(),
41+
elapsedSeconds: z.number(),
42+
})
43+
.optional(),
44+
message: z.string().optional(),
45+
error: z.string().optional(),
46+
}),
47+
)
48+
type OutputSchema = ReturnType<typeof outputSchema>
49+
50+
export type Input = z.infer<InputSchema>
51+
export type Output = z.infer<OutputSchema>
52+
53+
export const GoalTool = buildTool({
54+
name: 'goal',
55+
searchHint: 'manage long-running task goals',
56+
maxResultSizeChars: 10_000,
57+
async description() {
58+
return DESCRIPTION
59+
},
60+
async prompt() {
61+
return generatePrompt()
62+
},
63+
get inputSchema(): InputSchema {
64+
return inputSchema()
65+
},
66+
get outputSchema(): OutputSchema {
67+
return outputSchema()
68+
},
69+
userFacingName() {
70+
return 'Goal'
71+
},
72+
shouldDefer: true,
73+
isConcurrencySafe() {
74+
return true
75+
},
76+
isReadOnly(input: Input) {
77+
return input.action === 'get'
78+
},
79+
toAutoClassifierInput(input) {
80+
if (input.action === 'get') return 'get goal status'
81+
if (input.action === 'set') return `set goal: ${input.objective}`
82+
return `complete goal: ${input.message ?? ''}`
83+
},
84+
async checkPermissions(input: Input) {
85+
if (input.action === 'get') {
86+
return { behavior: 'allow' as const, updatedInput: input }
87+
}
88+
return {
89+
behavior: 'ask' as const,
90+
message:
91+
input.action === 'set'
92+
? `Set goal: ${input.objective}`
93+
: `Complete goal${input.message ? `: ${input.message}` : ''}`,
94+
}
95+
},
96+
async call({ action, objective, message }: Input): Promise<{ data: Output }> {
97+
if (action === 'get') {
98+
const goal = getGoal()
99+
if (!goal) {
100+
return { data: { success: true, action, message: 'No active goal.' } }
101+
}
102+
const elapsedSeconds = Math.floor(getActiveElapsedMs(goal) / 1000)
103+
return {
104+
data: {
105+
success: true,
106+
action,
107+
goal: {
108+
objective: goal.objective,
109+
status: goal.status,
110+
tokensUsed: goal.tokensUsed,
111+
tokenBudget: goal.tokenBudget,
112+
elapsedSeconds,
113+
},
114+
},
115+
}
116+
}
117+
118+
if (action === 'set') {
119+
if (!objective) {
120+
return {
121+
data: {
122+
success: false,
123+
action,
124+
error: 'objective is required for set action.',
125+
},
126+
}
127+
}
128+
setGoal(objective)
129+
return {
130+
data: {
131+
success: true,
132+
action,
133+
message: `Goal set: ${objective}`,
134+
goal: {
135+
objective,
136+
status: 'active',
137+
tokensUsed: 0,
138+
tokenBudget: null,
139+
elapsedSeconds: 0,
140+
},
141+
},
142+
}
143+
}
144+
145+
if (action === 'complete') {
146+
if (!completeGoal()) {
147+
return {
148+
data: {
149+
success: false,
150+
action,
151+
error: 'No active goal to complete.',
152+
},
153+
}
154+
}
155+
return {
156+
data: {
157+
success: true,
158+
action,
159+
message: message
160+
? `Goal completed: ${message}`
161+
: 'Goal marked as complete.',
162+
},
163+
}
164+
}
165+
166+
return {
167+
data: { success: false, action, error: `Unknown action: ${action}` },
168+
}
169+
},
170+
renderToolUseMessage(input: Partial<Input>) {
171+
if (input.action === 'get') return 'Getting goal status'
172+
if (input.action === 'set') return `Setting goal: ${input.objective ?? ''}`
173+
if (input.action === 'complete') return 'Completing goal'
174+
return 'Managing goal'
175+
},
176+
renderToolResultMessage(content: Output) {
177+
if (!content.success) return `Error: ${content.error}`
178+
if (content.action === 'get' && content.goal) {
179+
const g = content.goal
180+
return `Goal: ${g.objective} [${g.status}]`
181+
}
182+
return content.message ?? 'Done.'
183+
},
184+
mapToolResultToToolResultBlockParam(
185+
content: Output,
186+
toolUseID: string,
187+
): ToolResultBlockParam {
188+
if (!content.success) {
189+
return {
190+
tool_use_id: toolUseID,
191+
type: 'tool_result' as const,
192+
content: `Error: ${content.error}`,
193+
is_error: true,
194+
}
195+
}
196+
197+
if (content.action === 'get' && content.goal) {
198+
const g = content.goal
199+
return {
200+
tool_use_id: toolUseID,
201+
type: 'tool_result' as const,
202+
content: `Goal: ${g.objective}\nStatus: ${g.status}\nTokens: ${g.tokensUsed}${g.tokenBudget !== null ? ` / ${g.tokenBudget}` : ''}\nElapsed: ${g.elapsedSeconds}s`,
203+
}
204+
}
205+
206+
return {
207+
tool_use_id: toolUseID,
208+
type: 'tool_result' as const,
209+
content: content.message ?? 'Done.',
210+
}
211+
},
212+
} satisfies ToolDef<InputSchema, Output>)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export const DESCRIPTION = 'Manage the active goal for long-running tasks.'
2+
3+
export function generatePrompt(): string {
4+
return `Manage the active goal for long-running tasks.
5+
6+
Use this tool to get, set, or complete a goal. A goal is an objective that the system tracks across the session, injecting continuation prompts to keep working toward it.
7+
8+
## Actions
9+
- **get** — Get the current goal status
10+
- **set** — Set or update the goal objective
11+
- **complete** — Mark the goal as complete when the objective is achieved
12+
13+
## Examples
14+
- Get current goal: { "action": "get" }
15+
- Set a goal: { "action": "set", "objective": "Improve test coverage to 80%" }
16+
- Complete a goal: { "action": "complete", "message": "All tests now pass with 82% coverage." }
17+
`
18+
}

src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ import thinkbackPlay from './commands/thinkback-play/index.js'
167167
import permissions from './commands/permissions/index.js'
168168
import plan from './commands/plan/index.js'
169169
import fast from './commands/fast/index.js'
170+
import goal from './commands/goal/index.js'
170171
import passes from './commands/passes/index.js'
171172
import privacySettings from './commands/privacy-settings/index.js'
172173
import hooks from './commands/hooks/index.js'
@@ -316,6 +317,7 @@ const COMMANDS = memoize((): Command[] => [
316317
exit,
317318
fast,
318319
files,
320+
goal,
319321
heapDump,
320322
help,
321323
ide,

src/commands/goal/goal.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { LocalCommandCall } from '../../types/command.js'
2+
import {
3+
clearGoal,
4+
completeGoal,
5+
formatGoalStatus,
6+
getGoal,
7+
pauseGoal,
8+
resumeGoal,
9+
setGoal,
10+
} from '../../services/goal/goalState.js'
11+
12+
export const call: LocalCommandCall = async args => {
13+
const trimmed = args.trim()
14+
15+
// No arguments — show current goal status
16+
if (!trimmed) {
17+
return { type: 'text', value: formatGoalStatus() }
18+
}
19+
20+
const lower = trimmed.toLowerCase()
21+
22+
// Control subcommands
23+
if (lower === 'clear') {
24+
const goal = getGoal()
25+
if (!goal) {
26+
return { type: 'text', value: 'No active goal to clear.' }
27+
}
28+
clearGoal()
29+
return { type: 'text', value: 'Goal cleared.' }
30+
}
31+
32+
if (lower === 'pause') {
33+
if (pauseGoal()) {
34+
return { type: 'text', value: 'Goal paused.' }
35+
}
36+
return { type: 'text', value: 'No active goal to pause.' }
37+
}
38+
39+
if (lower === 'resume') {
40+
if (resumeGoal()) {
41+
return { type: 'text', value: 'Goal resumed.' }
42+
}
43+
return { type: 'text', value: 'No paused goal to resume.' }
44+
}
45+
46+
if (lower === 'complete') {
47+
if (completeGoal()) {
48+
return { type: 'text', value: 'Goal marked as complete.' }
49+
}
50+
return { type: 'text', value: 'No active goal to complete.' }
51+
}
52+
53+
// Set a new goal
54+
const existing = getGoal()
55+
if (existing && existing.status === 'active') {
56+
// Replace existing active goal
57+
setGoal(trimmed)
58+
return {
59+
type: 'text',
60+
value: `Goal replaced.\n\n${formatGoalStatus()}`,
61+
}
62+
}
63+
64+
setGoal(trimmed)
65+
return { type: 'text', value: `Goal set.\n\n${formatGoalStatus()}` }
66+
}

src/commands/goal/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { Command } from '../../commands.js'
2+
3+
const goal = {
4+
type: 'local',
5+
name: 'goal',
6+
description: 'Set or view the goal for a long-running task',
7+
supportsNonInteractive: true,
8+
argumentHint: '<objective> | clear | pause | resume',
9+
load: () => import('./goal.js'),
10+
} satisfies Command
11+
12+
export default goal

src/constants/prompts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
resolveSystemPromptSections,
5858
} from './systemPromptSections.js'
5959
import { SLEEP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SleepTool/prompt.js'
60+
import { getGoalContinuationPrompt } from '../services/goal/goalState.js'
6061
import { TICK_TAG } from './xml.js'
6162
import { logForDebugging } from '../utils/debug.js'
6263
import { loadMemoryPrompt } from '../memdir/memdir.js'
@@ -505,6 +506,11 @@ ${CYBER_RISK_INSTRUCTION}`,
505506
...(feature('KAIROS') || feature('KAIROS_BRIEF')
506507
? [systemPromptSection('brief', () => getBriefSection())]
507508
: []),
509+
DANGEROUS_uncachedSystemPromptSection(
510+
'goal_continuation',
511+
() => getGoalContinuationPrompt(),
512+
'Goal state changes between turns',
513+
),
508514
]
509515

510516
const resolvedDynamicSections =

src/query.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
} from '@anthropic-ai/sdk/resources/index.mjs'
66
import type { CanUseToolFn } from './hooks/useCanUseTool.js'
77
import { FallbackTriggeredError } from './services/api/withRetry.js'
8+
import { updateGoalTokens } from './services/goal/goalState.js'
89
import {
910
calculateTokenWarningState,
1011
estimateMaxTurnGrowth,
@@ -1265,6 +1266,13 @@ async function* queryLoop(
12651266
if (warningInfo) {
12661267
yield createCacheWarningMessage(warningInfo)
12671268
}
1269+
1270+
// Update goal token usage
1271+
const totalTokens =
1272+
usage.input_tokens +
1273+
(usage.cache_creation_input_tokens ?? 0) +
1274+
(usage.cache_read_input_tokens ?? 0)
1275+
updateGoalTokens(totalTokens)
12681276
}
12691277
}
12701278

0 commit comments

Comments
 (0)