[BUG] Desktop app workflow panel: per-agent tokens/tools/time stay empty during long parallel runs — progress-only batches omit workflowProgress

Open 💬 0 comments Opened Jul 10, 2026 by zettalyst

What's Wrong?

In the Claude Desktop (macOS) app, the Workflow progress panel shows the header aggregates updating live (e.g. 4 agents · 657.0k tokens, elapsed 10m 42s), but the per-agent table below it — the tokens / tools / time columns — stays completely empty for the entire time the agents are running. Agent labels render fine; the metric cells never populate until an agent finishes.

This reliably happens with long-running parallel fan-outs: a Workflow run that spawns N agents in pure parallel() where each agent runs for many minutes and the script calls no log()/phase() in between. In my case: 4 agents, all running 10+ minutes → all 4 rows empty for 10+ minutes while the header token total kept climbing.

The terminal TUI (/workflows in CLI) is not affected — same run shows live per-agent tokens/tools there.

What Should Happen?

Per-agent tokens/toolCalls (and elapsed time) should update live in the desktop app's workflow panel, matching what the terminal TUI shows for the same run.

Steps to Reproduce

  1. In Claude Code inside the Claude Desktop macOS app, run a Workflow that spawns several long-running agents concurrently, e.g.:

``js
const results = await parallel(PROMPTS.map(p => () => agent(p, {schema: S})))
`
with prompts heavy enough that each agent runs for several minutes, and no
log()` calls while they run.

  1. Open the workflow panel in the app.
  2. Observe: header shows N agents · X tokens updating live, but every agent row's tokens/tools/time cells are empty until an agent reaches done/error.

Root Cause Analysis

Recovered from the shipped CLI binary (bun-compiled; identifiers below are minified and vary per build). Verified identical logic in 2.1.205 (bundled in the desktop app) and 2.1.206 (standalone, latest as of 2026-07-10).

The workflow runner tracks per-agent metrics correctly — each agent's stream loop emits progress ticks with live counters, and durationMs only on completion:

Ie("progress", { tokens: sr + Tn, toolCalls: ht + hn })
// on completion:
Ie("done", { tokens: sr + Tn, toolCalls: ht + hn, durationMs: wt + si, resultPreview: ... })
// on start (no tokens unless journal-resume respawn):
Ie("start", sr || ht ? { tokens: sr, toolCalls: ht } : void 0)

The in-process reducer merges these into workflowProgress[] and sums totalTokens/totalToolCalls. The bug is in the bridge that forwards state to the host app (onSdkEmit):

onSdkEmit: (q) => {
  let G = q.filter(obd);
  if (G.length === 0) return;
  let U = S.getAppState()?.tasks?.[t];
  if (U?.type !== "local_workflow" || U.status !== "running") return;
  let $ = G.findLast((W) => W.type === "workflow_agent"),
      j = G.every((W) => W.type === "workflow_agent" && W.state === "progress");  // ←
  XKt({
    taskId: t, toolUseId: u, description: ..., startTime: _.startTime,
    totalTokens: U.totalTokens, toolUses: U.totalToolCalls,
    lastToolName: $?.label, summary: m,
    workflowProgress: j ? void 0 : U.workflowProgress.filter(obd)  // ← elided
  })
}

If the emitted batch consists solely of workflow_agent events with state === "progress", the per-agent list (workflowProgress) is set to undefined and only the header aggregates go out. The row list is only re-sent when a batch contains a start/done/error, workflow_log, or workflow_phase event.

Consequences:

  • The state:"start" snapshot (the last full list the app receives) carries no tokens/toolCalls yet, and durationMs only ever appears on done.
  • So in any window where all agents are mid-flight and nothing starts/finishes and no log() fires, every batch is progress-only → the host UI never receives a row list with populated metrics → the table stays frozen at the empty start snapshot. Header totals keep updating because they're always included. This matches the observed screenshot exactly.
  • It also explains the intermittent nature: in pipeline() runs or staggered fan-outs, each start/done event flushes a full list (which includes the other running agents' current counters), so rows appear to update "sometimes".
  • Terminal TUI is unaffected because it renders the in-process workflowProgress state directly, not the elided bridge payload.

The data layer is intact — after completion, <session>/workflows/wf_*.json contains correct per-agent metrics:

{ "type": "workflow_agent", "label": "verify:plan", "state": "done",
  "tokens": 178902, "toolCalls": 51, "durationMs": 769722, ... }

Suggested Fix

The elision is presumably a bandwidth optimization (don't re-serialize the whole list every tick). Keeping that intent, any of:

  1. Include workflowProgress in progress-only batches on a throttle (e.g. at most once every 2–5 s), or
  2. Send row-level diffs — only the workflow_agent entries whose tokens/toolCalls changed since the last emit, or
  3. At minimum, include the full list whenever some row's counters changed materially since the last emitted list.

Since the omission is CLI-side, fixing it there fixes every SDK-stream host (desktop app, and any other embedder) at once.

Environment

  • Claude Desktop (macOS): 1.20186.0, bundled Claude Code CLI 2.1.205
  • Same logic verified in standalone CLI 2.1.206 (latest at time of filing)
  • macOS 26.4.1 (arm64)
  • Workflow: dynamic script, 4 agents via parallel(), each running 10+ min, no log() during the fan-out

Error Messages/Logs

N/A — no errors; this is a display/data-propagation gap. Header aggregates prove the event stream itself is healthy.

View original on GitHub ↗