Land autonomous goal loop support on main

The /goal slash command was developed and verified in an isolated worktree, while local main has advanced with unrelated desktop fixes. Merge the feature branch rather than rebasing so both lines of local history remain intact.

Constraint: local main contains newer independent commits after the feature base
Rejected: Fast-forward merge | main is not an ancestor of the feature branch
Confidence: high
Scope-risk: moderate
Directive: Keep /goal continuation limits bounded and provider-agnostic when modifying the loop
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 DeepSeek v4 pro CLI and desktop /goal validation in /tmp business fixtures
Not-tested: push or remote PR creation
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 19:23:47 +08:00
commit 966070d496
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.