diff --git a/agents/__tests__/base3.test.ts b/agents/__tests__/base3.test.ts
index cfbd9acd6e..9a7d886d76 100644
--- a/agents/__tests__/base3.test.ts
+++ b/agents/__tests__/base3.test.ts
@@ -160,4 +160,10 @@ describe('base3 CLI roots', () => {
// Codebuff's paid modes explain credits; Freebuff has none to explain.
expect(base3.systemPrompt).toContain('/usage')
})
+
+ test('ships the optimization rules for long-running commands and anti-looping', () => {
+ expect(base3FreeDeepseek.systemPrompt).toContain('LONG-RUNNING COMMANDS (PREVENT HANGS)')
+ expect(base3FreeDeepseek.systemPrompt).toContain('ANTI-LOOPING')
+ expect(base3FreeDeepseek.systemPrompt).toContain('write_todos')
+ })
})
diff --git a/agents/base3.ts b/agents/base3.ts
index d2f193f698..65aef24ef9 100644
--- a/agents/base3.ts
+++ b/agents/base3.ts
@@ -176,6 +176,19 @@ ${
}
${PLACEHOLDER.SYSTEM_INFO_PROMPT}
+
+# LONG-RUNNING COMMANDS (PREVENT HANGS)
+- Commands like "bun run build:freebuff", compilations, or dev servers MUST use process_type: "BACKGROUND".
+- NEVER run a compilation in SYNC mode, as it will deadlock the terminal.
+- To wait for a background build, pipe its output (e.g., "> build.log 2>&1 < NUL") and then use "sleep 2" or "sleep 5" in SYNC mode repeatedly as a biological timer, then read the log to check if it finished.
+
+# ANTI-LOOPING
+- If you find yourself repeatedly executing the exact same tool calls without progress (e.g. reading the same file continuously), you MUST STOP. Change your approach, write to a different file, run a different search, or ask the user for help. Do NOT loop blindly.
+
+# METAS E SUBMETAS (write_todos)
+- Você DEVE EXECUTAR A FERRAMENTA/TOOL \`write_todos\` no início de qualquer tarefa para quebrar o objetivo em etapas hiper-granulares.
+- Conforme você avança, chame a tool \`write_todos\` novamente para atualizar o progresso.
+- NUNCA escreva os seus TODOs no texto normal do chat como markdown. Use APENAS a tool \`write_todos\` para que a UI possa ler os dados.
`
}
diff --git a/agents/mcp-enabled.ts b/agents/mcp-enabled.ts
new file mode 100644
index 0000000000..86c26fe82f
--- /dev/null
+++ b/agents/mcp-enabled.ts
@@ -0,0 +1,32 @@
+import { FREEBUFF_MIMO_V25_MODEL_ID } from '@codebuff/common/constants/freebuff-models'
+
+import { createBase3CliRoot } from './base3'
+
+const definition = {
+ ...createBase3CliRoot({
+ model: FREEBUFF_MIMO_V25_MODEL_ID,
+ isFreebuff: true,
+ }),
+ id: 'mcp-enabled',
+ displayName: 'Buffy with MCP',
+ mcpServers: {
+ filesystem: {
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-filesystem'],
+ },
+ memory: {
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-memory'],
+ },
+ 'sequential-thinking': {
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
+ },
+ puppeteer: {
+ command: 'npx',
+ args: ['-y', '@modelcontextprotocol/server-puppeteer'],
+ },
+ },
+}
+
+export default definition
diff --git a/bun.lock b/bun.lock
index 011afe310a..aa09ab73f0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -17,6 +17,7 @@
"@types/node": "^22.9.0",
"@types/node-fetch": "^2.6.12",
"@types/parse-path": "^7.1.0",
+ "@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "^6.17",
"bun-types": "1.3.11",
"eslint-config-prettier": "^9.1.0",
@@ -25,6 +26,7 @@
"ignore": "^6.0.2",
"lodash": "4.17.23",
"prettier": "^3.7.4",
+ "tar": "7.5.10",
"ts-node": "^10.9.2",
"ts-pattern": "^5.9.0",
"tsc-alias": "^1.8.16",
@@ -326,6 +328,8 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
+ "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
+
"@jimp/core": ["@jimp/core@1.6.1", "", { "dependencies": { "@jimp/file-ops": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^21.3.3", "mime": "3" } }, "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A=="],
"@jimp/diff": ["@jimp/diff@1.6.1", "", { "dependencies": { "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "pixelmatch": "^5.3.0" } }, "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ=="],
@@ -504,6 +508,8 @@
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
+ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+
"@types/react-reconciler": ["@types/react-reconciler@0.32.3", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-cMi5ZrLG7UtbL7LTK6hq9w/EZIRk4Mf1Z5qHoI+qBh7/WkYkFXQ7gOto2yfUvPzF5ERMAhaXS5eTQ2SAnHjLzA=="],
"@types/readable-stream": ["@types/readable-stream@4.0.24", "", { "dependencies": { "@types/node": "*" } }, "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg=="],
@@ -652,7 +658,7 @@
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
- "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
+ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
@@ -1184,6 +1190,10 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
+ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
+
+ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
+
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -1534,6 +1544,8 @@
"systeminformation": ["systeminformation@5.33.5", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-0v8l1CwFOAjfkv6ynpMrv3YGjH0M7PWCpZwusr8J1TEoQFPK7WXO6gbeAiandaWoh7vbMdnFtDqVotJVnLJtIg=="],
+ "tar": ["tar@7.5.10", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw=="],
+
"tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
@@ -1654,7 +1666,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
- "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
+ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="],
@@ -1774,6 +1786,8 @@
"log-update/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
+ "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
+
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
@@ -1800,6 +1814,8 @@
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+ "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
+
"tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"ts-node/diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="],
diff --git a/cli/scripts/build-binary.ts b/cli/scripts/build-binary.ts
index d20fa22a79..b0665dd776 100644
--- a/cli/scripts/build-binary.ts
+++ b/cli/scripts/build-binary.ts
@@ -168,6 +168,17 @@ async function main() {
['process.env.CODEBUFF_CLI_VERSION', `"${version}"`],
['process.env.CODEBUFF_CLI_TARGET', `"${getCliTargetLabel(targetInfo)}"`],
['process.env.FREEBUFF_MODE', `"${process.env.FREEBUFF_MODE ?? 'false'}"`],
+ ['process.env.NEXT_PUBLIC_API_URL', '"https://www.codebuff.com"'],
+ ['process.env.NEXT_PUBLIC_CODEBUFF_APP_URL', '"https://www.codebuff.com"'],
+ ['process.env.NEXT_PUBLIC_SUPERTOKENS_APP_URL', '"https://www.codebuff.com"'],
+ ['process.env.NEXT_PUBLIC_CB_ENVIRONMENT', '"prod"'],
+ ['process.env.NEXT_PUBLIC_SUPPORT_EMAIL', '"support@codebuff.com"'],
+ ['process.env.NEXT_PUBLIC_POSTHOG_API_KEY', '"dummy"'],
+ ['process.env.NEXT_PUBLIC_POSTHOG_HOST_URL', '"https://app.posthog.com"'],
+ ['process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', '"pk_dummy"'],
+ ['process.env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL', '"https://stripe.com"'],
+ ['process.env.NEXT_PUBLIC_WEB_PORT', '"3000"'],
+ ['process.env.NEXT_PUBLIC_IS_FREEBUFF', '"true"'],
...nextPublicEnvVars,
]
diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx
index 88f6662fe8..714b0faa90 100644
--- a/cli/src/chat.tsx
+++ b/cli/src/chat.tsx
@@ -11,10 +11,14 @@ import {
useState,
} from 'react'
import { useShallow } from 'zustand/react/shallow'
+import fs from 'node:fs'
+import path from 'node:path'
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 { getMissionPath } from './missions/mission-store'
import { ChatInputBar } from './components/chat-input-bar'
import { ChatHeader } from './components/chat-header'
import { FreebuffActiveSessionSummary } from './components/freebuff-active-session-summary'
@@ -1628,6 +1632,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
@@ -1652,6 +1658,19 @@ export const Chat = ({
) : isFreebuffSessionOver && !askUserState ? (
{
+ const missionPath = getMissionPath(getProjectRoot() ?? process.cwd())
+ try {
+ if (fs.existsSync(missionPath)) {
+ const missionData = JSON.parse(fs.readFileSync(missionPath, 'utf8'))
+ if (missionData.status === 'active') {
+ setTimeout(() => {
+ onSubmitPrompt('continue', agentMode).catch(() => {})
+ }, 100)
+ }
+ }
+ } catch (e) {}
+ }}
/>
) : (
<>
diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts
index a0a6f291a7..503958b4aa 100644
--- a/cli/src/commands/command-registry.ts
+++ b/cli/src/commands/command-registry.ts
@@ -4,6 +4,8 @@ import { handleAdsEnable, handleAdsDisable } from './ads'
import { handleCopyConversationCommand } from './copy-conversation'
import { handleHelpCommand } from './help'
import { handleImageCommand } from './image'
+import { runMCPCommand } from './mcp'
+import { runMissionCommand } from './mission'
import { handleInitializationFlowLocally } from './init'
import {
collectProcessDiagnostics,
@@ -186,6 +188,40 @@ const FREEBUFF_ONLY_COMMANDS = new Set([
])
const ALL_COMMANDS: CommandDefinition[] = [
+ defineCommandWithArgs({
+ name: 'mcp',
+ handler: async (params, args) => {
+ const input = params.inputValue.trim()
+ params.saveToHistory(input)
+ clearInput(params)
+ const message = await runMCPCommand(args)
+ params.setMessages((prev) => [
+ ...prev,
+ getUserMessage(input),
+ getSystemMessage(message),
+ ])
+ },
+ }),
+ defineCommandWithArgs({
+ name: 'mission',
+ aliases: ['goal'],
+ handler: (params, args) => {
+ const input = params.inputValue.trim()
+ params.saveToHistory(input)
+ clearInput(params)
+ const result = runMissionCommand(args)
+ if (result.kind === 'start') {
+ params.sendMessage({ content: result.prompt, agentMode: params.agentMode })
+ setTimeout(() => params.scrollToLatest(), 0)
+ return
+ }
+ params.setMessages((prev) => [
+ ...prev,
+ getUserMessage(input),
+ getSystemMessage(result.message),
+ ])
+ },
+ }),
defineCommand({
name: 'ads:enable',
handler: (params) => {
diff --git a/cli/src/commands/mcp.ts b/cli/src/commands/mcp.ts
new file mode 100644
index 0000000000..0a794edb23
--- /dev/null
+++ b/cli/src/commands/mcp.ts
@@ -0,0 +1,71 @@
+import {
+ closeAllMCPClients,
+ getMCPClient,
+ getMCPClientId,
+ getMCPClientStatuses,
+ listMCPTools,
+} from '@codebuff/common/mcp/client'
+
+import {
+ getLoadedMCPServers,
+ initializeAgentRegistry,
+} from '../utils/local-agent-registry'
+
+import type { MCPConfig } from '@codebuff/common/types/mcp'
+
+function describeConfig(config: MCPConfig): string {
+ if (config.type === 'stdio') return `stdio · ${config.command}`
+ try {
+ const url = new URL(config.url)
+ return `${config.type} · ${url.origin}${url.pathname}`
+ } catch {
+ return `${config.type} · endereço inválido`
+ }
+}
+
+export function formatMCPStatus(): string {
+ const servers = getLoadedMCPServers()
+ const statuses = getMCPClientStatuses()
+ if (!Object.keys(servers).length) {
+ return 'Nenhum servidor MCP configurado. Use .agents/mcp.json, .mcp.json ou .cursor/mcp.json.'
+ }
+
+ const readyIds = new Set(
+ statuses.filter((status) => status.state === 'ready').map((status) => status.id),
+ )
+ return Object.entries(servers)
+ .map(([name, config]) => {
+ const active = readyIds.has(getMCPClientId(config))
+ ? 'conectado'
+ : 'configurado'
+ return `${name} · ${describeConfig(config)} · ${active}`
+ })
+ .join('\n')
+}
+
+export async function runMCPCommand(args: string): Promise {
+ const trimmed = args.trim()
+ const [verb, name] = trimmed ? trimmed.split(/\s+/, 2) : ['status']
+ if (verb === 'status' || verb === 'list') return formatMCPStatus()
+
+ if (verb === 'reload') {
+ await closeAllMCPClients()
+ await initializeAgentRegistry()
+ return `MCP recarregado.\n${formatMCPStatus()}`
+ }
+
+ if (verb === 'test') {
+ if (!name) return 'Uso: /mcp test '
+ const config = getLoadedMCPServers()[name]
+ if (!config) return `Servidor MCP não encontrado: ${name}`
+ try {
+ const clientId = await getMCPClient(config)
+ const result = await listMCPTools(clientId)
+ return `${name} conectado · ${result.tools.length} ferramentas disponíveis`
+ } catch (error) {
+ return `${name} falhou: ${error instanceof Error ? error.message : String(error)}`
+ }
+ }
+
+ return 'Uso: /mcp [status|list|reload|test ]'
+}
diff --git a/cli/src/commands/mission.ts b/cli/src/commands/mission.ts
new file mode 100644
index 0000000000..eaa7a38b85
--- /dev/null
+++ b/cli/src/commands/mission.ts
@@ -0,0 +1,66 @@
+import { getProjectRoot } from '../project-files'
+import {
+ buildMissionPrompt,
+ cancelMission,
+ completeMission,
+ createMission,
+ formatMissionStatus,
+ loadMission,
+} from '../missions/mission-store'
+
+function root(): string {
+ return getProjectRoot() || process.cwd()
+}
+
+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())) }
+ }
+ if (verb === 'start') {
+ if (!value) {
+ return { kind: 'message', message: 'Uso: /mission start ' }
+ }
+ const mission = createMission(root(), value)
+ return { kind: 'start', prompt: buildMissionPrompt(root(), mission) }
+ }
+ if (verb === 'complete') {
+ const evidence = value ? value.split('|').map((item) => item.trim()) : []
+ try {
+ const mission = completeMission(root(), evidence)
+ 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())
+ 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..3ff25de973
--- /dev/null
+++ b/cli/src/components/mission-todos-tracker.tsx
@@ -0,0 +1,112 @@
+import React, { useMemo, useState } from 'react'
+import fs from 'node:fs'
+import { TextAttributes } from '@opentui/core'
+import { useTheme } from '../hooks/use-theme'
+import { 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(() => 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..2e83d16620 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,7 @@ 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
}
/**
@@ -37,6 +38,7 @@ interface SessionEndedBannerProps {
*/
export const SessionEndedBanner: React.FC = ({
isStreaming,
+ onSessionRenewed,
}) => {
const theme = useTheme()
const [pendingAction, setPendingAction] = useState<
@@ -112,8 +114,12 @@ 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])
useKeyboard(
useCallback(
diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts
index 0f0b234a91..50909c3939 100644
--- a/cli/src/data/slash-commands.ts
+++ b/cli/src/data/slash-commands.ts
@@ -48,6 +48,17 @@ const FREEBUFF_ONLY_COMMAND_IDS = new Set([
])
const ALL_SLASH_COMMANDS: SlashCommand[] = [
+ {
+ id: 'mission',
+ label: 'mission',
+ description: 'Start, resume, inspect, or finish a persistent autonomous mission',
+ aliases: ['goal'],
+ },
+ {
+ id: 'mcp',
+ label: 'mcp',
+ description: 'Inspect, reload, and test Model Context Protocol servers',
+ },
{
id: 'help',
label: 'help',
diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts
index 39065317ee..5967017923 100644
--- a/cli/src/hooks/use-send-message.ts
+++ b/cli/src/hooks/use-send-message.ts
@@ -93,12 +93,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 +251,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,
@@ -692,6 +693,23 @@ export const useSendMessage = ({
isQueuePausedRef,
hasReceivedContent: hasReceivedContentRef.current,
})
+
+ const errorStr = (error instanceof Error ? error.message : String(error)).toLowerCase()
+ if (
+ errorStr.includes('internal server error') ||
+ errorStr.includes('fetch failed') ||
+ errorStr.includes('network') ||
+ errorStr.includes('socket hang up') ||
+ errorStr.includes('econnreset')
+ ) {
+ const retryContent = hasReceivedContentRef.current ? 'continue' : (typeof content === 'string' ? content : 'continue')
+ setTimeout(() => {
+ if (runChatIsCurrent() && !abortController.signal.aborted) {
+ runSendMessage({ content: retryContent, agentMode, postUserMessage: false, attachments: [] })
+ }
+ }, 3000)
+ }
+
// 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-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-store.ts b/cli/src/missions/mission-store.ts
new file mode 100644
index 0000000000..dc4a5aae08
--- /dev/null
+++ b/cli/src/missions/mission-store.ts
@@ -0,0 +1,126 @@
+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'
+const MISSION_FILE = 'mission.json'
+
+export function getMissionPath(projectRoot: string): string {
+ let branch = 'default'
+ try {
+ const execSync = require('child_process').execSync
+ branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectRoot, stdio: 'pipe' }).toString().trim()
+ } catch (e) {}
+
+ const safeBranch = branch.replace(/[^a-zA-Z0-9_-]/g, '_')
+ return path.join(projectRoot, MISSION_DIRECTORY, `mission-${safeBranch}.json`)
+}
+
+function writeMission(projectRoot: string, mission: MissionState): void {
+ const missionPath = getMissionPath(projectRoot)
+ 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): MissionState | null {
+ const missionPath = getMissionPath(projectRoot)
+ 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,
+): MissionState {
+ const trimmedObjective = objective.trim()
+ if (!trimmedObjective) throw new Error('A missão precisa de um objetivo.')
+ 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)
+ return mission
+}
+
+export function completeMission(
+ projectRoot: string,
+ evidence: string[],
+): MissionState {
+ const mission = loadMission(projectRoot)
+ if (!mission) throw new Error('Nenhuma missão encontrada.')
+ const completed: MissionState = {
+ ...mission,
+ status: 'completed',
+ evidence: evidence.map((item) => item.trim()).filter(Boolean),
+ updatedAt: new Date().toISOString(),
+ }
+ writeMission(projectRoot, completed)
+ return completed
+}
+
+export function cancelMission(projectRoot: string): MissionState {
+ const mission = loadMission(projectRoot)
+ if (!mission) throw new Error('Nenhuma missão encontrada.')
+ const cancelled: MissionState = {
+ ...mission,
+ status: 'cancelled',
+ updatedAt: new Date().toISOString(),
+ }
+ writeMission(projectRoot, cancelled)
+ return cancelled
+}
+
+export function buildMissionPrompt(projectRoot: string, mission: MissionState): string {
+ const relPath = path.relative(projectRoot, getMissionPath(projectRoot)).replace(/\\/g, '/')
+ return `MISSÃO ATIVA: ${mission.objective}
+
+Execute a missão inteira de forma autônoma. Pesquise de forma estreita, edite, valide e corrija até a evidência confirmar o resultado. Enquanto existir trabalho seguro e acionável, não encerre nem devolva apenas diagnóstico ou próximos passos. Use write_todos como checklist vivo e agrupe alterações antes de executar testes caros.
+
+O estado persistente está em ${relPath}. **PROTOCOLO DE DOUBLE-CHECK OBRIGATÓRIO**: Antes de atualizar o status para "completed", você DEVE realizar uma auto-revisão crítica. Questione-se: "Eu realmente concluí tudo? O código foi testado? As evidências são reais ou são suposições?". Se houver qualquer falha ou falta de verificação real, CONTINUE TRABALHANDO. Nunca declare conclusão baseado em suposições.
+Somente após essa verificação, atualize status para "completed", updatedAt para o horário ISO atual e evidence para uma lista das verificações realmente executadas. Se houver bloqueio externo real, use status "blocked" e registre a evidência do bloqueio.`
+}
+
+export function buildMissionContinuation(projectRoot: string, mission: MissionState): string {
+ const relPath = path.relative(projectRoot, getMissionPath(projectRoot)).replace(/\\/g, '/')
+ return `\n\n[Missão persistente ${mission.id}] Continue até concluir: ${mission.objective}. Não encerre enquanto houver trabalho acionável. LEMBRE-SE DO PROTOCOLO DE DOUBLE-CHECK: Antes de se dar por satisfeito e marcar "completed" em ${relPath}, você deve PROVAR que testou e que a solução funciona. Se você apenas acha que funciona, você não terminou. Atualize ${relPath} com estado e evidência REAIS apenas quando tiver certeza absoluta.`
+}
+
+export function formatMissionStatus(mission: MissionState | null): string {
+ if (!mission) return 'Nenhuma missão persistente.'
+ const evidence = mission.evidence.length
+ ? `\nEvidências:\n${mission.evidence.map((item) => `- ${item}`).join('\n')}`
+ : ''
+ return `Missão ${mission.status}: ${mission.objective}${evidence}`
+}
diff --git a/cli/src/utils/auth.ts b/cli/src/utils/auth.ts
index 7d81f48ef9..cbf7ae9daa 100644
--- a/cli/src/utils/auth.ts
+++ b/cli/src/utils/auth.ts
@@ -14,8 +14,8 @@ import type { CiEnv } from '@codebuff/common/types/contracts/env'
// User schema
const userSchema = z.object({
id: z.string().optional(),
- name: z.string(),
- email: z.string(),
+ name: z.string().nullish(),
+ email: z.string().nullish(),
authToken: z.string(),
fingerprintId: z.string().optional(),
fingerprintHash: z.string().optional(),
diff --git a/common/src/mcp/__tests__/client-pool.test.ts b/common/src/mcp/__tests__/client-pool.test.ts
new file mode 100644
index 0000000000..c19c979df9
--- /dev/null
+++ b/common/src/mcp/__tests__/client-pool.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'bun:test'
+
+import { MCPClientPool } from '../client-pool'
+
+type Config = { id: string }
+type FakeClient = { id: string }
+
+describe('MCPClientPool', () => {
+ it('deduplicates concurrent connections and reports ready status', async () => {
+ let connects = 0
+ const pool = new MCPClientPool({
+ keyOf: (config) => config.id,
+ connect: async (config) => {
+ connects++
+ await Bun.sleep(5)
+ return { id: config.id }
+ },
+ close: async () => {},
+ })
+
+ const [first, second] = await Promise.all([
+ pool.get({ id: 'docs' }),
+ pool.get({ id: 'docs' }),
+ ])
+
+ expect(connects).toBe(1)
+ expect(first.client).toBe(second.client)
+ expect(pool.statuses()).toEqual([
+ expect.objectContaining({ id: 'docs', state: 'ready' }),
+ ])
+ })
+
+ it('removes failed connections so the next request can retry', async () => {
+ let attempts = 0
+ const pool = new MCPClientPool({
+ keyOf: (config) => config.id,
+ connect: async (config) => {
+ attempts++
+ if (attempts === 1) throw new Error('offline')
+ return { id: config.id }
+ },
+ close: async () => {},
+ })
+
+ await expect(pool.get({ id: 'retry' })).rejects.toThrow('offline')
+ expect((await pool.get({ id: 'retry' })).client.id).toBe('retry')
+ expect(attempts).toBe(2)
+ })
+
+ it('closes one or every live client', async () => {
+ const closed: string[] = []
+ const pool = new MCPClientPool({
+ keyOf: (config) => config.id,
+ connect: async (config) => ({ id: config.id }),
+ close: async (client) => {
+ closed.push(client.id)
+ },
+ })
+
+ await pool.get({ id: 'one' })
+ await pool.get({ id: 'two' })
+ expect(await pool.close('one')).toBe(true)
+ await pool.closeAll()
+
+ expect(closed.sort()).toEqual(['one', 'two'])
+ expect(pool.statuses()).toEqual([])
+ })
+
+ it('times out a stalled connection and allows a later retry', async () => {
+ let shouldHang = true
+ const pool = new MCPClientPool(
+ {
+ keyOf: (config) => config.id,
+ connect: async (config) => {
+ if (shouldHang) await new Promise(() => {})
+ return { id: config.id }
+ },
+ close: async () => {},
+ },
+ { connectTimeoutMs: 10 },
+ )
+
+ await expect(pool.get({ id: 'slow' })).rejects.toThrow(
+ 'MCP connection timed out',
+ )
+ shouldHang = false
+ expect((await pool.get({ id: 'slow' })).client.id).toBe('slow')
+ })
+})
diff --git a/common/src/mcp/__tests__/client.integration.test.ts b/common/src/mcp/__tests__/client.integration.test.ts
new file mode 100644
index 0000000000..ca7fa6459e
--- /dev/null
+++ b/common/src/mcp/__tests__/client.integration.test.ts
@@ -0,0 +1,37 @@
+import path from 'path'
+
+import { afterEach, describe, expect, it } from 'bun:test'
+
+import {
+ callMCPTool,
+ closeAllMCPClients,
+ getMCPClient,
+ getMCPClientStatuses,
+ listMCPTools,
+} from '../client'
+
+afterEach(async () => {
+ await closeAllMCPClients()
+})
+
+describe('MCP client end to end', () => {
+ it('discovers and calls a stdio tool through the managed pool', async () => {
+ const fixture = path.join(import.meta.dir, 'fixtures', 'echo-server.ts')
+ const clientId = await getMCPClient({
+ type: 'stdio',
+ command: process.execPath,
+ args: [fixture],
+ env: {},
+ })
+
+ const tools = await listMCPTools(clientId)
+ const output = await callMCPTool(clientId, {
+ name: 'echo',
+ arguments: { text: 'mcp-ok' },
+ })
+
+ expect(tools.tools.map((tool) => tool.name)).toContain('echo')
+ expect(output).toEqual([{ type: 'json', value: 'echo:mcp-ok' }])
+ expect(getMCPClientStatuses()[0]?.state).toBe('ready')
+ })
+})
diff --git a/common/src/mcp/__tests__/fixtures/echo-server.ts b/common/src/mcp/__tests__/fixtures/echo-server.ts
new file mode 100644
index 0000000000..4e923b6153
--- /dev/null
+++ b/common/src/mcp/__tests__/fixtures/echo-server.ts
@@ -0,0 +1,16 @@
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
+import { z } from 'zod/v4'
+
+const server = new McpServer({ name: 'freebuff-test-echo', version: '1.0.0' })
+
+server.registerTool(
+ 'echo',
+ {
+ description: 'Echo text for the Freebuff MCP smoke test',
+ inputSchema: { text: z.string() },
+ },
+ async ({ text }) => ({ content: [{ type: 'text', text: `echo:${text}` }] }),
+)
+
+await server.connect(new StdioServerTransport())
diff --git a/common/src/mcp/client-pool.ts b/common/src/mcp/client-pool.ts
new file mode 100644
index 0000000000..da63481613
--- /dev/null
+++ b/common/src/mcp/client-pool.ts
@@ -0,0 +1,125 @@
+export type MCPClientPoolStatus = {
+ id: string
+ state: 'connecting' | 'ready'
+ connectedAt: number | null
+ lastUsedAt: number
+}
+
+type MCPClientPoolAdapter = {
+ keyOf: (config: TConfig) => string
+ connect: (config: TConfig) => Promise
+ close: (client: TClient) => Promise
+}
+
+type MCPClientPoolOptions = {
+ connectTimeoutMs?: number
+}
+
+type PoolEntry = {
+ client: TClient | null
+ connecting: Promise
+ connectedAt: number | null
+ lastUsedAt: number
+}
+
+export function withTimeout(
+ promise: Promise,
+ timeoutMs: number,
+ message: string,
+): Promise {
+ if (timeoutMs <= 0) return promise
+
+ let timeout: ReturnType | undefined
+ const rejection = new Promise((_, reject) => {
+ timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
+ })
+ return Promise.race([promise, rejection]).finally(() => {
+ if (timeout) clearTimeout(timeout)
+ })
+}
+
+/**
+ * Reuses MCP transports across turns and owns their complete lifecycle.
+ * Concurrent callers for the same config share one handshake.
+ */
+export class MCPClientPool {
+ private readonly entries = new Map>()
+ private readonly connectTimeoutMs: number
+
+ constructor(
+ private readonly adapter: MCPClientPoolAdapter,
+ options: MCPClientPoolOptions = {},
+ ) {
+ this.connectTimeoutMs = options.connectTimeoutMs ?? 30_000
+ }
+
+ async get(config: TConfig): Promise<{ id: string; client: TClient }> {
+ const id = this.adapter.keyOf(config)
+ const existing = this.entries.get(id)
+ if (existing) {
+ existing.lastUsedAt = Date.now()
+ return { id, client: existing.client ?? (await existing.connecting) }
+ }
+
+ const now = Date.now()
+ const rawConnection = this.adapter.connect(config)
+ const connecting = withTimeout(
+ rawConnection,
+ this.connectTimeoutMs,
+ `MCP connection timed out after ${this.connectTimeoutMs}ms`,
+ )
+ const entry: PoolEntry = {
+ client: null,
+ connecting,
+ connectedAt: null,
+ lastUsedAt: now,
+ }
+ this.entries.set(id, entry)
+
+ try {
+ const client = await connecting
+ entry.client = client
+ entry.connectedAt = Date.now()
+ entry.lastUsedAt = entry.connectedAt
+ return { id, client }
+ } catch (error) {
+ if (this.entries.get(id) === entry) this.entries.delete(id)
+ // A timed-out transport can still finish later. Close it instead of
+ // leaking a child process or socket no caller can reach.
+ rawConnection.then(this.adapter.close).catch(() => {})
+ throw error
+ }
+ }
+
+ getReady(id: string): TClient | undefined {
+ const entry = this.entries.get(id)
+ if (!entry?.client) return undefined
+ entry.lastUsedAt = Date.now()
+ return entry.client
+ }
+
+ statuses(): MCPClientPoolStatus[] {
+ return [...this.entries.entries()]
+ .map(([id, entry]) => ({
+ id,
+ state: entry.client ? ('ready' as const) : ('connecting' as const),
+ connectedAt: entry.connectedAt,
+ lastUsedAt: entry.lastUsedAt,
+ }))
+ .sort((a, b) => a.id.localeCompare(b.id))
+ }
+
+ async close(id: string): Promise {
+ const entry = this.entries.get(id)
+ if (!entry) return false
+ this.entries.delete(id)
+ const client = entry.client ?? (await entry.connecting.catch(() => null))
+ if (client) await this.adapter.close(client)
+ return true
+ }
+
+ async closeAll(): Promise {
+ const ids = [...this.entries.keys()]
+ await Promise.allSettled(ids.map((id) => this.close(id)))
+ }
+}
diff --git a/common/src/mcp/client.ts b/common/src/mcp/client.ts
index 5a5608d57f..a130d89cf3 100644
--- a/common/src/mcp/client.ts
+++ b/common/src/mcp/client.ts
@@ -4,6 +4,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { getErrorObject } from '../util/error'
+import { MCPClientPool, withTimeout } from './client-pool'
import type { MCPConfig } from '../types/mcp'
import type { ToolResultOutput } from '../types/messages/content-part'
@@ -18,11 +19,12 @@ import type {
// message — enough to show the real failure without unbounded growth.
const STDERR_BUFFER_CAP = 8192
-const runningClients: Record = {}
-const listToolsCache: Record<
+const LIST_TOOLS_TIMEOUT_MS = 30_000
+const CALL_TOOL_TIMEOUT_MS = 120_000
+const listToolsCache = new Map<
string,
ReturnType
-> = {}
+>()
/**
* Substitutes environment variable references ($VAR_NAME) in a string with their values.
@@ -80,12 +82,7 @@ function hashConfig(config: MCPConfig): string {
)
}
-export async function getMCPClient(config: MCPConfig): Promise {
- let key = hashConfig(config)
- if (key in runningClients) {
- return key
- }
-
+async function connectMCPClient(config: MCPConfig): Promise {
let transport: Transport
// Buffer the child process's stderr so that a server which crashes during
// startup produces an actionable error instead of the opaque MCP SDK message
@@ -154,23 +151,68 @@ export async function getMCPClient(config: MCPConfig): Promise {
`${baseMessage}. Failed to connect to MCP server at ${config.url}.`,
)
}
- runningClients[key] = client
+ return client
+}
+
+export function getMCPClientId(config: MCPConfig): string {
+ return hashConfig(config)
+}
+
+const clientPool = new MCPClientPool({
+ keyOf: hashConfig,
+ connect: connectMCPClient,
+ close: async (client) => client.close(),
+})
+
+export async function getMCPClient(config: MCPConfig): Promise {
+ return (await clientPool.get(config)).id
+}
+
+export type MCPClientStatus = ReturnType[number]
- return key
+export function getMCPClientStatuses(): MCPClientStatus[] {
+ return clientPool.statuses()
+}
+
+export async function closeMCPClient(clientId: string): Promise {
+ listToolsCache.delete(clientId)
+ return clientPool.close(clientId)
+}
+
+export async function closeAllMCPClients(): Promise {
+ listToolsCache.clear()
+ await clientPool.closeAll()
+}
+
+export async function reloadMCPClient(config: MCPConfig): Promise {
+ const clientId = hashConfig(config)
+ await closeMCPClient(clientId)
+ return getMCPClient(config)
}
export function listMCPTools(
clientId: string,
...args: Parameters
): ReturnType {
- const client = runningClients[clientId]
+ const client = clientPool.getReady(clientId)
if (!client) {
throw new Error(`listTools: client not found with id: ${clientId}`)
}
- if (!listToolsCache[clientId]) {
- listToolsCache[clientId] = client.listTools(...args)
- }
- return listToolsCache[clientId]
+ const cached = listToolsCache.get(clientId)
+ if (cached) return cached
+
+ const request = withTimeout(
+ client.listTools(...args),
+ LIST_TOOLS_TIMEOUT_MS,
+ `MCP listTools timed out after ${LIST_TOOLS_TIMEOUT_MS}ms`,
+ ) as ReturnType
+ listToolsCache.set(clientId, request)
+ request.catch(() => {
+ if (listToolsCache.get(clientId) === request) {
+ listToolsCache.delete(clientId)
+ }
+ })
+ return request
}
function getResourceData(
@@ -185,11 +227,15 @@ export async function callMCPTool(
clientId: string,
...args: Parameters
): Promise {
- const client = runningClients[clientId]
+ const client = clientPool.getReady(clientId)
if (!client) {
throw new Error(`callTool: client not found with id: ${clientId}`)
}
- const callResult = await client.callTool(...args)
+ const callResult = await withTimeout(
+ client.callTool(...args),
+ CALL_TOOL_TIMEOUT_MS,
+ `MCP callTool timed out after ${CALL_TOOL_TIMEOUT_MS}ms`,
+ )
const result = callResult as CallToolResult
const content = result.content
diff --git a/common/src/tools/params/tool/run-terminal-command.ts b/common/src/tools/params/tool/run-terminal-command.ts
index 197c02f3c7..cefa58da83 100644
--- a/common/src/tools/params/tool/run-terminal-command.ts
+++ b/common/src/tools/params/tool/run-terminal-command.ts
@@ -20,7 +20,7 @@ import type { $ToolParams } from '../../constants'
* two better doors that this does not touch: `-1` for an explicit indefinite
* wait, and `process_type: BACKGROUND` for long-running processes.
*/
-export const MAX_TERMINAL_TIMEOUT_SECONDS = 600
+export const MAX_TERMINAL_TIMEOUT_SECONDS = 180
export const MAX_TERMINAL_TIMEOUT_MINUTES = MAX_TERMINAL_TIMEOUT_SECONDS / 60
/** Clamp a model-supplied timeout, preserving the -1 "no timeout" sentinel and
diff --git a/package.json b/package.json
index 4010004d09..15d241e5d0 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
"@types/node": "^22.9.0",
"@types/node-fetch": "^2.6.12",
"@types/parse-path": "^7.1.0",
+ "@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "^6.17",
"bun-types": "1.3.11",
"eslint-config-prettier": "^9.1.0",
@@ -59,6 +60,7 @@
"ignore": "^6.0.2",
"lodash": "4.17.23",
"prettier": "^3.7.4",
+ "tar": "7.5.10",
"ts-node": "^10.9.2",
"ts-pattern": "^5.9.0",
"tsc-alias": "^1.8.16",
diff --git a/sdk/src/__tests__/load-mcp-config.test.ts b/sdk/src/__tests__/load-mcp-config.test.ts
index 829726d00a..67e9bc1712 100644
--- a/sdk/src/__tests__/load-mcp-config.test.ts
+++ b/sdk/src/__tests__/load-mcp-config.test.ts
@@ -132,6 +132,54 @@ describe('loadMCPConfigSync', () => {
expect(result._sourceFilePath).toContain('mcp.json')
})
+ it('loads standard root .mcp.json and Cursor .cursor/mcp.json files', () => {
+ fs.mkdirSync(path.join(tempDir, '.cursor'), { recursive: true })
+ fs.writeFileSync(
+ path.join(tempDir, '.mcp.json'),
+ JSON.stringify({
+ mcpServers: { rootServer: { command: 'root-mcp' } },
+ }),
+ )
+ fs.writeFileSync(
+ path.join(tempDir, '.cursor', 'mcp.json'),
+ JSON.stringify({
+ mcpServers: { cursorServer: { command: 'cursor-mcp' } },
+ }),
+ )
+
+ const result = loadMCPConfigSync({ verbose: false })
+
+ expect(result.mcpServers.rootServer).toBeDefined()
+ expect(result.mcpServers.cursorServer).toBeDefined()
+ expect(result._sourceFilePaths).toContain(path.join(tempDir, '.mcp.json'))
+ expect(result._sourceFilePaths).toContain(
+ path.join(tempDir, '.cursor', 'mcp.json'),
+ )
+ })
+
+ it('gives project .agents/mcp.json precedence over compatible files', () => {
+ fs.mkdirSync(path.join(tempDir, '.agents'), { recursive: true })
+ fs.writeFileSync(
+ path.join(tempDir, '.mcp.json'),
+ JSON.stringify({
+ mcpServers: { shared: { command: 'root-command' } },
+ }),
+ )
+ fs.writeFileSync(
+ path.join(tempDir, '.agents', 'mcp.json'),
+ JSON.stringify({
+ mcpServers: { shared: { command: 'project-command' } },
+ }),
+ )
+
+ const result = loadMCPConfigSync({ verbose: false })
+ const shared = result.mcpServers.shared
+
+ expect(shared && isStdioConfig(shared) ? shared.command : '').toBe(
+ 'project-command',
+ )
+ })
+
it('should resolve environment variable references', () => {
const agentsDir = path.join(tempDir, '.agents')
fs.mkdirSync(agentsDir, { recursive: true })
diff --git a/sdk/src/agents/load-mcp-config.ts b/sdk/src/agents/load-mcp-config.ts
index 51e953c617..d8d80c12c5 100644
--- a/sdk/src/agents/load-mcp-config.ts
+++ b/sdk/src/agents/load-mcp-config.ts
@@ -25,6 +25,8 @@ export type LoadedMCPConfig = {
mcpServers: Record
/** The file path this config was loaded from */
_sourceFilePath: string
+ /** Every compatible config file merged, from lowest to highest precedence. */
+ _sourceFilePaths: string[]
}
/**
@@ -86,28 +88,46 @@ function resolveMcpConfigEnv(config: MCPFileConfig): void {
}
}
-const MCP_CONFIG_FILE_NAME = 'mcp.json'
-
/**
- * Get default directories to search for mcp.json.
- * Matches the agent loading directories for consistency.
+ * Compatible config paths ordered from lowest to highest precedence.
+ * Supports Freebuff/Codebuff, the MCP root convention, and Cursor projects.
*/
-const getDefaultMcpConfigDirs = (): string[] => {
- const cwdAgents = path.join(process.cwd(), '.agents')
- const parentAgents = path.join(process.cwd(), '..', '.agents')
- const homeAgents = path.join(os.homedir(), '.agents')
- return [cwdAgents, parentAgents, homeAgents]
+export const getDefaultMcpConfigPaths = (): string[] => {
+ const cwd = process.cwd()
+ const parent = path.dirname(cwd)
+ const home = os.homedir()
+ const realHome =
+ process.env.REAL_USERPROFILE ||
+ (process.env.SystemDrive && process.env.HOMEPATH
+ ? path.join(process.env.SystemDrive, process.env.HOMEPATH)
+ : null) ||
+ (process.env.USERNAME
+ ? path.join('C:', 'Users', process.env.USERNAME)
+ : null) ||
+ home
+
+ const paths = [
+ path.join(realHome, '.config', 'freebuff', 'mcp.json'),
+ path.join(realHome, '.agents', 'mcp.json'),
+ path.join(home, '.config', 'freebuff', 'mcp.json'),
+ path.join(home, '.agents', 'mcp.json'),
+ path.join(parent, '.mcp.json'),
+ path.join(parent, '.cursor', 'mcp.json'),
+ path.join(parent, '.agents', 'mcp.json'),
+ path.join(cwd, '.mcp.json'),
+ path.join(cwd, '.cursor', 'mcp.json'),
+ path.join(cwd, '.agents', 'mcp.json'),
+ path.join(cwd, 'node_modules', '.freebuff', 'mcp.json'),
+ ]
+
+ return Array.from(new Set(paths))
}
/**
- * Load MCP configuration from `mcp.json` files in `.agents` directories.
- *
- * By default, searches for mcp.json in:
- * - `{cwd}/.agents/mcp.json`
- * - `{cwd}/../.agents/mcp.json`
- * - `{homedir}/.agents/mcp.json`
+ * Load and merge MCP configuration from Freebuff, MCP, Cursor and `.agents`
+ * locations. Project-local files override parent and global files.
*
- * Later directories take precedence, so project MCP servers override global ones.
+ * The complete ordered path list is returned by getDefaultMcpConfigPaths().
* Environment variable references (e.g., `$API_KEY`) are resolved from process.env.
*
* @param options.verbose - Whether to log errors during loading
@@ -132,12 +152,10 @@ export async function loadMCPConfig(options: {
const mergedConfig: LoadedMCPConfig = {
mcpServers: {},
_sourceFilePath: '',
+ _sourceFilePaths: [],
}
- const mcpConfigDirs = getDefaultMcpConfigDirs()
-
- for (const dir of mcpConfigDirs) {
- const configPath = path.join(dir, MCP_CONFIG_FILE_NAME)
+ for (const configPath of getDefaultMcpConfigPaths()) {
try {
// Check if file exists asynchronously
@@ -182,6 +200,7 @@ export async function loadMCPConfig(options: {
// Track the last successfully loaded config path
if (Object.keys(parsedConfig.mcpServers).length > 0) {
mergedConfig._sourceFilePath = configPath
+ mergedConfig._sourceFilePaths.push(configPath)
}
} catch (error) {
if (verbose) {
@@ -197,7 +216,7 @@ export async function loadMCPConfig(options: {
}
/**
- * Synchronously load MCP configuration from `mcp.json` files in `.agents` directories.
+ * Synchronously load MCP configuration from all compatible locations.
* This is a sync version for use in contexts where async is not available.
*
* @param options.verbose - Whether to log errors during loading
@@ -211,12 +230,10 @@ export function loadMCPConfigSync(options: {
const mergedConfig: LoadedMCPConfig = {
mcpServers: {},
_sourceFilePath: '',
+ _sourceFilePaths: [],
}
- const mcpConfigDirs = getDefaultMcpConfigDirs()
-
- for (const dir of mcpConfigDirs) {
- const configPath = path.join(dir, MCP_CONFIG_FILE_NAME)
+ for (const configPath of getDefaultMcpConfigPaths()) {
try {
if (!fs.existsSync(configPath)) {
@@ -258,6 +275,7 @@ export function loadMCPConfigSync(options: {
// Track the last successfully loaded config path
if (Object.keys(parsedConfig.mcpServers).length > 0) {
mergedConfig._sourceFilePath = configPath
+ mergedConfig._sourceFilePaths.push(configPath)
}
} catch (error) {
if (verbose) {
diff --git a/sdk/src/run-state.ts b/sdk/src/run-state.ts
index c13a6ab1a1..814d8fa8f6 100644
--- a/sdk/src/run-state.ts
+++ b/sdk/src/run-state.ts
@@ -1083,8 +1083,13 @@ export function withMessageHistory({
runState: RunState
messages: Message[]
}): RunState {
- // Deep copy
- const newRunState = JSON.parse(JSON.stringify(runState)) as typeof runState
+ // Deep copy safely
+ let newRunState: typeof runState
+ try {
+ newRunState = JSON.parse(JSON.stringify(runState)) as typeof runState
+ } catch {
+ newRunState = cloneDeep(runState)
+ }
if (newRunState.sessionState) {
newRunState.sessionState.mainAgentState.messageHistory = messages
@@ -1111,10 +1116,15 @@ export async function applyOverridesToSessionState(
maxAgentSteps?: number
},
): Promise {
- // Deep clone to avoid mutating the original session state
- const sessionState = JSON.parse(
- JSON.stringify(baseSessionState),
- ) as SessionState
+ // Deep clone to avoid mutating the original session state safely
+ let sessionState: SessionState
+ try {
+ sessionState = JSON.parse(
+ JSON.stringify(baseSessionState),
+ ) as SessionState
+ } catch {
+ sessionState = cloneDeep(baseSessionState)
+ }
// Apply maxAgentSteps override
if (overrides.maxAgentSteps !== undefined) {
diff --git a/sdk/src/run.ts b/sdk/src/run.ts
index 21d5a804c2..6dd7e0078f 100644
--- a/sdk/src/run.ts
+++ b/sdk/src/run.ts
@@ -589,6 +589,9 @@ async function runOnce({
}
}
+ let identicalToolCount = 0
+ let lastToolSignature = ''
+
const agentRuntimeImpl = getAgentRuntimeImpl({
logger,
traceWriter,
@@ -597,6 +600,30 @@ async function runOnce({
// Does nothing for now
},
requestToolCall: async ({ userInputId, toolName, input, mcpConfig }) => {
+ const signature = JSON.stringify({ toolName, input })
+ if (signature === lastToolSignature) {
+ identicalToolCount++
+ } else {
+ lastToolSignature = signature
+ identicalToolCount = 1
+ }
+
+ if (identicalToolCount > 3) {
+ return {
+ output: [
+ {
+ type: 'json',
+ value: {
+ message:
+ 'ANTI-LOOPING TRIGGERED: You have repeated the exact same tool call ' +
+ identicalToolCount +
+ ' times without progress. STOP LOOPING. You MUST completely change your strategy, try a different approach, or ask the user for help.',
+ },
+ },
+ ],
+ }
+ }
+
return handleToolCall({
action: {
type: 'tool-call-request',
diff --git a/sdk/src/tools/change-file.ts b/sdk/src/tools/change-file.ts
index ac1fcfc59f..66b3952ef8 100644
--- a/sdk/src/tools/change-file.ts
+++ b/sdk/src/tools/change-file.ts
@@ -89,7 +89,14 @@ async function applyChange(params: {
await fs.writeFile(fullPath, content)
} else {
const oldContent = await fs.readFile(fullPath, 'utf-8')
- const newContent = applyPatch(oldContent, content)
+ let newContent = applyPatch(oldContent, content, { fuzzFactor: 2 })
+
+ if (newContent === false) {
+ const normalizedOld = oldContent.replace(/\r\n/g, '\n')
+ const normalizedPatch = content.replace(/\r\n/g, '\n')
+ newContent = applyPatch(normalizedOld, normalizedPatch, { fuzzFactor: 3 })
+ }
+
if (newContent === false) {
return { status: 'patchFailed', file: relativePath, patch: content }
}
diff --git a/sdk/src/tools/code-search.ts b/sdk/src/tools/code-search.ts
index 23c70e6db7..42ee6cc5fa 100644
--- a/sdk/src/tools/code-search.ts
+++ b/sdk/src/tools/code-search.ts
@@ -80,6 +80,12 @@ export function codeSearch({
'--no-config',
'-n',
'--json',
+ '--glob', '!node_modules/**',
+ '--glob', '!.git/**',
+ '--glob', '!dist/**',
+ '--glob', '!build/**',
+ '--glob', '!.cache/**',
+ '--glob', '!.next/**',
...flagsArray,
'--',
pattern,
diff --git a/sdk/src/tools/run-terminal-command.ts b/sdk/src/tools/run-terminal-command.ts
index 6450b2bace..34d3799ee4 100644
--- a/sdk/src/tools/run-terminal-command.ts
+++ b/sdk/src/tools/run-terminal-command.ts
@@ -17,7 +17,7 @@ import {
import type { CodebuffToolOutput } from '../../../common/src/tools/list'
-const COMMAND_OUTPUT_LIMIT = 50_000
+const COMMAND_OUTPUT_LIMIT = 10_000
const TRUNCATION_MARKER = '\n[...TRUNCATED DUE TO LENGTH...]\n'
const MAX_PENDING_COLOR_SEQUENCE_LENGTH = 32
const INCOMPLETE_COLOR_SEQUENCE_REGEX = /\x1B\[[0-9;]*$/
@@ -319,6 +319,10 @@ export function runTerminalCommand({
const isWindows = os.platform() === 'win32'
const processEnv = {
...getSystemProcessEnv(),
+ CI: 'true',
+ DEBIAN_FRONTEND: 'noninteractive',
+ FORCE_COLOR: '0',
+ NONINTERACTIVE: '1',
...(env ?? {}),
} as NodeJS.ProcessEnv
if (isWindows) {