From d51b1ddc470d5f15e7636675cea09413f30794b2 Mon Sep 17 00:00:00 2001 From: MayDay-wpf <2424020953@qq.com> Date: Wed, 25 Mar 2026 18:30:29 +0800 Subject: [PATCH] feat: Agent Team Mode - orchestrate multiple agents with independent Git worktrees - Add teamMode toggle and persistence - Add team service with MCP-style tools (team-spawn_teammate, team-message_teammate, etc.) - Add teammate executor with full tool access - Add team tracker for message routing and plan approval - Add team worktree management (create, merge, cleanup) - Add task list management for team coordination - Add team mode system prompt for lead agent - Add team UI indicators and teammate stream display - Add i18n translations for team mode Enables parallel agent execution with isolated Git worktrees for conflict-free collaborative development. --- source/hooks/conversation/chatLogic/types.ts | 1 + .../chatLogic/useMessageProcessing.ts | 3 + .../conversation/core/conversationSetup.ts | 3 +- .../conversation/core/conversationTypes.ts | 1 + .../conversation/core/sessionInitializer.ts | 3 +- .../core/subAgentMessageHandler.ts | 88 +- .../hooks/conversation/useCommandHandler.ts | 18 +- source/hooks/conversation/useConversation.ts | 1 + source/hooks/input/useKeyboardInput.ts | 9 +- source/hooks/ui/useCommandPanel.ts | 6 + source/i18n/lang/en.ts | 3 + source/i18n/lang/zh-TW.ts | 3 + source/i18n/lang/zh.ts | 3 + source/i18n/types.ts | 2 + source/mcp/team.ts | 837 ++++++++++++++++++ source/prompt/systemPrompt.ts | 6 + source/prompt/teamModeSystemPrompt.ts | 179 ++++ source/ui/components/chat/ChatFooter.tsx | 6 + source/ui/components/chat/ChatInput.tsx | 6 + .../ui/components/chat/LoadingIndicator.tsx | 80 +- source/ui/components/common/StatusLine.tsx | 17 + source/ui/pages/ChatScreen.tsx | 6 + .../pages/chatScreen/useChatScreenCommands.ts | 1 + .../ui/pages/chatScreen/useChatScreenModes.ts | 9 + source/utils/commands/team.ts | 13 + source/utils/config/projectSettings.ts | 12 + source/utils/execution/commandExecutor.ts | 1 + source/utils/execution/mcpToolsManager.ts | 46 +- source/utils/execution/teamExecutor.ts | 738 +++++++++++++++ source/utils/execution/teamTracker.ts | 426 +++++++++ source/utils/execution/toolExecutor.ts | 51 +- source/utils/index.ts | 1 + source/utils/team/teamConfig.ts | 207 +++++ source/utils/team/teamTaskList.ts | 213 +++++ source/utils/team/teamWorktree.ts | 373 ++++++++ 35 files changed, 3350 insertions(+), 22 deletions(-) create mode 100644 source/mcp/team.ts create mode 100644 source/prompt/teamModeSystemPrompt.ts create mode 100644 source/utils/commands/team.ts create mode 100644 source/utils/execution/teamExecutor.ts create mode 100644 source/utils/execution/teamTracker.ts create mode 100644 source/utils/team/teamConfig.ts create mode 100644 source/utils/team/teamTaskList.ts create mode 100644 source/utils/team/teamWorktree.ts diff --git a/source/hooks/conversation/chatLogic/types.ts b/source/hooks/conversation/chatLogic/types.ts index 98b7adbe..ba4fe2bc 100644 --- a/source/hooks/conversation/chatLogic/types.ts +++ b/source/hooks/conversation/chatLogic/types.ts @@ -21,6 +21,7 @@ export interface UseChatLogicProps { yoloMode: boolean; planMode: boolean; vulnerabilityHuntingMode: boolean; + teamMode: boolean; toolSearchDisabled: boolean; saveMessage: (msg: any) => Promise; clearSavedMessages: () => void; diff --git a/source/hooks/conversation/chatLogic/useMessageProcessing.ts b/source/hooks/conversation/chatLogic/useMessageProcessing.ts index c54a9044..2809d0df 100644 --- a/source/hooks/conversation/chatLogic/useMessageProcessing.ts +++ b/source/hooks/conversation/chatLogic/useMessageProcessing.ts @@ -59,6 +59,7 @@ export function useMessageProcessing(props: UseChatLogicProps) { yoloMode, planMode, vulnerabilityHuntingMode, + teamMode, toolSearchDisabled, saveMessage, clearSavedMessages, @@ -233,6 +234,7 @@ export function useMessageProcessing(props: UseChatLogicProps) { yoloModeRef, planMode, vulnerabilityHuntingMode, + teamMode, toolSearchDisabled, setContextUsage: streamingState.setContextUsage, useBasicModel, @@ -664,6 +666,7 @@ export function useMessageProcessing(props: UseChatLogicProps) { yoloModeRef, planMode, vulnerabilityHuntingMode, + teamMode, toolSearchDisabled, setContextUsage: streamingState.setContextUsage, getPendingMessages: () => pendingMessagesRef.current, diff --git a/source/hooks/conversation/core/conversationSetup.ts b/source/hooks/conversation/core/conversationSetup.ts index 9376161f..14df812f 100644 --- a/source/hooks/conversation/core/conversationSetup.ts +++ b/source/hooks/conversation/core/conversationSetup.ts @@ -21,13 +21,14 @@ export type PreparedConversationSetup = { export async function prepareConversationSetup( options: Pick< ConversationHandlerOptions, - 'planMode' | 'vulnerabilityHuntingMode' | 'toolSearchDisabled' + 'planMode' | 'vulnerabilityHuntingMode' | 'teamMode' | 'toolSearchDisabled' >, ): Promise { let {conversationMessages} = await initializeConversationSession( options.planMode || false, options.vulnerabilityHuntingMode || false, options.toolSearchDisabled || false, + options.teamMode || false, ); const allMCPTools = await collectAllMCPTools(); diff --git a/source/hooks/conversation/core/conversationTypes.ts b/source/hooks/conversation/core/conversationTypes.ts index 93edd073..6db988d9 100644 --- a/source/hooks/conversation/core/conversationTypes.ts +++ b/source/hooks/conversation/core/conversationTypes.ts @@ -49,6 +49,7 @@ export type ConversationHandlerOptions = { yoloModeRef: React.MutableRefObject; planMode?: boolean; vulnerabilityHuntingMode?: boolean; + teamMode?: boolean; toolSearchDisabled?: boolean; setContextUsage: React.Dispatch>; useBasicModel?: boolean; diff --git a/source/hooks/conversation/core/sessionInitializer.ts b/source/hooks/conversation/core/sessionInitializer.ts index dce5721f..3f2f27cc 100644 --- a/source/hooks/conversation/core/sessionInitializer.ts +++ b/source/hooks/conversation/core/sessionInitializer.ts @@ -16,6 +16,7 @@ export async function initializeConversationSession( planMode: boolean, vulnerabilityHuntingMode: boolean, toolSearchDisabled = false, + teamMode = false, ): Promise<{ conversationMessages: ChatMessage[]; currentSession: any; @@ -36,7 +37,7 @@ export async function initializeConversationSession( const conversationMessages: ChatMessage[] = [ { role: 'system', - content: getSystemPromptForMode(planMode, vulnerabilityHuntingMode, toolSearchDisabled), + content: getSystemPromptForMode(planMode, vulnerabilityHuntingMode, toolSearchDisabled, teamMode), }, ]; diff --git a/source/hooks/conversation/core/subAgentMessageHandler.ts b/source/hooks/conversation/core/subAgentMessageHandler.ts index 0411767d..d7ea89ee 100644 --- a/source/hooks/conversation/core/subAgentMessageHandler.ts +++ b/source/hooks/conversation/core/subAgentMessageHandler.ts @@ -3,6 +3,57 @@ import type {SubAgentMessage} from '../../../utils/execution/subAgentExecutor.js import {formatToolCallMessage} from '../../../utils/ui/messageFormatter.js'; import {isToolNeedTwoStepDisplay} from '../../../utils/config/toolDisplayConfig.js'; +// ── Module-level store: per-teammate streaming data (useSyncExternalStore compatible) ── + +export interface TeammateStreamInfo { + agentId: string; + agentName: string; + tokenCount: number; + isReasoning: boolean; +} + +const _teammateStreamMap = new Map(); +const _teammateStreamListeners = new Set<() => void>(); +let _teammateStreamSnapshot: TeammateStreamInfo[] = []; +let _pendingNotify = false; + +function rebuildTeammateSnapshot(): void { + _teammateStreamSnapshot = Array.from(_teammateStreamMap.values()); + if (!_pendingNotify) { + _pendingNotify = true; + queueMicrotask(() => { + _pendingNotify = false; + for (const listener of _teammateStreamListeners) { + try { listener(); } catch { /* noop */ } + } + }); + } +} + +function setTeammateStreamEntry(agentId: string, agentName: string, tokenCount: number, isReasoning: boolean): void { + const prev = _teammateStreamMap.get(agentId); + if (prev && prev.tokenCount === tokenCount && prev.isReasoning === isReasoning) return; + _teammateStreamMap.set(agentId, {agentId, agentName, tokenCount, isReasoning}); + rebuildTeammateSnapshot(); +} + +function removeTeammateStreamEntry(agentId: string): void { + if (_teammateStreamMap.delete(agentId)) { + rebuildTeammateSnapshot(); + } +} + +export function subscribeTeammateStream(listener: () => void): () => void { + _teammateStreamListeners.add(listener); + return () => { _teammateStreamListeners.delete(listener); }; +} + +export function getTeammateStreamSnapshot(): TeammateStreamInfo[] { + return _teammateStreamSnapshot; +} + +// ── Types ── + type CtxUsage = {percentage: number; inputTokens: number; maxTokens: number}; type StreamState = { @@ -49,6 +100,7 @@ export class SubAgentUIHandler { readonly latestCtxUsage: Record = {}; private readonly streamStates: Record = {}; private readonly activeReasoningAgents = new Set(); + private readonly agentNameMap: Record = {}; private readonly FLUSH_INTERVAL = 100; constructor( @@ -66,6 +118,10 @@ export class SubAgentUIHandler { handleMessage(prev: Message[], subAgentMessage: SubAgentMessage): Message[] { const {message} = subAgentMessage; + if (subAgentMessage.agentId.startsWith('teammate-')) { + this.agentNameMap[subAgentMessage.agentId] = subAgentMessage.agentName; + } + switch (message.type) { case 'context_usage': return this.handleContextUsage(prev, subAgentMessage); @@ -129,14 +185,24 @@ export class SubAgentUIHandler { private clearStreamState(agentId: string): void { delete this.streamStates[agentId]; this.updateGlobalTokenCount(); + removeTeammateStreamEntry(agentId); } private updateGlobalTokenCount(): void { - const total = Object.values(this.streamStates).reduce( - (sum, state) => sum + state.tokenCount, - 0, - ); - this.setStreamTokenCount(total); + let leadTotal = 0; + for (const [agentId, state] of Object.entries(this.streamStates)) { + if (agentId.startsWith('teammate-')) { + setTeammateStreamEntry( + agentId, + this.agentNameMap[agentId] || agentId, + state.tokenCount, + this.activeReasoningAgents.has(agentId), + ); + } else { + leadTotal += state.tokenCount; + } + } + this.setStreamTokenCount(leadTotal); } private setAgentReasoning(agentId: string, isReasoning: boolean): void { @@ -146,6 +212,18 @@ export class SubAgentUIHandler { this.activeReasoningAgents.delete(agentId); } this.setIsReasoning?.(this.activeReasoningAgents.size > 0); + + if (agentId.startsWith('teammate-')) { + const state = this.streamStates[agentId]; + if (state) { + setTeammateStreamEntry( + agentId, + this.agentNameMap[agentId] || agentId, + state.tokenCount, + isReasoning, + ); + } + } } private addTokens(agentId: string, text: string): void { diff --git a/source/hooks/conversation/useCommandHandler.ts b/source/hooks/conversation/useCommandHandler.ts index a83e52b1..a94060f3 100644 --- a/source/hooks/conversation/useCommandHandler.ts +++ b/source/hooks/conversation/useCommandHandler.ts @@ -426,6 +426,7 @@ type CommandHandlerOptions = { setVulnerabilityHuntingMode: React.Dispatch>; setToolSearchDisabled: React.Dispatch>; setHybridCompressEnabled: React.Dispatch>; + setTeamMode: React.Dispatch>; setContextUsage: React.Dispatch>; setCurrentContextPercentage: React.Dispatch>; currentContextPercentageRef: React.MutableRefObject; @@ -984,34 +985,39 @@ export function useCommandHandler(options: CommandHandlerOptions) { options.setYoloMode(prev => !prev); // Don't add command message to keep UI clean } else if (result.success && result.action === 'togglePlan') { - // Toggle Plan mode without adding command message options.setPlanMode(prev => { const newValue = !prev; - // If enabling Plan mode, disable Vulnerability Hunting mode if (newValue) { options.setVulnerabilityHuntingMode(false); + options.setTeamMode(false); } return newValue; }); - // Don't add command message to keep UI clean } else if ( result.success && result.action === 'toggleVulnerabilityHunting' ) { - // Toggle Vulnerability Hunting mode without adding command message options.setVulnerabilityHuntingMode(prev => { const newValue = !prev; - // If enabling Vulnerability Hunting mode, disable Plan mode if (newValue) { options.setPlanMode(false); + options.setTeamMode(false); } return newValue; }); - // Don't add command message to keep UI clean } else if (result.success && result.action === 'toggleToolSearch') { options.setToolSearchDisabled(prev => !prev); } else if (result.success && result.action === 'toggleHybridCompress') { options.setHybridCompressEnabled(prev => !prev); + } else if (result.success && result.action === 'toggleTeam') { + options.setTeamMode(prev => { + const newValue = !prev; + if (newValue) { + options.setPlanMode(false); + options.setVulnerabilityHuntingMode(false); + } + return newValue; + }); } else if ( result.success && result.action === 'initProject' && diff --git a/source/hooks/conversation/useConversation.ts b/source/hooks/conversation/useConversation.ts index b1159a04..06cdfc51 100644 --- a/source/hooks/conversation/useConversation.ts +++ b/source/hooks/conversation/useConversation.ts @@ -58,6 +58,7 @@ export async function handleConversationWithTools( } = await prepareConversationSetup({ planMode: options.planMode, vulnerabilityHuntingMode: options.vulnerabilityHuntingMode, + teamMode: options.teamMode, toolSearchDisabled: options.toolSearchDisabled, }); diff --git a/source/hooks/input/useKeyboardInput.ts b/source/hooks/input/useKeyboardInput.ts index cc6aef1c..0db40733 100644 --- a/source/hooks/input/useKeyboardInput.ts +++ b/source/hooks/input/useKeyboardInput.ts @@ -22,6 +22,8 @@ type KeyboardInputOptions = { setPlanMode: (value: boolean) => void; vulnerabilityHuntingMode: boolean; setVulnerabilityHuntingMode: (value: boolean) => void; + teamMode: boolean; + setTeamMode: (value: boolean) => void; // Command panel showCommands: boolean; setShowCommands: (show: boolean) => void; @@ -198,6 +200,8 @@ export function useKeyboardInput(options: KeyboardInputOptions) { setPlanMode, vulnerabilityHuntingMode: _vulnerabilityHuntingMode, setVulnerabilityHuntingMode, + teamMode: _teamMode, + setTeamMode, showCommands, setShowCommands, commandSelectedIndex, @@ -316,6 +320,7 @@ export function useKeyboardInput(options: KeyboardInputOptions) { void selectedGitLineCommits; void gitLineIsLoading; void gitLineError; + void _teamMode; void runningAgentsSelectedIndex; void selectedRunningAgents; @@ -445,8 +450,8 @@ export function useKeyboardInput(options: KeyboardInputOptions) { if (yoloMode && !planMode) { // YOLO only -> YOLO + Plan setPlanMode(true); - // Disable Vulnerability Hunting when enabling Plan setVulnerabilityHuntingMode(false); + setTeamMode(false); } else if (yoloMode && planMode) { // YOLO + Plan -> Plan only setYoloMode(false); @@ -465,8 +470,8 @@ export function useKeyboardInput(options: KeyboardInputOptions) { if (yoloMode && !planMode) { // YOLO only -> YOLO + Plan setPlanMode(true); - // Disable Vulnerability Hunting when enabling Plan setVulnerabilityHuntingMode(false); + setTeamMode(false); } else if (yoloMode && planMode) { // YOLO + Plan -> Plan only setYoloMode(false); diff --git a/source/hooks/ui/useCommandPanel.ts b/source/hooks/ui/useCommandPanel.ts index 5bb0fff4..fea4f824 100644 --- a/source/hooks/ui/useCommandPanel.ts +++ b/source/hooks/ui/useCommandPanel.ts @@ -195,6 +195,12 @@ export function useCommandPanel(buffer: TextBuffer, isProcessing = false) { t.commandPanel.commands.newPrompt || 'Generate a refined prompt from your requirement using AI', }, + { + name: 'team', + description: + t.commandPanel.commands.team || + 'Toggle Agent Team mode - orchestrate multiple agents working together', + }, { name: 'quit', description: t.commandPanel.commands.quit, diff --git a/source/i18n/lang/en.ts b/source/i18n/lang/en.ts index f359c108..1cd6f4b2 100644 --- a/source/i18n/lang/en.ts +++ b/source/i18n/lang/en.ts @@ -596,6 +596,8 @@ export const en: TranslationKeys = { 'Toggle Tool Search (progressive tool loading). Enabled by default to save context', hybridCompress: 'Toggle Hybrid Compress mode (AI summary + smart truncation for /compact and auto-compress)', + team: + 'Toggle Agent Team mode - orchestrate multiple agents working together in independent Git worktrees', worktree: 'Open Git branch management panel for switching, creating and deleting branches', diff: 'Review file changes from a conversation in IDE diff view', @@ -1079,6 +1081,7 @@ export const en: TranslationKeys = { '⍨ Vulnerability Hunting Mode Active - Focused on vulnerability discovery and security analysis', toolSearchEnabled: '♾︎ Tool Search ON - Tools loaded on demand', hybridCompressEnabled: '⇌ Hybrid Compress ON - AI summary + smart truncation', + teamModeActive: '⚑ Agent Team Mode Active - Orchestrating multiple agents with independent worktrees', tokens: ' tokens', cached: 'cached', newCache: 'new cache', diff --git a/source/i18n/lang/zh-TW.ts b/source/i18n/lang/zh-TW.ts index 664c92fe..5e72d1e8 100644 --- a/source/i18n/lang/zh-TW.ts +++ b/source/i18n/lang/zh-TW.ts @@ -559,6 +559,8 @@ export const zhTW: TranslationKeys = { toolSearch: '切換工具搜尋(漸進式工具載入)。預設啟用以節省上下文', hybridCompress: '切換混合壓縮模式(AI 摘要 + 智慧截斷,用於 /compact 和自動壓縮)', + team: + '切換 Agent Team 模式 - 協調多個代理在獨立 Git Worktree 中並行工作', worktree: '開啟 Git 分支管理面板,支援切換、新建和刪除分支', diff: '在 IDE 中查看對話的檔案修改 Diff', connect: '連接到 Snow Instance 進行 AI 處理', @@ -1025,6 +1027,7 @@ export const zhTW: TranslationKeys = { '⍨ Vulnerability Hunting 模式已啟用 - 專注漏洞挖掘與安全分析', toolSearchEnabled: '♾︎ 工具搜尋已開啟 - 按需搜尋載入工具', hybridCompressEnabled: '⇌ 混合壓縮已開啟 - AI 摘要 + 智慧截斷', + teamModeActive: '⚑ Agent Team 模式已啟用 - 多代理獨立 Worktree 協同工作', tokens: ' 個token', cached: '已快取', newCache: '新快取', diff --git a/source/i18n/lang/zh.ts b/source/i18n/lang/zh.ts index f6cdfe71..9540cda5 100644 --- a/source/i18n/lang/zh.ts +++ b/source/i18n/lang/zh.ts @@ -558,6 +558,8 @@ export const zh: TranslationKeys = { toolSearch: '切换工具搜索(渐进式工具加载)。默认启用以节省上下文', hybridCompress: '切换混合压缩模式(AI 摘要 + 智能截断,用于 /compact 和自动压缩)', + team: + '切换 Agent Team 模式 - 协调多个代理在独立 Git Worktree 中并行工作', worktree: '打开 Git 分支管理面板,支持切换、新建和删除分支', diff: '在 IDE 中查看对话的文件修改 Diff', connect: '连接到 Snow Instance 进行 AI 处理', @@ -1024,6 +1026,7 @@ export const zh: TranslationKeys = { '⍨ Vulnerability Hunting 模式已激活 - 专注漏洞挖掘与安全分析', toolSearchEnabled: '♾︎ 工具搜索已开启 - 按需搜索加载工具', hybridCompressEnabled: '⇌ 混合压缩已开启 - AI 摘要 + 智能截断', + teamModeActive: '⚑ Agent Team 模式已激活 - 多代理独立 Worktree 协同工作', tokens: ' 个token', cached: '已缓存', newCache: '新缓存', diff --git a/source/i18n/types.ts b/source/i18n/types.ts index d2952126..b02b741f 100644 --- a/source/i18n/types.ts +++ b/source/i18n/types.ts @@ -554,6 +554,7 @@ export type TranslationKeys = { autoFormat: string; toolSearch: string; hybridCompress: string; + team: string; worktree: string; // Git branch management panel diff: string; connect: string; @@ -1019,6 +1020,7 @@ export type TranslationKeys = { vulnerabilityHuntingModeActive: string; toolSearchEnabled: string; hybridCompressEnabled: string; + teamModeActive: string; tokens: string; cached: string; newCache: string; diff --git a/source/mcp/team.ts b/source/mcp/team.ts new file mode 100644 index 00000000..ee1c167c --- /dev/null +++ b/source/mcp/team.ts @@ -0,0 +1,837 @@ +/** + * Team Service + * Provides team management tools for the lead agent in Agent Team mode. + * Tools are registered as MCP-style tools with "team-" prefix. + */ + +import { + createTeam, + getActiveTeam, + addMember, + disbandTeam, +} from '../utils/team/teamConfig.js'; +import { + createTask, + assignTask, + updateTaskStatus, + listTasks, +} from '../utils/team/teamTaskList.js'; +import { + createTeamWorktree, + cleanupTeamWorktrees, + isGitRepo, + autoCommitWorktreeChanges, + mergeTeammateBranch, + getTeammateDiffSummary, + isInMergeState, + getConflictedFiles, + completeMerge, + abortCurrentMerge, + type MergeStrategy, +} from '../utils/team/teamWorktree.js'; +import {existsSync} from 'fs'; +import {teamTracker} from '../utils/execution/teamTracker.js'; +import {executeTeammate} from '../utils/execution/teamExecutor.js'; +import type {SubAgentMessage} from '../utils/execution/subAgentExecutor.js'; +import type {ConfirmationResult} from '../ui/components/tools/ToolConfirmation.js'; + +export interface TeamToolExecutionOptions { + toolName: string; + args: Record; + onMessage?: (message: SubAgentMessage) => void; + abortSignal?: AbortSignal; + requestToolConfirmation?: ( + toolName: string, + toolArgs: any, + ) => Promise; + isToolAutoApproved?: (toolName: string) => boolean; + yoloMode?: boolean; + addToAlwaysApproved?: (toolName: string) => void; + requestUserQuestion?: ( + question: string, + options: string[], + multiSelect?: boolean, + ) => Promise<{selected: string | string[]; customInput?: string}>; +} + +export class TeamService { + async execute(options: TeamToolExecutionOptions): Promise { + const {toolName, args} = options; + + switch (toolName) { + case 'spawn_teammate': + return this.spawnTeammate(options); + case 'message_teammate': + return this.messageTeammate(args); + case 'broadcast_to_team': + return this.broadcastToTeam(args); + case 'shutdown_teammate': + return this.shutdownTeammate(args); + case 'wait_for_teammates': + return this.waitForTeammates(args, options.abortSignal); + case 'create_task': + return this.createTask(args); + case 'update_task': + return this.updateTask(args); + case 'list_tasks': + return this.listTasks(); + case 'list_teammates': + return this.listTeammates(); + case 'merge_teammate_work': + return this.mergeTeammateWork(args); + case 'merge_all_teammate_work': + return this.mergeAllTeammateWork(args); + case 'resolve_merge_conflicts': + return this.resolveMergeConflicts(); + case 'abort_merge': + return this.abortMerge(); + case 'cleanup_team': + return this.cleanupTeam(); + case 'approve_plan': + return this.approvePlan(args); + default: + throw new Error(`Unknown team tool: ${toolName}`); + } + } + + private async spawnTeammate(options: TeamToolExecutionOptions): Promise { + const {args, onMessage, abortSignal, requestToolConfirmation, isToolAutoApproved, yoloMode, addToAlwaysApproved, requestUserQuestion} = options; + const name = args['name'] as string; + const role = args['role'] as string | undefined; + const prompt = args['prompt'] as string; + const requirePlanApproval = args['require_plan_approval'] as boolean | undefined; + + if (!name || !prompt) { + throw new Error('spawn_teammate requires "name" and "prompt" parameters'); + } + + if (!isGitRepo()) { + throw new Error('Agent Teams require a Git repository. Initialize git first.'); + } + + // Ensure a team exists + let team = getActiveTeam(); + if (!team) { + const teamName = `team-${Date.now()}`; + team = createTeam(teamName, 'lead'); + teamTracker.setActiveTeam(teamName); + } + + // Create Git worktree for this teammate + const worktreePath = await createTeamWorktree(team.name, name); + + // Add member to team config + const member = addMember(team.name, name, worktreePath, role); + + // Spawn teammate execution (fire-and-forget) + executeTeammate( + member.id, + name, + prompt, + worktreePath, + team.name, + role, + { + onMessage, + abortSignal, + requestToolConfirmation, + isToolAutoApproved, + yoloMode, + addToAlwaysApproved, + requestUserQuestion, + requirePlanApproval, + }, + ).catch(error => { + console.error(`Teammate ${name} failed:`, error); + }); + + return { + success: true, + result: `Teammate "${name}" spawned successfully.`, + memberId: member.id, + worktreePath, + role: role || 'general', + }; + } + + private messageTeammate(args: Record): any { + const targetId = args['target_id'] as string; + const content = args['content'] as string; + + if (!targetId || !content) { + throw new Error('message_teammate requires "target_id" and "content"'); + } + + // Find teammate by member ID or name + let teammate = teamTracker.findByMemberId(targetId) + || teamTracker.findByMemberName(targetId); + + if (!teammate) { + return { + success: false, + error: `Teammate "${targetId}" not found or not running.`, + }; + } + + const sent = teamTracker.sendMessageToTeammate( + 'lead', + teammate.instanceId, + content, + ); + + return { + success: sent, + result: sent + ? `Message sent to ${teammate.memberName}.` + : `Failed to send message to ${targetId}.`, + }; + } + + private broadcastToTeam(args: Record): any { + const content = args['content'] as string; + if (!content) { + throw new Error('broadcast_to_team requires "content"'); + } + + const count = teamTracker.broadcastToTeammates('lead', content); + return { + success: true, + result: `Broadcast sent to ${count} teammate(s).`, + }; + } + + private shutdownTeammate(args: Record): any { + const targetId = args['target_id'] as string; + const reason = args['reason'] as string | undefined; + + if (!targetId) { + throw new Error('shutdown_teammate requires "target_id"'); + } + + let teammate = teamTracker.findByMemberId(targetId) + || teamTracker.findByMemberName(targetId); + + if (!teammate) { + return { + success: false, + error: `Teammate "${targetId}" not found or not running.`, + }; + } + + const shutdownMsg = reason + ? `[Shutdown Request] The lead has requested you to shut down. Reason: ${reason}. Please finish current work and call shutdown_self.` + : '[Shutdown Request] The lead has requested you to shut down. Please finish current work and call shutdown_self.'; + + teamTracker.sendMessageToTeammate('lead', teammate.instanceId, shutdownMsg); + + return { + success: true, + result: `Shutdown request sent to ${teammate.memberName}. They will finish current work and exit.`, + }; + } + + private async waitForTeammates( + args: Record, + abortSignal?: AbortSignal, + ): Promise { + const running = teamTracker.getRunningTeammates(); + if (running.length === 0) { + const results = teamTracker.drainResults(); + const messages = teamTracker.dequeueLeadMessages(); + return { + success: true, + result: 'All teammates have already finished.', + completedResults: results.map(r => ({ + name: r.memberName, + success: r.success, + summary: r.result?.slice(0, 500), + error: r.error, + })), + messages: messages.map(m => ({ + from: m.fromMemberName, + content: m.content?.slice(0, 500), + })), + }; + } + + const timeoutMs = Math.min( + Math.max((args['timeout_seconds'] as number || 600) * 1000, 10_000), + 1_800_000, + ); + + const allDone = await teamTracker.waitForAllTeammates(timeoutMs, abortSignal); + + const results = teamTracker.drainResults(); + const messages = teamTracker.dequeueLeadMessages(); + const stillRunning = teamTracker.getRunningTeammates(); + + return { + success: allDone, + result: allDone + ? `All teammates have completed. ${results.length} result(s) collected.` + : `Timed out after ${timeoutMs / 1000}s. ${stillRunning.length} teammate(s) still running: ${stillRunning.map(t => t.memberName).join(', ')}`, + completedResults: results.map(r => ({ + name: r.memberName, + success: r.success, + summary: r.result?.slice(0, 500), + error: r.error, + })), + messages: messages.map(m => ({ + from: m.fromMemberName, + content: m.content?.slice(0, 500), + })), + stillRunning: stillRunning.map(t => t.memberName), + }; + } + + private createTask(args: Record): any { + const team = getActiveTeam(); + if (!team) { + throw new Error('No active team. Spawn a teammate first to create a team.'); + } + + const title = args['title'] as string; + const description = args['description'] as string | undefined; + const dependencies = args['dependencies'] as string[] | undefined; + const assigneeId = args['assignee_id'] as string | undefined; + const assigneeName = args['assignee_name'] as string | undefined; + + if (!title) { + throw new Error('create_task requires "title"'); + } + + const task = createTask( + team.name, title, description, + dependencies, assigneeId, assigneeName, + ); + + return { + success: true, + result: `Task created: "${task.title}" (${task.id})`, + taskId: task.id, + }; + } + + private updateTask(args: Record): any { + const team = getActiveTeam(); + if (!team) { + throw new Error('No active team.'); + } + + const taskId = args['task_id'] as string; + const status = args['status'] as string | undefined; + const assigneeId = args['assignee_id'] as string | undefined; + const assigneeName = args['assignee_name'] as string | undefined; + + if (!taskId) { + throw new Error('update_task requires "task_id"'); + } + + if (status) { + updateTaskStatus(team.name, taskId, status as any); + } + if (assigneeId) { + assignTask(team.name, taskId, assigneeId, assigneeName || assigneeId); + } + + return {success: true, result: `Task ${taskId} updated.`}; + } + + private listTasks(): any { + const team = getActiveTeam(); + if (!team) { + return {success: true, result: 'No active team.', tasks: []}; + } + + const tasks = listTasks(team.name); + return { + success: true, + tasks: tasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + status: t.status, + assignee: t.assigneeName || t.assigneeId, + dependencies: t.dependencies, + })), + }; + } + + private listTeammates(): any { + const teammates = teamTracker.getRunningTeammates(); + return { + success: true, + teammates: teammates.map(t => ({ + memberId: t.memberId, + name: t.memberName, + role: t.role, + instanceId: t.instanceId, + worktreePath: t.worktreePath, + currentTaskId: t.currentTaskId, + runningFor: `${Math.round((Date.now() - t.startedAt.getTime()) / 1000)}s`, + })), + }; + } + + private mergeTeammateWork(args: Record): any { + const team = getActiveTeam(); + if (!team) { + throw new Error('No active team.'); + } + + if (isInMergeState()) { + return { + success: false, + error: 'A merge is already in progress. Call team-resolve_merge_conflicts to complete it or team-abort_merge to cancel.', + }; + } + + const targetName = args['name'] as string; + if (!targetName) { + throw new Error('merge_teammate_work requires "name"'); + } + + const strategy = (args['strategy'] as MergeStrategy) || 'manual'; + + const member = team.members.find( + m => m.name.toLowerCase() === targetName.toLowerCase(), + ); + if (!member) { + return {success: false, error: `Member "${targetName}" not found in team.`}; + } + + if (member.worktreePath && existsSync(member.worktreePath)) { + autoCommitWorktreeChanges(member.worktreePath, member.name); + } + + const result = mergeTeammateBranch(team.name, member.name, strategy); + + if (result.success && result.merged) { + return { + success: true, + result: `Merged ${result.commitCount} commit(s) from ${member.name} (${result.filesChanged} files changed).`, + }; + } else if (result.success && !result.merged) { + return { + success: true, + result: `${member.name} has no changes to merge.`, + }; + } else if (result.hasConflicts) { + return { + success: false, + hasConflicts: true, + conflictFiles: result.conflictFiles, + error: result.error, + hint: 'Read the conflicted files, edit them to resolve conflict markers (<<<<<<< / ======= / >>>>>>>), then call team-resolve_merge_conflicts.', + }; + } else { + return { + success: false, + error: result.error, + conflictFiles: result.conflictFiles, + }; + } + } + + private mergeAllTeammateWork(args: Record): any { + const team = getActiveTeam(); + if (!team) { + throw new Error('No active team.'); + } + + if (isInMergeState()) { + return { + success: false, + error: 'A merge is already in progress. Call team-resolve_merge_conflicts to complete it or team-abort_merge to cancel.', + }; + } + + const running = teamTracker.getRunningTeammates(); + if (running.length > 0) { + return { + success: false, + error: `Cannot merge: ${running.length} teammate(s) still running. Wait for them to finish first.`, + runningTeammates: running.map(t => t.memberName), + }; + } + + const strategy = (args['strategy'] as MergeStrategy) || 'manual'; + const results: Array<{name: string; merged: boolean; commits: number; files: number; error?: string; conflictFiles?: string[]}> = []; + + for (const member of team.members) { + if (member.worktreePath && existsSync(member.worktreePath)) { + autoCommitWorktreeChanges(member.worktreePath, member.name); + } + + const diff = getTeammateDiffSummary(team.name, member.name); + if (!diff || diff.commitCount === 0) { + results.push({name: member.name, merged: false, commits: 0, files: 0}); + continue; + } + + const mergeResult = mergeTeammateBranch(team.name, member.name, strategy); + if (mergeResult.success && mergeResult.merged) { + results.push({ + name: member.name, + merged: true, + commits: mergeResult.commitCount, + files: mergeResult.filesChanged, + }); + } else if (mergeResult.hasConflicts) { + results.push({ + name: member.name, + merged: false, + commits: mergeResult.commitCount, + files: 0, + error: mergeResult.error, + conflictFiles: mergeResult.conflictFiles, + }); + const mergedCount = results.filter(r => r.merged).length; + return { + success: false, + hasConflicts: true, + error: `Merge conflicts at ${member.name}. ${mergedCount} teammate(s) merged before the conflict. Working directory is in merge state — resolve conflicts then call team-resolve_merge_conflicts.`, + conflictFiles: mergeResult.conflictFiles, + results, + stoppedAt: member.name, + }; + } else if (!mergeResult.success) { + results.push({ + name: member.name, + merged: false, + commits: mergeResult.commitCount, + files: 0, + error: mergeResult.error, + }); + break; + } else { + results.push({name: member.name, merged: false, commits: 0, files: 0}); + } + } + + const mergedCount = results.filter(r => r.merged).length; + const totalCommits = results.reduce((sum, r) => sum + r.commits, 0); + const failedResult = results.find(r => r.error && !r.conflictFiles?.length); + + if (failedResult) { + return { + success: false, + error: `Merge failed at ${failedResult.name}: ${failedResult.error}`, + results, + }; + } + + return { + success: true, + result: `All teammate work merged. ${mergedCount} teammate(s) with changes, ${totalCommits} total commit(s).`, + results, + }; + } + + private resolveMergeConflicts(): any { + if (!isInMergeState()) { + return {success: false, error: 'Not currently in a merge state. Nothing to resolve.'}; + } + + const remaining = getConflictedFiles(); + if (remaining.length > 0) { + return { + success: false, + error: `${remaining.length} file(s) still have unresolved conflict markers: ${remaining.join(', ')}. Edit them to remove <<<<<<< / ======= / >>>>>>> markers first.`, + unresolvedFiles: remaining, + }; + } + + const result = completeMerge(); + if (result.success) { + return { + success: true, + result: 'Merge completed successfully. All conflicts resolved and committed.', + }; + } + return {success: false, error: result.error}; + } + + private abortMerge(): any { + if (!isInMergeState()) { + return {success: false, error: 'Not currently in a merge state.'}; + } + + const result = abortCurrentMerge(); + if (result.success) { + return { + success: true, + result: 'Merge aborted. Working directory restored to pre-merge state.', + }; + } + return {success: false, error: result.error}; + } + + private async cleanupTeam(): Promise { + const team = getActiveTeam(); + if (!team) { + return {success: false, error: 'No active team to clean up.'}; + } + + const running = teamTracker.getRunningTeammates(); + if (running.length > 0) { + return { + success: false, + error: `Cannot clean up: ${running.length} teammate(s) still running. Shut them down first.`, + runningTeammates: running.map(t => t.memberName), + }; + } + + // Check for unmerged work + const unmergedMembers: string[] = []; + for (const member of team.members) { + const diff = getTeammateDiffSummary(team.name, member.name); + if (diff && diff.commitCount > 0) { + unmergedMembers.push(`${member.name} (${diff.commitCount} commits, ${diff.filesChanged} files)`); + } + } + + if (unmergedMembers.length > 0) { + return { + success: false, + error: `Cannot clean up: ${unmergedMembers.length} teammate(s) have unmerged work that will be LOST. Run team-merge_all_teammate_work first.`, + unmergedMembers, + }; + } + + // Clean up Git worktrees + try { + await cleanupTeamWorktrees(team.name); + } catch (e: any) { + console.error('Failed to cleanup worktrees:', e); + } + + // Disband team and clear tracker + disbandTeam(team.name); + teamTracker.clearActiveTeam(); + + return { + success: true, + result: `Team "${team.name}" has been cleaned up. Worktrees removed, team disbanded.`, + }; + } + + private approvePlan(args: Record): any { + const targetId = args['target_id'] as string; + const approved = args['approved'] as boolean; + const feedback = args['feedback'] as string | undefined; + + if (!targetId || approved === undefined) { + throw new Error('approve_plan requires "target_id" and "approved"'); + } + + let teammate = teamTracker.findByMemberId(targetId) + || teamTracker.findByMemberName(targetId); + + if (!teammate) { + return {success: false, error: `Teammate "${targetId}" not found.`}; + } + + const resolved = teamTracker.resolvePlanApproval( + teammate.instanceId, + approved, + feedback, + ); + + return { + success: resolved, + result: resolved + ? `Plan ${approved ? 'approved' : 'rejected'} for ${teammate.memberName}.` + : `No pending plan approval found for ${targetId}.`, + }; + } + + getTools(): Array<{ + name: string; + description: string; + inputSchema: any; + }> { + return [ + { + name: 'spawn_teammate', + description: 'Spawn a new teammate agent that works independently in its own Git worktree. Each teammate has full tool access and can communicate with other teammates.', + inputSchema: { + type: 'object', + properties: { + name: {type: 'string', description: 'A short, descriptive name for this teammate (e.g., "frontend", "backend", "tester").'}, + role: {type: 'string', description: 'Optional role description guiding the teammate\'s focus area.'}, + prompt: {type: 'string', description: 'The task prompt for this teammate. Include all relevant context since teammates don\'t inherit your conversation history.'}, + require_plan_approval: {type: 'boolean', description: 'If true, the teammate must submit a plan for your approval before making changes.'}, + }, + required: ['name', 'prompt'], + }, + }, + { + name: 'message_teammate', + description: 'Send a direct message to a specific teammate. Use to provide guidance, share findings, or redirect their approach.', + inputSchema: { + type: 'object', + properties: { + target_id: {type: 'string', description: 'The member ID or name of the target teammate.'}, + content: {type: 'string', description: 'The message content.'}, + }, + required: ['target_id', 'content'], + }, + }, + { + name: 'broadcast_to_team', + description: 'Send a message to all teammates simultaneously. Use sparingly as costs scale with team size.', + inputSchema: { + type: 'object', + properties: { + content: {type: 'string', description: 'The message to broadcast to all teammates.'}, + }, + required: ['content'], + }, + }, + { + name: 'shutdown_teammate', + description: 'Request a specific teammate to gracefully shut down after finishing current work.', + inputSchema: { + type: 'object', + properties: { + target_id: {type: 'string', description: 'The member ID or name of the teammate to shut down.'}, + reason: {type: 'string', description: 'Optional reason for the shutdown.'}, + }, + required: ['target_id'], + }, + }, + { + name: 'wait_for_teammates', + description: 'Block and wait until ALL running teammates have completed and shut down. Returns collected results and messages. MUST be called after spawning teammates and before synthesizing final results.', + inputSchema: { + type: 'object', + properties: { + timeout_seconds: {type: 'number', description: 'Maximum time to wait in seconds. Default: 600 (10 min). Range: 10-1800.'}, + }, + required: [], + }, + }, + { + name: 'create_task', + description: 'Create a new task in the shared task list. Teammates can claim and work on tasks independently.', + inputSchema: { + type: 'object', + properties: { + title: {type: 'string', description: 'Brief title for the task.'}, + description: {type: 'string', description: 'Detailed description of what needs to be done.'}, + dependencies: {type: 'array', items: {type: 'string'}, description: 'Task IDs that must be completed before this task can be claimed.'}, + assignee_id: {type: 'string', description: 'Optional member ID to pre-assign this task to.'}, + assignee_name: {type: 'string', description: 'Optional member name for the pre-assignment.'}, + }, + required: ['title'], + }, + }, + { + name: 'update_task', + description: 'Update a task\'s status or reassign it.', + inputSchema: { + type: 'object', + properties: { + task_id: {type: 'string', description: 'The task ID to update.'}, + status: {type: 'string', enum: ['pending', 'in_progress', 'completed'], description: 'New status for the task.'}, + assignee_id: {type: 'string', description: 'New assignee member ID.'}, + assignee_name: {type: 'string', description: 'New assignee name.'}, + }, + required: ['task_id'], + }, + }, + { + name: 'list_tasks', + description: 'View all tasks in the shared task list with status, assignees, and dependencies.', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, + { + name: 'list_teammates', + description: 'View all currently running teammates with their status, roles, and current tasks.', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, + { + name: 'merge_teammate_work', + description: 'Merge a specific teammate\'s Git branch into the main branch. Auto-commits first. On conflict with strategy "manual" (default), leaves the working directory in merge state so you can read/edit conflicted files and then call team-resolve_merge_conflicts.', + inputSchema: { + type: 'object', + properties: { + name: {type: 'string', description: 'The name of the teammate whose work to merge.'}, + strategy: {type: 'string', enum: ['manual', 'theirs', 'ours'], description: '"manual" (default): pause on conflicts for you to resolve. "theirs": auto-accept all teammate changes on conflict. "ours": auto-keep main branch changes on conflict.'}, + }, + required: ['name'], + }, + }, + { + name: 'merge_all_teammate_work', + description: 'Merge ALL teammates\' branches sequentially. Stops on first conflict (in "manual" mode) so you can resolve it. MUST call before cleanup_team.', + inputSchema: { + type: 'object', + properties: { + strategy: {type: 'string', enum: ['manual', 'theirs', 'ours'], description: '"manual" (default): stop on conflicts for resolution. "theirs": auto-accept teammate changes. "ours": auto-keep main branch.'}, + }, + required: [], + }, + }, + { + name: 'resolve_merge_conflicts', + description: 'Complete a merge after manually resolving conflicts. Stages all changes and commits. Call this after editing conflicted files to remove <<<<<<< / ======= / >>>>>>> markers.', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, + { + name: 'abort_merge', + description: 'Abort the current merge and restore the working directory to pre-merge state. Use when you decide not to merge a teammate\'s conflicting changes.', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, + { + name: 'cleanup_team', + description: 'Clean up the team: remove Git worktrees and disband. All teammates must be shut down AND their work must be merged first (will refuse if unmerged changes exist).', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, + { + name: 'approve_plan', + description: 'Approve or reject a teammate\'s implementation plan. Only applicable when the teammate was spawned with require_plan_approval.', + inputSchema: { + type: 'object', + properties: { + target_id: {type: 'string', description: 'The member ID or name of the teammate whose plan to review.'}, + approved: {type: 'boolean', description: 'Whether to approve the plan.'}, + feedback: {type: 'string', description: 'Optional feedback, especially useful when rejecting.'}, + }, + required: ['target_id', 'approved'], + }, + }, + ]; + } +} + +export const teamService = new TeamService(); + +export function getTeamMCPTools(): Array<{ + name: string; + description: string; + inputSchema: any; +}> { + return teamService.getTools(); +} diff --git a/source/prompt/systemPrompt.ts b/source/prompt/systemPrompt.ts index 40df396b..02c6e1e6 100644 --- a/source/prompt/systemPrompt.ts +++ b/source/prompt/systemPrompt.ts @@ -420,7 +420,13 @@ export function getSystemPromptForMode( planMode: boolean, vulnerabilityHuntingMode: boolean, toolSearchDisabled = false, + teamMode = false, ): string { + // Team mode takes highest precedence + if (teamMode) { + const {getTeamModeSystemPrompt} = require('./teamModeSystemPrompt.js'); + return getTeamModeSystemPrompt(toolSearchDisabled); + } // Vulnerability Hunting mode takes precedence over Plan mode if (vulnerabilityHuntingMode) { // Import dynamically to avoid circular dependency diff --git a/source/prompt/teamModeSystemPrompt.ts b/source/prompt/teamModeSystemPrompt.ts new file mode 100644 index 00000000..6567f01f --- /dev/null +++ b/source/prompt/teamModeSystemPrompt.ts @@ -0,0 +1,179 @@ +/** + * Team Mode System Prompt + * Used when the user enables Agent Team mode. + * The lead agent receives guidance on orchestrating a team of + * independent teammate agents working in parallel. + */ + +import { + getSystemPromptWithRole, + getSystemEnvironmentInfo, + isCodebaseEnabled, + getCurrentTimeInfo, + appendSystemContext, +} from './shared/promptHelpers.js'; + +const TEAM_MODE_SYSTEM_PROMPT = `You are Snow AI CLI, operating in **Agent Team Mode** as the Team Lead. + +## Your Role + +You are the lead orchestrator of a multi-agent team. Your job is to: +1. Analyze the user's task and determine how to decompose it for parallel work +2. Spawn specialized teammates, each working independently in their own Git worktree +3. Create a shared task list with clear ownership and dependencies +4. Coordinate teammate communication and resolve conflicts +5. Synthesize results when teammates complete their work +6. Clean up the team when the task is done + +## Architecture + +- **You (Lead)**: Orchestrate, coordinate, and synthesize. You have full access to all tools plus team management tools. +- **Teammates**: Independent agents, each with their own context window and Git worktree. They can message each other directly and claim tasks from the shared list. +- **Git Worktrees**: Each teammate works in an isolated branch/directory. This prevents file conflicts and allows parallel edits. +- **Shared Task List**: A centralized list of work items with status tracking and dependency resolution. + +## Team Tools Available + +- \`team-spawn_teammate\`: Create a new teammate with a name, role, prompt, and optional plan approval requirement +- \`team-message_teammate\`: Send a message to a specific teammate +- \`team-broadcast_to_team\`: Send a message to all teammates (use sparingly) +- \`team-shutdown_teammate\`: Request a teammate to gracefully shut down +- \`team-wait_for_teammates\`: **Block and wait** until ALL teammates have completed. Returns collected results and messages. **MUST call this before synthesizing results.** +- \`team-create_task\`: Add a task to the shared task list +- \`team-update_task\`: Update task status or reassign +- \`team-list_tasks\`: View the current task list +- \`team-list_teammates\`: View running teammates and their status +- \`team-merge_teammate_work\`: Merge a specific teammate's branch into main (supports strategy: "manual"/"theirs"/"ours") +- \`team-merge_all_teammate_work\`: Merge ALL teammates' branches sequentially. **MUST call before cleanup.** +- \`team-resolve_merge_conflicts\`: Complete a merge after manually resolving conflicts +- \`team-abort_merge\`: Abort current merge and restore working directory +- \`team-cleanup_team\`: Remove all worktrees and disband (refuses if unmerged work exists) +- \`team-approve_plan\`: Approve or reject a teammate's implementation plan + +## When to Create a Team + +Create a team when the task involves: +- **Parallel research/review**: Multiple perspectives investigating simultaneously +- **Independent modules**: Different parts of the codebase that can be worked on separately +- **Cross-layer work**: Frontend, backend, tests each owned by different teammates +- **Competing hypotheses**: Multiple theories to investigate in parallel + +Do NOT create a team for: +- Simple sequential tasks +- Work that requires editing the same files +- Tasks with heavy dependencies where parallelism adds no value + +## Best Practices + +### 1. Task Decomposition +- Break work into 5-6 tasks per teammate for optimal productivity +- Define clear file ownership boundaries to prevent merge conflicts +- Use task dependencies when order matters + +### 2. Teammate Spawning +- Start with 3-5 teammates for most workflows +- Give each teammate a clear, focused role +- Include ALL relevant context in the spawn prompt (teammates don't inherit your conversation history) +- Use \`require_plan_approval: true\` for risky or complex changes + +### 3. Coordination +- Create the task list BEFORE spawning teammates so they can self-claim +- Monitor progress with \`team-list_teammates\` and \`team-list_tasks\` +- Use \`team-message_teammate\` for targeted guidance +- Use \`team-broadcast_to_team\` sparingly (costs scale with team size) + +### 4. Avoiding Merge Conflicts +- Assign different files/directories to different teammates — this is the most important rule +- Each teammate works in their own Git worktree (branch isolation) +- If teammates need to coordinate on shared concerns, have them message each other +- NEVER assign the same file to multiple teammates + +### 5. Resolving Merge Conflicts +When \`team-merge_teammate_work\` or \`team-merge_all_teammate_work\` reports conflicts: +1. The working directory is left in a **merge state** with conflict markers in files +2. **Read** each conflicted file — look for \`<<<<<<<\`, \`=======\`, \`>>>>>>>\` markers +3. **Edit** the files to keep the correct content from both sides, removing all markers +4. Call \`team-resolve_merge_conflicts\` to complete the merge +5. If the remaining teammates haven't been merged yet, call \`team-merge_all_teammate_work\` again to continue + +Alternatively, use \`strategy: "theirs"\` to auto-accept all teammate changes, or \`"ours"\` to keep main branch content. Use \`team-abort_merge\` to cancel a conflicting merge entirely. + +### 6. Completion (**CRITICAL - follow this order exactly**) +- After spawning all teammates and creating tasks, call \`team-wait_for_teammates\` to **block until all teammates finish**. Do NOT poll with list_teammates in a loop. +- Review the returned results and messages +- If teammates edited files, call \`team-merge_all_teammate_work\` to merge their Git branches into main. **This step is mandatory when teammates make file changes — without it, all their work is lost on cleanup.** +- If merge conflicts occur, resolve them manually then retry +- Call \`team-cleanup_team\` to remove worktrees (will refuse if unmerged work exists) +- **NEVER** provide a final summary before \`team-wait_for_teammates\` returns + +## Workflow Template + +1. **Analyze** the user's request and determine if a team is appropriate +2. **Plan** the team structure: how many teammates, what roles, what tasks +3. **Create tasks** in the shared task list with dependencies +4. **Spawn teammates** with detailed prompts including relevant context +5. **Wait** — call \`team-wait_for_teammates\` to block until ALL teammates complete +6. **Merge** — call \`team-merge_all_teammate_work\` to integrate file changes into main branch +7. **Synthesize** results and report back to the user +8. **Clean up** — call \`team-cleanup_team\` to remove worktrees and disband + +PLACEHOLDER_FOR_TOOL_DISCOVERY_SECTION + +PLACEHOLDER_FOR_CODE_SEARCH_SECTION + +You also have access to all standard Snow AI CLI tools for your own direct use. +`; + +function getCodeSearchSection(hasCodebase: boolean): string { + if (hasCodebase) { + return `## Code Search (for Lead's own use) + +**PRIMARY TOOL - \`codebase-search\` (Semantic Search):** +- Use for code exploration before spawning teammates or during synthesis +- Query by meaning: "authentication logic", "error handling patterns" +- Returns relevant code with full context across the entire codebase + +**Fallback tools:** +- \`ace-find_definition\` - Jump to exact symbol definition +- \`ace-find_references\` - Find all usages of a known symbol +- \`ace-text_search\` - Literal string search`; + } + return `## Code Search (for Lead's own use) + +- \`ace-semantic_search\` - Symbol search with fuzzy matching +- \`ace-find_definition\` - Go to definition of a symbol +- \`ace-find_references\` - Find all usages of a symbol +- \`ace-text_search\` - Literal text/regex search`; +} + +const TOOL_DISCOVERY_SECTIONS = { + preloaded: `## Tool Discovery +All tools are preloaded and available. Team tools are prefixed with \`team-\`.`, + progressive: `## Tool Discovery +Tools are loaded on demand. Use tool search when you need specific functionality. Team tools are always available and prefixed with \`team-\`.`, +}; + +export function getTeamModeSystemPrompt( + toolSearchDisabled = false, +): string { + const basePrompt = getSystemPromptWithRole( + TEAM_MODE_SYSTEM_PROMPT, + 'You are Snow AI CLI, operating in **Agent Team Mode** as the Team Lead.', + ); + + const systemEnv = getSystemEnvironmentInfo(true); + const hasCodebase = isCodebaseEnabled(); + const timeInfo = getCurrentTimeInfo(); + + const toolDiscoverySection = toolSearchDisabled + ? TOOL_DISCOVERY_SECTIONS.preloaded + : TOOL_DISCOVERY_SECTIONS.progressive; + + const codeSearchSection = getCodeSearchSection(hasCodebase); + + const finalPrompt = basePrompt + .replace('PLACEHOLDER_FOR_TOOL_DISCOVERY_SECTION', toolDiscoverySection) + .replace('PLACEHOLDER_FOR_CODE_SEARCH_SECTION', codeSearchSection); + + return appendSystemContext(finalPrompt, systemEnv, timeInfo); +} diff --git a/source/ui/components/chat/ChatFooter.tsx b/source/ui/components/chat/ChatFooter.tsx index 3cd44c5a..5d067aed 100644 --- a/source/ui/components/chat/ChatFooter.tsx +++ b/source/ui/components/chat/ChatFooter.tsx @@ -56,6 +56,8 @@ type ChatFooterProps = { setVulnerabilityHuntingMode: (value: boolean) => void; toolSearchDisabled: boolean; hybridCompressEnabled: boolean; + teamMode: boolean; + setTeamMode: (value: boolean) => void; contextUsage?: { inputTokens: number; maxContextTokens: number; @@ -250,6 +252,7 @@ const ChatFooter = React.memo(function ChatFooter(props: ChatFooterProps) { streamTokenCount={props.streamTokenCount} elapsedSeconds={props.elapsedSeconds} currentModel={props.currentModel} + teamMode={props.teamMode} /> void; vulnerabilityHuntingMode?: boolean; setVulnerabilityHuntingMode?: (value: boolean) => void; + teamMode?: boolean; + setTeamMode?: (value: boolean) => void; contextUsage?: { inputTokens: number; maxContextTokens: number; @@ -273,6 +275,8 @@ export default function ChatInput({ setPlanMode, vulnerabilityHuntingMode = false, setVulnerabilityHuntingMode, + teamMode = false, + setTeamMode, contextUsage, initialContent = null, draftContent = null, @@ -474,6 +478,8 @@ export default function ChatInput({ setPlanMode: setPlanMode || (() => {}), vulnerabilityHuntingMode, setVulnerabilityHuntingMode: setVulnerabilityHuntingMode || (() => {}), + teamMode, + setTeamMode: setTeamMode || (() => {}), showCommands, setShowCommands, commandSelectedIndex, diff --git a/source/ui/components/chat/LoadingIndicator.tsx b/source/ui/components/chat/LoadingIndicator.tsx index c3172bb3..2bfdfa8e 100644 --- a/source/ui/components/chat/LoadingIndicator.tsx +++ b/source/ui/components/chat/LoadingIndicator.tsx @@ -1,10 +1,14 @@ -import React from 'react'; +import React, {useSyncExternalStore} from 'react'; import {Box, Text} from 'ink'; import {useTheme} from '../../contexts/ThemeContext.js'; import {useI18n} from '../../../i18n/I18nContext.js'; import ShimmerText from '../common/ShimmerText.js'; import CodebaseSearchStatus from './CodebaseSearchStatus.js'; import {formatElapsedTime} from '../../../utils/core/textUtils.js'; +import { + subscribeTeammateStream, + getTeammateStreamSnapshot, +} from '../../../hooks/conversation/core/subAgentMessageHandler.js'; /** * 截断错误消息,避免过长显示 @@ -19,6 +23,11 @@ function truncateErrorMessage( return message.substring(0, maxLength) + '...'; } +function formatTokens(count: number): string { + if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; + return String(count); +} + type LoadingIndicatorProps = { isStreaming: boolean; isStopping: boolean; @@ -48,6 +57,7 @@ type LoadingIndicatorProps = { streamTokenCount: number; elapsedSeconds: number; currentModel?: string | null; + teamMode?: boolean; }; export default function LoadingIndicator({ @@ -65,11 +75,16 @@ export default function LoadingIndicator({ streamTokenCount, elapsedSeconds, currentModel, + teamMode, }: LoadingIndicatorProps) { const {theme} = useTheme(); const {t} = useI18n(); - // 不显示加载指示器的条件 + const teammateStream = useSyncExternalStore( + subscribeTeammateStream, + getTeammateStreamSnapshot, + ); + if ( (!isStreaming && !isSaving && !isStopping) || hasPendingToolConfirmation || @@ -79,6 +94,8 @@ export default function LoadingIndicator({ return null; } + const showTeamTree = teamMode && teammateStream.length > 0 && isStreaming; + return ( @@ -122,6 +139,61 @@ export default function LoadingIndicator({ ) : codebaseSearchStatus?.isSearching ? ( + ) : showTeamTree ? ( + + + + ({' '} + {currentModel && ( + <> + {currentModel} + {' · '} + + )} + {formatElapsedTime(elapsedSeconds)} + {' · '} + + ↓ {formatTokens(streamTokenCount)} tokens + + {')'} + + {teammateStream.map((tm, idx) => { + const isLast = idx === teammateStream.length - 1; + const branch = isLast ? '└─' : '├─'; + const status = tm.isReasoning + ? 'Thinking' + : tm.tokenCount > 0 + ? 'Writing' + : 'Idle'; + const statusColor = tm.isReasoning + ? '#FFD700' + : tm.tokenCount > 0 + ? '#00FFFF' + : theme.colors.menuSecondary; + return ( + + + {' '}{branch}{' '} + + + {tm.agentName} + + + {' '}({status} + {tm.tokenCount > 0 && ( + <> + {' · '} + + ↓ {formatTokens(tm.tokenCount)} + + + )} + ) + + + ); + })} + ) : ( ↓{' '} - {streamTokenCount >= 1000 - ? `${(streamTokenCount / 1000).toFixed(1)}k` - : streamTokenCount}{' '} + {formatTokens(streamTokenCount)}{' '} tokens diff --git a/source/ui/components/common/StatusLine.tsx b/source/ui/components/common/StatusLine.tsx index fb3e70a9..111cf8ff 100644 --- a/source/ui/components/common/StatusLine.tsx +++ b/source/ui/components/common/StatusLine.tsx @@ -209,6 +209,7 @@ type Props = { vulnerabilityHuntingMode?: boolean; toolSearchDisabled?: boolean; hybridCompressEnabled?: boolean; + teamMode?: boolean; // IDE连接信息 vscodeConnectionStatus?: VSCodeConnectionStatus; @@ -285,6 +286,7 @@ export default function StatusLine({ vulnerabilityHuntingMode = false, toolSearchDisabled = true, hybridCompressEnabled = false, + teamMode = false, vscodeConnectionStatus, editorContext, connectionStatus, @@ -335,6 +337,7 @@ export default function StatusLine({ vulnerabilityHunting: vulnerabilityHuntingMode, toolSearchEnabled: !toolSearchDisabled, hybridCompress: hybridCompressEnabled, + team: teamMode, simple: simpleMode, }, ide: { @@ -413,6 +416,7 @@ export default function StatusLine({ t.chatScreen.gitBranch, toolSearchDisabled, hybridCompressEnabled, + teamMode, vscodeConnectionStatus, vulnerabilityHuntingMode, watcherEnabled, @@ -493,6 +497,7 @@ export default function StatusLine({ yoloMode || planMode || vulnerabilityHuntingMode || + teamMode || !toolSearchDisabled || hybridCompressEnabled || (vscodeConnectionStatus && vscodeConnectionStatus !== 'disconnected') || @@ -548,6 +553,10 @@ export default function StatusLine({ }); } + if (teamMode) { + statusItems.push({text: '⚑ Team', color: '#10B981'}); + } + if (hybridCompressEnabled) { statusItems.push({ text: '⇌ Hybrid Compress', @@ -709,6 +718,14 @@ export default function StatusLine({ )} + {teamMode && ( + + + {t.chatScreen.teamModeActive} + + + )} + {hybridCompressEnabled && ( diff --git a/source/ui/pages/ChatScreen.tsx b/source/ui/pages/ChatScreen.tsx index 6af29c6c..a6690508 100644 --- a/source/ui/pages/ChatScreen.tsx +++ b/source/ui/pages/ChatScreen.tsx @@ -109,6 +109,8 @@ export default function ChatScreen({ setToolSearchDisabled, hybridCompressEnabled, setHybridCompressEnabled, + teamMode, + setTeamMode, simpleMode, showThinking, } = useChatScreenModes({enableYolo, enablePlan}); @@ -191,6 +193,7 @@ export default function ChatScreen({ yoloMode, planMode, vulnerabilityHuntingMode, + teamMode, toolSearchDisabled, saveMessage, clearSavedMessages, @@ -282,6 +285,7 @@ export default function ChatScreen({ setVulnerabilityHuntingMode, setToolSearchDisabled, setHybridCompressEnabled, + setTeamMode, setContextUsage: streamingState.setContextUsage, setCurrentContextPercentage, currentContextPercentageRef, @@ -514,6 +518,8 @@ export default function ChatScreen({ setVulnerabilityHuntingMode={setVulnerabilityHuntingMode} toolSearchDisabled={toolSearchDisabled} hybridCompressEnabled={hybridCompressEnabled} + teamMode={teamMode} + setTeamMode={setTeamMode} contextUsage={footerContextUsage} initialContent={restoreInputContent} draftContent={inputDraftContent} diff --git a/source/ui/pages/chatScreen/useChatScreenCommands.ts b/source/ui/pages/chatScreen/useChatScreenCommands.ts index 5825da95..6d4e9880 100644 --- a/source/ui/pages/chatScreen/useChatScreenCommands.ts +++ b/source/ui/pages/chatScreen/useChatScreenCommands.ts @@ -43,6 +43,7 @@ export function useChatScreenCommands(workingDirectory: string) { import('../../../utils/commands/autoformat.js'), import('../../../utils/commands/toolsearch.js'), import('../../../utils/commands/hybridCompress.js'), + import('../../../utils/commands/team.js'), ]) .then(async () => { await registerCustomCommands(workingDirectory); diff --git a/source/ui/pages/chatScreen/useChatScreenModes.ts b/source/ui/pages/chatScreen/useChatScreenModes.ts index 30161c7e..72a5fc2b 100644 --- a/source/ui/pages/chatScreen/useChatScreenModes.ts +++ b/source/ui/pages/chatScreen/useChatScreenModes.ts @@ -12,6 +12,8 @@ import { setVulnerabilityHuntingMode as persistVulnerabilityHuntingMode, getHybridCompressEnabled, setHybridCompressEnabled as persistHybridCompressEnabled, + getTeamMode, + setTeamMode as persistTeamMode, } from '../../../utils/config/projectSettings.js'; import {getSimpleMode} from '../../../utils/config/themeConfig.js'; @@ -44,6 +46,7 @@ export function useChatScreenModes({enableYolo, enablePlan}: Options) { const [hybridCompressEnabled, setHybridCompressEnabled] = useState( () => getHybridCompressEnabled(), ); + const [teamMode, setTeamMode] = useState(() => getTeamMode()); const [simpleMode, setSimpleMode] = useState(() => getSimpleMode()); const [showThinking, setShowThinking] = useState(() => { const config = getOpenAiConfig(); @@ -70,6 +73,10 @@ export function useChatScreenModes({enableYolo, enablePlan}: Options) { persistHybridCompressEnabled(hybridCompressEnabled); }, [hybridCompressEnabled]); + useEffect(() => { + persistTeamMode(teamMode); + }, [teamMode]); + useEffect(() => { const interval = setInterval(() => { const currentSimpleMode = getSimpleMode(); @@ -106,6 +113,8 @@ export function useChatScreenModes({enableYolo, enablePlan}: Options) { setToolSearchDisabled, hybridCompressEnabled, setHybridCompressEnabled, + teamMode, + setTeamMode, simpleMode, showThinking, }; diff --git a/source/utils/commands/team.ts b/source/utils/commands/team.ts new file mode 100644 index 00000000..0b7899ab --- /dev/null +++ b/source/utils/commands/team.ts @@ -0,0 +1,13 @@ +import {registerCommand, type CommandResult} from '../execution/commandExecutor.js'; + +registerCommand('team', { + execute: (): CommandResult => { + return { + success: true, + action: 'toggleTeam', + message: 'Toggling Team mode', + }; + }, +}); + +export default {}; diff --git a/source/utils/config/projectSettings.ts b/source/utils/config/projectSettings.ts index 4b6eb7b8..0110461e 100644 --- a/source/utils/config/projectSettings.ts +++ b/source/utils/config/projectSettings.ts @@ -11,6 +11,7 @@ export interface ProjectSettings { planMode?: boolean; vulnerabilityHuntingMode?: boolean; hybridCompressEnabled?: boolean; + teamMode?: boolean; } const PROJECT_SNOW_DIR = path.join(process.cwd(), '.snow'); @@ -157,3 +158,14 @@ export function setHybridCompressEnabled(enabled: boolean): void { settings.hybridCompressEnabled = enabled; saveSettings(settings); } + +export function getTeamMode(): boolean { + const settings = loadSettings(); + return settings.teamMode ?? false; +} + +export function setTeamMode(enabled: boolean): void { + const settings = loadSettings(); + settings.teamMode = enabled; + saveSettings(settings); +} diff --git a/source/utils/execution/commandExecutor.ts b/source/utils/execution/commandExecutor.ts index 5d36ee94..3e600a1f 100644 --- a/source/utils/execution/commandExecutor.ts +++ b/source/utils/execution/commandExecutor.ts @@ -43,6 +43,7 @@ export interface CommandResult { | 'copyLastMessage' | 'toggleCodebase' | 'toggleHybridCompress' + | 'toggleTeam' | 'showBranchPanel' | 'showDiffReviewPanel' | 'showConnectionPanel' diff --git a/source/utils/execution/mcpToolsManager.ts b/source/utils/execution/mcpToolsManager.ts index 1c3c9075..cc63029e 100644 --- a/source/utils/execution/mcpToolsManager.ts +++ b/source/utils/execution/mcpToolsManager.ts @@ -21,6 +21,10 @@ import { getMCPTools as getSubAgentTools, subAgentService, } from '../../mcp/subagent.js'; +import { + getTeamMCPTools as getTeamTools, + teamService, +} from '../../mcp/team.js'; import { getMCPTools as getSkillTools, executeSkillTool, @@ -338,6 +342,35 @@ async function refreshToolsCache(): Promise { } } + // Add team tools (for Agent Team mode) + const {getTeamMode} = await import('../config/projectSettings.js'); + if (getTeamMode()) { + const teamTools = getTeamTools(); + if (teamTools.length > 0) { + const teamEnabled = isBuiltInServiceEnabled('team'); + servicesInfo.push({ + serviceName: 'team', + tools: teamTools, + isBuiltIn: true, + connected: true, + enabled: teamEnabled !== false, + }); + + if (teamEnabled !== false) { + for (const tool of teamTools) { + allTools.push({ + type: 'function', + function: { + name: `team-${tool.name}`, + description: tool.description, + parameters: tool.inputSchema, + }, + }); + } + } + } + } + // Add skill tools (dynamically generated from available skills) const projectRoot = process.cwd(); const skillTools = await getSkillTools(projectRoot); @@ -513,7 +546,8 @@ export async function reconnectMCPService(serviceName: string): Promise { serviceName === 'codebase' || serviceName === 'askuser' || serviceName === 'scheduler' || - serviceName === 'subagent' + serviceName === 'subagent' || + serviceName === 'team' ) { return; } @@ -1145,6 +1179,9 @@ export async function executeMCPTool( } else if (toolName.startsWith('subagent-')) { serviceName = 'subagent'; actualToolName = toolName.substring('subagent-'.length); + } else if (toolName.startsWith('team-')) { + serviceName = 'team'; + actualToolName = toolName.substring('team-'.length); } else { // Check configured MCP services try { @@ -1712,6 +1749,13 @@ export async function executeMCPTool( prompt: args.prompt, abortSignal, }); + } else if (serviceName === 'team') { + // Handle team tools + result = await teamService.execute({ + toolName: actualToolName, + args, + abortSignal, + }); } else { // Handle user-configured MCP service tools - connect only when needed const mcpConfig = getMCPConfig(); diff --git a/source/utils/execution/teamExecutor.ts b/source/utils/execution/teamExecutor.ts new file mode 100644 index 00000000..f581b0c9 --- /dev/null +++ b/source/utils/execution/teamExecutor.ts @@ -0,0 +1,738 @@ +/** + * Team Executor + * Executes teammate sessions in an Agent Team. + * Based on executeSubAgent but with key differences: + * - Each teammate runs in its own Git worktree + * - Full tool access (not restricted like subagents) + * - Team-specific synthetic tools (message, task management) + * - Team-aware context (task list, other teammates) + */ + +import type {ChatMessage} from '../../api/chat.js'; +import type {MCPTool} from './mcpToolsManager.js'; +import {teamTracker} from './teamTracker.js'; +import type {SubAgentMessage, TokenUsage} from './subAgentExecutor.js'; + +export interface TeammateExecutionOptions { + onMessage?: (message: SubAgentMessage) => void; + abortSignal?: AbortSignal; + requestToolConfirmation?: ( + toolName: string, + toolArgs: any, + ) => Promise; + isToolAutoApproved?: (toolName: string) => boolean; + yoloMode?: boolean; + addToAlwaysApproved?: (toolName: string) => void; + requestUserQuestion?: ( + question: string, + options: string[], + multiSelect?: boolean, + ) => Promise<{selected: string | string[]; customInput?: string}>; + requirePlanApproval?: boolean; +} + +export interface TeammateExecutionResult { + success: boolean; + result: string; + error?: string; + usage?: TokenUsage; +} + +export async function executeTeammate( + memberId: string, + memberName: string, + prompt: string, + worktreePath: string, + teamName: string, + role: string | undefined, + options: TeammateExecutionOptions, +): Promise { + const { + onMessage, + abortSignal, + requestToolConfirmation, + isToolAutoApproved, + yoloMode, + addToAlwaysApproved, + requirePlanApproval, + } = options; + + const instanceId = `teammate-${memberId}-${Date.now()}`; + + // Register with team tracker + teamTracker.register({ + instanceId, + memberId, + memberName, + role, + worktreePath, + teamName, + prompt, + startedAt: new Date(), + }); + + // Update team config member status + const {updateMember} = await import('../team/teamConfig.js'); + updateMember(teamName, memberId, {instanceId, status: 'active'}); + + try { + const {collectAllMCPTools} = await import('./mcpToolsManager.js'); + const {executeMCPTool} = await import('./mcpToolsManager.js'); + const {getOpenAiConfig} = await import('../config/apiConfig.js'); + const {sessionManager} = await import('../session/sessionManager.js'); + const {createStreamingChatCompletion} = await import( + '../../api/chat.js' + ); + const {createStreamingAnthropicCompletion} = await import( + // @ts-ignore - generated at build time + '../../api/anthropic.js' + ); + const {createStreamingGeminiCompletion} = await import( + '../../api/gemini.js' + ); + const {createStreamingResponse} = await import( + '../../api/responses.js' + ); + const { + shouldCompressSubAgentContext, + compressSubAgentContext, + } = await import('../core/subAgentContextCompressor.js'); + const {listTasks, claimTask, completeTask} = await import( + '../team/teamTaskList.js' + ); + + // Collect all MCP tools (full access for teammates) + const allMCPTools = await collectAllMCPTools(); + const allowedTools: MCPTool[] = [...allMCPTools]; + + // Build teammate-specific synthetic tools + const messageTeammateTool: MCPTool = { + type: 'function' as const, + function: { + name: 'message_teammate', + description: + 'Send a message to another teammate or the team lead. Use to share findings, coordinate work, or request help.', + parameters: { + type: 'object', + properties: { + target: { + type: 'string', + description: + 'The name or member ID of the target teammate, or "lead" to message the team lead.', + }, + content: { + type: 'string', + description: 'The message content to send.', + }, + }, + required: ['target', 'content'], + }, + }, + }; + + const claimTaskTool: MCPTool = { + type: 'function' as const, + function: { + name: 'claim_task', + description: + 'Claim a pending task from the shared task list. The task must be pending and have no unresolved dependencies.', + parameters: { + type: 'object', + properties: { + task_id: { + type: 'string', + description: 'The ID of the task to claim.', + }, + }, + required: ['task_id'], + }, + }, + }; + + const completeTaskTool: MCPTool = { + type: 'function' as const, + function: { + name: 'complete_task', + description: + 'Mark a task as completed after finishing the work.', + parameters: { + type: 'object', + properties: { + task_id: { + type: 'string', + description: 'The ID of the task to mark as completed.', + }, + }, + required: ['task_id'], + }, + }, + }; + + const listTasksTool: MCPTool = { + type: 'function' as const, + function: { + name: 'list_team_tasks', + description: + 'View all tasks in the shared task list with their status, assignees, and dependencies.', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + }, + }; + + const requestPlanApprovalTool: MCPTool = { + type: 'function' as const, + function: { + name: 'request_plan_approval', + description: + 'Submit your implementation plan to the team lead for review and approval. Required when the lead specified plan approval for this teammate.', + parameters: { + type: 'object', + properties: { + plan: { + type: 'string', + description: + 'Your detailed implementation plan in markdown format.', + }, + }, + required: ['plan'], + }, + }, + }; + + const shutdownSelfTool: MCPTool = { + type: 'function' as const, + function: { + name: 'shutdown_self', + description: + 'Gracefully shut down this teammate session. Use when all assigned tasks are complete.', + parameters: { + type: 'object', + properties: { + summary: { + type: 'string', + description: 'Brief summary of completed work.', + }, + }, + required: ['summary'], + }, + }, + }; + + allowedTools.push( + messageTeammateTool, + claimTaskTool, + completeTaskTool, + listTasksTool, + shutdownSelfTool, + ); + if (requirePlanApproval) { + allowedTools.push(requestPlanApprovalTool); + } + + // Build initial prompt with team context + const otherTeammates = teamTracker + .getRunningTeammates() + .filter(t => t.instanceId !== instanceId); + + const tasks = listTasks(teamName); + let teamContext = `\n\n## Team Context +You are teammate "${memberName}" in team "${teamName}". +Your working directory (Git worktree): ${worktreePath} +${role ? `Your role: ${role}` : ''} + +### Other Teammates`; + + if (otherTeammates.length > 0) { + teamContext += '\n' + otherTeammates + .map(t => `- ${t.memberName}${t.role ? ` (${t.role})` : ''} [${t.instanceId}]`) + .join('\n'); + } else { + teamContext += '\nNo other teammates are currently active.'; + } + + teamContext += '\n\n### Shared Task List'; + if (tasks.length > 0) { + teamContext += '\n' + tasks + .map(t => { + const deps = t.dependencies?.length + ? ` (depends on: ${t.dependencies.join(', ')})` + : ''; + const assignee = t.assigneeName ? ` [assigned to: ${t.assigneeName}]` : ''; + return `- [${t.status}] ${t.id}: ${t.title}${deps}${assignee}`; + }) + .join('\n'); + } else { + teamContext += '\nNo tasks defined yet.'; + } + + teamContext += `\n\n### Available Tools +- \`message_teammate\`: Send a message to another teammate or the lead +- \`claim_task\`: Claim a pending task from the task list +- \`complete_task\`: Mark a task as completed +- \`list_team_tasks\`: View the current task list +- \`shutdown_self\`: Shut down when all work is done`; + + if (requirePlanApproval) { + teamContext += `\n- \`request_plan_approval\`: Submit your plan to the lead for approval (REQUIRED before making changes)`; + teamContext += `\n\n**IMPORTANT**: You are in plan-approval mode. You must submit your plan via \`request_plan_approval\` and wait for approval before making any file changes.`; + } + + const finalPrompt = `${prompt}${teamContext}`; + + const messages: ChatMessage[] = [ + {role: 'user', content: finalPrompt}, + ]; + + let finalResponse = ''; + let totalUsage: TokenUsage | undefined; + let latestTotalTokens = 0; + let planApproved = !requirePlanApproval; // Skip approval if not required + + // eslint-disable-next-line no-constant-condition + while (true) { + if (abortSignal?.aborted) { + return { + success: false, + result: finalResponse, + error: 'Teammate execution aborted', + }; + } + + // Dequeue messages from lead or other teammates + const teammateMessages = teamTracker.dequeueTeammateMessages(instanceId); + for (const msg of teammateMessages) { + messages.push({ + role: 'user', + content: `[Message from ${msg.fromMemberName}]\n${msg.content}`, + }); + + if (onMessage) { + onMessage({ + type: 'sub_agent_message', + agentId: `teammate-${memberId}`, + agentName: memberName, + message: { + type: 'inter_agent_received', + fromAgentId: msg.fromMemberId, + fromAgentName: msg.fromMemberName, + content: msg.content, + }, + }); + } + } + + // API call + const config = getOpenAiConfig(); + const model = config.advancedModel || 'gpt-5'; + const currentSession = sessionManager.getCurrentSession(); + + const stream = + config.requestMethod === 'anthropic' + ? createStreamingAnthropicCompletion( + {model, messages, temperature: 0, max_tokens: config.maxTokens || 4096, tools: allowedTools, sessionId: currentSession?.id}, + abortSignal, + ) + : config.requestMethod === 'gemini' + ? createStreamingGeminiCompletion( + {model, messages, temperature: 0, tools: allowedTools}, + abortSignal, + ) + : config.requestMethod === 'responses' + ? createStreamingResponse( + {model, messages, temperature: 0, tools: allowedTools, prompt_cache_key: currentSession?.id}, + abortSignal, + ) + : createStreamingChatCompletion( + {model, messages, temperature: 0, tools: allowedTools}, + abortSignal, + ); + + let currentContent = ''; + let toolCalls: any[] = []; + let currentThinking: {type: 'thinking'; thinking: string; signature?: string} | undefined; + let currentReasoningContent: string | undefined; + let currentReasoning: {summary?: any; content?: any; encrypted_content?: string} | undefined; + + for await (const event of stream) { + if (onMessage) { + onMessage({ + type: 'sub_agent_message', + agentId: `teammate-${memberId}`, + agentName: memberName, + message: event, + }); + } + + if (event.type === 'usage' && event.usage) { + const eu = event.usage; + latestTotalTokens = eu.total_tokens || (eu.prompt_tokens || 0) + (eu.completion_tokens || 0); + + if (!totalUsage) { + totalUsage = { + inputTokens: eu.prompt_tokens || 0, + outputTokens: eu.completion_tokens || 0, + cacheCreationInputTokens: eu.cache_creation_input_tokens, + cacheReadInputTokens: eu.cache_read_input_tokens, + }; + } else { + totalUsage.inputTokens += eu.prompt_tokens || 0; + totalUsage.outputTokens += eu.completion_tokens || 0; + } + } + + if (event.type === 'content' && event.content) { + currentContent += event.content; + } else if (event.type === 'tool_calls' && event.tool_calls) { + toolCalls = event.tool_calls; + } else if (event.type === 'reasoning_data' && 'reasoning' in event) { + currentReasoning = event.reasoning as typeof currentReasoning; + } else if (event.type === 'done') { + if ('thinking' in event && event.thinking) { + currentThinking = event.thinking as typeof currentThinking; + } + if ('reasoning_content' in event && event.reasoning_content) { + currentReasoningContent = event.reasoning_content as string; + } + } + } + + // Build assistant message + if (currentContent || toolCalls.length > 0) { + const assistantMessage: ChatMessage = { + role: 'assistant', + content: currentContent || '', + }; + if (currentThinking) assistantMessage.thinking = currentThinking; + if (currentReasoningContent) (assistantMessage as any).reasoning_content = currentReasoningContent; + if (currentReasoning) (assistantMessage as any).reasoning = currentReasoning; + if (toolCalls.length > 0) assistantMessage.tool_calls = toolCalls; + messages.push(assistantMessage); + finalResponse = currentContent; + } + + // Context compression + let justCompressed = false; + if (latestTotalTokens > 0 && config.maxContextTokens) { + if (shouldCompressSubAgentContext(latestTotalTokens, config.maxContextTokens)) { + try { + const compressionResult = await compressSubAgentContext( + messages, latestTotalTokens, config.maxContextTokens, + {model, requestMethod: config.requestMethod, maxTokens: config.maxTokens}, + ); + if (compressionResult.compressed) { + messages.length = 0; + messages.push(...compressionResult.messages); + justCompressed = true; + if (compressionResult.afterTokensEstimate) { + latestTotalTokens = compressionResult.afterTokensEstimate; + } + } + } catch { + // Continue without compression + } + } + } + + if (justCompressed && toolCalls.length === 0) { + while (messages.length > 0 && messages[messages.length - 1]?.role === 'assistant') { + messages.pop(); + } + messages.push({ + role: 'user', + content: '[System] Context has been auto-compressed. Your task is NOT finished. Continue working.', + }); + continue; + } + + // No tool calls = done + if (toolCalls.length === 0) { + break; + } + + // Handle synthetic team tools internally + const syntheticToolNames = new Set([ + 'message_teammate', 'claim_task', 'complete_task', + 'list_team_tasks', 'request_plan_approval', 'shutdown_self', + ]); + + const syntheticCalls = toolCalls.filter(tc => syntheticToolNames.has(tc.function.name)); + const regularCalls = toolCalls.filter(tc => !syntheticToolNames.has(tc.function.name)); + + // Process synthetic tools + let shouldShutdown = false; + for (const tc of syntheticCalls) { + let args: any = {}; + try { + args = JSON.parse(tc.function.arguments); + } catch { /* empty */ } + + let resultContent = ''; + + switch (tc.function.name) { + case 'message_teammate': { + const target = args.target as string; + const content = args.content as string; + + if (target === 'lead' || target === 'Team Lead') { + const sent = teamTracker.sendMessageToLead(instanceId, content); + resultContent = sent + ? 'Message sent to team lead.' + : 'Failed to send message to team lead.'; + } else { + // Find teammate by name or ID + let targetTeammate = teamTracker.findByMemberName(target) + || teamTracker.findByMemberId(target); + + if (targetTeammate) { + const sent = teamTracker.sendMessageToTeammate( + instanceId, targetTeammate.instanceId, content, + ); + resultContent = sent + ? `Message sent to ${targetTeammate.memberName}.` + : `Failed to send message to ${target}.`; + } else { + resultContent = `Teammate "${target}" not found. Use list_team_tasks to see current teammates.`; + } + } + break; + } + + case 'claim_task': { + try { + const task = claimTask(teamName, args.task_id, memberId, memberName); + if (task) { + teamTracker.setCurrentTask(instanceId, task.id); + resultContent = `Successfully claimed task "${task.title}" (${task.id}).`; + } else { + resultContent = `Task "${args.task_id}" not found.`; + } + } catch (e: any) { + resultContent = `Failed to claim task: ${e.message}`; + } + break; + } + + case 'complete_task': { + try { + const task = completeTask(teamName, args.task_id); + if (task) { + teamTracker.setCurrentTask(instanceId, undefined); + teamTracker.sendMessageToLead( + instanceId, + `Task completed: "${task.title}" (${task.id})`, + ); + resultContent = `Task "${task.title}" marked as completed.`; + } else { + resultContent = `Task "${args.task_id}" not found.`; + } + } catch (e: any) { + resultContent = `Failed to complete task: ${e.message}`; + } + break; + } + + case 'list_team_tasks': { + const currentTasks = listTasks(teamName); + if (currentTasks.length === 0) { + resultContent = 'No tasks in the task list.'; + } else { + resultContent = currentTasks + .map(t => { + const deps = t.dependencies?.length ? ` (deps: ${t.dependencies.join(', ')})` : ''; + const assignee = t.assigneeName ? ` [${t.assigneeName}]` : ''; + return `[${t.status}] ${t.id}: ${t.title}${assignee}${deps}`; + }) + .join('\n'); + } + break; + } + + case 'request_plan_approval': { + teamTracker.requestPlanApproval(instanceId, args.plan); + resultContent = 'Plan submitted for approval. Waiting for lead response...'; + break; + } + + case 'shutdown_self': { + const summary = args.summary || 'No summary provided.'; + teamTracker.sendMessageToLead( + instanceId, + `[Shutdown] ${memberName} is shutting down. Summary: ${summary}`, + ); + shouldShutdown = true; + resultContent = 'Shutdown acknowledged. Finishing current work.'; + break; + } + } + + messages.push({ + role: 'tool' as const, + tool_call_id: tc.id, + content: resultContent, + }); + } + + if (shouldShutdown && regularCalls.length === 0) { + break; + } + + // Process regular MCP tool calls + if (regularCalls.length > 0) { + // Plan approval gate: block file-modifying tools until approved + if (!planApproved) { + const blockedTools = regularCalls.filter(tc => { + const name = tc.function.name; + return name.includes('write') || name.includes('create') || + name.includes('delete') || name.includes('execute') || + name.includes('bash') || name.includes('terminal'); + }); + + if (blockedTools.length > 0) { + for (const tc of blockedTools) { + messages.push({ + role: 'tool' as const, + tool_call_id: tc.id, + content: 'Error: Plan approval required before making changes. Use request_plan_approval first.', + }); + } + // Only execute non-blocked regular calls + const nonBlockedCalls = regularCalls.filter(tc => !blockedTools.includes(tc)); + if (nonBlockedCalls.length === 0 && syntheticCalls.length > 0) { + continue; + } + // Fall through to execute non-blocked calls + for (const tc of nonBlockedCalls) { + try { + const toolArgs = JSON.parse(tc.function.arguments || '{}'); + const result = await executeMCPTool(tc.function.name, toolArgs, abortSignal); + messages.push({ + role: 'tool' as const, + tool_call_id: tc.id, + content: typeof result === 'string' ? result : JSON.stringify(result), + }); + } catch (e: any) { + messages.push({ + role: 'tool' as const, + tool_call_id: tc.id, + content: `Error: ${e.message}`, + }); + } + } + continue; + } + } + + for (const tc of regularCalls) { + const toolName = tc.function.name; + let toolArgs: any = {}; + try { + toolArgs = JSON.parse(tc.function.arguments || '{}'); + } catch { /* empty */ } + + let approved = yoloMode || false; + if (!approved && isToolAutoApproved) { + approved = isToolAutoApproved(toolName); + } + if (!approved && requestToolConfirmation) { + const confirmResult = await requestToolConfirmation(toolName, toolArgs); + if (confirmResult === 'approve' || confirmResult === 'approve_always') { + approved = true; + if (confirmResult === 'approve_always' && addToAlwaysApproved) { + addToAlwaysApproved(toolName); + } + } else { + const feedback = typeof confirmResult === 'object' && confirmResult.type === 'reject_with_reply' + ? confirmResult.reason + : 'Tool execution denied by user.'; + messages.push({ + role: 'tool' as const, + tool_call_id: tc.id, + content: feedback, + }); + continue; + } + } else { + approved = true; + } + + if (approved) { + try { + const result = await executeMCPTool(toolName, toolArgs, abortSignal); + messages.push({ + role: 'tool' as const, + tool_call_id: tc.id, + content: typeof result === 'string' ? result : JSON.stringify(result), + }); + } catch (e: any) { + messages.push({ + role: 'tool' as const, + tool_call_id: tc.id, + content: `Error: ${e.message}`, + }); + } + } + } + } + + // If plan approval was requested and approved, mark it + const approvalCheck = teamTracker.getPendingApprovals() + .find(a => a.fromInstanceId === instanceId && a.status === 'approved'); + if (approvalCheck) { + planApproved = true; + } + } + + // Notify lead that this teammate is done + teamTracker.storeResult({ + instanceId, + memberId, + memberName, + success: true, + result: finalResponse, + completedAt: new Date(), + }); + + if (onMessage) { + onMessage({ + type: 'sub_agent_message', + agentId: `teammate-${memberId}`, + agentName: memberName, + message: {type: 'done'}, + }); + } + + return { + success: true, + result: finalResponse, + usage: totalUsage, + }; + } catch (error: any) { + teamTracker.storeResult({ + instanceId, + memberId, + memberName, + success: false, + result: '', + error: error.message, + completedAt: new Date(), + }); + + return { + success: false, + result: '', + error: error.message, + }; + } finally { + // Auto-commit any uncommitted work before unregistering + try { + const {autoCommitWorktreeChanges} = await import('../team/teamWorktree.js'); + autoCommitWorktreeChanges(worktreePath, memberName); + } catch { /* best effort */ } + + updateMember(teamName, memberId, {status: 'shutdown', shutdownAt: new Date().toISOString()}); + teamTracker.unregister(instanceId); + } +} diff --git a/source/utils/execution/teamTracker.ts b/source/utils/execution/teamTracker.ts new file mode 100644 index 00000000..d0aa0810 --- /dev/null +++ b/source/utils/execution/teamTracker.ts @@ -0,0 +1,426 @@ +/** + * Team Tracker + * Tracks running teammates in an Agent Team session. + * Provides message routing (direct + broadcast), plan approval queue, + * and task status integration. + */ + +export interface TeammateMessage { + fromInstanceId: string; + fromMemberId: string; + fromMemberName: string; + content: string; + sentAt: Date; +} + +export interface RunningTeammate { + instanceId: string; + memberId: string; + memberName: string; + role?: string; + worktreePath: string; + teamName: string; + prompt: string; + startedAt: Date; + currentTaskId?: string; +} + +export interface TeammateResult { + instanceId: string; + memberId: string; + memberName: string; + success: boolean; + result: string; + error?: string; + completedAt: Date; +} + +export interface PlanApprovalRequest { + fromInstanceId: string; + fromMemberId: string; + fromMemberName: string; + plan: string; + requestedAt: Date; + status: 'pending' | 'approved' | 'rejected'; + feedback?: string; +} + +export interface TeammateMessageEvent { + from: RunningTeammate; + to: RunningTeammate | 'lead'; + message: TeammateMessage; + isBroadcast: boolean; +} + +type Listener = () => void; +type MessageListener = (event: TeammateMessageEvent) => void; + +class TeamTracker { + private teammates: Map = new Map(); + private listeners: Set = new Set(); + private cachedSnapshot: RunningTeammate[] = []; + + /** Messages from teammates → lead */ + private leadMessageQueue: TeammateMessage[] = []; + + /** Messages from lead/teammates → specific teammate */ + private teammateMessageQueues: Map = new Map(); + + /** Completed teammate results awaiting lead consumption */ + private completedResults: TeammateResult[] = []; + + /** Plan approval requests from teammates */ + private planApprovals: PlanApprovalRequest[] = []; + + private messageListeners: Set = new Set(); + + /** Active team name (only one team at a time) */ + private activeTeamName: string | null = null; + + // ── Team lifecycle ── + + setActiveTeam(teamName: string): void { + this.activeTeamName = teamName; + } + + getActiveTeamName(): string | null { + return this.activeTeamName; + } + + clearActiveTeam(): void { + this.activeTeamName = null; + this.clear(); + } + + // ── Teammate registration ── + + register(teammate: RunningTeammate): void { + this.teammates.set(teammate.instanceId, teammate); + this.teammateMessageQueues.set(teammate.instanceId, []); + this.rebuildSnapshot(); + this.notifyListeners(); + } + + unregister(instanceId: string): void { + if (this.teammates.delete(instanceId)) { + this.teammateMessageQueues.delete(instanceId); + this.rebuildSnapshot(); + this.notifyListeners(); + } + } + + getRunningTeammates(): RunningTeammate[] { + return this.cachedSnapshot; + } + + getCount(): number { + return this.teammates.size; + } + + isRunning(instanceId: string): boolean { + return this.teammates.has(instanceId); + } + + getTeammate(instanceId: string): RunningTeammate | undefined { + return this.teammates.get(instanceId); + } + + findByMemberId(memberId: string): RunningTeammate | undefined { + for (const t of this.teammates.values()) { + if (t.memberId === memberId) return t; + } + return undefined; + } + + findByMemberName(memberName: string): RunningTeammate | undefined { + const lowerName = memberName.toLowerCase(); + for (const t of this.teammates.values()) { + if (t.memberName.toLowerCase() === lowerName) return t; + } + return undefined; + } + + // ── Messaging: teammate → lead ── + + sendMessageToLead( + fromInstanceId: string, + content: string, + ): boolean { + const from = this.teammates.get(fromInstanceId); + if (!from) return false; + + const message: TeammateMessage = { + fromInstanceId, + fromMemberId: from.memberId, + fromMemberName: from.memberName, + content, + sentAt: new Date(), + }; + this.leadMessageQueue.push(message); + + this.notifyMessageListeners({ + from, + to: 'lead', + message, + isBroadcast: false, + }); + return true; + } + + dequeueLeadMessages(): TeammateMessage[] { + if (this.leadMessageQueue.length === 0) return []; + const messages = [...this.leadMessageQueue]; + this.leadMessageQueue.length = 0; + return messages; + } + + // ── Messaging: lead/teammate → teammate ── + + sendMessageToTeammate( + fromInstanceId: string | 'lead', + targetInstanceId: string, + content: string, + ): boolean { + const queue = this.teammateMessageQueues.get(targetInstanceId); + if (!queue) return false; + + const from = fromInstanceId === 'lead' + ? null + : this.teammates.get(fromInstanceId); + + const message: TeammateMessage = { + fromInstanceId: fromInstanceId === 'lead' ? 'lead' : fromInstanceId, + fromMemberId: from?.memberId || 'lead', + fromMemberName: from?.memberName || 'Team Lead', + content, + sentAt: new Date(), + }; + queue.push(message); + + const target = this.teammates.get(targetInstanceId); + if (target) { + this.notifyMessageListeners({ + from: from || ({instanceId: 'lead', memberId: 'lead', memberName: 'Team Lead'} as RunningTeammate), + to: target, + message, + isBroadcast: false, + }); + } + return true; + } + + dequeueTeammateMessages(instanceId: string): TeammateMessage[] { + const queue = this.teammateMessageQueues.get(instanceId); + if (!queue || queue.length === 0) return []; + const messages = [...queue]; + queue.length = 0; + return messages; + } + + // ── Broadcast: lead → all teammates ── + + broadcastToTeammates( + fromInstanceId: string | 'lead', + content: string, + ): number { + let count = 0; + for (const instanceId of this.teammates.keys()) { + if (instanceId !== fromInstanceId) { + this.sendMessageToTeammate(fromInstanceId, instanceId, content); + count++; + } + } + return count; + } + + // ── Completed results ── + + storeResult(result: TeammateResult): void { + this.completedResults.push(result); + this.notifyListeners(); + } + + drainResults(): TeammateResult[] { + if (this.completedResults.length === 0) return []; + const results = [...this.completedResults]; + this.completedResults.length = 0; + return results; + } + + hasResults(): boolean { + return this.completedResults.length > 0; + } + + // ── Plan approval ── + + requestPlanApproval( + fromInstanceId: string, + plan: string, + ): boolean { + const from = this.teammates.get(fromInstanceId); + if (!from) return false; + + this.planApprovals.push({ + fromInstanceId, + fromMemberId: from.memberId, + fromMemberName: from.memberName, + plan, + requestedAt: new Date(), + status: 'pending', + }); + + this.sendMessageToLead(fromInstanceId, `[Plan Approval Request]\n${plan}`); + return true; + } + + getPendingApprovals(): PlanApprovalRequest[] { + return this.planApprovals.filter(a => a.status === 'pending'); + } + + resolvePlanApproval( + fromInstanceId: string, + approved: boolean, + feedback?: string, + ): boolean { + const approval = this.planApprovals.find( + a => a.fromInstanceId === fromInstanceId && a.status === 'pending', + ); + if (!approval) return false; + + approval.status = approved ? 'approved' : 'rejected'; + approval.feedback = feedback; + + const content = approved + ? `Your plan has been approved.${feedback ? ` Feedback: ${feedback}` : ''}` + : `Your plan has been rejected.${feedback ? ` Feedback: ${feedback}` : ' Please revise and resubmit.'}`; + + this.sendMessageToTeammate('lead', fromInstanceId, content); + return true; + } + + // ── Wait for all teammates ── + + waitForAllTeammates( + timeoutMs = 600_000, + abortSignal?: AbortSignal, + ): Promise { + return new Promise(resolve => { + if (this.teammates.size === 0) { + resolve(true); + return; + } + + const startTime = Date.now(); + let unsubscribe: (() => void) | undefined; + + const checkDone = () => { + if (abortSignal?.aborted) { + cleanup(); + resolve(false); + return; + } + if (this.teammates.size === 0) { + cleanup(); + resolve(true); + return; + } + if (Date.now() - startTime > timeoutMs) { + cleanup(); + resolve(false); + return; + } + }; + + const cleanup = () => { + if (unsubscribe) { + unsubscribe(); + unsubscribe = undefined; + } + }; + + unsubscribe = this.subscribe(() => { + checkDone(); + }); + + if (abortSignal) { + abortSignal.addEventListener('abort', () => { + cleanup(); + resolve(false); + }, {once: true}); + } + + checkDone(); + }); + } + + // ── Task tracking ── + + setCurrentTask(instanceId: string, taskId: string | undefined): void { + const teammate = this.teammates.get(instanceId); + if (teammate) { + teammate.currentTaskId = taskId; + } + } + + // ── Subscription ── + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + onMessage(listener: MessageListener): () => void { + this.messageListeners.add(listener); + return () => { + this.messageListeners.delete(listener); + }; + } + + // ── Cleanup ── + + clear(): void { + if ( + this.teammates.size > 0 || + this.completedResults.length > 0 || + this.leadMessageQueue.length > 0 + ) { + this.teammates.clear(); + this.teammateMessageQueues.clear(); + this.leadMessageQueue.length = 0; + this.completedResults.length = 0; + this.planApprovals.length = 0; + this.rebuildSnapshot(); + this.notifyListeners(); + } + } + + // ── Internal ── + + private rebuildSnapshot(): void { + this.cachedSnapshot = Array.from(this.teammates.values()); + } + + private notifyListeners(): void { + for (const listener of this.listeners) { + try { + listener(); + } catch { + // Ignore listener errors + } + } + } + + private notifyMessageListeners(event: TeammateMessageEvent): void { + for (const listener of this.messageListeners) { + try { + listener(event); + } catch { + // Ignore listener errors + } + } + } +} + +export const teamTracker = new TeamTracker(); diff --git a/source/utils/execution/toolExecutor.ts b/source/utils/execution/toolExecutor.ts index 523faa96..3fcd08af 100644 --- a/source/utils/execution/toolExecutor.ts +++ b/source/utils/execution/toolExecutor.ts @@ -1,6 +1,8 @@ import {executeMCPTool} from './mcpToolsManager.js'; import {subAgentService} from '../../mcp/subagent.js'; +import {teamService} from '../../mcp/team.js'; import {runningSubAgentTracker} from './runningSubAgentTracker.js'; + import type {SubAgentMessage} from './subAgentExecutor.js'; import type {ConfirmationResult} from '../../ui/components/tools/ToolConfirmation.js'; import type {ImageContent} from '../../api/types.js'; @@ -305,8 +307,55 @@ export async function executeToolCall( console.warn('Failed to execute beforeToolCall hook:', error); } + // Check if this is a team tool + if (toolCall.function.name.startsWith('team-')) { + const teamToolName = toolCall.function.name.substring('team-'.length); + const teamArgs = args as Record; + + try { + const teamResult = await teamService.execute({ + toolName: teamToolName, + args: teamArgs, + onMessage: onSubAgentMessage, + abortSignal, + requestToolConfirmation: requestToolConfirmation + ? async (toolName: string, toolArgs: any) => { + const fakeToolCall = { + id: 'team-tool', + type: 'function' as const, + function: {name: toolName, arguments: JSON.stringify(toolArgs)}, + }; + return await requestToolConfirmation(fakeToolCall); + } + : undefined, + isToolAutoApproved, + yoloMode, + addToAlwaysApproved: addToAlwaysApproved + ? (name: string) => addToAlwaysApproved(name) + : undefined, + requestUserQuestion: onUserInteractionNeeded + ? async (q: string, opts: string[], multi?: boolean) => { + const r = await onUserInteractionNeeded(q, opts, multi); + return {selected: r.selected, customInput: r.customInput}; + } + : undefined, + }); + + result = { + tool_call_id: toolCall.id, + role: 'tool', + content: JSON.stringify(teamResult), + }; + } catch (error: any) { + result = { + tool_call_id: toolCall.id, + role: 'tool', + content: JSON.stringify({success: false, error: error.message}), + }; + } + } // Check if this is a sub-agent tool - if (toolCall.function.name.startsWith('subagent-')) { + else if (toolCall.function.name.startsWith('subagent-')) { const agentId = toolCall.function.name.substring('subagent-'.length); const subAgentPrompt = (args['prompt'] as string) || ''; diff --git a/source/utils/index.ts b/source/utils/index.ts index 957e4381..f984ae53 100644 --- a/source/utils/index.ts +++ b/source/utils/index.ts @@ -39,6 +39,7 @@ import './commands/hybridCompress.js'; import './commands/usage.js'; import './commands/vulnerability-hunting.js'; import './commands/autoformat.js'; +import './commands/team.js'; import './commands/worktree.js'; import './commands/yolo.js'; diff --git a/source/utils/team/teamConfig.ts b/source/utils/team/teamConfig.ts new file mode 100644 index 00000000..2f5f74c3 --- /dev/null +++ b/source/utils/team/teamConfig.ts @@ -0,0 +1,207 @@ +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'fs'; +import {join} from 'path'; +import {homedir} from 'os'; +import {randomUUID} from 'crypto'; + +export type TeamMemberStatus = 'pending' | 'active' | 'idle' | 'shutdown'; +export type TeamStatus = 'active' | 'cleanup' | 'disbanded'; + +export interface TeamMember { + id: string; + name: string; + role?: string; + instanceId?: string; + worktreePath: string; + status: TeamMemberStatus; + spawnedAt?: string; + shutdownAt?: string; +} + +export interface TeamConfig { + name: string; + leadInstanceId: string; + members: TeamMember[]; + createdAt: string; + status: TeamStatus; +} + +const SNOW_DIR = join(homedir(), '.snow'); +const TEAMS_DIR = join(SNOW_DIR, 'teams'); + +function ensureDir(dir: string): void { + if (!existsSync(dir)) { + mkdirSync(dir, {recursive: true}); + } +} + +function getTeamDir(teamName: string): string { + return join(TEAMS_DIR, teamName); +} + +function getTeamConfigPath(teamName: string): string { + return join(getTeamDir(teamName), 'config.json'); +} + +export function createTeam( + teamName: string, + leadInstanceId: string, +): TeamConfig { + const existing = getActiveTeam(); + if (existing) { + throw new Error( + `An active team "${existing.name}" already exists. Clean it up before creating a new one.`, + ); + } + + const teamDir = getTeamDir(teamName); + ensureDir(teamDir); + + const config: TeamConfig = { + name: teamName, + leadInstanceId, + members: [], + createdAt: new Date().toISOString(), + status: 'active', + }; + + writeFileSync(getTeamConfigPath(teamName), JSON.stringify(config, null, 2)); + return config; +} + +export function getTeam(teamName: string): TeamConfig | null { + const configPath = getTeamConfigPath(teamName); + if (!existsSync(configPath)) { + return null; + } + try { + return JSON.parse(readFileSync(configPath, 'utf8')) as TeamConfig; + } catch { + return null; + } +} + +export function getActiveTeam(): TeamConfig | null { + ensureDir(TEAMS_DIR); + try { + const {readdirSync} = require('fs'); + const entries = readdirSync(TEAMS_DIR, {withFileTypes: true}); + for (const entry of entries) { + if (entry.isDirectory()) { + const team = getTeam(entry.name); + if (team && team.status === 'active') { + return team; + } + } + } + } catch { + // ignore + } + return null; +} + +export function updateTeam(teamName: string, updates: Partial): TeamConfig | null { + const team = getTeam(teamName); + if (!team) return null; + + const updated: TeamConfig = {...team, ...updates, name: teamName}; + writeFileSync(getTeamConfigPath(teamName), JSON.stringify(updated, null, 2)); + return updated; +} + +export function addMember( + teamName: string, + name: string, + worktreePath: string, + role?: string, +): TeamMember { + const team = getTeam(teamName); + if (!team) { + throw new Error(`Team "${teamName}" not found`); + } + if (team.status !== 'active') { + throw new Error(`Team "${teamName}" is not active`); + } + + const member: TeamMember = { + id: randomUUID().slice(0, 8), + name, + role, + worktreePath, + status: 'pending', + spawnedAt: new Date().toISOString(), + }; + + team.members.push(member); + writeFileSync(getTeamConfigPath(teamName), JSON.stringify(team, null, 2)); + return member; +} + +export function updateMember( + teamName: string, + memberId: string, + updates: Partial>, +): TeamMember | null { + const team = getTeam(teamName); + if (!team) return null; + + const member = team.members.find(m => m.id === memberId); + if (!member) return null; + + Object.assign(member, updates); + writeFileSync(getTeamConfigPath(teamName), JSON.stringify(team, null, 2)); + return member; +} + +export function removeMember(teamName: string, memberId: string): boolean { + const team = getTeam(teamName); + if (!team) return false; + + const idx = team.members.findIndex(m => m.id === memberId); + if (idx === -1) return false; + + team.members.splice(idx, 1); + writeFileSync(getTeamConfigPath(teamName), JSON.stringify(team, null, 2)); + return true; +} + +export function getMember(teamName: string, memberId: string): TeamMember | null { + const team = getTeam(teamName); + if (!team) return null; + return team.members.find(m => m.id === memberId) || null; +} + +export function getActiveMembers(teamName: string): TeamMember[] { + const team = getTeam(teamName); + if (!team) return []; + return team.members.filter( + m => m.status === 'active' || m.status === 'pending', + ); +} + +export function disbandTeam(teamName: string): boolean { + const team = getTeam(teamName); + if (!team) return false; + + team.status = 'disbanded'; + team.members.forEach(m => { + if (m.status !== 'shutdown') { + m.status = 'shutdown'; + m.shutdownAt = new Date().toISOString(); + } + }); + + writeFileSync(getTeamConfigPath(teamName), JSON.stringify(team, null, 2)); + return true; +} + +export function deleteTeamData(teamName: string): boolean { + const teamDir = getTeamDir(teamName); + if (!existsSync(teamDir)) return false; + try { + const {rmSync} = require('fs'); + rmSync(teamDir, {recursive: true, force: true}); + return true; + } catch { + return false; + } +} diff --git a/source/utils/team/teamTaskList.ts b/source/utils/team/teamTaskList.ts new file mode 100644 index 00000000..62bd50d9 --- /dev/null +++ b/source/utils/team/teamTaskList.ts @@ -0,0 +1,213 @@ +import {existsSync, mkdirSync, readFileSync, writeFileSync, renameSync} from 'fs'; +import {join, dirname} from 'path'; +import {homedir} from 'os'; +import {randomUUID} from 'crypto'; + +export type TaskStatus = 'pending' | 'in_progress' | 'completed'; + +export interface TeamTask { + id: string; + title: string; + description?: string; + status: TaskStatus; + assigneeId?: string; + assigneeName?: string; + dependencies?: string[]; + createdAt: string; + updatedAt: string; + completedAt?: string; +} + +interface TaskListData { + tasks: TeamTask[]; + updatedAt: string; +} + +const SNOW_DIR = join(homedir(), '.snow'); +const TEAMS_DIR = join(SNOW_DIR, 'teams'); + +function getTaskListPath(teamName: string): string { + return join(TEAMS_DIR, teamName, 'tasks.json'); +} + +function ensureDir(dir: string): void { + if (!existsSync(dir)) { + mkdirSync(dir, {recursive: true}); + } +} + +function readTaskList(teamName: string): TaskListData { + const filePath = getTaskListPath(teamName); + if (!existsSync(filePath)) { + return {tasks: [], updatedAt: new Date().toISOString()}; + } + try { + return JSON.parse(readFileSync(filePath, 'utf8')) as TaskListData; + } catch { + return {tasks: [], updatedAt: new Date().toISOString()}; + } +} + +function writeTaskList(teamName: string, data: TaskListData): void { + const filePath = getTaskListPath(teamName); + ensureDir(dirname(filePath)); + + // Atomic write via temp file + rename + const tmpPath = filePath + '.tmp.' + process.pid; + data.updatedAt = new Date().toISOString(); + writeFileSync(tmpPath, JSON.stringify(data, null, 2)); + renameSync(tmpPath, filePath); +} + +export function createTask( + teamName: string, + title: string, + description?: string, + dependencies?: string[], + assigneeId?: string, + assigneeName?: string, +): TeamTask { + const data = readTaskList(teamName); + const now = new Date().toISOString(); + + const task: TeamTask = { + id: randomUUID().slice(0, 8), + title, + description, + status: 'pending', + assigneeId, + assigneeName, + dependencies: dependencies && dependencies.length > 0 ? dependencies : undefined, + createdAt: now, + updatedAt: now, + }; + + data.tasks.push(task); + writeTaskList(teamName, data); + return task; +} + +export function claimTask( + teamName: string, + taskId: string, + assigneeId: string, + assigneeName: string, +): TeamTask | null { + const data = readTaskList(teamName); + const task = data.tasks.find(t => t.id === taskId); + if (!task) return null; + + if (task.status !== 'pending') { + throw new Error( + `Task "${task.title}" is already ${task.status}`, + ); + } + + // Check unresolved dependencies + if (task.dependencies && task.dependencies.length > 0) { + const unresolved = task.dependencies.filter(depId => { + const dep = data.tasks.find(t => t.id === depId); + return !dep || dep.status !== 'completed'; + }); + if (unresolved.length > 0) { + throw new Error( + `Task "${task.title}" has unresolved dependencies: ${unresolved.join(', ')}`, + ); + } + } + + task.status = 'in_progress'; + task.assigneeId = assigneeId; + task.assigneeName = assigneeName; + task.updatedAt = new Date().toISOString(); + + writeTaskList(teamName, data); + return task; +} + +export function completeTask( + teamName: string, + taskId: string, +): TeamTask | null { + const data = readTaskList(teamName); + const task = data.tasks.find(t => t.id === taskId); + if (!task) return null; + + task.status = 'completed'; + task.completedAt = new Date().toISOString(); + task.updatedAt = task.completedAt; + + writeTaskList(teamName, data); + return task; +} + +export function assignTask( + teamName: string, + taskId: string, + assigneeId: string, + assigneeName: string, +): TeamTask | null { + const data = readTaskList(teamName); + const task = data.tasks.find(t => t.id === taskId); + if (!task) return null; + + task.assigneeId = assigneeId; + task.assigneeName = assigneeName; + task.updatedAt = new Date().toISOString(); + + writeTaskList(teamName, data); + return task; +} + +export function updateTaskStatus( + teamName: string, + taskId: string, + status: TaskStatus, +): TeamTask | null { + const data = readTaskList(teamName); + const task = data.tasks.find(t => t.id === taskId); + if (!task) return null; + + task.status = status; + task.updatedAt = new Date().toISOString(); + if (status === 'completed') { + task.completedAt = task.updatedAt; + } + + writeTaskList(teamName, data); + return task; +} + +export function listTasks(teamName: string): TeamTask[] { + return readTaskList(teamName).tasks; +} + +export function getClaimableTasks(teamName: string): TeamTask[] { + const data = readTaskList(teamName); + return data.tasks.filter(task => { + if (task.status !== 'pending') return false; + if (!task.dependencies || task.dependencies.length === 0) return true; + + return task.dependencies.every(depId => { + const dep = data.tasks.find(t => t.id === depId); + return dep && dep.status === 'completed'; + }); + }); +} + +export function getTask(teamName: string, taskId: string): TeamTask | null { + const data = readTaskList(teamName); + return data.tasks.find(t => t.id === taskId) || null; +} + +export function getTasksByAssignee(teamName: string, assigneeId: string): TeamTask[] { + const data = readTaskList(teamName); + return data.tasks.filter(t => t.assigneeId === assigneeId); +} + +export function clearTasks(teamName: string): void { + const filePath = getTaskListPath(teamName); + if (existsSync(filePath)) { + writeTaskList(teamName, {tasks: [], updatedAt: new Date().toISOString()}); + } +} diff --git a/source/utils/team/teamWorktree.ts b/source/utils/team/teamWorktree.ts new file mode 100644 index 00000000..f121f121 --- /dev/null +++ b/source/utils/team/teamWorktree.ts @@ -0,0 +1,373 @@ +import {execSync} from 'child_process'; +import {existsSync} from 'fs'; +import {join} from 'path'; +import {mkdirSync, rmSync} from 'fs'; + +const WORKTREE_BASE = join(process.cwd(), '.snow', 'worktrees'); + +function sanitizeName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); +} + +export function getWorktreeBase(): string { + return WORKTREE_BASE; +} + +export function getWorktreePath(teamName: string, memberName: string): string { + return join( + WORKTREE_BASE, + sanitizeName(teamName), + sanitizeName(memberName), + ); +} + +export async function createTeamWorktree( + teamName: string, + memberName: string, +): Promise { + const worktreePath = getWorktreePath(teamName, memberName); + const branchName = `snow-team/${sanitizeName(teamName)}/${sanitizeName(memberName)}`; + + if (existsSync(worktreePath)) { + return worktreePath; + } + + const parentDir = join(worktreePath, '..'); + if (!existsSync(parentDir)) { + mkdirSync(parentDir, {recursive: true}); + } + + try { + // Create a new worktree with a new branch based on HEAD + execSync( + `git worktree add -b "${branchName}" "${worktreePath}" HEAD`, + {stdio: 'pipe', encoding: 'utf8'}, + ); + } catch (error: any) { + // Branch may already exist, try without -b + if (error.message?.includes('already exists')) { + try { + execSync( + `git worktree add "${worktreePath}" "${branchName}"`, + {stdio: 'pipe', encoding: 'utf8'}, + ); + } catch (retryError: any) { + throw new Error( + `Failed to create worktree for ${memberName}: ${retryError.message}`, + ); + } + } else { + throw new Error( + `Failed to create worktree for ${memberName}: ${error.message}`, + ); + } + } + + return worktreePath; +} + +export async function removeTeamWorktree(worktreePath: string): Promise { + if (!existsSync(worktreePath)) return; + + try { + execSync(`git worktree remove "${worktreePath}" --force`, { + stdio: 'pipe', + encoding: 'utf8', + }); + } catch { + // If git worktree remove fails, try manual cleanup + try { + rmSync(worktreePath, {recursive: true, force: true}); + // Prune stale worktree entries + execSync('git worktree prune', {stdio: 'pipe'}); + } catch { + // Best effort + } + } +} + +export async function removeWorktreeBranch( + teamName: string, + memberName: string, +): Promise { + const branchName = `snow-team/${sanitizeName(teamName)}/${sanitizeName(memberName)}`; + try { + execSync(`git branch -D "${branchName}"`, {stdio: 'pipe', encoding: 'utf8'}); + } catch { + // Branch may not exist + } +} + +export async function cleanupTeamWorktrees(teamName: string): Promise { + const teamDir = join(WORKTREE_BASE, sanitizeName(teamName)); + if (!existsSync(teamDir)) return; + + try { + const {readdirSync} = require('fs'); + const entries = readdirSync(teamDir, {withFileTypes: true}); + + for (const entry of entries) { + if (entry.isDirectory()) { + const worktreePath = join(teamDir, entry.name); + await removeTeamWorktree(worktreePath); + await removeWorktreeBranch(teamName, entry.name); + } + } + + // Remove the team worktree directory + rmSync(teamDir, {recursive: true, force: true}); + + // Prune any stale worktree references + execSync('git worktree prune', {stdio: 'pipe'}); + } catch { + // Best effort cleanup + } +} + +export function listTeamWorktrees(teamName: string): string[] { + const teamDir = join(WORKTREE_BASE, sanitizeName(teamName)); + if (!existsSync(teamDir)) return []; + + try { + const {readdirSync} = require('fs'); + const entries = readdirSync(teamDir, {withFileTypes: true}); + return entries + .filter((e: any) => e.isDirectory()) + .map((e: any) => join(teamDir, e.name)); + } catch { + return []; + } +} + +// ── Merge helpers ── + +export function getTeammateBranchName(teamName: string, memberName: string): string { + return `snow-team/${sanitizeName(teamName)}/${sanitizeName(memberName)}`; +} + +export function hasUncommittedChanges(worktreePath: string): boolean { + try { + const status = execSync('git status --porcelain', { + cwd: worktreePath, + encoding: 'utf8', + stdio: 'pipe', + }); + return status.trim().length > 0; + } catch { + return false; + } +} + +export function autoCommitWorktreeChanges( + worktreePath: string, + memberName: string, + message?: string, +): boolean { + if (!hasUncommittedChanges(worktreePath)) return false; + + try { + execSync('git add -A', {cwd: worktreePath, stdio: 'pipe'}); + const commitMsg = message || `[Snow Team] ${memberName}: auto-commit work`; + execSync(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, { + cwd: worktreePath, + stdio: 'pipe', + encoding: 'utf8', + }); + return true; + } catch { + return false; + } +} + +export type MergeStrategy = 'manual' | 'theirs' | 'ours'; + +export interface MergeResult { + success: boolean; + merged: boolean; + commitCount: number; + filesChanged: number; + hasConflicts?: boolean; + conflictFiles?: string[]; + error?: string; +} + +export function getTeammateDiffSummary( + teamName: string, + memberName: string, +): {commitCount: number; filesChanged: number; diffStat: string} | null { + const branchName = getTeammateBranchName(teamName, memberName); + try { + const countStr = execSync( + `git rev-list HEAD..${branchName} --count`, + {encoding: 'utf8', stdio: 'pipe'}, + ).trim(); + const commitCount = parseInt(countStr, 10) || 0; + if (commitCount === 0) return null; + + const diffStat = execSync( + `git diff HEAD...${branchName} --stat`, + {encoding: 'utf8', stdio: 'pipe'}, + ).trim(); + + const filesChangedMatch = diffStat.match(/(\d+) files? changed/); + const filesChanged = filesChangedMatch ? parseInt(filesChangedMatch[1]!, 10) : 0; + + return {commitCount, filesChanged, diffStat}; + } catch { + return null; + } +} + +export function mergeTeammateBranch( + teamName: string, + memberName: string, + strategy: MergeStrategy = 'manual', +): MergeResult { + const branchName = getTeammateBranchName(teamName, memberName); + + try { + const countStr = execSync( + `git rev-list HEAD..${branchName} --count`, + {encoding: 'utf8', stdio: 'pipe'}, + ).trim(); + const commitCount = parseInt(countStr, 10) || 0; + + if (commitCount === 0) { + return {success: true, merged: false, commitCount: 0, filesChanged: 0}; + } + + // Build merge command based on strategy + const strategyFlag = strategy === 'theirs' ? ' -X theirs' + : strategy === 'ours' ? ' -X ours' + : ''; + const mergeCmd = `git merge ${branchName} --no-edit${strategyFlag} -m "[Snow Team] Merge ${memberName}'s work"`; + + try { + execSync(mergeCmd, {encoding: 'utf8', stdio: 'pipe'}); + + let filesChanged = 0; + try { + const stat = execSync('git diff HEAD~1 --stat --numstat', { + encoding: 'utf8', + stdio: 'pipe', + }); + filesChanged = stat.trim().split('\n').filter(l => l.trim()).length; + } catch { /* best effort */ } + + return {success: true, merged: true, commitCount, filesChanged}; + } catch (mergeError: any) { + const conflictFiles = getConflictedFiles(); + + if (strategy !== 'manual' || conflictFiles.length === 0) { + // Auto-strategy failed or no actual conflict markers — abort + try { execSync('git merge --abort', {stdio: 'pipe'}); } catch { /* noop */ } + return { + success: false, + merged: false, + commitCount, + filesChanged: 0, + conflictFiles, + error: conflictFiles.length > 0 + ? `Merge conflicts in ${conflictFiles.length} file(s) even with strategy "${strategy}": ${conflictFiles.join(', ')}` + : mergeError.message, + }; + } + + // strategy === 'manual': leave conflicts in working directory for lead to resolve + return { + success: false, + merged: false, + hasConflicts: true, + commitCount, + filesChanged: 0, + conflictFiles, + error: `Merge conflicts in ${conflictFiles.length} file(s). Working directory is in merge state — edit the conflicted files to remove <<<<<<< / ======= / >>>>>>> markers, then call team-resolve_merge_conflicts.`, + }; + } + } catch (e: any) { + return { + success: false, + merged: false, + commitCount: 0, + filesChanged: 0, + error: e.message, + }; + } +} + +// ── Merge state helpers ── + +export function isInMergeState(): boolean { + try { + execSync('git rev-parse --verify MERGE_HEAD', {stdio: 'pipe', encoding: 'utf8'}); + return true; + } catch { + return false; + } +} + +export function getConflictedFiles(): string[] { + try { + const output = execSync('git diff --name-only --diff-filter=U', { + encoding: 'utf8', + stdio: 'pipe', + }); + return output.trim().split('\n').filter(f => f); + } catch { + return []; + } +} + +export function completeMerge(message?: string): {success: boolean; error?: string} { + if (!isInMergeState()) { + return {success: false, error: 'Not currently in a merge state.'}; + } + + const remaining = getConflictedFiles(); + if (remaining.length > 0) { + return { + success: false, + error: `${remaining.length} file(s) still have unresolved conflicts: ${remaining.join(', ')}. Edit them to remove conflict markers first.`, + }; + } + + try { + execSync('git add -A', {stdio: 'pipe'}); + if (message) { + execSync(`git commit --no-edit -m "${message.replace(/"/g, '\\"')}"`, { + stdio: 'pipe', + encoding: 'utf8', + }); + } else { + execSync('git commit --no-edit', {stdio: 'pipe', encoding: 'utf8'}); + } + return {success: true}; + } catch (e: any) { + return {success: false, error: e.message}; + } +} + +export function abortCurrentMerge(): {success: boolean; error?: string} { + if (!isInMergeState()) { + return {success: false, error: 'Not currently in a merge state.'}; + } + + try { + execSync('git merge --abort', {stdio: 'pipe'}); + return {success: true}; + } catch (e: any) { + return {success: false, error: e.message}; + } +} + +export function isGitRepo(): boolean { + try { + execSync('git rev-parse --is-inside-work-tree', { + stdio: 'pipe', + encoding: 'utf8', + }); + return true; + } catch { + return false; + } +}