Skip to content

Fix: detect Workflow subagents nested under subagents/workflows/ - #56

Open
EdwinChua wants to merge 2 commits into
patoles:mainfrom
EdwinChua:fix/workflow-nested-subagents
Open

Fix: detect Workflow subagents nested under subagents/workflows/#56
EdwinChua wants to merge 2 commits into
patoles:mainfrom
EdwinChua:fix/workflow-nested-subagents

Conversation

@EdwinChua

Copy link
Copy Markdown

Problem

Subagents spawned by the Workflow tool (multi-agent orchestration) never appear in the visualizer. The orchestrator node shows up, but its children are invisible — including when running via npx agent-flow-app.

Root cause

Newer Claude Code writes each Workflow subagent's transcript to:

~/.claude/projects/<encoded>/<sessionId>/subagents/workflows/wf_<id>/agent-*.jsonl

two levels below the session's subagents/ directory. scanSubagentsDir() used a flat fs.readdirSync, so it only ever discovered top-level subagents/*.jsonl files and missed every nested Workflow subagent.

The other two detection paths don't help here either: the Workflow tool_use is not emitted as a Task/Agent block in the main transcript (so the transcript parser never spawns a node), and there are no agent_progress inline events. File tailing is the only path that can surface these subagents — and it was looking one directory too shallow.

Confirmed on a real session: 0 Task/Agent tool_use, 0 agent_progress, 1 Workflow tool_use, 1 flat subagent file, and 22 nested subagents/workflows/wf_*/agent-*.jsonl files that were silently ignored. The nested meta is just {"agentType":"workflow-subagent"}.

Changes

  • collectSubagentJsonlFiles() — recursively walks the subagents/ tree so transcripts at any depth are discovered.
  • scanSubagentsDir() — uses the recursive collector; upgrades to a recursive fs.watch (Windows/macOS) with a flat-watch fallback. The existing POLL_FALLBACK_MS poll covers platforms without recursive watch support (Linux).
  • resolveNameFromMeta() — when the meta carries no descriptive name (Workflow subagent meta has none), derives a stable name from the transcript file id (agent-<hash>.jsonl) so each renders as a distinct node instead of colliding on a generic label.
  • Adds extension/test/subagent-watcher.test.ts covering flat + nested discovery, .meta.json/non-jsonl exclusion, and the missing-directory case.

Reproduction

  1. npx agent-flow-app, connect a Claude Code session.
  2. Trigger a Workflow-tool run in Claude.
  3. Before: orchestrator appears, none of the workflow subagents do. After: each nested subagent renders as its own node.

Testing

  • tsc --noEmit clean.
  • Extension test suite: 23/23 pass (20 existing + 3 new).

Known related limitation (not addressed here, to keep the diff tight)

The "is this session still active?" mtime checks in relay.ts and session-watcher.ts also scan only the flat subagents/ dir, so a session whose main transcript goes stale during a long workflow could be dropped from detection. Easy follow-up using the same recursive walk.

🤖 Generated with Claude Code

Newer Claude Code spawns multi-agent runs via the Workflow tool and writes
each subagent's transcript to:

  ~/.claude/projects/<encoded>/<sessionId>/subagents/workflows/wf_<id>/agent-*.jsonl

These live two levels below the session's `subagents/` directory, but
scanSubagentsDir() used a flat fs.readdirSync, so it only ever saw
top-level `subagents/*.jsonl` files. As a result, Workflow-spawned
subagents never appeared in the visualizer — the orchestrator showed up,
but its children were invisible. (Workflow tool_use blocks are also not
emitted as Task/Agent in the main transcript and produce no agent_progress
events, so file tailing is the only path that can surface them.)

Changes:
- collectSubagentJsonlFiles(): recursively walk the subagents/ tree so
  transcripts at any depth are discovered.
- scanSubagentsDir(): use the recursive collector, and prefer a recursive
  fs.watch (Windows/macOS) with a flat-watch fallback; the existing
  POLL_FALLBACK_MS poll covers platforms without recursive watch (Linux).
- resolveNameFromMeta(): when meta carries no descriptive name (Workflow
  subagent meta is just {"agentType":"workflow-subagent"}), derive a stable
  name from the transcript file id so each renders as a distinct node.

Adds extension/test/subagent-watcher.test.ts covering flat + nested
discovery, sidecar/non-jsonl exclusion, and the missing-dir case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cla-assistant

