feat: enable autonomous goal loops from slash commands

Coding sessions need a native way to keep working until a stated completion condition is actually met. This adds a CLI /goal command, goal state and evaluator logic, and a headless-safe command filter so interactive and print-mode runs can both start a goal and continue when the evaluator says more work remains.

Constraint: The evaluator must work with saved third-party provider runtimes that may reject structured output schemas.
Constraint: Completion evidence must come from visible transcript text, not hidden goal prompts or assistant thinking blocks.
Rejected: Implement /goal as a bundled skill only | it would not own the query loop or support native continuation semantics.
Rejected: Expose all local-jsx commands to headless mode | interactive UI commands would become available in print mode.
Confidence: high
Scope-risk: moderate
Directive: Keep /goal evaluator evidence scoped to visible transcript text before adding richer completion signals.
Tested: bun test src/commands/headless.test.ts src/goals/goalState.test.ts src/goals/goalEvaluator.test.ts
Tested: bun run check:server
Tested: bun run verify
Tested: Real provider /goal smoke with MiniMax-M2.7-highspeed, including a two-turn continuation that logged Goal continuation #1 and completed with GOAL_SMOKE_LOOP_DONE.
Not-tested: Durable cross-process goal persistence for resumed sessions.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 18:08:46 +08:00
parent 523db62b3b
commit 1e32942eab
12 changed files with 894 additions and 1 deletions

View File

