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.
This commit is contained in:
iliya 2026-03-30 21:47:40 +03:00
parent 9a1ba76324
commit bd242fac5a
10 changed files with 166 additions and 22 deletions

View file

@ -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<string, HTMLImageElement>();
const avatarLoading = new Set<string>();
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);
};

View file

@ -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;

View file

@ -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);
}
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -201,9 +201,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(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);

View file

@ -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}`
);
}
});
}
}
/**

View file

@ -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<string>();
const taskDisplayIds = new Map<string, string>();
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 },
});

View file

@ -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 (
<div className="min-w-[180px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
<div className="min-w-[180px] max-w-[260px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
<div className="font-mono text-xs font-bold text-[var(--color-text)]">{node.label}</div>
{node.processCommand && (
<div className="mt-1 truncate font-mono text-[10px] text-[var(--color-text-muted)]">
$ {node.processCommand}
</div>
)}
<div className="mt-2 space-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{node.processRegisteredBy && (
<div>
Started by: <span className="text-[var(--color-text)]">{node.processRegisteredBy}</span>
</div>
)}
{node.processRegisteredAt && (
<div>At: {new Date(node.processRegisteredAt).toLocaleTimeString()}</div>
)}
</div>
{node.processUrl && (
<a
href={node.processUrl}
@ -254,7 +269,7 @@ const MemberPopoverContent = ({
Recent tools
</div>
<div className="space-y-1">
{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}
</Badge>
)}
{node.isBlocked && (
<Badge
variant="outline"
className="border-red-500/30 px-1.5 py-0 text-[10px] text-red-400"
>
<Ban size={10} className="mr-0.5" /> blocked
</Badge>
)}
{node.needsClarification && (
<Badge
variant="outline"
@ -378,6 +401,18 @@ const TaskPopoverContent = ({
)}
</div>
{/* Task dependencies */}
{node.blockedByDisplayIds && node.blockedByDisplayIds.length > 0 && (
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
<span className="text-red-400">Blocked by:</span> {node.blockedByDisplayIds.join(', ')}
</div>
)}
{node.blocksDisplayIds && node.blocksDisplayIds.length > 0 && (
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
<span className="text-amber-400">Blocks:</span> {node.blocksDisplayIds.join(', ')}
</div>
)}
<div className="mt-3">
<Button
variant="outline"