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) {