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.
This commit is contained in:
iliya 2026-03-12 22:56:04 +02:00
parent 81ac59e46b
commit 70ee5f3b20
4 changed files with 100 additions and 56 deletions

View file

@ -827,22 +827,26 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process
members: request.members, 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. 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. 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} ${userPromptBlock}
${persistentContext} ${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} ${step2Block}
${step3Block} ${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}"] 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. 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. You are "${leadName}", the team lead.
Goal: Reconnect with existing team "${request.teamName}" and resume pending work. Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
@ -2178,7 +2183,9 @@ export class TeamProvisioningService {
run.processKilled = true; run.processKilled = true;
run.cancelRequested = 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); killProcessTree(run.child);
this.cleanupRun(run); this.cleanupRun(run);
} }
@ -2327,7 +2334,6 @@ export class TeamProvisioningService {
run.finalizingByTimeout = true; run.finalizingByTimeout = true;
void (async () => { void (async () => {
const readyOnTimeout = await this.tryCompleteAfterTimeout(run); const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
run.child?.stdin?.end();
killProcessTree(run.child); killProcessTree(run.child);
if (readyOnTimeout) return; if (readyOnTimeout) return;
@ -2566,7 +2572,7 @@ export class TeamProvisioningService {
'--mcp-config', '--mcp-config',
mcpConfigPath, mcpConfigPath,
'--disallowedTools', '--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 // Explicit --permission-mode overrides user's defaultMode in ~/.claude/settings.json
// (e.g. "acceptEdits") which otherwise takes precedence over CLI flags // (e.g. "acceptEdits") which otherwise takes precedence over CLI flags
...(request.skipPermissions !== false ...(request.skipPermissions !== false
@ -2629,7 +2635,6 @@ export class TeamProvisioningService {
run.finalizingByTimeout = true; run.finalizingByTimeout = true;
void (async () => { void (async () => {
const readyOnTimeout = await this.tryCompleteAfterTimeout(run); const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
run.child?.stdin?.end();
killProcessTree(run.child); killProcessTree(run.child);
if (readyOnTimeout) { if (readyOnTimeout) {
return; // cleanupRun already called inside tryCompleteAfterTimeout return; // cleanupRun already called inside tryCompleteAfterTimeout
@ -2953,7 +2958,7 @@ export class TeamProvisioningService {
'--mcp-config', '--mcp-config',
mcpConfigPath, mcpConfigPath,
'--disallowedTools', '--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 // Explicit --permission-mode overrides user's defaultMode in ~/.claude/settings.json
// (e.g. "acceptEdits") which otherwise takes precedence over CLI flags // (e.g. "acceptEdits") which otherwise takes precedence over CLI flags
...(request.skipPermissions !== false ...(request.skipPermissions !== false
@ -2976,8 +2981,8 @@ export class TeamProvisioningService {
launchArgs.push('--worktree', request.worktree); launchArgs.push('--worktree', request.worktree);
} }
launchArgs.push(...parseCliArgs(request.extraCliArgs)); launchArgs.push(...parseCliArgs(request.extraCliArgs));
// New sessions: CLI creates its own ID. No --resume with synthetic name — docs say // --resume is added above when a valid previous session JSONL exists.
// --resume is for existing sessions and may show an interactive picker if not found. // Without it, CLI creates a fresh session ID automatically.
try { try {
child = spawnCli(claudePath, launchArgs, { child = spawnCli(claudePath, launchArgs, {
@ -3035,7 +3040,6 @@ export class TeamProvisioningService {
run.finalizingByTimeout = true; run.finalizingByTimeout = true;
void (async () => { void (async () => {
const readyOnTimeout = await this.tryCompleteAfterTimeout(run); const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
run.child?.stdin?.end();
killProcessTree(run.child); killProcessTree(run.child);
if (readyOnTimeout) { if (readyOnTimeout) {
return; return;
@ -3093,7 +3097,9 @@ export class TeamProvisioningService {
run.cancelRequested = true; run.cancelRequested = true;
run.processKilled = 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); killProcessTree(run.child);
const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user'); const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user');
run.onProgress(progress); run.onProgress(progress);
@ -4166,7 +4172,9 @@ export class TeamProvisioningService {
} }
run.processKilled = true; run.processKilled = true;
run.cancelRequested = 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); killProcessTree(run.child);
const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
run.onProgress(progress); run.onProgress(progress);
@ -4504,7 +4512,6 @@ export class TeamProvisioningService {
run.onProgress(progress); run.onProgress(progress);
// Kill the process on provisioning error // Kill the process on provisioning error
run.processKilled = true; run.processKilled = true;
run.child?.stdin?.end();
killProcessTree(run.child); killProcessTree(run.child);
this.cleanupRun(run); this.cleanupRun(run);
} else if (run.provisioningComplete) { } else if (run.provisioningComplete) {
@ -4690,6 +4697,7 @@ export class TeamProvisioningService {
``, ``,
`You are "${leadName}", the team lead of team "${run.teamName}".`, `You are "${leadName}", the team lead of team "${run.teamName}".`,
`You are running in a non-interactive CLI session. Do not ask questions.`, `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, persistentContext,
taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '', taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',
@ -5169,7 +5177,6 @@ export class TeamProvisioningService {
}); });
run.onProgress(progress); run.onProgress(progress);
run.processKilled = true; run.processKilled = true;
run.child?.stdin?.end();
killProcessTree(run.child); killProcessTree(run.child);
this.cleanupRun(run); this.cleanupRun(run);
return; return;

View file

@ -127,6 +127,16 @@ export const ProvisioningProgressBlock = ({
const outputScrollRef = useRef<HTMLDivElement>(null); const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error'; const isError = tone === 'error';
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError); 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 // Auto-scroll assistant output
useEffect(() => { useEffect(() => {
@ -148,6 +158,29 @@ export const ProvisioningProgressBlock = ({
} }
}, [isError, cliLogsTail]); }, [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 ( return (
<div <div
className={cn( className={cn(

View file

@ -962,39 +962,46 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
className="relative shrink-0 overflow-hidden border-r border-[var(--color-border)]" className="relative shrink-0 overflow-hidden border-r border-[var(--color-border)]"
style={{ width: messagesPanelWidth }} style={{ width: messagesPanelWidth }}
> >
<MessagesPanel <div className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]">
teamName={teamName} <div className="shrink-0 overflow-hidden px-3">
position="sidebar" <ClaudeLogsSection teamName={teamName} />
onTogglePosition={toggleMessagesPanelMode} </div>
members={activeMembers} <div className="min-h-0 flex-1 border-t border-[var(--color-border)]">
tasks={data.tasks} <MessagesPanel
messages={data.messages} teamName={teamName}
isTeamAlive={data.isAlive} position="sidebar"
timeWindow={timeWindow} onTogglePosition={toggleMessagesPanelMode}
teamSessionIds={teamSessionIds} members={activeMembers}
currentLeadSessionId={data?.config.leadSessionId} tasks={data.tasks}
pendingRepliesByMember={pendingRepliesByMember} messages={data.messages}
onPendingReplyChange={setPendingRepliesByMember} isTeamAlive={data.isAlive}
onMemberClick={setSelectedMember} timeWindow={timeWindow}
onTaskClick={setSelectedTask} teamSessionIds={teamSessionIds}
onCreateTaskFromMessage={(subject, description) => { currentLeadSessionId={data?.config.leadSessionId}
openCreateTaskDialog(subject, description); pendingRepliesByMember={pendingRepliesByMember}
}} onPendingReplyChange={setPendingRepliesByMember}
onReplyToMessage={(message) => { onMemberClick={setSelectedMember}
setSendDialogRecipient(message.from); onTaskClick={setSelectedTask}
setSendDialogDefaultText(undefined); onCreateTaskFromMessage={(subject, description) => {
setSendDialogDefaultChip(undefined); openCreateTaskDialog(subject, description);
setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); }}
setSendDialogOpen(true); onReplyToMessage={(message) => {
}} setSendDialogRecipient(message.from);
onRestartTeam={() => setLaunchDialogOpen(true)} setSendDialogDefaultText(undefined);
onTaskIdClick={(taskId) => { setSendDialogDefaultChip(undefined);
const task = setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) });
taskMap.get(taskId) ?? setSendDialogOpen(true);
data.tasks.find((candidate) => candidate.displayId === taskId); }}
if (task) setSelectedTask(task); onRestartTeam={() => setLaunchDialogOpen(true)}
}} onTaskIdClick={(taskId) => {
/> const task =
taskMap.get(taskId) ??
data.tasks.find((candidate) => candidate.displayId === taskId);
if (task) setSelectedTask(task);
}}
/>
</div>
</div>
{/* Resize handle */} {/* Resize handle */}
<div <div
className={`absolute inset-y-0 right-0 z-20 w-1 cursor-col-resize transition-colors hover:bg-blue-500/30 ${isMessagesPanelResizing ? 'bg-blue-500/40' : ''}`} className={`absolute inset-y-0 right-0 z-20 w-1 cursor-col-resize transition-colors hover:bg-blue-500/30 ${isMessagesPanelResizing ? 'bg-blue-500/40' : ''}`}
@ -1551,7 +1558,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</CollapsibleTeamSection> </CollapsibleTeamSection>
)} )}
<ClaudeLogsSection teamName={teamName} /> {messagesPanelMode !== 'sidebar' && <ClaudeLogsSection teamName={teamName} />}
{messagesPanelMode === 'inline' && ( {messagesPanelMode === 'inline' && (
<MessagesPanel <MessagesPanel

View file

@ -29,24 +29,21 @@ export const KanbanColumn = ({
return ( return (
<section <section
className={cn( className={cn(
'rounded-md border border-[var(--color-border)]', 'relative rounded-md border border-[var(--color-border)]',
className, className,
!bodyBg && 'bg-[var(--color-surface)]' !bodyBg && 'bg-[var(--color-surface)]'
)} )}
style={bodyBg ? { backgroundColor: bodyBg } : undefined} style={bodyBg ? { backgroundColor: bodyBg } : undefined}
> >
<header <header
className={cn( className={cn('border-b border-[var(--color-border)] px-3 py-2 pr-14', headerClassName)}
'flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2',
headerClassName
)}
style={headerBg ? { backgroundColor: headerBg } : undefined} style={headerBg ? { backgroundColor: headerBg } : undefined}
> >
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]"> <h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">
{icon} {icon}
{title} {title}
</h4> </h4>
<div className="flex items-center gap-2"> <div className="absolute right-2 top-2 z-10 flex items-center gap-2">
{headerAccessory} {headerAccessory}
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal"> <Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
{count} {count}