cla-assistant Bot commented Jun 9, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@patoles patoles left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and for the tests! The recursive discovery is a real gap and the fix is well done. Before merging I'd like to sort out three things, because they all bite the exact workflow sessions this PR targets:

  1. Session discovery is still flat. scanForActiveSessions and newestMtime in session-watcher.ts (and the relay's copy in scripts/relay.ts:303) compute staleness from a top-level readdir, so a session whose only recent activity is nested workflow writes is considered inactive after 10 minutes and never attached, and the recursive tailing never starts. Since you already export collectSubagentJsonlFiles, reusing it in those three spots would complete the fix. Careful with the 10-minute ACTIVE_SESSION_AGE_S behavior there, it's a deliberate invariant.
  2. The filename-derived fallback names can collide with the inline-progress path. That path still names no-meta agents subagent-1 (transcript-parser.ts:402) while the file watcher now produces subagent-<hash6>, and all duplicate suppression between the two paths is name-based (inlineProgressAgents, spawnedSubagents). If a workflow agent also streams agent_progress into the parent transcript, it will render as two nodes with doubled events. Do workflow subagents emit inline progress in your transcripts? If yes we need a dedup story before merge; if not, a comment noting the assumption is fine.
  3. Workflow nodes never complete: subagent agent_complete only fires from a Task/Agent tool_result, which workflow agents don't get, so their nodes stay "running" forever. A transcript-inactivity completion (or an explicit "known limitation" note) would round this out.

Three tiny ones while you're in there: filter for agent-*.jsonl specifically (any stray .jsonl under subagents/ would currently become a phantom node), add a log.debug to the first watch catch so the recursive-to-flat fallback is diagnosable, and consider a short debounce on the recursive watcher callback since every nested append currently triggers a full tree walk (the 3s poll already covers correctness).

Happy to help with any of these if you're short on time. Thanks again!

…completion

Concern 1 (session discovery still flat): extract newestSubagentMtime()
— a recursive walk over the subagents tree — and use it in both
scanForActiveSessions/newestMtime (session-watcher) and the relay's
scanner, so sessions whose only recent activity is nested
workflows/wf_*/ writes (including journal.jsonl, often the freshest
file during a run) are no longer considered inactive. The walk only
runs when the main JSONL alone doesn't qualify, keeping the common
per-tick path flat.

Concern 3 (workflow nodes never complete): when a Workflow tool_result
arrives, the parser now re-scans the subagents dir (the flat fs.watch
fallback can't see writes below workflows/) and emits subagent_return +
agent_complete for every workflow-path subagent with no unmatched
pending tool calls. Idle-check rather than run-id parsing because the
result's "Run ID:" line is conditional (absent for remote launches)
and completing all workflow-path agents would kill a concurrent run's
live nodes. A new SubagentState.completed flag prevents re-completion,
and a transcript that grows again revives its node.

Also align the no-descriptive-meta fallback name with the SubagentStop
hook convention (<agentType>-<id suffix>, e.g. workflow-subagent-87d5b)
so hook-emitted completions resolve to the same node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NdLrLGZKzuRkoRvJmtYB8U
@EdwinChua

Copy link
Copy Markdown
Author

Thanks for the thorough review — pushed 1efc5d2 addressing points 1 and 3, and I have a concrete answer for point 2.

1. Session discovery (fixed). Extracted a shared newestSubagentMtime() (recursive walk, exported from subagent-watcher.ts) and wired it into both scanForActiveSessions() and the relay's scanner, so nested workflows/wf_*/ writes now count toward the activity check. Two details worth noting: the walk deliberately includes journal.jsonl — during a run it's often the most recently written file, so it's a liveness signal even though discovery must not tail it — and it only runs when the main JSONL alone doesn't qualify, so the common per-tick path stays flat. I unified newestMtime() too, though its value is immediately overwritten by resetInactivityTimer in watchSession, so that half was cosmetic.

2. Duplicate nodes (answered — no fix needed for current Claude Code). I verified this empirically against Claude Code v2.1.42 (live experiment plus inspecting the transcript-writing code in the binary): current versions never persist agent_progress entries into the session transcript at all — they're written only for forked slash commands, to background-task output files, and to in-memory UI callbacks. The inline-progress parser path is legacy back-compat, so workflow subagents are file-tailed only and can't double-render. Even on old versions the symptom wouldn't be a second node: handleProgressEvent never emits agent_spawn, and the webview drops events for unknown agents. Happy to add a defensive skip for legacy transcripts if you'd like, but it'd be guarding a path current CC doesn't produce.

3. Workflow nodes never complete (fixed). When a Workflow tool_result arrives, the parser now re-scans the subagents dir (the flat fs.watch fallback can't see writes below workflows/) and emits subagent_return + agent_complete for every workflow-path subagent with no unmatched pending tool calls. I went with the idle-check rather than parsing the run id out of the result text because the Run ID: line is conditional (absent entirely for remote launches), and completing all workflow-path agents would kill a concurrent run's live nodes. A new SubagentState.completed flag prevents re-completion, and a transcript that grows again revives its node. I also aligned the fallback name for descriptive-name-less metas with the SubagentStop hook convention (<agentType>-<id suffix>, e.g. workflow-subagent-87d5b), so hook-emitted completions resolve to the same node as a bonus.

Tests: added a suite for the completion logic (idle vs. busy vs. never-spawned vs. re-completion) and for newestSubagentMtime; 38 extension + 24 script tests pass, tsc clean, relay bundle builds.

@patoles

patoles commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Thanks for the thorough response, and for the tests. I verified everything locally: the liveness walk behaves as described in both the watcher and the relay, the completion logic handles the late-discovery ordering correctly, and your point 2 checks out on my own transcripts too (zero persisted progress entries), so I'm happy to leave that path alone.

One thing before this can merge. Your journal.jsonl observation surfaced a real gap in the tailing path: collectSubagentJsonlFiles picks up every .jsonl, so the journal gets watched as an agent transcript, and the lazy-spawn in readSubagentNewLines will create a phantom node for it once the journal is written mid-run (I confirmed the layout on a real workflow dir). Since newestSubagentMtime rightly keeps the journal for liveness, the fix is just filtering the collector to agent-*.jsonl, plus a test fixture tweak.

The idle-but-unread-tail race (complete then revive, node stays alive until session end) is fine by me given the revival mechanism; a short comment there would be plenty. Really nice work on this round, the completion heuristic reasoning is exactly right.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants