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:
parent
81ac59e46b
commit
70ee5f3b20
4 changed files with 100 additions and 56 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue