Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,24 @@ You can also point Agent Flow at a JSONL event log file:
- Claude Code CLI
- For the VS Code extension: a VSCode-compatible IDE 1.85+ (e.g. [VS Code](https://code.visualstudio.com/), [Cursor](https://cursor.sh/), [Windsurf](https://windsurf.com/))

## Query Parameters

### Label Size

Control agent name label width with the `labelSize` query parameter:

- `?labelSize=compact` — Short labels (multiplier: 2x)
- `?labelSize=normal` — Default (multiplier: 3.5x)
- `?labelSize=wide` — Wider labels (multiplier: 5x)
- `?labelSize=full` — Full width labels (multiplier: 8x)

Example: `http://127.0.0.1:4321?labelSize=wide`

### Debug Overlays

- `?perf` — Show performance overlay (FPS, frame times)
- `?stress` — Enable stress test mode with performance overlay

## Development

```bash
Expand Down
5 changes: 3 additions & 2 deletions app/src/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,16 @@ const MIME_TYPES: Record<string, string> = {

export function serveStatic(req: http.IncomingMessage, res: http.ServerResponse) {
const url = req.url || '/'
const pathname = url.split('?')[0]

if (url === '/' || url === '/index.html') {
if (pathname === '/' || pathname === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(HTML_SHELL)
return
}

// Only serve known asset files from the webview directory
const basename = path.basename(url)
const basename = path.basename(pathname)
const ext = path.extname(basename)
const mime = MIME_TYPES[ext]

Expand Down
2 changes: 1 addition & 1 deletion extension/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export function generateSubagentFallbackName(id: string, index: number): string
/** Extract a child agent name from a tool_use input block (Agent or Task tool).
* Used by both live processing and prescan to avoid duplicating the extraction logic. */
export function resolveSubagentChildName(input: Record<string, unknown>): string {
return String(input.description || input.subagent_type || 'subagent').slice(0, CHILD_NAME_MAX)
return String(input.description || input.subagent_type || input.agentType || 'subagent').slice(0, CHILD_NAME_MAX)
}

/** Prefixes that identify system-injected content (not real user messages) */
Expand Down
51 changes: 44 additions & 7 deletions extension/src/hook-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
ORCHESTRATOR_NAME, PREVIEW_MAX, RESULT_MAX,
SESSION_ID_DISPLAY, FAILED_RESULT_MAX, HOOK_MAX_BODY_SIZE,
SUBAGENT_ID_SUFFIX_LENGTH, HOOK_SERVER_HOST, HOOK_SERVER_NOT_STARTED,
generateSubagentFallbackName,
generateSubagentFallbackName, resolveSubagentChildName,
} from './constants'
import { summarizeInput, summarizeResult, extractFilePath, buildDiscovery } from './tool-summarizer'
import { estimateTokenCost } from './token-estimator'
Expand Down Expand Up @@ -259,14 +259,51 @@ export class HookServer implements vscode.Disposable {
const agentType = payload.agent_type || 'subagent'
const agentId = payload.agent_id || ''
const sessionAgents = this.getOrCreateSession(payload.session_id).agentNames
const childName = agentId ? `${agentType}-${agentId.slice(-SUBAGENT_ID_SUFFIX_LENGTH)}` : generateSubagentFallbackName(String(Date.now()), sessionAgents.size + 1)

sessionAgents.set(agentId, childName)
const self = this
const tryReadMeta = (): string | null => {
try {
if (agentId && payload.session_id && payload.cwd) {
const fs = require('fs')
const path = require('path')
const os = require('os')
const resolvedCwd = fs.realpathSync(payload.cwd)
const encoded = resolvedCwd.replace(/[/\\:.]/g, '-')
const metaPath = path.join(
os.homedir(), '.claude', 'projects', encoded,
payload.session_id, 'subagents', `agent-${agentId}.meta.json`,
)
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
const name = resolveSubagentChildName(meta)
if (name && name !== 'subagent') return name
}
} catch { /* meta.json may not exist yet */ }
return null
}

const doSpawn = (childName: string) => {
sessionAgents.set(agentId, childName)
emitSubagentSpawn(
{ emit: (e, s) => self.emit(e, s), elapsed: (s) => self.elapsedSeconds(s) },
parentName, childName, agentType, payload.session_id, agentType,
)
}

// Try immediately — meta.json may already exist
const immediate = tryReadMeta()
if (immediate) {
doSpawn(immediate)
return
}

emitSubagentSpawn(
{ emit: (e, s) => this.emit(e, s), elapsed: (s) => this.elapsedSeconds(s) },
parentName, childName, agentType, payload.session_id,
)
// Retry after 250ms — Claude Code writes meta.json shortly after SubagentStart
setTimeout(() => {
const name = tryReadMeta()
|| (agentId
? `${agentType}-${agentId.slice(-SUBAGENT_ID_SUFFIX_LENGTH)}`
: generateSubagentFallbackName(String(Date.now()), sessionAgents.size + 1))
doSpawn(name)
}, 250)
}

private handleSubagentStop(payload: HookPayload): void {
Expand Down
3 changes: 2 additions & 1 deletion extension/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function emitSubagentSpawn(
child: string,
task: string,
sessionId?: string,
agentType?: string,
): void {
emitter.emit({
time: emitter.elapsed(sessionId),
Expand All @@ -136,7 +137,7 @@ export function emitSubagentSpawn(
emitter.emit({
time: emitter.elapsed(sessionId),
type: 'agent_spawn',
payload: { name: child, parent, task },
payload: { name: child, parent, task, ...(agentType ? { agentType } : {}) },
}, sessionId)
}

Expand Down
3 changes: 2 additions & 1 deletion extension/src/transcript-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,13 @@ export class TranscriptParser {
// Check if this is a subagent call (Task in older Claude Code, Agent in newer versions)
if (toolName === 'Task' || toolName === 'Agent') {
const childName = resolveSubagentChildName(block.input)
const agentType = String(block.input.subagent_type || block.input.agentType || '')
this.subagentChildNames.set(block.id, childName)
// Only emit spawn once per subagent name (file watcher may have already spawned it)
const session = sessionId ? this.delegate.getSession(sessionId) : undefined
if (!session?.spawnedSubagents.has(childName)) {
session?.spawnedSubagents.add(childName)
emitSubagentSpawn(this.delegate, agentName, childName, args, sessionId)
emitSubagentSpawn(this.delegate, agentName, childName, args, sessionId, agentType || undefined)
}
}

Expand Down
4 changes: 2 additions & 2 deletions scripts/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ function scanForActiveSessions(workspace: string) {

let resolved = workspace
try { resolved = fs.realpathSync(resolved) } catch {}
const encoded = resolved.replace(/[/\\:]/g, '-')
const encoded = resolved.replace(/[/\\:.]/g, '-')

const dirsToScan: string[] = []
const projectDir = path.join(CLAUDE_DIR, encoded)
Expand Down Expand Up @@ -353,7 +353,7 @@ export async function createRelay(options: RelayOptions): Promise<Relay> {
const scanInterval = setInterval(() => scanForActiveSessions(workspace), SCAN_INTERVAL_MS)

const resolved = (() => { try { return fs.realpathSync(workspace) } catch { return workspace } })()
const encoded = resolved.replace(/[/\\:]/g, '-')
const encoded = resolved.replace(/[/\\:.]/g, '-')
const projectDir = path.join(CLAUDE_DIR, encoded)
let projectDirWatcher: fs.FSWatcher | null = null
if (fs.existsSync(projectDir)) {
Expand Down
11 changes: 7 additions & 4 deletions web/components/agent-visualizer/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
drawEdges, getActiveEdgeIds,
drawParticles, buildEdgeMap,
drawToolCalls,
drawServiceNodes,
drawDiscoveries, drawDiscoveryConnections,
drawCostLabels, drawCostSummaryPanel,
detectStateChanges as detectStateChangesPure,
Expand Down Expand Up @@ -90,7 +91,7 @@ export function AgentCanvas({
// at the top of each draw frame, so it's always fresh even without re-renders.
const sim = simulationRef.current
const makeDrawProps = (prev?: { isDragging: boolean }) => ({
agents: sim.agents, toolCalls: sim.toolCalls,
agents: sim.agents, toolCalls: sim.toolCalls, serviceNodes: sim.serviceNodes,
particles: sim.particles, edges: sim.edges, discoveries: sim.discoveries,
selectedAgentId, hoveredAgentId, showStats, showHexGrid,
showCostOverlay, selectedToolCallId, selectedDiscoveryId,
Expand Down Expand Up @@ -182,14 +183,15 @@ export function AgentCanvas({
const p = drawPropsRef.current
p.agents = s.agents
p.toolCalls = s.toolCalls
p.serviceNodes = s.serviceNodes
p.particles = s.particles
p.edges = s.edges
p.discoveries = s.discoveries
p.simTime = s.currentTime
}

const {
agents, toolCalls, particles, edges, discoveries,
agents, toolCalls, serviceNodes, particles, edges, discoveries,
selectedAgentId, hoveredAgentId, showStats, showHexGrid,
showCostOverlay, selectedToolCallId, selectedDiscoveryId,
simTime, pauseAutoFit, dimensions, onAgentDrag,
Expand Down Expand Up @@ -266,13 +268,14 @@ export function AgentCanvas({
}

drawDiscoveryConnections(ctx, discoveries, agents)
drawEdges(ctx, edges, agents, toolCalls, activeEdgeIds, timeRef.current)
drawEdges(ctx, edges, agents, toolCalls, activeEdgeIds, timeRef.current, serviceNodes)
drawServiceNodes(ctx, serviceNodes, timeRef.current)
drawToolCalls(ctx, toolCalls, timeRef.current, selectedToolCallId)
drawDiscoveries(ctx, discoveries, agents, selectedDiscoveryId)
drawAgents(ctx, agents, selectedAgentId, hoveredAgentId, showStats, timeRef.current)
drawMessageBubblesWorld(ctx, agents, simTimeRef.current)
if (showCostOverlay) drawCostLabels(ctx, agents, toolCalls)
drawParticles(ctx, particles, edgeMap, agents, toolCalls, timeRef.current)
drawParticles(ctx, particles, edgeMap, agents, toolCalls, timeRef.current, serviceNodes)
drawEffects(ctx, effectsRef.current)

if (selectedAgentId) {
Expand Down
20 changes: 16 additions & 4 deletions web/components/agent-visualizer/canvas/draw-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,25 @@ function drawWaitingRipples(ctx: CanvasRenderingContext2D, agent: Agent, r: numb
}

function drawAgentLabel(ctx: CanvasRenderingContext2D, agent: Agent, r: number, isHovered: boolean) {
ctx.fillStyle = isHovered ? COLORS.textPrimary : COLORS.textDim
ctx.font = '10px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const maxLabelW = r * AGENT_DRAW.labelWidthMultiplier
const agentLabel = truncateText(ctx, agent.name, maxLabelW)
ctx.fillText(agentLabel, agent.x, agent.y + r + AGENT_DRAW.labelYOffset)
let labelY = agent.y + r + AGENT_DRAW.labelYOffset

// Line 1: agent type (for subagents) or agent name (for orchestrator) — bold, bright
const primaryLabel = agent.agentType || agent.name
ctx.fillStyle = isHovered ? COLORS.textPrimary : COLORS.textPrimary
ctx.font = 'bold 10px monospace'
ctx.fillText(truncateText(ctx, primaryLabel, maxLabelW), agent.x, labelY)
labelY += 13

// Line 2: description (for subagents) or task/user message (for orchestrator) — dim
const secondaryLabel = agent.agentType ? agent.name : agent.task
if (secondaryLabel) {
ctx.fillStyle = isHovered ? COLORS.textPrimary : COLORS.textDim
ctx.font = '10px monospace'
ctx.fillText(truncateText(ctx, secondaryLabel, maxLabelW), agent.x, labelY)
}
}

function drawStatsOverlay(ctx: CanvasRenderingContext2D, agent: Agent, r: number) {
Expand Down
17 changes: 11 additions & 6 deletions web/components/agent-visualizer/canvas/draw-edges.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Agent, ToolCallNode, Particle, Edge, BEAM, ANIM } from '@/lib/agent-types'
import { Agent, ToolCallNode, ServiceNode, Particle, Edge, BEAM, ANIM } from '@/lib/agent-types'
import { COLORS } from '@/lib/colors'
import { alphaHex } from '@/lib/utils'
import { MIN_VISIBLE_OPACITY } from '@/lib/canvas-constants'
Expand All @@ -8,15 +8,19 @@ export function bezierPoint(t: number, p0: number, p1: number, p2: number, p3: n
return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3
}

/** Resolve edge endpoint to {x, y} from either agents or toolCalls map */
/** Resolve edge endpoint to {x, y} from agents, toolCalls, or serviceNodes map */
export function resolveEdgeTarget(
edge: Edge, agents: Map<string, Agent>, toolCalls: Map<string, ToolCallNode>,
minOpacity = 0,
minOpacity = 0, serviceNodes?: Map<string, ServiceNode>,
): { x: number; y: number } | null {
const toAgent = agents.get(edge.to)
if (toAgent && toAgent.opacity >= minOpacity) return toAgent
const toTool = toolCalls.get(edge.to)
if (toTool && toTool.opacity >= minOpacity) return toTool
if (serviceNodes) {
const toService = serviceNodes.get(edge.to)
if (toService && toService.opacity >= minOpacity) return toService
}
return null
}

Expand Down Expand Up @@ -105,12 +109,13 @@ export function drawEdges(
toolCalls: Map<string, ToolCallNode>,
activeEdgeIds: Set<string>,
time: number,
serviceNodes?: Map<string, ServiceNode>,
) {
for (const edge of edges) {
const fromAgent = agents.get(edge.from)
if (!fromAgent || fromAgent.opacity < MIN_VISIBLE_OPACITY) continue

const target = resolveEdgeTarget(edge, agents, toolCalls, MIN_VISIBLE_OPACITY)
const target = resolveEdgeTarget(edge, agents, toolCalls, MIN_VISIBLE_OPACITY, serviceNodes)
if (!target) continue
const toX = target.x, toY = target.y

Expand All @@ -123,8 +128,8 @@ export function drawEdges(
if (!cp) continue
const { cp1x, cp1y, cp2x, cp2y } = cp

const beamColor = edge.type === 'tool' ? COLORS.tool : COLORS.holoBase
const bw = edge.type === 'tool' ? BEAM.tool : BEAM.parentChild
const beamColor = edge.type === 'service' ? COLORS.service : edge.type === 'tool' ? COLORS.tool : COLORS.holoBase
const bw = edge.type === 'tool' ? BEAM.tool : edge.type === 'service' ? BEAM.parentChild : BEAM.parentChild

ctx.save()

Expand Down
5 changes: 3 additions & 2 deletions web/components/agent-visualizer/canvas/draw-particles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Agent, ToolCallNode, Particle, Edge, BEAM, FX } from '@/lib/agent-types'
import { Agent, ToolCallNode, ServiceNode, Particle, Edge, BEAM, FX } from '@/lib/agent-types'
import { COLORS } from '@/lib/colors'
import { PARTICLE_DRAW } from '@/lib/canvas-constants'
import { alphaHex } from '@/lib/utils'
Expand All @@ -19,6 +19,7 @@ export function drawParticles(
agents: Map<string, Agent>,
toolCalls: Map<string, ToolCallNode>,
time: number,
serviceNodes?: Map<string, ServiceNode>,
) {
for (const particle of particles) {
const edge = edgeMap.get(particle.edgeId)
Expand All @@ -27,7 +28,7 @@ export function drawParticles(
const fromAgent = agents.get(edge.from)
if (!fromAgent) continue

const target = resolveEdgeTarget(edge, agents, toolCalls)
const target = resolveEdgeTarget(edge, agents, toolCalls, 0, serviceNodes)
if (!target) continue
const toX = target.x, toY = target.y

Expand Down
Loading