@ -6,6 +6,7 @@ import btw from './commands/btw/index.js'
import goodClaude from './commands/good-claude/index.js'
import issue from './commands/issue/index.js'
import feedback from './commands/feedback/index.js'
import goal from './commands/goal/index.js'
import clear from './commands/clear/index.js'
import color from './commands/color/index.js'
import commit from './commands/commit.js'
@ -303,6 +304,7 @@ const COMMANDS = memoize((): Command[] => [
tag,
theme,
feedback,
goal,
review,
ultrareview,
rewind,

View File

@ -0,0 +1,72 @@
import * as React from 'react'
import { getSessionId } from '../../bootstrap/state.js'
import type { LocalJSXCommandContext } from '../../commands.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import {
buildGoalStartPrompt,
clearThreadGoal,
formatGoalStatus,
getThreadGoal,
parseGoalCommand,
setThreadGoal,
updateThreadGoalStatus,
} from '../../goals/goalState.js'
export async function call(
onDone: LocalJSXCommandOnDone,
_context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const threadId = getSessionId()
try {
const parsed = parseGoalCommand(args)
if (parsed.type === 'status') {
onDone(formatGoalStatus(getThreadGoal(threadId)), { display: 'system' })
return null
}
if (parsed.type === 'clear') {
const cleared = clearThreadGoal(threadId)
onDone(cleared ? 'Goal cleared.' : 'No active goal.', { display: 'system' })
return null
}
if (parsed.type === 'pause') {
const goal = updateThreadGoalStatus(threadId, 'paused')
onDone(goal ? formatGoalStatus(goal) : 'No active goal.', {
display: 'system',
})
return null
}
if (parsed.type === 'resume') {
const goal = updateThreadGoalStatus(threadId, 'active')
onDone(goal ? formatGoalStatus(goal) : 'No goal to resume.', {
display: 'system',
shouldQuery: Boolean(goal),
metaMessages: goal ? [buildGoalStartPrompt(goal)] : [],
})
return null
}
if (parsed.type === 'complete') {
const goal = updateThreadGoalStatus(threadId, 'complete')
onDone(goal ? 'Goal marked complete.' : 'No active goal.', {
display: 'system',
})
return null
}
const goal = setThreadGoal(threadId, {
objective: parsed.objective,
tokenBudget: parsed.tokenBudget,
})
onDone(formatGoalStatus(goal), {
display: 'system',
shouldQuery: true,
metaMessages: [buildGoalStartPrompt(goal)],
})
return null
} catch (error) {
onDone(error instanceof Error ? error.message : String(error), {
display: 'system',
})
return null
}
}

View File

@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const goal = {
type: 'local-jsx',
supportsNonInteractive: true,
name: 'goal',
description: 'Set or manage a completion goal that keeps the session working until it is met',
argumentHint: '[status|clear|pause|resume|--tokens <budget> <objective>|<objective>]',
load: () => import('./goal.js'),
} satisfies Command
export default goal

View File

@ -0,0 +1,47 @@
import { describe, expect, test } from 'bun:test'
import type { Command } from '../types/command.js'
import { filterCommandsForHeadlessMode } from './headless.js'
describe('filterCommandsForHeadlessMode', () => {
test('keeps /goal without exposing other local-jsx UI commands', () => {
const commands = [
{
type: 'local-jsx',
supportsNonInteractive: true,
name: 'goal',
description: 'Set a goal',
load: async () => ({ call: async () => null }),
},
{
type: 'local-jsx',
name: 'config',
description: 'Open config UI',
load: async () => ({ call: async () => null }),
},
{
type: 'prompt',
name: 'review',
description: 'Review code',
progressMessage: 'reviewing',
contentLength: 0,
source: 'builtin',
getPromptForCommand: async () => [],
},
{
type: 'prompt',
name: 'statusline',
description: 'Hidden from print mode',
progressMessage: 'checking',
contentLength: 0,
source: 'builtin',
disableNonInteractive: true,
getPromptForCommand: async () => [],
},
] satisfies Command[]
expect(filterCommandsForHeadlessMode(commands).map(command => command.name)).toEqual([
'goal',
'review',
])
})
})

11
src/commands/headless.ts Normal file
View File

@ -0,0 +1,11 @@
import type { Command } from '../types/command.js'
export function supportsHeadlessSlashCommand(command: Command): boolean {
if (command.type === 'prompt') return command.disableNonInteractive !== true
if (command.type === 'local') return command.supportsNonInteractive
return command.supportsNonInteractive === true
}
export function filterCommandsForHeadlessMode(commands: Command[]): Command[] {
return commands.filter(supportsHeadlessSlashCommand)
}

View File

@ -0,0 +1,136 @@
import { describe, expect, test } from 'bun:test'
import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { Message } from '../types/message.js'
import { createAssistantMessage, createUserMessage } from '../utils/messages.js'
import { evaluateThreadGoalAfterTurn } from './goalEvaluator.js'
import { getThreadGoal, setThreadGoal } from './goalState.js'
describe('goalEvaluator', () => {
test('continues an active goal when the evaluator says it is incomplete', async () => {
const goal = setThreadGoal('thread-eval-continue', {
objective: 'tests pass',
now: 1_000,
})
const messages: Message[] = [
createUserMessage({ content: 'run the tests' }),
createAssistantMessage({
content: [{ type: 'text', text: 'I changed code but did not test it.' }],
}),
]
const decision = await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-continue',
messages,
assistantMessages: [],
signal: new AbortController().signal,
now: 2_000,
evaluate: async () => ({
complete: false,
reason: 'Tests have not been run.',
}),
})
expect(decision.action).toBe('continue')
expect(decision.goal.goalId).toBe(goal.goalId)
expect(decision.prompt).toContain('Tests have not been run.')
expect(getThreadGoal('thread-eval-continue')?.status).toBe('active')
})
test('does not let hidden goal prompts satisfy the evaluator transcript', async () => {
setThreadGoal('thread-eval-meta', {
objective: 'finish after MAGIC_DONE appears',
now: 1_000,
})
let capturedTranscript = ''
const decision = await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-meta',
messages: [
createUserMessage({
content: 'The hidden instruction says MAGIC_DONE is the target.',
isMeta: true,
}),
createAssistantMessage({
content: [{ type: 'text', text: 'Still working.' }],
}),
],
assistantMessages: [],
signal: new AbortController().signal,
now: 2_000,
evaluate: async ({ transcript }) => {
capturedTranscript = transcript
return {
complete: false,
reason: 'No completion evidence.',
}
},
})
expect(decision.action).toBe('continue')
expect(capturedTranscript).not.toContain('MAGIC_DONE')
expect(capturedTranscript).toContain('Assistant: Still working.')
})
test('does not let hidden assistant thinking satisfy the evaluator transcript', async () => {
setThreadGoal('thread-eval-thinking', {
objective: 'finish after LOOP_DONE appears',
now: 1_000,
})
let capturedTranscript = ''
await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-thinking',
messages: [
createAssistantMessage({
content: [
{
type: 'thinking',
thinking: 'I will output LOOP_DONE next turn.',
signature: 'test',
} as unknown as BetaContentBlock,
{ type: 'text', text: 'STEP_ONE' },
],
}),
],
assistantMessages: [],
signal: new AbortController().signal,
now: 2_000,
evaluate: async ({ transcript }) => {
capturedTranscript = transcript
return {
complete: false,
reason: 'No visible completion evidence.',
}
},
})
expect(capturedTranscript).not.toContain('LOOP_DONE')
expect(capturedTranscript).toContain('Assistant: STEP_ONE')
})
test('marks an active goal complete when the evaluator says it is complete', async () => {
setThreadGoal('thread-eval-complete', {
objective: 'tests pass',
now: 1_000,
})
const decision = await evaluateThreadGoalAfterTurn({
threadId: 'thread-eval-complete',
messages: [createUserMessage({ content: 'bun test passed' })],
assistantMessages: [],
signal: new AbortController().signal,
now: 3_000,
evaluate: async () => ({
complete: true,
reason: 'The transcript shows the tests passed.',
}),
})
expect(decision.action).toBe('complete')
expect(decision.reason).toBe('The transcript shows the tests passed.')
expect(getThreadGoal('thread-eval-complete')?.status).toBe('complete')
expect(getThreadGoal('thread-eval-complete')?.lastReason).toBe(
'The transcript shows the tests passed.',
)
})
})

