From 70ee5f3b20863576a301b20b6a4e43dc2ec30738 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 12 Mar 2026 22:56:04 +0200 Subject: [PATCH] feat: enhance team provisioning service and UI components - Updated TeamProvisioningService to clarify team creation steps and critical execution instructions for non-interactive CLI sessions. - Improved error handling and process management to prevent unintended file deletions during process termination. - Enhanced UI components in TeamDetailView and ProvisioningProgressBlock for better user experience and visibility of CLI logs. - Refactored KanbanColumn styling for improved layout consistency and accessibility. - Added logic to manage visibility of CLI logs based on the spawning process state. --- .../services/team/TeamProvisioningService.ts | 39 ++++++---- .../team/ProvisioningProgressBlock.tsx | 33 ++++++++ .../components/team/TeamDetailView.tsx | 75 ++++++++++--------- .../components/team/kanban/KanbanColumn.tsx | 9 +-- 4 files changed, 100 insertions(+), 56 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index dafc28f1..a2f71788 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -827,22 +827,26 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process members: request.members, }); - return `Team Start [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] + return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it. You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. +CRITICAL: Execute ALL steps directly yourself. Do NOT use the Agent tool to delegate provisioning to a sub-agent. The ONLY valid use of the Agent tool is spawning individual teammates in step 2. You are “${leadName}”, the team lead. -Goal: Provision a Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}. +Goal: Create and provision a NEW Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}. +The team does NOT exist yet — no config, no state, nothing. Step 1 is MANDATORY. ${userPromptBlock} ${persistentContext} -Steps (execute in this exact order): +Steps (execute in this exact order — do NOT skip any step): + +1) MANDATORY FIRST STEP: Call the TeamCreate tool with team_name=”${request.teamName}”. This creates the team config and in-memory state. Without this step, teammate spawns will FAIL. Do NOT assume the team already exists based on this prompt header. ${step2Block} ${step3Block} -3) After all steps, output a short summary. +${isSolo ? '3' : '4'}) After all steps, output a short summary. `; } @@ -955,6 +959,7 @@ ${memberSpawnInstructions} return `${startLabel} [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. +CRITICAL: Execute ALL steps directly yourself. Do NOT use the Agent tool to delegate work to a sub-agent. The ONLY valid use of the Agent tool is spawning individual teammates in step 2. You are "${leadName}", the team lead. Goal: Reconnect with existing team "${request.teamName}" and resume pending work. @@ -2178,7 +2183,9 @@ export class TeamProvisioningService { run.processKilled = true; run.cancelRequested = true; - run.child?.stdin?.end(); + // Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful + // shutdown which deletes team files (config.json, inboxes/, tasks/). + // SIGTERM alone kills the process before cleanup runs, preserving files. killProcessTree(run.child); this.cleanupRun(run); } @@ -2327,7 +2334,6 @@ export class TeamProvisioningService { run.finalizingByTimeout = true; void (async () => { const readyOnTimeout = await this.tryCompleteAfterTimeout(run); - run.child?.stdin?.end(); killProcessTree(run.child); if (readyOnTimeout) return; @@ -2566,7 +2572,7 @@ export class TeamProvisioningService { '--mcp-config', mcpConfigPath, '--disallowedTools', - 'TeamDelete,TodoWrite', + 'TeamDelete,TodoWrite,mcp__agent-teams__team_launch,mcp__agent-teams__team_stop', // Explicit --permission-mode overrides user's defaultMode in ~/.claude/settings.json // (e.g. "acceptEdits") which otherwise takes precedence over CLI flags ...(request.skipPermissions !== false @@ -2629,7 +2635,6 @@ export class TeamProvisioningService { run.finalizingByTimeout = true; void (async () => { const readyOnTimeout = await this.tryCompleteAfterTimeout(run); - run.child?.stdin?.end(); killProcessTree(run.child); if (readyOnTimeout) { return; // cleanupRun already called inside tryCompleteAfterTimeout @@ -2953,7 +2958,7 @@ export class TeamProvisioningService { '--mcp-config', mcpConfigPath, '--disallowedTools', - 'TeamDelete,TodoWrite', + 'TeamDelete,TodoWrite,mcp__agent-teams__team_launch,mcp__agent-teams__team_stop', // Explicit --permission-mode overrides user's defaultMode in ~/.claude/settings.json // (e.g. "acceptEdits") which otherwise takes precedence over CLI flags ...(request.skipPermissions !== false @@ -2976,8 +2981,8 @@ export class TeamProvisioningService { launchArgs.push('--worktree', request.worktree); } launchArgs.push(...parseCliArgs(request.extraCliArgs)); - // New sessions: CLI creates its own ID. No --resume with synthetic name — docs say - // --resume is for existing sessions and may show an interactive picker if not found. + // --resume is added above when a valid previous session JSONL exists. + // Without it, CLI creates a fresh session ID automatically. try { child = spawnCli(claudePath, launchArgs, { @@ -3035,7 +3040,6 @@ export class TeamProvisioningService { run.finalizingByTimeout = true; void (async () => { const readyOnTimeout = await this.tryCompleteAfterTimeout(run); - run.child?.stdin?.end(); killProcessTree(run.child); if (readyOnTimeout) { return; @@ -3093,7 +3097,9 @@ export class TeamProvisioningService { run.cancelRequested = true; run.processKilled = true; - run.child?.stdin?.end(); + // Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful + // shutdown which deletes team files (config.json, inboxes/, tasks/). + // SIGTERM alone kills the process before cleanup runs, preserving files. killProcessTree(run.child); const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user'); run.onProgress(progress); @@ -4166,7 +4172,9 @@ export class TeamProvisioningService { } run.processKilled = true; run.cancelRequested = true; - run.child?.stdin?.end(); + // Note: do NOT call stdin.end() before kill — EOF triggers CLI's graceful + // shutdown which deletes team files (config.json, inboxes/, tasks/). + // SIGTERM alone kills the process before cleanup runs, preserving files. killProcessTree(run.child); const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); run.onProgress(progress); @@ -4504,7 +4512,6 @@ export class TeamProvisioningService { run.onProgress(progress); // Kill the process on provisioning error run.processKilled = true; - run.child?.stdin?.end(); killProcessTree(run.child); this.cleanupRun(run); } else if (run.provisioningComplete) { @@ -4690,6 +4697,7 @@ export class TeamProvisioningService { ``, `You are "${leadName}", the team lead of team "${run.teamName}".`, `You are running in a non-interactive CLI session. Do not ask questions.`, + `CRITICAL: Execute ALL steps directly yourself. Do NOT use the Agent tool to delegate work to a sub-agent. The ONLY valid use of the Agent tool is spawning individual teammates.`, ``, persistentContext, taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '', @@ -5169,7 +5177,6 @@ export class TeamProvisioningService { }); run.onProgress(progress); run.processKilled = true; - run.child?.stdin?.end(); killProcessTree(run.child); this.cleanupRun(run); return; diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index ecf44b13..83e91ba5 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -127,6 +127,16 @@ export const ProvisioningProgressBlock = ({ const outputScrollRef = useRef(null); const isError = tone === 'error'; const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError); + const spawningStepIndex = STEP_ORDER.indexOf('spawning'); + const isCliLaunchMessage = + message?.toLowerCase().includes('starting claude cli process') ?? false; + const isCliStarting = + !isError && + Boolean(cliLogsTail) && + loading && + (currentStepIndex <= spawningStepIndex || isCliLaunchMessage); + const wasCliStartingRef = useRef(false); + const hadCliStartingPhaseRef = useRef(false); // Auto-scroll assistant output useEffect(() => { @@ -148,6 +158,29 @@ export const ProvisioningProgressBlock = ({ } }, [isError, cliLogsTail]); + // Keep CLI logs visible while the launch command is still starting, + // then collapse them once the spawned process is actually running. + useEffect(() => { + if (isError || !cliLogsTail) { + wasCliStartingRef.current = false; + hadCliStartingPhaseRef.current = false; + return; + } + + if (isCliStarting && !wasCliStartingRef.current) { + setLogsOpen(true); + } + + if (!isCliStarting && wasCliStartingRef.current && hadCliStartingPhaseRef.current) { + setLogsOpen(false); + } + + wasCliStartingRef.current = isCliStarting; + if (isCliStarting) { + hadCliStartingPhaseRef.current = true; + } + }, [cliLogsTail, isCliStarting, isError]); + return (
- { - openCreateTaskDialog(subject, description); - }} - onReplyToMessage={(message) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }} - onRestartTeam={() => setLaunchDialogOpen(true)} - onTaskIdClick={(taskId) => { - const task = - taskMap.get(taskId) ?? - data.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); - }} - /> +
+
+ +
+
+ { + openCreateTaskDialog(subject, description); + }} + onReplyToMessage={(message) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }} + onRestartTeam={() => setLaunchDialogOpen(true)} + onTaskIdClick={(taskId) => { + const task = + taskMap.get(taskId) ?? + data.tasks.find((candidate) => candidate.displayId === taskId); + if (task) setSelectedTask(task); + }} + /> +
+
{/* Resize handle */}
)} - + {messagesPanelMode !== 'sidebar' && } {messagesPanelMode === 'inline' && (

{icon} {title}

-
+
{headerAccessory} {count}