diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx
index 88f6662fe8..b41ff956e7 100644
--- a/cli/src/chat.tsx
+++ b/cli/src/chat.tsx
@@ -14,7 +14,14 @@ import { useShallow } from 'zustand/react/shallow'
import { getAdsEnabled } from './commands/ads'
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
+import { MissionTodosTracker } from './components/mission-todos-tracker'
import { SingleAdBanner } from './components/ad-banner'
+import {
+ buildMissionContinuation,
+ loadMission,
+ refreshMissionCompletion,
+} from './missions/mission-store'
+import { getMissionAutopilotAction } from './missions/mission-autopilot'
import { ChatInputBar } from './components/chat-input-bar'
import { ChatHeader } from './components/chat-header'
import { FreebuffActiveSessionSummary } from './components/freebuff-active-session-summary'
@@ -52,7 +59,7 @@ import { usePublishMutation } from './hooks/use-publish-mutation'
import { useSuggestionEngine } from './hooks/use-suggestion-engine'
import { useUsageMonitor } from './hooks/use-usage-monitor'
import { WEBSITE_URL } from './login/constants'
-import { getProjectRoot } from './project-files'
+import { getMissionScopeId, getProjectRoot } from './project-files'
import { useChatHistoryStore } from './state/chat-history-store'
import { useChatStore } from './state/chat-store'
import { useQueuePanelStore } from './state/queue-panel-store'
@@ -570,6 +577,13 @@ export const Chat = ({
},
)
+ const missionAutopilotPendingRef = useRef(false)
+ let missionAutopilotActive = false
+ try {
+ missionAutopilotActive =
+ loadMission(getProjectRoot(), getMissionScopeId())?.status === 'active'
+ } catch {}
+
// Retire onboarding suggested prompts once the user submits anything
// (typed or clicked), persisting so they don't return on future launches.
useEffect(() => {
@@ -1486,6 +1500,52 @@ export const Chat = ({
IS_FREEBUFF && freebuffSession?.status === 'active'
const isFreebuffSessionOver =
IS_FREEBUFF && freebuffSession?.status === 'ended'
+
+ // A mission is a persistent worker, not a single assistant turn. Whenever
+ // its chat becomes idle, re-check the authoritative plan and start the next
+ // turn. The per-chat mission file prevents parallel terminals from sharing
+ // or completing each other's work.
+ useEffect(() => {
+ let root: string
+ let scopeId: string
+ try {
+ root = getProjectRoot()
+ scopeId = getMissionScopeId()
+ } catch {
+ return
+ }
+ const mission = refreshMissionCompletion(root, scopeId)
+ const action = getMissionAutopilotAction({
+ active: mission?.status === 'active',
+ idle:
+ !isStreaming &&
+ !isWaitingForResponse &&
+ !isChainInProgressRef.current &&
+ !askUserState &&
+ !reviewMode,
+ sessionOver: isFreebuffSessionOver,
+ })
+ if (action !== 'continue' || !mission || missionAutopilotPendingRef.current) return
+
+ missionAutopilotPendingRef.current = true
+ const timer = setTimeout(() => {
+ const current = refreshMissionCompletion(root, scopeId)
+ if (!current || current.status !== 'active') {
+ missionAutopilotPendingRef.current = false
+ return
+ }
+ onSubmitPrompt(buildMissionContinuation(root, current, scopeId), agentMode)
+ .catch((error) => logger.error({ error }, '[mission-autopilot] Failed to continue mission'))
+ .finally(() => {
+ missionAutopilotPendingRef.current = false
+ })
+ }, 1500)
+ return () => {
+ clearTimeout(timer)
+ missionAutopilotPendingRef.current = false
+ }
+ }, [messages.length, isStreaming, isWaitingForResponse, isFreebuffSessionOver, askUserState, reviewMode, agentMode, onSubmitPrompt])
+
const shouldShowStatusLine =
!feedbackMode &&
(hasStatusIndicatorContent ||
@@ -1628,6 +1688,8 @@ export const Chat = ({
/>
)}
+
+
{reviewMode ? (
// Review and ask_user take precedence over the session-ended banner:
// during the grace window the agent may still be asking to run tools
@@ -1649,9 +1711,10 @@ export const Chat = ({
width={separatorWidth}
maxVisibleRows={isCompactHeight ? 4 : 8}
/>
- ) : isFreebuffSessionOver && !askUserState ? (
+ ) : isFreebuffSessionOver && !askUserState && !isStreaming && !isWaitingForResponse ? (
) : (
<>
diff --git a/cli/src/commands/mission.ts b/cli/src/commands/mission.ts
new file mode 100644
index 0000000000..fb02806521
--- /dev/null
+++ b/cli/src/commands/mission.ts
@@ -0,0 +1,74 @@
+import { getMissionScopeId, getProjectRoot } from '../project-files'
+import {
+ buildMissionPrompt,
+ cancelMission,
+ completeMission,
+ createMission,
+ formatMissionStatus,
+ loadMission,
+} from '../missions/mission-store'
+
+function root(): string {
+ return getProjectRoot() || process.cwd()
+}
+
+function scope(): string | undefined {
+ try {
+ return getMissionScopeId()
+ } catch {
+ return undefined
+ }
+}
+
+export type MissionCommandResult =
+ | { kind: 'message'; message: string }
+ | { kind: 'start'; prompt: string }
+
+export function runMissionCommand(args: string): MissionCommandResult {
+ const trimmed = args.trim()
+ let [verb, ...rest] = trimmed ? trimmed.split(/\s+/) : ['status']
+
+ if (verb.endsWith(',')) {
+ verb = verb.slice(0, -1)
+ }
+
+ const value = rest.join(' ').trim()
+
+ if (verb === 'status') {
+ return { kind: 'message', message: formatMissionStatus(loadMission(root(), scope())) }
+ }
+ if (verb === 'start') {
+ if (!value) {
+ return { kind: 'message', message: 'Usage: /mission start ' }
+ }
+ const mission = createMission(root(), value, scope())
+ return { kind: 'start', prompt: buildMissionPrompt(root(), mission, scope()) }
+ }
+ if (verb === 'complete') {
+ const evidence = value ? value.split('|').map((item) => item.trim()) : []
+ try {
+ const mission = completeMission(root(), evidence, scope())
+ return { kind: 'message', message: formatMissionStatus(mission) }
+ } catch (error) {
+ return {
+ kind: 'message',
+ message: error instanceof Error ? error.message : String(error),
+ }
+ }
+ }
+ if (verb === 'cancel') {
+ try {
+ const mission = cancelMission(root(), scope())
+ return { kind: 'message', message: formatMissionStatus(mission) }
+ } catch (error) {
+ return {
+ kind: 'message',
+ message: error instanceof Error ? error.message : String(error),
+ }
+ }
+ }
+ return {
+ kind: 'message',
+ message: 'Uso: /mission [status|start |complete [evidência]|cancel]',
+ }
+}
diff --git a/cli/src/commands/router.ts b/cli/src/commands/router.ts
index d9b08aa766..e6a93eb19f 100644
--- a/cli/src/commands/router.ts
+++ b/cli/src/commands/router.ts
@@ -13,6 +13,10 @@ import {
} from './router-utils'
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPrompt } from './prompt-builders'
import { getProjectRoot } from '../project-files'
+import {
+ buildMissionContinuation,
+ loadMission,
+} from '../missions/mission-store'
import { useChatStore } from '../state/chat-store'
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
import { trackEvent } from '../utils/analytics'
@@ -457,7 +461,13 @@ export async function routeUserPrompt(
return
}
- sendMessage({ content: trimmed, agentMode })
+ const projectRoot = getProjectRoot() || process.cwd()
+ const mission = loadMission(projectRoot)
+ const content =
+ mission?.status === 'active'
+ ? trimmed + buildMissionContinuation(projectRoot, mission)
+ : trimmed
+ sendMessage({ content, agentMode })
setTimeout(() => {
scrollToLatest()
diff --git a/cli/src/components/mission-todos-tracker.tsx b/cli/src/components/mission-todos-tracker.tsx
new file mode 100644
index 0000000000..4a82823694
--- /dev/null
+++ b/cli/src/components/mission-todos-tracker.tsx
@@ -0,0 +1,118 @@
+import React, { useMemo, useState } from 'react'
+import fs from 'node:fs'
+import { TextAttributes } from '@opentui/core'
+import { useTheme } from '../hooks/use-theme'
+import { getMissionScopeId, getProjectRoot } from '../project-files'
+import { getMissionPath } from '../missions/mission-store'
+import { ClickableTitleBox } from './clickable-title-box'
+import { BORDER_CHARS } from '../utils/ui-constants'
+
+import type { ChatMessage, ToolContentBlock } from '../types/chat'
+
+interface MissionTodosTrackerProps {
+ messages: ChatMessage[]
+}
+
+export const MissionTodosTracker: React.FC = ({ messages }) => {
+ const theme = useTheme()
+ const projectRoot = getProjectRoot() ?? process.cwd()
+ const [isExpanded, setIsExpanded] = useState(false)
+
+ const missionJsonPath = useMemo(() => {
+ try {
+ return getMissionPath(projectRoot, getMissionScopeId())
+ } catch {
+ return getMissionPath(projectRoot)
+ }
+ }, [projectRoot, messages])
+
+ const missionText = useMemo(() => {
+ try {
+ if (fs.existsSync(missionJsonPath)) {
+ const data = JSON.parse(fs.readFileSync(missionJsonPath, 'utf8'))
+ if (data.status === 'active' || data.status === 'completed' || data.status === 'blocked') {
+ return { objective: data.objective, status: data.status }
+ }
+ }
+ } catch {}
+ return null
+ }, [missionJsonPath, messages])
+
+ const todos = useMemo(() => {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i]
+ if (msg.blocks) {
+ for (const block of msg.blocks) {
+ if (block.type === 'tool' && (block as ToolContentBlock).toolName === 'write_todos') {
+ try {
+ const input = (block as ToolContentBlock).input
+ if (input && Array.isArray(input.todos)) {
+ return input.todos as Array<{task: string, completed: boolean}>
+ }
+ } catch {}
+ }
+ }
+ }
+ }
+ return null
+ }, [messages])
+
+ if (!missionText && !todos) return null
+
+ // Clean objective text for single line, heavily truncated to avoid Opentui dropping the title on small terminals
+ const shortObjective = missionText?.objective
+ ? missionText.objective.replace(/[\r\n]+/g, ' ').substring(0, 30) + (missionText.objective.length > 30 ? '...' : '')
+ : 'Sem meta definida'
+
+ const completedCount = todos ? todos.filter(t => t.completed).length : 0
+ const totalCount = todos ? todos.length : 0
+
+ const title = ` META: ${shortObjective} | ETAPAS (${completedCount}/${totalCount}) [${isExpanded ? '▲' : '▼'}] `
+
+ let visibleTodos = todos || []
+
+ if (!isExpanded && todos && todos.length > 0) {
+ const firstUncompletedIndex = todos.findIndex(t => !t.completed)
+ const targetIndex = firstUncompletedIndex === -1 ? todos.length - 1 : firstUncompletedIndex
+ visibleTodos = [todos[targetIndex]]
+ // Attach index so we know which one it is
+ visibleTodos[0] = { ...visibleTodos[0], originalIndex: targetIndex } as any
+ } else if (todos) {
+ visibleTodos = todos.map((t, idx) => ({ ...t, originalIndex: idx })) as any
+ }
+
+ return (
+ setIsExpanded(!isExpanded)}
+ style={{
+ flexDirection: 'column',
+ width: '100%',
+ marginBottom: 1,
+ paddingLeft: 1,
+ paddingRight: 1,
+ borderStyle: 'single',
+ customBorderChars: BORDER_CHARS,
+ borderColor: theme.success,
+ }}
+ >
+ {visibleTodos.map((todo: any) => (
+
+
+ {todo.completed ? (
+ <>
+ [x]
+ {todo.task}
+ >
+ ) : (
+ <>
+ [ ]
+ {todo.task}
+ >
+ )}
+
+
+ ))}
+
+ )
+}
diff --git a/cli/src/components/session-ended-banner.tsx b/cli/src/components/session-ended-banner.tsx
index 382b1235f0..4d69232e31 100644
--- a/cli/src/components/session-ended-banner.tsx
+++ b/cli/src/components/session-ended-banner.tsx
@@ -7,7 +7,7 @@ import {
import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session'
import { TextAttributes } from '@opentui/core'
import { useKeyboard } from '@opentui/react'
-import React, { useCallback, useState } from 'react'
+import React, { useCallback, useState, useEffect } from 'react'
import { Button } from './button'
import {
@@ -28,6 +28,8 @@ interface SessionEndedBannerProps {
* grace window. Swaps the Enter-to-rejoin affordance for a "let it
* finish" hint so the user doesn't abort their in-flight work. */
isStreaming: boolean
+ onSessionRenewed?: () => void
+ autoRestart?: boolean
}
/**
@@ -37,6 +39,8 @@ interface SessionEndedBannerProps {
*/
export const SessionEndedBanner: React.FC = ({
isStreaming,
+ onSessionRenewed,
+ autoRestart,
}) => {
const theme = useTheme()
const [pendingAction, setPendingAction] = useState<
@@ -112,8 +116,19 @@ export const SessionEndedBanner: React.FC = ({
}
// Re-POST with the currently selected model and keep the chat/run state
// intact so the next prompt continues the same conversation.
- refreshFreebuffSession().catch(() => setPendingAction(null))
- }, [canRestart, continueOnFallback])
+ refreshFreebuffSession()
+ .then(() => {
+ onSessionRenewed?.()
+ })
+ .catch(() => setPendingAction(null))
+ }, [canRestart, continueOnFallback, onSessionRenewed])
+
+ useEffect(() => {
+ if (autoRestart && canRestart) {
+ const timer = setTimeout(startSameChatSession, 1500)
+ return () => clearTimeout(timer)
+ }
+ }, [autoRestart, canRestart, startSameChatSession])
useKeyboard(
useCallback(
diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts
index 39065317ee..830fb1ed0d 100644
--- a/cli/src/hooks/use-send-message.ts
+++ b/cli/src/hooks/use-send-message.ts
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto'
import { useCallback, useEffect, useRef } from 'react'
-import { setCurrentChatId } from '../project-files'
+import { setCurrentChatId, getMissionScopeId, getProjectRoot } from '../project-files'
+import { loadMission, buildMissionContinuation } from '../missions/mission-store'
import { createStreamController } from './stream-state'
import { useChatStore } from '../state/chat-store'
import {
@@ -93,12 +94,13 @@ const resolveAgent = (
agentId: string | undefined,
agentDefinitions: AgentDefinition[],
): AgentDefinition | string => {
+ const targetId = agentId ?? getAgentIdForMode(agentMode)
const selectedAgentDefinition =
- agentId && agentDefinitions.length > 0
- ? agentDefinitions.find((definition) => definition.id === agentId)
+ agentDefinitions.length > 0
+ ? agentDefinitions.find((definition) => definition.id === targetId)
: undefined
- return selectedAgentDefinition ?? agentId ?? getAgentIdForMode(agentMode)
+ return selectedAgentDefinition ?? targetId
}
// Respect bash context, but avoid sending empty prompts when only images are attached.
@@ -250,7 +252,7 @@ export const useSendMessage = ({
)
const sendMessage = useCallback(
- async ({ content, agentMode, postUserMessage, attachments }) => {
+ async function runSendMessage({ content, agentMode, postUserMessage, attachments }) {
// CRITICAL: Set chain in progress immediately (synchronously) before any async work.
// This ensures the router can detect that we're busy and queue subsequent messages.
// Set the ref directly first to guarantee immediate visibility to other code paths,
@@ -540,7 +542,17 @@ export const useSendMessage = ({
// Execute SDK run with streaming handlers
try {
const agentDefinitions = loadAgentDefinitions()
- const resolvedAgent = resolveAgent(agentMode, agentId, agentDefinitions)
+ let resolvedAgent = resolveAgent(agentMode, agentId, agentDefinitions)
+
+ // INJECT MISSION INTO SYSTEM PROMPT SO HE NEVER FORGETS IT DURING LONG LOOPS
+ try {
+ const root = getProjectRoot() || process.cwd()
+ const scopeId = getMissionScopeId()
+ const mission = loadMission(root, scopeId)
+ if (mission && mission.status === 'active' && typeof resolvedAgent !== 'string' && resolvedAgent.systemPrompt) {
+ resolvedAgent = { ...resolvedAgent, systemPrompt: resolvedAgent.systemPrompt + '\n\n' + buildMissionContinuation(root, mission, scopeId) }
+ }
+ } catch(e) {}
const promptWithBashContext = bashContextForPrompt
? bashContextForPrompt + finalContent
@@ -692,6 +704,30 @@ export const useSendMessage = ({
isQueuePausedRef,
hasReceivedContent: hasReceivedContentRef.current,
})
+
+ const errorStr = (error instanceof Error ? error.message : String(error)).toLowerCase()
+ const isNetworkError =
+ errorStr.includes('internal server error') ||
+ errorStr.includes('fetch failed') ||
+ errorStr.includes('network') ||
+ errorStr.includes('socket hang up') ||
+ errorStr.includes('econnreset')
+
+ // Extract retryCount from the first argument (hidden property)
+ const currentRetry = (arguments[0] as any).retryCount || 0
+
+ if (isNetworkError && currentRetry < 5) {
+ const retryContent = hasReceivedContentRef.current ? 'continue' : (typeof content === 'string' ? content : 'continue')
+ const backoff = Math.min(3000 * Math.pow(1.5, currentRetry), 15000)
+ setTimeout(() => {
+ if (runChatIsCurrent() && !abortController.signal.aborted) {
+ const nextArgs = { content: retryContent, agentMode, postUserMessage: false, attachments: [] }
+ ;(nextArgs as any).retryCount = currentRetry + 1
+ runSendMessage(nextArgs as any)
+ }
+ }, backoff)
+ }
+
// Persist the last checkpoint plus the error banner so a restart
// after a failed run still shows this turn. Settle async checkpoints
// first so a stale write can't clobber this one. Skipped after a
diff --git a/cli/src/missions/__tests__/mission-autopilot.test.ts b/cli/src/missions/__tests__/mission-autopilot.test.ts
new file mode 100644
index 0000000000..64131833c9
--- /dev/null
+++ b/cli/src/missions/__tests__/mission-autopilot.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'bun:test'
+
+import { getMissionAutopilotAction, shouldAutoStartMissionSession } from '../mission-autopilot'
+
+describe('mission autopilot', () => {
+ it('continues an active mission whenever the chat becomes idle', () => {
+ expect(getMissionAutopilotAction({ active: true, idle: true, sessionOver: false })).toBe('continue')
+ })
+
+ it('renews an ended session instead of waiting for Enter', () => {
+ expect(getMissionAutopilotAction({ active: true, idle: true, sessionOver: true })).toBe('renew')
+ })
+
+ it('does nothing while work is running or after completion', () => {
+ expect(getMissionAutopilotAction({ active: true, idle: false, sessionOver: false })).toBe('none')
+ expect(getMissionAutopilotAction({ active: false, idle: true, sessionOver: true })).toBe('none')
+ })
+
+ it('starts DeepSeek automatically from the landing screen for active missions', () => {
+ expect(shouldAutoStartMissionSession(true, 'none')).toBe(true)
+ expect(shouldAutoStartMissionSession(false, 'none')).toBe(false)
+ expect(shouldAutoStartMissionSession(true, 'active')).toBe(false)
+ })
+})
diff --git a/cli/src/missions/__tests__/mission-store.test.ts b/cli/src/missions/__tests__/mission-store.test.ts
new file mode 100644
index 0000000000..9fbbb5ee3c
--- /dev/null
+++ b/cli/src/missions/__tests__/mission-store.test.ts
@@ -0,0 +1,52 @@
+import fs from 'fs'
+import os from 'os'
+import path from 'path'
+
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+
+import {
+ buildMissionPrompt,
+ completeMission,
+ createMission,
+ loadMission,
+} from '../mission-store'
+
+describe('mission store', () => {
+ let root: string
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'freebuff-mission-'))
+ })
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true })
+ })
+
+ it('persists a resumable mission atomically', () => {
+ const mission = createMission(root, 'Corrigir streaming e provar com testes')
+
+ expect(loadMission(root)).toEqual(mission)
+ expect(mission.status).toBe('active')
+ expect(mission.objective).toContain('streaming')
+ })
+
+ it('marks a mission complete with evidence', () => {
+ createMission(root, 'Finalizar suporte MCP')
+ const completed = completeMission(root, ['60 testes passaram', 'CLI abriu'])
+
+ expect(completed.status).toBe('completed')
+ expect(completed.evidence).toHaveLength(2)
+ expect(loadMission(root)?.status).toBe('completed')
+ })
+
+ it('builds an action-first prompt that prevents premature completion', () => {
+ const mission = createMission(root, 'Entregar recurso completo')
+ const prompt = buildMissionPrompt(root, mission)
+
+ expect(prompt).toContain('MISSÃO ATIVA')
+ expect(prompt).toContain('Entregar recurso completo')
+ expect(prompt).toContain('não encerre')
+ expect(prompt).toContain('.freebuff/mission')
+ expect(prompt).toContain('evidência')
+ })
+})
diff --git a/cli/src/missions/mission-autopilot.ts b/cli/src/missions/mission-autopilot.ts
new file mode 100644
index 0000000000..d1a9490613
--- /dev/null
+++ b/cli/src/missions/mission-autopilot.ts
@@ -0,0 +1,12 @@
+export type MissionAutopilotParams = {
+ active: boolean
+ idle: boolean
+ sessionOver: boolean
+}
+
+export function getMissionAutopilotAction(params: MissionAutopilotParams): 'continue' | 'none' {
+ if (params.active && params.idle) {
+ return 'continue'
+ }
+ return 'none'
+}
diff --git a/cli/src/missions/mission-store.ts b/cli/src/missions/mission-store.ts
new file mode 100644
index 0000000000..d639b3e26f
--- /dev/null
+++ b/cli/src/missions/mission-store.ts
@@ -0,0 +1,141 @@
+import fs from 'fs'
+import path from 'path'
+
+export type MissionStatus = 'active' | 'completed' | 'cancelled' | 'blocked'
+
+export type MissionState = {
+ version: 1
+ id: string
+ objective: string
+ status: MissionStatus
+ createdAt: string
+ updatedAt: string
+ evidence: string[]
+}
+
+const MISSION_DIRECTORY = '.freebuff'
+
+export function getMissionPath(projectRoot: string, scopeId?: string): string {
+ // Isolate by scope (chat/conversation) instead of branch
+ const id = scopeId ? scopeId.replace(/[^a-zA-Z0-9_-]/g, '_') : 'default'
+ return path.join(projectRoot, MISSION_DIRECTORY, `mission-${id}.json`)
+}
+
+function writeMission(projectRoot: string, mission: MissionState, scopeId?: string): void {
+ const missionPath = getMissionPath(projectRoot, scopeId)
+ fs.mkdirSync(path.dirname(missionPath), { recursive: true })
+ const temporaryPath = `${missionPath}.${process.pid}.${Date.now()}.tmp`
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(mission, null, 2)}\n`, 'utf8')
+ fs.renameSync(temporaryPath, missionPath)
+}
+
+export function loadMission(projectRoot: string, scopeId?: string): MissionState | null {
+ const missionPath = getMissionPath(projectRoot, scopeId)
+ if (!fs.existsSync(missionPath)) return null
+ try {
+ const value = JSON.parse(fs.readFileSync(missionPath, 'utf8')) as MissionState
+ if (
+ value.version !== 1 ||
+ typeof value.id !== 'string' ||
+ typeof value.objective !== 'string' ||
+ !Array.isArray(value.evidence)
+ ) {
+ return null
+ }
+ return value
+ } catch {
+ return null
+ }
+}
+
+export function createMission(
+ projectRoot: string,
+ objective: string,
+ scopeId?: string,
+): MissionState {
+ const trimmedObjective = objective.trim()
+ if (!trimmedObjective) throw new Error('Mission requires an objective.')
+ const now = new Date().toISOString()
+ const mission: MissionState = {
+ version: 1,
+ id: crypto.randomUUID(),
+ objective: trimmedObjective,
+ status: 'active',
+ createdAt: now,
+ updatedAt: now,
+ evidence: [],
+ }
+ writeMission(projectRoot, mission, scopeId)
+ return mission
+}
+
+export function completeMission(
+ projectRoot: string,
+ evidence: string[],
+ scopeId?: string,
+): MissionState {
+ const mission = loadMission(projectRoot, scopeId)
+ if (!mission) throw new Error('No mission found.')
+ const completed: MissionState = {
+ ...mission,
+ status: 'completed',
+ evidence: evidence.map((item) => item.trim()).filter(Boolean),
+ updatedAt: new Date().toISOString(),
+ }
+ writeMission(projectRoot, completed, scopeId)
+ return completed
+}
+
+export function cancelMission(projectRoot: string, scopeId?: string): MissionState {
+ const mission = loadMission(projectRoot, scopeId)
+ if (!mission) throw new Error('No mission found.')
+ const cancelled: MissionState = {
+ ...mission,
+ status: 'cancelled',
+ updatedAt: new Date().toISOString(),
+ }
+ writeMission(projectRoot, cancelled, scopeId)
+ return cancelled
+}
+
+export function refreshMissionCompletion(projectRoot: string, scopeId?: string): MissionState | null {
+ const mission = loadMission(projectRoot, scopeId)
+ if (!mission) return null
+
+ if (mission.status === 'completed') {
+ // Mission is marked as completed. However, if task_plan.md has pending tasks...
+ const planPath = path.join(projectRoot, 'task_plan.md')
+ if (fs.existsSync(planPath)) {
+ const plan = fs.readFileSync(planPath, 'utf8')
+ if (plan.includes('- [ ]') || plan.includes('- [/]')) {
+ mission.status = 'active'
+ mission.evidence = ['Forced back to active: there are pending tasks in the plan']
+ writeMission(projectRoot, mission, scopeId)
+ }
+ }
+ }
+ return mission
+}
+
+export function buildMissionPrompt(projectRoot: string, mission: MissionState, scopeId?: string): string {
+ const relPath = path.relative(projectRoot, getMissionPath(projectRoot, scopeId)).replace(/\\/g, '/')
+ return `ACTIVE MISSION: ${mission.objective}
+
+Execute the entire mission autonomously. Strictly follow the task_plan.md file. Do not edit the mission via str_replace, as this is ignored. Instead, use the write_todos tool to update progress in the task plan.
+
+The persistent state of this conversation is in ${relPath}. **PROTOCOLO DE DOUBLE-CHECK OBRIGATÓRIO**: Antes de se dar por satisfeito, você DEVE garantir que não há itens \`- [ ]\` no arquivo task_plan.md. Se houver falhas, CONTINUE TRABALHANDO.
+`
+}
+
+export function buildMissionContinuation(projectRoot: string, mission: MissionState, scopeId?: string): string {
+ const relPath = path.relative(projectRoot, getMissionPath(projectRoot, scopeId)).replace(/\\/g, '/')
+ return `\n\n[Persistent mission ${mission.id}] Continue until complete: ${mission.objective}. Do not stop while there are unchecked boxes in task_plan.md.`
+}
+
+export function formatMissionStatus(mission: MissionState | null): string {
+ if (!mission) return 'No persistent mission.'
+ const evidence = mission.evidence.length
+ ? `\nEvidence:\n${mission.evidence.map((item) => `- ${item}`).join('\n')}`
+ : ''
+ return `Mission ${mission.status}: ${mission.objective}${evidence}`
+}
diff --git a/cli/src/project-files.ts b/cli/src/project-files.ts
index 99ffa73705..53621ab2e6 100644
--- a/cli/src/project-files.ts
+++ b/cli/src/project-files.ts
@@ -43,6 +43,10 @@ export function startNewChat() {
return currentChatId
}
+export function getMissionScopeId() {
+ return getCurrentChatId()
+}
+
// Get the project-specific data directory
export function getProjectDataDir(): string {
const root = getProjectRoot()