266
src/goals/goalEvaluator.ts Normal file
View File

@ -0,0 +1,266 @@
import type {
BetaMessage,
BetaContentBlock,
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { QuerySource } from '../constants/querySource.js'
import type { AssistantMessage, Message } from '../types/message.js'
import { extractTextContent } from '../utils/messages.js'
import { getSmallFastModel } from '../utils/model/model.js'
import { safeParseJSON } from '../utils/json.js'
import { sideQuery } from '../utils/sideQuery.js'
import {
accountThreadGoalUsage,
buildGoalContinuationPrompt,
getThreadGoal,
incrementThreadGoalContinuation,
markThreadGoalComplete,
updateThreadGoalStatus,
type ThreadGoal,
} from './goalState.js'
export type GoalEvaluation = {
complete: boolean
reason: string
}
export type GoalTurnDecision =
| { action: 'none' }
| { action: 'continue'; goal: ThreadGoal; prompt: string; reason: string }
| { action: 'complete'; goal: ThreadGoal; reason: string }
| { action: 'budget_limited'; goal: ThreadGoal }
type EvaluateFn = (input: {
goal: ThreadGoal
transcript: string
signal: AbortSignal
querySource?: QuerySource
}) => Promise<GoalEvaluation>
const DEFAULT_MAX_CONTINUATIONS = 500
export async function evaluateThreadGoalAfterTurn(input: {
threadId: string
messages: Message[]
assistantMessages: AssistantMessage[]
signal: AbortSignal
now?: number
querySource?: QuerySource
evaluate?: EvaluateFn
}): Promise<GoalTurnDecision> {
const now = input.now ?? Date.now()
const current = getThreadGoal(input.threadId)
if (!current || current.status !== 'active') return { action: 'none' }
const tokens = input.assistantMessages.reduce(
(sum, msg) =>
sum +
(msg.message.usage?.input_tokens ?? 0) +
(msg.message.usage?.output_tokens ?? 0),
0,
)
const accounted = accountThreadGoalUsage(input.threadId, tokens, now) ?? current
if (
accounted.tokenBudget !== null &&
accounted.tokensUsed >= accounted.tokenBudget
) {
const limited =
updateThreadGoalStatus(input.threadId, 'budget_limited', now) ?? accounted
return { action: 'budget_limited', goal: limited }
}
if (accounted.continuationCount >= getMaxContinuations()) {
const limited =
updateThreadGoalStatus(input.threadId, 'budget_limited', now) ?? accounted
return { action: 'budget_limited', goal: limited }
}
const transcript = formatTranscript([
...input.messages,
...input.assistantMessages,
])
const evaluator = input.evaluate ?? evaluateGoalCompletion
const evaluation = await evaluator({
goal: accounted,
transcript,
signal: input.signal,
querySource: input.querySource,
})
if (evaluation.complete) {
const completed =
markThreadGoalComplete(input.threadId, {
reason: evaluation.reason,
now,
}) ?? accounted
return {
action: 'complete',
goal: completed,
reason: evaluation.reason,
}
}
const continued =
incrementThreadGoalContinuation(input.threadId, {
reason: evaluation.reason,
now,
}) ?? accounted
return {
action: 'continue',
goal: continued,
reason: evaluation.reason,
prompt: buildGoalContinuationPrompt(continued, evaluation.reason),
}
}
async function evaluateGoalCompletion(input: {
goal: ThreadGoal
transcript: string
signal: AbortSignal
querySource?: QuerySource
}): Promise<GoalEvaluation> {
const baseRequest = {
querySource: input.querySource ?? 'hook_prompt',
model: getSmallFastModel(),
skipSystemPromptPrefix: true,
thinking: false,
temperature: 0,
max_tokens: 512,
signal: input.signal,
system:
'You evaluate whether a coding-agent goal is complete. ' +
'Return JSON only. Say complete=true only when the transcript contains concrete visible evidence that the objective is satisfied.',
messages: [
{
role: 'user' as const,
content: [
{
type: 'text' as const,
text: [
`<objective>${input.goal.objective}</objective>`,
'',
'<transcript>',
input.transcript,
'</transcript>',
].join('\n'),
},
],
},
],
}
try {
const response = await sideQuery({
...baseRequest,
output_format: {
type: 'json_schema',
schema: {
type: 'object',
properties: {
complete: { type: 'boolean' },
reason: { type: 'string' },
},
required: ['complete', 'reason'],
additionalProperties: false,
},
},
})
return parseEvaluationResponse(response)
} catch (error) {
if (input.signal.aborted) throw error
}
const response = await sideQuery({
...baseRequest,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: [
`<objective>${input.goal.objective}</objective>`,
'',
'<transcript>',
input.transcript,
'</transcript>',
'',
'Return exactly one JSON object with this shape and no markdown:',
'{"complete": false, "reason": "short evidence-based reason"}',
].join('\n'),
},
],
},
],
})
return parseEvaluationResponse(response)
}
function parseEvaluationResponse(response: BetaMessage): GoalEvaluation {
const text = extractTextContent(response.content, '').trim()
const parsed = safeParseJSON(text) ?? safeParseJSON(extractJsonObject(text))
if (
parsed &&
typeof parsed === 'object' &&
'complete' in parsed &&
typeof parsed.complete === 'boolean'
) {
return {
complete: parsed.complete,
reason:
'reason' in parsed && typeof parsed.reason === 'string'
? parsed.reason
: '',
}
}
return {
complete: false,
reason: 'The evaluator did not return a valid completion decision.',
}
}
function extractJsonObject(text: string): string {
const start = text.indexOf('{')
const end = text.lastIndexOf('}')
if (start === -1 || end <= start) return text
return text.slice(start, end + 1)
}
function formatTranscript(messages: Message[]): string {
const lines: string[] = []
const recent = messages.slice(-40)
for (const message of recent) {
if (message.type === 'user') {
if (message.isMeta) continue
lines.push(`User: ${contentToText(message.message.content)}`)
} else if (message.type === 'assistant') {
lines.push(`Assistant: ${assistantVisibleText(message.message.content)}`)
} else if (message.type === 'system' && typeof message.content === 'string') {
lines.push(`System: ${message.content}`)
}
}
return lines.join('\n\n').slice(-24_000)
}
function contentToText(content: string | readonly BetaContentBlock[]): string {
if (typeof content === 'string') return content
return extractTextContent(content, '\n')
}
function assistantVisibleText(content: readonly BetaContentBlock[]): string {
return content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
}
function getMaxContinuations(): number {
const raw = process.env.CLAUDE_CODE_GOAL_MAX_CONTINUES
if (!raw) return DEFAULT_MAX_CONTINUATIONS
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_MAX_CONTINUATIONS
}

View File

@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test'
import {
buildGoalContinuationPrompt,
clearThreadGoal,
formatGoalStatus,
getThreadGoal,
markThreadGoalComplete,
parseGoalCommand,
setThreadGoal,
updateThreadGoalStatus,
} from './goalState.js'
describe('goalState', () => {
test('parses token budget before the goal objective', () => {
const parsed = parseGoalCommand(
'--tokens 250K migrate auth to the new API until tests pass',
)
expect(parsed).toEqual({
type: 'set',
objective: 'migrate auth to the new API until tests pass',
tokenBudget: 250_000,
})
})
test('stores and formats the current thread goal', () => {
const goal = setThreadGoal('thread-a', {
objective: 'all provider tests pass',
tokenBudget: 10_000,
now: 1_000,
})
expect(goal.status).toBe('active')
expect(getThreadGoal('thread-a')?.objective).toBe('all provider tests pass')
expect(formatGoalStatus(goal, 61_000)).toContain('Goal: active')
expect(formatGoalStatus(goal, 61_000)).toContain('Budget: 0 / 10,000 tokens')
expect(formatGoalStatus(goal, 61_000)).toContain('Elapsed: 1m')
})
test('pause, resume, complete, and clear are scoped to the thread', () => {
setThreadGoal('thread-a', { objective: 'ship feature', now: 1_000 })
setThreadGoal('thread-b', { objective: 'different work', now: 1_000 })
expect(updateThreadGoalStatus('thread-a', 'paused', 2_000)?.status).toBe(
'paused',
)
expect(updateThreadGoalStatus('thread-a', 'active', 3_000)?.status).toBe(
'active',
)
expect(
markThreadGoalComplete('thread-a', {
reason: 'Done according to the transcript.',
now: 4_000,
})?.status,
).toBe('complete')
expect(formatGoalStatus(getThreadGoal('thread-a'), 4_000)).toContain(
'Latest reason: Done according to the transcript.',
)
expect(getThreadGoal('thread-b')?.status).toBe('active')
expect(clearThreadGoal('thread-a')).toBe(true)
expect(getThreadGoal('thread-a')).toBeNull()
})
test('builds a native-style continuation prompt', () => {
const goal = setThreadGoal('thread-c', {
objective: 'PR is ready and all tests pass',
now: 1_000,
})
expect(
buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'),
).toContain('Continue working toward the active /goal')
expect(
buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'),
).toContain('<objective>PR is ready and all tests pass</objective>')
expect(
buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'),
).toContain('Tests have not been run yet.')
})
})

