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,
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -127,6 +127,16 @@ export const ProvisioningProgressBlock = ({
|
|||
const outputScrollRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
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)]"
|
||||
style={{ width: messagesPanelWidth }}
|
||||
>
|
||||
<MessagesPanel
|
||||
teamName={teamName}
|
||||
position="sidebar"
|
||||
onTogglePosition={toggleMessagesPanelMode}
|
||||
members={activeMembers}
|
||||
tasks={data.tasks}
|
||||
messages={data.messages}
|
||||
isTeamAlive={data.isAlive}
|
||||
timeWindow={timeWindow}
|
||||
teamSessionIds={teamSessionIds}
|
||||
currentLeadSessionId={data?.config.leadSessionId}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
onPendingReplyChange={setPendingRepliesByMember}
|
||||
onMemberClick={setSelectedMember}
|
||||
onTaskClick={setSelectedTask}
|
||||
onCreateTaskFromMessage={(subject, description) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]">
|
||||
<div className="shrink-0 overflow-hidden px-3">
|
||||
<ClaudeLogsSection teamName={teamName} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 border-t border-[var(--color-border)]">
|
||||
<MessagesPanel
|
||||
teamName={teamName}
|
||||
position="sidebar"
|
||||
onTogglePosition={toggleMessagesPanelMode}
|
||||
members={activeMembers}
|
||||
tasks={data.tasks}
|
||||
messages={data.messages}
|
||||
isTeamAlive={data.isAlive}
|
||||
timeWindow={timeWindow}
|
||||
teamSessionIds={teamSessionIds}
|
||||
currentLeadSessionId={data?.config.leadSessionId}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
onPendingReplyChange={setPendingRepliesByMember}
|
||||
onMemberClick={setSelectedMember}
|
||||
onTaskClick={setSelectedTask}
|
||||
onCreateTaskFromMessage={(subject, description) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Resize handle */}
|
||||
<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' : ''}`}
|
||||
|
|
@ -1551,7 +1558,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
</CollapsibleTeamSection>
|
||||
)}
|
||||
|
||||
<ClaudeLogsSection teamName={teamName} />
|
||||
{messagesPanelMode !== 'sidebar' && <ClaudeLogsSection teamName={teamName} />}
|
||||
|
||||
{messagesPanelMode === 'inline' && (
|
||||
<MessagesPanel
|
||||
|
|
|
|||
|
|
@ -29,24 +29,21 @@ export const KanbanColumn = ({
|
|||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-md border border-[var(--color-border)]',
|
||||
'relative rounded-md border border-[var(--color-border)]',
|
||||
className,
|
||||
!bodyBg && 'bg-[var(--color-surface)]'
|
||||
)}
|
||||
style={bodyBg ? { backgroundColor: bodyBg } : undefined}
|
||||
>
|
||||
<header
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2',
|
||||
headerClassName
|
||||
)}
|
||||
className={cn('border-b border-[var(--color-border)] px-3 py-2 pr-14', headerClassName)}
|
||||
style={headerBg ? { backgroundColor: headerBg } : undefined}
|
||||
>
|
||||
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">
|
||||
{icon}
|
||||
{title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="absolute right-2 top-2 z-10 flex items-center gap-2">
|
||||
{headerAccessory}
|
||||
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
|
||||
{count}
|
||||
|
|
|
|||
Loading…
Reference in a new issue