diff --git a/src/commands.ts b/src/commands.ts index 0d8ae711..55c82fa7 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -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, diff --git a/src/commands/goal/goal.tsx b/src/commands/goal/goal.tsx new file mode 100644 index 00000000..2d040b81 --- /dev/null +++ b/src/commands/goal/goal.tsx @@ -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 { + 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 + } +} diff --git a/src/commands/goal/index.ts b/src/commands/goal/index.ts new file mode 100644 index 00000000..534df503 --- /dev/null +++ b/src/commands/goal/index.ts @@ -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 |]', + load: () => import('./goal.js'), +} satisfies Command + +export default goal diff --git a/src/commands/headless.test.ts b/src/commands/headless.test.ts new file mode 100644 index 00000000..535656d7 --- /dev/null +++ b/src/commands/headless.test.ts @@ -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', + ]) + }) +}) diff --git a/src/commands/headless.ts b/src/commands/headless.ts new file mode 100644 index 00000000..902001be --- /dev/null +++ b/src/commands/headless.ts @@ -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) +} diff --git a/src/goals/goalEvaluator.test.ts b/src/goals/goalEvaluator.test.ts new file mode 100644 index 00000000..5cdb3390 --- /dev/null +++ b/src/goals/goalEvaluator.test.ts @@ -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.', + ) + }) +}) diff --git a/src/goals/goalEvaluator.ts b/src/goals/goalEvaluator.ts new file mode 100644 index 00000000..5bc8005e --- /dev/null +++ b/src/goals/goalEvaluator.ts @@ -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 + +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 { + 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 { + 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: [ + `${input.goal.objective}`, + '', + '', + input.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: [ + `${input.goal.objective}`, + '', + '', + input.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 +} diff --git a/src/goals/goalState.test.ts b/src/goals/goalState.test.ts new file mode 100644 index 00000000..c4f99424 --- /dev/null +++ b/src/goals/goalState.test.ts @@ -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('PR is ready and all tests pass') + expect( + buildGoalContinuationPrompt(goal, 'Tests have not been run yet.'), + ).toContain('Tests have not been run yet.') + }) +}) diff --git a/src/goals/goalState.ts b/src/goals/goalState.ts new file mode 100644 index 00000000..f37724fd --- /dev/null +++ b/src/goals/goalState.ts @@ -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() + +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 ') + } + tokenBudget = parseTokenBudget(rawBudget) + objectiveStart = 2 + } + + const objective = parts.slice(objectiveStart).join(' ').trim() + if (!objective) { + throw new Error('Usage: /goal [--tokens ] ') + } + + 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.', + '', + `${goal.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.', + '', + `${goal.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` +} diff --git a/src/main.tsx b/src/main.tsx index 799d4dca..8e72ac17 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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 { // 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, diff --git a/src/query.ts b/src/query.ts index 6491c1a1..a0bf10df 100644 --- a/src/query.ts +++ b/src/query.ts @@ -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!, diff --git a/src/types/command.ts b/src/types/command.ts index 313383c6..e1314a12 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -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.