218
src/goals/goalState.ts Normal file
View File

@ -0,0 +1,218 @@
import { randomUUID } from 'crypto'
export type ThreadGoalStatus = 'active' | 'paused' | 'complete' | 'budget_limited'
export type ThreadGoal = {
goalId: string
threadId: string
objective: string
status: ThreadGoalStatus
tokenBudget: number | null
tokensUsed: number
continuationCount: number
lastReason: string | null
createdAt: number
updatedAt: number
}
export type ParsedGoalCommand =
| { type: 'status' }
| { type: 'clear' }
| { type: 'pause' }
| { type: 'resume' }
| { type: 'complete' }
| { type: 'set'; objective: string; tokenBudget?: number | null }
const goalsByThread = new Map<string, ThreadGoal>()
export function parseGoalCommand(args: string): ParsedGoalCommand {
const trimmed = args.trim()
if (!trimmed || trimmed === 'status') return { type: 'status' }
if (['clear', 'stop', 'off', 'reset', 'none', 'cancel'].includes(trimmed)) {
return { type: 'clear' }
}
if (trimmed === 'pause') return { type: 'pause' }
if (trimmed === 'resume') return { type: 'resume' }
if (trimmed === 'complete') return { type: 'complete' }
const parts = trimmed.split(/\s+/)
let tokenBudget: number | null | undefined
let objectiveStart = 0
if (parts[0] === '--tokens') {
const rawBudget = parts[1]
if (!rawBudget) {
throw new Error('Usage: /goal --tokens <budget> <objective>')
}
tokenBudget = parseTokenBudget(rawBudget)
objectiveStart = 2
}
const objective = parts.slice(objectiveStart).join(' ').trim()
if (!objective) {
throw new Error('Usage: /goal [--tokens <budget>] <objective>')
}
return { type: 'set', objective, tokenBudget }
}
export function setThreadGoal(
threadId: string,
input: {
objective: string
tokenBudget?: number | null
now?: number
},
): ThreadGoal {
const now = input.now ?? Date.now()
const existing = goalsByThread.get(threadId)
const goal: ThreadGoal = {
goalId: existing?.goalId ?? randomUUID(),
threadId,
objective: input.objective.trim(),
status: 'active',
tokenBudget:
input.tokenBudget !== undefined
? input.tokenBudget
: (existing?.tokenBudget ?? null),
tokensUsed: existing?.tokensUsed ?? 0,
continuationCount: 0,
lastReason: null,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
}
goalsByThread.set(threadId, goal)
return goal
}
export function getThreadGoal(threadId: string): ThreadGoal | null {
return goalsByThread.get(threadId) ?? null
}
export function clearThreadGoal(threadId: string): boolean {
return goalsByThread.delete(threadId)
}
export function updateThreadGoalStatus(
threadId: string,
status: ThreadGoalStatus,
now = Date.now(),
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal) return null
const updated = { ...goal, status, updatedAt: now }
goalsByThread.set(threadId, updated)
return updated
}
export function markThreadGoalComplete(
threadId: string,
input: { reason?: string; now?: number } = {},
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal) return null
const updated = {
...goal,
status: 'complete' as const,
lastReason: input.reason ?? goal.lastReason,
updatedAt: input.now ?? Date.now(),
}
goalsByThread.set(threadId, updated)
return updated
}
export function accountThreadGoalUsage(
threadId: string,
tokens: number,
now = Date.now(),
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal || tokens <= 0) return goal ?? null
const updated = {
...goal,
tokensUsed: goal.tokensUsed + tokens,
updatedAt: now,
}
goalsByThread.set(threadId, updated)
return updated
}
export function incrementThreadGoalContinuation(
threadId: string,
input: { reason?: string; now?: number } = {},
): ThreadGoal | null {
const goal = goalsByThread.get(threadId)
if (!goal) return null
const updated = {
...goal,
continuationCount: goal.continuationCount + 1,
lastReason: input.reason ?? goal.lastReason,
updatedAt: input.now ?? Date.now(),
}
goalsByThread.set(threadId, updated)
return updated
}
export function formatGoalStatus(goal: ThreadGoal | null, now = Date.now()): string {
if (!goal) return 'No active goal.'
return [
`Goal: ${goal.status}`,
`Objective: ${goal.objective}`,
`Budget: ${goal.tokensUsed.toLocaleString()} / ${
goal.tokenBudget === null ? 'unlimited' : goal.tokenBudget.toLocaleString()
} tokens`,
`Elapsed: ${formatElapsed(Math.max(0, now - goal.createdAt))}`,
`Continuations: ${goal.continuationCount.toLocaleString()}`,
goal.lastReason ? `Latest reason: ${goal.lastReason}` : null,
]
.filter((line): line is string => line !== null)
.join('\n')
}
export function buildGoalStartPrompt(goal: ThreadGoal): string {
return [
'You are now pursuing this /goal until the completion condition is met.',
'',
`<objective>${goal.objective}</objective>`,
'',
'Work autonomously. Research, implement, test, and review as needed.',
'Before claiming completion, perform a concrete completion audit against the objective.',
].join('\n')
}
export function buildGoalContinuationPrompt(
goal: ThreadGoal,
reason: string,
): string {
return [
'Continue working toward the active /goal.',
'',
`<objective>${goal.objective}</objective>`,
'',
'The goal evaluator says the objective is not complete yet.',
`Reason: ${reason || 'No reason provided.'}`,
'',
'Resume directly from the current state. Do not ask the user to continue. Do the next concrete step, then test or review before stopping.',
].join('\n')
}
function parseTokenBudget(raw: string): number {
const match = raw.trim().match(/^(\d+(?:\.\d+)?)([kKmM])?$/)
if (!match) throw new Error(`Invalid token budget: ${raw}`)
const value = Number(match[1])
const suffix = match[2]?.toLowerCase()
const multiplier = suffix === 'm' ? 1_000_000 : suffix === 'k' ? 1_000 : 1
const budget = Math.floor(value * multiplier)
if (!Number.isFinite(budget) || budget <= 0) {
throw new Error(`Invalid token budget: ${raw}`)
}
return budget
}
function formatElapsed(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) return `${hours}h ${minutes % 60}m`
if (minutes > 0) return `${minutes}m`
return `${seconds}s`
}

View File

@ -86,6 +86,7 @@ import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEve
import { initializeAnalyticsGates } from 'src/services/analytics/sink.js';
import { getOriginalCwd, setAdditionalDirectoriesForClaudeMd, setIsRemoteMode, setMainLoopModelOverride, setMainThreadAgentType, setTeleportedSessionInfo } from './bootstrap/state.js';
import { filterCommandsForRemoteMode, getCommands } from './commands.js';
import { filterCommandsForHeadlessMode } from './commands/headless.js';
import type { StatsStore } from './context/stats.js';
import { launchAssistantInstallWizard, launchAssistantSessionChooser, launchInvalidSettingsDialog, launchResumeChooser, launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper } from './dialogLaunchers.js';
import { SHOW_CURSOR } from './ink/termio/dec.js';
@ -2639,7 +2640,7 @@ async function run(): Promise<CommanderCommand> {
// Headless mode supports all prompt commands and some local commands
// If disableSlashCommands is true, return empty array
const commandsHeadless = disableSlashCommands ? [] : commands.filter(command => command.type === 'prompt' && !command.disableNonInteractive || command.type === 'local' && command.supportsNonInteractive);
const commandsHeadless = disableSlashCommands ? [] : filterCommandsForHeadlessMode(commands);
const defaultState = getDefaultAppState();
const headlessInitialState: AppState = {
...defaultState,

View File

@ -99,12 +99,14 @@ import { runTools } from './services/tools/toolOrchestration.js'
import { applyToolResultBudget } from './utils/toolResultStorage.js'
import { recordContentReplacement } from './utils/sessionStorage.js'
import { handleStopHooks } from './query/stopHooks.js'
import { evaluateThreadGoalAfterTurn } from './goals/goalEvaluator.js'
import { buildQueryConfig } from './query/config.js'
import { productionDeps, type QueryDeps } from './query/deps.js'
import type { Terminal, Continue } from './query/transitions.js'
import { feature } from 'bun:bundle'
import {
getCurrentTurnTokenBudget,
getSessionId,
getTurnOutputTokens,
incrementBudgetContinuationCount,
} from './bootstrap/state.js'
@ -1305,6 +1307,51 @@ async function* queryLoop(
continue
}
if (!toolUseContext.agentId) {
try {
const goalDecision = await evaluateThreadGoalAfterTurn({
threadId: getSessionId(),
messages: messagesForQuery,
assistantMessages,
signal: toolUseContext.abortController.signal,
querySource,
})
if (goalDecision.action === 'continue') {
logForDebugging(
`Goal continuation #${goalDecision.goal.continuationCount}: ${goalDecision.reason}`,
)
state = {
messages: [
...messagesForQuery,
...assistantMessages,
createUserMessage({
content: goalDecision.prompt,
isMeta: true,
}),
],
toolUseContext,
autoCompactTracking: tracking,
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false,
maxOutputTokensOverride: undefined,
pendingToolUseSummary: undefined,
stopHookActive: undefined,
turnCount,
transition: { reason: 'goal_continuation' } as Continue,
}
continue
}
if (goalDecision.action === 'budget_limited') {
logForDebugging('Goal stopped because its budget was reached')
}
} catch (error) {
logError(error)
logForDebugging('Goal evaluator failed; returning control to user')
}
}
if (feature('TOKEN_BUDGET')) {
const decision = checkTokenBudget(
budgetTracker!,

View File

@ -143,6 +143,7 @@ export type LocalJSXCommandModule = {
type LocalJSXCommand = {
type: 'local-jsx'
supportsNonInteractive?: boolean
/**
* Lazy-load the command implementation.
* Returns a module with a call() function.