From bd242fac5a6583fa5b26c4e0158264ed3733c387 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 21:47:40 +0300 Subject: [PATCH] fix(team): re-add control_response via stdin for teammate permissions Belt-and-suspenders approach: 1. Settings file: handles all FUTURE calls (teammate finds rule on retry) 2. control_response via stdin: may unblock CURRENT waiting prompt (now includes updatedInput: {} which was the previous ZodError fix) Without #2, approved teammates stay stuck until team restart because the CLI doesn't hot-reload settings.local.json for pending prompts. --- .../agent-graph/src/canvas/draw-agents.ts | 17 ++++++-- .../agent-graph/src/canvas/draw-effects.ts | 9 ++-- packages/agent-graph/src/canvas/draw-tasks.ts | 37 ++++++++++++++--- .../src/hooks/useGraphSimulation.ts | 5 ++- .../agent-graph/src/layout/kanbanLayout.ts | 8 ++++ packages/agent-graph/src/ports/types.ts | 12 ++++++ packages/agent-graph/src/ui/GraphCanvas.tsx | 6 +-- .../services/team/TeamProvisioningService.ts | 22 ++++++++++ .../agent-graph/adapters/TeamGraphAdapter.ts | 31 +++++++++++++- .../agent-graph/ui/GraphNodePopover.tsx | 41 +++++++++++++++++-- 10 files changed, 166 insertions(+), 22 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 1d0cc7c5..e30b5c17 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -52,7 +52,7 @@ export function drawAgents( // Pending approval indicator: pulsing amber ring if (node.pendingApproval) { - const pulseAlpha = 0.3 + 0.35 * Math.sin(time * 3); + const pulseAlpha = 0.3 + 0.35 * Math.sin(time * 7); const ringR = r + 5; ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); @@ -367,20 +367,31 @@ function drawBreathing( } } -// ─── Avatar image cache ───────────────────────────────────────────────────── +// ─── Avatar image cache with LRU eviction ─────────────────────────────────── +const AVATAR_CACHE_MAX = 100; const avatarCache = new Map(); const avatarLoading = new Set(); function getAvatarImage(url: string): HTMLImageElement | null { const cached = avatarCache.get(url); - if (cached) return cached; + if (cached) { + // Move to end (most recently used) + avatarCache.delete(url); + avatarCache.set(url, cached); + return cached; + } if (avatarLoading.has(url)) return null; avatarLoading.add(url); const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { + // Evict oldest entry if over limit + if (avatarCache.size >= AVATAR_CACHE_MAX) { + const first = avatarCache.keys().next().value; + if (first != null) avatarCache.delete(first); + } avatarCache.set(url, img); avatarLoading.delete(url); }; diff --git a/packages/agent-graph/src/canvas/draw-effects.ts b/packages/agent-graph/src/canvas/draw-effects.ts index b2e4e68e..4bdcd8e0 100644 --- a/packages/agent-graph/src/canvas/draw-effects.ts +++ b/packages/agent-graph/src/canvas/draw-effects.ts @@ -17,6 +17,8 @@ export interface VisualEffect { color: string; age: number; duration: number; + /** Node radius for scaling the effect */ + nodeRadius?: number; particles?: ShatterParticle[]; } @@ -29,8 +31,8 @@ interface ShatterParticle { /** * Create a spawn effect at position. */ -export function createSpawnEffect(x: number, y: number, color: string): VisualEffect { - return { type: 'spawn', x, y, color, age: 0, duration: 0.8 }; +export function createSpawnEffect(x: number, y: number, color: string, nodeRadius?: number): VisualEffect { + return { type: 'spawn', x, y, color, age: 0, duration: 0.8, nodeRadius }; } /** @@ -76,7 +78,8 @@ export function drawEffects( function drawSpawnEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { const alpha = SPAWN_FX.maxAlpha * (1 - progress); - const ringR = SPAWN_FX.ringStart + SPAWN_FX.ringExpand * progress; + const baseR = fx.nodeRadius ?? SPAWN_FX.ringStart; + const ringR = baseR + SPAWN_FX.ringExpand * progress; ctx.save(); ctx.globalAlpha = alpha; diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index ab1f0614..9d970f9f 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -83,9 +83,11 @@ function drawTaskPill( ctx.translate(x, y); ctx.scale(scale, scale); - // Shadow — stronger for attention tasks - ctx.shadowColor = hexWithAlpha(statusColor, 0.25); - ctx.shadowBlur = needsAttention ? 12 : 4; + // Shadow — stronger for attention tasks, red for blocked + ctx.shadowColor = node.isBlocked + ? hexWithAlpha(COLORS.edgeBlocking, 0.3) + : hexWithAlpha(statusColor, 0.25); + ctx.shadowBlur = needsAttention || node.isBlocked ? 12 : 4; // Background fill ctx.beginPath(); @@ -98,13 +100,26 @@ function drawTaskPill( ctx.fill(); ctx.shadowBlur = 0; - // Border + // Border — red for blocked tasks ctx.beginPath(); ctx.roundRect(-halfW, -halfH, w, h, r); - ctx.strokeStyle = hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5); - ctx.lineWidth = isSelected ? 2 : 1; + if (node.isBlocked) { + ctx.strokeStyle = hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.9 : 0.7); + ctx.lineWidth = isSelected ? 2.5 : 1.8; + } else { + ctx.strokeStyle = hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5); + ctx.lineWidth = isSelected ? 2 : 1; + } ctx.stroke(); + // Blocked indicator — red left stripe + if (node.isBlocked) { + ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6); + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, 4, h, [r, 0, 0, r]); + ctx.fill(); + } + // Review state overlay border — pulsing for review/needsFix, STATIC for approved if (reviewColor !== 'transparent') { ctx.beginPath(); @@ -203,6 +218,16 @@ export function drawColumnHeaders( ctx.strokeStyle = hexWithAlpha(header.color, 0.2); ctx.lineWidth = 0.5; ctx.stroke(); + + // Overflow badge: "+N more" + if (header.overflowCount > 0) { + const badgeText = `+${header.overflowCount} more`; + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = hexWithAlpha(header.color, 0.45); + ctx.fillText(badgeText, header.x, header.overflowY + 4); + } } } } diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index a8494fcb..94ecb8ba 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -18,7 +18,7 @@ import { type SimulationLinkDatum, } from 'd3-force'; import type { GraphNode, GraphEdge, GraphParticle, GraphNodeKind } from '../ports/types'; -import { FORCE, ANIM_SPEED } from '../constants/canvas-constants'; +import { FORCE, ANIM_SPEED, NODE } from '../constants/canvas-constants'; import { getNodeStrategy } from '../strategies'; import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects'; import { getStateColor } from '../constants/colors'; @@ -200,7 +200,8 @@ export function useGraphSimulation(): UseGraphSimulationResult { // New node appeared → spawn effect (only if truly new, never seen before). // Nodes returning from filter (e.g. Tasks toggle OFF→ON) are already in allKnown. if (!allKnown.has(node.id) && node.x != null && node.y != null) { - state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state))); + const nodeR = node.kind === 'lead' ? NODE.radiusLead : node.kind === 'member' ? NODE.radiusMember : undefined; + state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state), nodeR)); } // Task completed → shatter effect diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index d28885d8..94fd1daa 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -17,6 +17,10 @@ export interface KanbanColumnHeader { x: number; y: number; color: string; + /** Number of hidden overflow tasks in this column */ + overflowCount: number; + /** Y position for the overflow badge */ + overflowY: number; } /** Zone info per owner for rendering headers */ @@ -125,6 +129,8 @@ export class KanbanLayoutEngine { for (const [colIdx, col] of activeColumns.entries()) { const colX = baseX + colIdx * columnWidth; const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; + const overflow = Math.max(0, col.tasks.length - maxVisibleRows); + const visibleCount = Math.min(col.tasks.length, maxVisibleRows); // Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y) headers.push({ @@ -132,6 +138,8 @@ export class KanbanLayoutEngine { x: colX, // pill center = task.x = colX y: baseY, color: config.color, + overflowCount: overflow, + overflowY: baseY + headerHeight + visibleCount * rowHeight, }); // Position tasks below header diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index ea7378f7..dafceec0 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -90,10 +90,22 @@ export interface GraphNode { reviewState?: 'none' | 'review' | 'needsFix' | 'approved'; /** Requires clarification indicator */ needsClarification?: 'lead' | 'user' | null; + /** Task is blocked by other tasks */ + isBlocked?: boolean; + /** Display IDs of tasks blocking this one */ + blockedByDisplayIds?: string[]; + /** Display IDs of tasks this one blocks */ + blocksDisplayIds?: string[]; // ─── Process-specific ────────────────────────────────────────────────── /** Clickable URL for process */ processUrl?: string; + /** Who registered the process */ + processRegisteredBy?: string; + /** Command used to start the process */ + processCommand?: string; + /** When the process was registered (ISO) */ + processRegisteredAt?: string; // ─── Force simulation (managed by the package internally) ────────────── x?: number; diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index cfd2553a..d832871d 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -201,9 +201,9 @@ export const GraphCanvas = forwardRef(funct } drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges); - // 2b. Particles (cap at 50 for performance) - const cappedParticles = state.particles.length > 50 - ? state.particles.slice(-50) + // 2b. Particles (cap at 100 for performance) + const cappedParticles = state.particles.length > 100 + ? state.particles.slice(-100) : state.particles; drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4ad82395..f86b2ff1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6457,6 +6457,28 @@ export class TeamProvisioningService { ); } } + + // Also attempt control_response via stdin — the lead runtime MAY forward it + // to the teammate subprocess. This was broken before (missing updatedInput: {}) + // but is now fixed. Belt-and-suspenders: settings handle future calls, + // control_response may unblock the CURRENT waiting prompt. + if (allow && run.child?.stdin?.writable) { + const controlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'allow', updatedInput: {} }, + }, + }; + run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => { + if (err) { + logger.warn( + `[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}` + ); + } + }); + } } /** diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index d6a84fd9..e0b4598f 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -279,7 +279,7 @@ export class TeamGraphAdapter { : undefined, recentTools: (toolHistory?.[leadName] ?? []) .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) - .slice(0, 3) + .slice(0, 5) .map((tool) => ({ name: tool.toolName, preview: tool.preview, @@ -346,7 +346,7 @@ export class TeamGraphAdapter { : undefined, recentTools: (toolHistory?.[member.name] ?? []) .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) - .slice(0, 3) + .slice(0, 5) .map((tool) => ({ name: tool.toolName, preview: tool.preview, @@ -369,11 +369,32 @@ export class TeamGraphAdapter { } #buildTaskNodes(nodes: GraphNode[], edges: GraphEdge[], data: TeamData, teamName: string): void { + // Build lookup tables for fast resolution + const completedTaskIds = new Set(); + const taskDisplayIds = new Map(); + for (const t of data.tasks) { + if (t.status === 'completed' || t.status === 'deleted') completedTaskIds.add(t.id); + taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`); + } + for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null; + // Task is blocked if any blockedBy task is still not completed + const isBlocked = + (task.blockedBy?.length ?? 0) > 0 && + task.blockedBy!.some((id) => !completedTaskIds.has(id)); + + // Resolve display IDs for dependencies + const blockedByDisplayIds = task.blockedBy?.length + ? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) + : undefined; + const blocksDisplayIds = task.blocks?.length + ? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) + : undefined; + nodes.push({ id: taskId, kind: 'task', @@ -385,6 +406,9 @@ export class TeamGraphAdapter { displayId: task.displayId ?? undefined, ownerId: ownerMemberId, needsClarification: task.needsClarification ?? null, + isBlocked, + blockedByDisplayIds, + blocksDisplayIds, domainRef: { kind: 'task', teamName, taskId: task.id }, }); @@ -453,6 +477,9 @@ export class TeamGraphAdapter { label: proc.label, state: 'active', processUrl: proc.url ?? undefined, + processRegisteredBy: proc.registeredBy ?? undefined, + processCommand: proc.command ?? undefined, + processRegisteredAt: proc.registeredAt, domainRef: { kind: 'process', teamName, processId: proc.id }, }); diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index e4c2db11..0dab486b 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -7,7 +7,7 @@ import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; -import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; +import { Ban, ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; import type { GraphNode } from '@claude-teams/agent-graph'; @@ -78,8 +78,23 @@ export const GraphNodePopover = ({ // Process return ( -
+
{node.label}
+ {node.processCommand && ( +
+ $ {node.processCommand} +
+ )} +
+ {node.processRegisteredBy && ( +
+ Started by: {node.processRegisteredBy} +
+ )} + {node.processRegisteredAt && ( +
At: {new Date(node.processRegisteredAt).toLocaleTimeString()}
+ )} +
{node.processUrl && (
- {node.recentTools.slice(0, 3).map((tool) => { + {node.recentTools.slice(0, 5).map((tool) => { const shortName = formatToolName(tool.name); const shortPreview = formatToolPreview(tool.preview); return ( @@ -368,6 +383,14 @@ const TaskPopoverContent = ({ {node.reviewState} )} + {node.isBlocked && ( + + blocked + + )} {node.needsClarification && ( + {/* Task dependencies */} + {node.blockedByDisplayIds && node.blockedByDisplayIds.length > 0 && ( +
+ Blocked by: {node.blockedByDisplayIds.join(', ')} +
+ )} + {node.blocksDisplayIds && node.blocksDisplayIds.length > 0 && ( +
+ Blocks: {node.blocksDisplayIds.join(', ')} +
+ )} +