Opt-in (showUltracodeAgents, default OFF) panel that visualizes ultracode / Workflow-tool runs like Claude Code's "working agents" TUI: LEFT = runs + phases (selectable tasks), RIGHT = each run's agents with model, live state, tokens burned, and tool calls. Standalone — ZERO edits to subagent-watcher.ts. A new workflow-run-watcher.ts singleton globs the run-state tree (~/.claude/projects/*/*/workflows/wf_*.json, disjoint from the transcript tree), strips the heavy script/scriptPath/result/logs fields (174KB -> ~25KB/run), and emits workflow:run_* SSE events. The LEFT list ships lightweight summaries (getLightState replay + SSE); the RIGHT pane fetches the full run (with agents[]) via GET /api/workflows/:runId on selection. Backend: workflow-run-watcher.ts, types/workflow-run.ts, config/workflow-config.ts, 3 SSE events, getLightState workflowRuns replay, GET /api/workflows[/:runId], showUltracodeAgents schema key + boot-gate (default OFF) + live toggleService. Frontend: ultracode-panel.js (debounced master-detail render, run/phase select), header launcher (btn-ultracode-agents--hidden marker -> mobile-guard-exempt), App Settings toggle (SYNCED, deliberately not in displayKeys). Agent states on disk are start|progress|done (start=queued; done has durationMs/resultPreview). Tests: workflow-run-watcher (9), workflow-routes (3). Verified: tsc/lint/prettier/frontend-syntax/public-assets/mobile-header-guard clean; full test:ci green (2986 passed); live server + Playwright e2e against 25 real runs (28-agent grid, phase filter, OFF hides launcher). Design: docs/ultracode-agent-viz-plan.md (rev. 3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
30 KiB
Ultracode / Workflow Agent Visualization — Design & Implementation Plan
Status: IMPLEMENTED (2026-06-15, rev. 3) — Phases 1–3 shipped & verified; Phase 4 (live-transcript link) deferred. A dedicated, opt-in master-detail tab (
showUltracodeAgents, default OFF) shows ultracode/Workflow runs as Claude Code's "working agents" TUI: LEFT = runs + phases (selectable tasks), RIGHT = each run's agents with model, live state, tokens burned, and tool calls.What rev. 3 changed vs. rev. 2 (decided during implementation against on-disk truth)
- UI is a master-detail TAB, not grouped floating subagent windows. The user asked for the CC "working agents" view (left task picker, right agent stats). Built as a new docked panel
#ultracodeAgentsPanel(clones.subagents-panelmaster-detail CSS) +src/web/public/ultracode-panel.js— NOT viaopenSubagentWindow/grouped windows.- STANDALONE — zero edits to
subagent-watcher.ts. w16-claudeman's commitf6a30d7already discovers the per-agent workflow transcripts (watchWorkflowDirs). The data the view needs (run/phase/per-agent tokens+toolCalls) lives in the run-state JSON, read by a brand-newsrc/workflow-run-watcher.ts(globs the disjoint…/workflows/wf_*.jsontree). No shared files with w16.- No per-agent transcript streaming needed for v1. The run-state JSON already carries
tokens/toolCalls/state/label/phaseper agent, so the whole view reads fromwf_<runId>.jsonalone. (Phase 4 will optionally link a card to its already-tracked transcript viaagentId— no watcher edits.)- Agent states are
start | progress | done(verified on disk) — NOT running/queued.start=queued (no agentId/tokens/toolCalls yet),donehasdurationMs/resultPreview.- The run JSON's
script(15–660KB embedded JS),scriptPath,result,logsare STRIPPED in the watcher before caching/broadcast (a 28-agent run drops 174KB → ~25KB;promptPreview/resultPreviewtruncated).- SSE/snapshot ship lightweight run SUMMARIES (no
agents[]); the RIGHT pane fetches the full run viaGET /api/workflows/:runIdon selection. (A 25-run snapshot is ~20KB vs ~900KB if it carried every agent.) The LEFT list shows ALL cached runs (LRU-bounded), not a recency window — a run browser must show past runs.Original rev. 2 proposal (grouped floating windows, extending subagent-watcher) preserved below for context; superseded by the above.
What changed in rev. 2 (vs. the first draft)
- No backend cross-watcher coupling. The per-agent label/phase/agentType/state join moves to the frontend at render time — the run object already carries every agent's entry keyed by
agentId. This deletessubagent-watcher's backward dependency onworkflow-run-watcher(getAgentLabel()+ its TTL cache), removes the registration-vs-run-state race (labels always track the latestworkflow:run_updated), and drops the per-agentmeta.jsonread from the hot path. SubagentInfogrows by 2 fields, not 4 (isWorkflowAgent,workflowRunId) — both derivable from the file path alone at registration, zero extra I/O.agentType/label/phase/statecome from the run object on the frontend.- The
isInternalAgentbypass covers BOTH drop sites —registerAgentFileand the late re-resolution inprocessEntry. The first draft named only one. - De-duplicated. Each trap (
journal.jsonl, theprojects/*/*/workflowsdepth, the gate-mismatch lesson, reuse-not-rebuild) is stated once in its owning section.
Code-reuse verified against the tree (2026-06-14)
Confirmed present and shaped as assumed: subagent-watcher.ts — watchSubagentDir/registerAgentFile/tailFile/processEntry, getRecentSubagents, isInternalAgent (drops on MIN_DESCRIPTION_LENGTH=5), STARTUP_MAX_FILE_AGE_MS=4h, MAX_TRACKED_AGENTS, knownSubagentDirs/dirWatchers. team-watcher.ts — configMtimes mtime-skip + chokidar + setInterval poll. server.ts — setupSubagentWatcherListeners, getLightState() (subagents: getRecentSubagents(15), LIGHT_STATE_CACHE_TTL_MS=1000), isSubagentTrackingEnabled() (settings.subagentTrackingEnabled ?? true). Frontend — _SSE_HANDLER_MAP, this.subagents Map, handleInit/cleanupAllFloatingWindows, renderSubagentPanel/_renderSubagentPanelImmediate, getTeammateBadgeHtml, openSubagentWindow + .subagent-window-parent sub-header.
1. The enabling fact: on-disk artifacts
The Workflow tool (what ultracode drives) persists each workflow agent as a transcript under the same subagents/ directory Codeman already watches, one level deeper. Empirically verified against a real run (wf_a8e09f2c-550); re-confirm the shape against a fresh run at implementation time (§8 mandates a live e2e pass anyway):
~/.claude/projects/<projHash>/<sessionUuid>/
├─ subagents/
│ ├─ agent-XX.jsonl ← regular Task subagent (tracked today)
│ └─ workflows/wf_<runId>/
│ ├─ agent-YY.jsonl ← WORKFLOW agent — IDENTICAL line format
│ ├─ agent-YY.meta.json ← {"agentType":"workflow-subagent"} (optional enrichment)
│ └─ journal.jsonl ← run journal {type:"started",...} — MUST be skipped
└─ workflows/wf_<runId>.json ← run state: runId, workflowName, summary, status,
phases[], workflowProgress[], totals (DIFFERENT tree)
The per-agent .jsonl line shape is identical to a regular subagent transcript:
{ "parentUuid": null, "isSidechain": true, "agentId": "ac6a1d27012a64e38",
"type": "user" | "assistant", "message": { "role": "...", "content": "..." }, ... }
Because the line shape is identical, the entire existing parse→event→render pipeline works unchanged once discovery reaches those files. The only new data is the run-level metadata in workflows/wf_<runId>.json (name, summary, phases, and workflowProgress[] — the per-agent labels/state/tools), which supplies the group header and per-agent labels.
Can show: per-agent live transcript (tool calls, messages, results); per-agent status (active/idle/completed via the existing mtime/PID/pgrep liveness); per-agent model + running token totals (from each agent's JSONL message.usage, exactly as today); the run's workflowName/summary/phases[]; per-agent label/phaseTitle/state/lastToolName (from workflowProgress[]); grouping under wf_<runId>.
Cannot show: anything absent from the artifacts — a live phase cursor beyond workflowProgress[].state; an authoritative budget/cost ceiling (only consumed totals exist — usage + run-state totalTokens, no remaining-budget field); runs older than STARTUP_MAX_FILE_AGE_MS (4h) after a server restart (live monitoring only).
2. Architecture
Decision: EXTEND subagent-watcher.ts for per-agent discovery/streaming; ADD a thin workflow-run-watcher.ts (modeled on team-watcher.ts) for the group-header metadata ONLY. The agent→run-metadata join happens on the FRONTEND, so the two watchers stay decoupled.
- The per-agent JSONL is identical in shape, so re-running it through
registerAgentFile()→tailFile()→processEntry()and the existingsubagent:*events is free and reconnect-safe (those agents land inagentInfo, replayed bygetRecentSubagents(15)). A parallel per-agent watcher would duplicate the liveness/token/tool-call/SSE machinery for zero benefit. - Run metadata lives in a different file under a different tree (
workflows/wf_<runId>.json, sibling tosubagents/). A smallWorkflowRunWatcherwatchingprojects/*/*/workflows/wf_*.json(mtime-skip, liketeam-watcher'sconfigMtimes) is the clean home; folding it intosubagent-watcherwould entangle two unrelated watch roots and put a JSON re-read in the hot per-line path. - The two watchers never call each other. The frontend receives both streams and joins agent→label by
agentIdat render time (the run object carries every agent's entry). This removes the timing coupling entirely.
~/.claude/projects/<projHash>/<sessionUuid>/
├─ subagents/
│ ├─ agent-XX.jsonl ──────────────► SubagentWatcher (EXTENDED: also descends
│ └─ workflows/wf_<runId>/ workflows/wf_<runId>/, tags isWorkflowAgent+runId)
│ ├─ agent-YY.jsonl ─┐ reuse registerAgentFile/tailFile/processEntry
│ └─ journal.jsonl (SKIP) emits subagent:* (now w/ 2 workflow fields)
└─ workflows/wf_<runId>.json ──────► WorkflowRunWatcher (NEW, team-watcher-shaped)
{workflowName,phases,workflowProgress[]} emits workflow:run_discovered|updated|removed
server.ts
setupSubagentWatcherListeners() ──► broadcast(subagent:*) ─┐
setupWorkflowRunWatcherListeners() ──► broadcast(workflow:run_*) │ SSE
getLightState(): subagents + workflowRuns ───────────────────────┘
│
▼ app.js dispatch table
panels-ui: partition this.subagents by workflowRunId; header + per-agent
labels JOINED from this.workflowRuns.get(runId).agents (by agentId)
3. Backend changes (ordered, file-by-file)
3a. src/subagent-watcher.ts — nested discovery + 2 tag fields
(1) Extend SubagentInfo with exactly two optional fields (optional → regular subagents and the wire shape are unaffected):
isWorkflowAgent?: boolean; // true when discovered under subagents/workflows/<wf_runId>/
workflowRunId?: string; // e.g. "wf_23dbeab2-152" (parent dir name)
Both are derived from the file path alone at registration — no extra reads. They ride existing subagent:discovered|updated|completed payloads (no new per-agent event). Do not add agentType/label/phase/workflowName here — those come from the run object on the frontend (§4c).
(2) Constant. const WORKFLOWS_SUBDIR = 'workflows'; near the existing dir constants.
(3) watchSubagentDir() — descend into workflows/<wf_runId>/. After the existing direct-child registration loop:
// Workflow agents live one level deeper: subagents/workflows/<wf_runId>/agent-*.jsonl
const wfRoot = join(dir, WORKFLOWS_SUBDIR);
try {
for (const runId of await readdir(wfRoot)) {
if (!runId.startsWith('wf_')) continue;
await this.watchWorkflowRunDir(join(wfRoot, runId), projectHash, sessionId, runId);
}
} catch { /* no workflows subdir — normal for most sessions */ }
The existing fs.watch(dir, …) on subagents/ is non-recursive on Linux and won't fire for writes inside workflows/<runId>/, so each run dir needs its own watcher.
(4) New private watchWorkflowRunDir(runDir, projectHash, sessionId, runId) — clone watchSubagentDir's structure, but:
- Register only files matching
^agent-.*\.jsonl$, explicitly skippingjournal.jsonl(it ends in.jsonlbut is{type:'started',…}, not a transcript — registering it would create a phantom agent). - Call
registerAgentFile(filePath, projectHash, sessionId, isInitialScan, runId)so the agent is tagged. - Install one
watch(runDir, …)per run dir; onerrorandstop(), reuse the existing teardown (close + delete fromdirWatchers/knownSubagentDirs/dirWatcherErrorHandlers). - Guard re-registration per run dir in
knownSubagentDirs, notwfRoot— the 5s full scan must still re-readdir(wfRoot)to pick up newwf_<runId>dirs created mid-session.
(5) registerAgentFile() — accept + apply runId. Add a trailing optional runId?: string. When set, the whole change is:
if (runId) { info.isWorkflowAgent = true; info.workflowRunId = runId; }
No meta.json read, no run-state lookup, no description override. agentIds are globally unique a<16hex> (verified: 0 collisions across a 370-agent corpus), so keep the flat agentInfo map keyed by agentId — do not switch to a composite key. Add a one-line dev-assert log if agentInfo.has(agentId) with a different workflowRunId, so a future collision is observable.
(6) isInternalAgent bypass — BOTH drop sites. Workflow agents have no Task-tool spawn record, so _resolveDescription yields only the first-user-message fallback (often a long phase prompt) or empty → isInternalAgent (length < MIN_DESCRIPTION_LENGTH) would wrongly drop them. They are real by construction (the subagents/workflows/wf_*/ path is the discriminator). Gate the drop on !info.isWorkflowAgent at both places:
registerAgentFileinitial check (isInternalAgent(description)),processEntry's late re-resolution (the secondisInternalAgentcall).
(7) stop() teardown. Per-run watchers live in dirWatchers, so the existing close-all loop covers them — verify no separate map was introduced (24h runs spawn many wf_<runId> dirs → FSWatcher leak risk).
3b. NEW src/workflow-run-watcher.ts (singleton, EventEmitter — model on team-watcher.ts)
- Watch root:
~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json— two levels underprojects(verified:projects/*/workflowsis empty; must beprojects/*/*/workflows/). chokidardepth:3+ a poll fallback, mirroringteam-watcher's dual discovery + interval. - mtime-skip:
runMtimes: Map<absPath, number>(mirrorteam-watcher.configMtimes). - Parse: read
wf_<runId>.json, take the top-level structured keys (runId,workflowName,summary,status,phases:[{title,detail}],agentCount,defaultModel,durationMs,totalTokens,totalToolCalls,workflowProgress[]). Do NOT parse the embeddedscriptstring — name/phases/summary are already top-level; the script'sexport const metais redundant and costly. DerivesessionUuidfrom the dir name,projectHashfrom the dir above; exposegetProjectHash(workingDir)for Codeman-session correlation. workflowProgress[] → agents[]: filtertype === 'workflow_agent', map each to aWorkflowAgentEntry(§3c) keyed byagentId. This array is the join source the frontend uses — no backendgetAgentLabel()API, no TTL cache, no import fromsubagent-watcher.- Emit
workflow:run_discovered|updated|removedcarryingWorkflowRunInfo; removal by set-diff (mirrorteam-watcher). - Lifecycle:
start()/stop()withCleanupManagerteardown of chokidar + interval + caches;LRUMap-bounded run cache (24h memory rule).
3c. src/types/ — workflow run types
export interface WorkflowAgentEntry { // one workflowProgress[type==='workflow_agent']
agentId: string; label: string; phaseIndex?: number; phaseTitle?: string;
agentType?: string; model?: string; state?: string; // 'done'|'running'|'queued'|...
lastToolName?: string; lastToolSummary?: string; tokens?: number; toolCalls?: number;
}
export interface WorkflowRunInfo {
runId: string; sessionUuid: string; projectHash: string;
workflowName?: string; summary?: string; status?: string; // 'running'|'completed'|...
phases: Array<{ title: string; detail?: string }>;
agentCount?: number; defaultModel?: string;
agents: WorkflowAgentEntry[]; // workflowProgress filtered to workflow_agent, keyed by agentId
startedAt?: number; durationMs?: number; totalTokens?: number; totalToolCalls?: number;
}
The two SubagentInfo workflow fields stay inline in subagent-watcher.ts (matching the existing convention).
3d. src/web/sse-events.ts — register run events
Add workflow:run_discovered, workflow:run_updated, workflow:run_removed after the subagent:* block and to the SseEvent union. No new per-agent event — workflow agents reuse subagent:*.
3e. src/web/server.ts — bridge, snapshot, gating
setupWorkflowRunWatcherListeners()(besidesetupSubagentWatcherListeners): map the three run events →this.broadcast(...). AddcleanupWorkflowRunWatcherListeners()(store handler refs).- Start/stop: call
workflowRunWatcher.start()/.stop()besidesubagentWatcher, gated on the same enable condition (§3f). getLightState(): addworkflowRuns: workflowRunWatcher.getRecentRuns(15)besidesubagents: subagentWatcher.getRecentSubagents(15)so headers replay on reconnect (agents already replay viasubagents). Keep theLIGHT_STATE_CACHE_TTL_MSmemoization.- Gating read: add
isWorkflowAgentTrackingEnabled()mirroringisSubagentTrackingEnabled()(boot-timedataPath('settings.json')read). GateworkflowRunWatcher.start()and the subagent-watcherworkflows/descent (§3a-3) onshowUltracodeAgentsso non-opted-in users never register historical workflow agents.
3f. src/web/schemas.ts — settings key
Add showUltracodeAgents: z.boolean().optional() to the .strict() settings update schema near showPlanUsageLimits (required — .strict() 400s the whole PUT on an unknown key).
3g. src/web/routes/system-routes.ts — poll API
GET /api/subagentsandGET /api/sessions/:id/subagentsinclude workflow agents once registered — no change (they carryisWorkflowAgent/workflowRunId; a consumer joins to/api/workflows/:runIdfor labels).- Add
GET /api/workflows→workflowRunWatcher.getRecentRuns()andGET /api/workflows/:runId(uniformApiResponsecontract; headers are also ingetLightState). GET /api/subagents/:agentId/transcriptworks for workflow agents (they're inagentInfo) — no new route.
4. Frontend changes (file-by-file)
4a. src/web/public/constants.js
- Add the three SSE strings to
SSE_EVENTS, matching §3d exactly (WORKFLOW_RUN_DISCOVERED: 'workflow:run_discovered', etc.). - Reuse
ZINDEX_SUBAGENT_BASE=1000for the agent windows (they ARE subagent windows). The group header/cluster is in-flow panel DOM, not a floating window — no new z-index (1100 is plan-subagent).
4b. src/web/public/app.js
- Constructor:
this.workflowRuns = new Map(); // runId -> WorkflowRunInfobesidethis.subagents. _SSE_HANDLER_MAP: add three rows →_onWorkflowRunDiscovered/Updated/Removed(must exist beforeconnectSSEbuilds the wrappers).handleInit: after seedingdata.subagents, seedthis.workflowRunsfromdata.workflowRuns(clear-then-set). Clearthis.workflowRunseverywhere the subagent Maps are cleared (incl.cleanupAllFloatingWindows) — 24h leak guard.
4c. src/web/public/panels-ui.js — the join lives here
_onWorkflowRunDiscovered/Updated(data)→this.workflowRuns.set(data.runId, data)+ debounced re-render;_onWorkflowRunRemoved→ delete + re-render.- No change to
_onSubagentDiscovered/Updated— they already store the whole payload, so the 2 new fields ride along. renderSubagentPanel/_renderSubagentPanelImmediate: whenshowUltracodeAgentsis on, partitionthis.subagentsinto flat (noworkflowRunId) vs grouped-by-workflowRunId. Flat agents render exactly as today. For each group: build the header fromthis.workflowRuns.get(runId)(workflowName+ phase/status chip fromphases[]), then render that run's agents reusing the existing per-agent row markup. Per-agent label/phase/agentType come from the JOIN — buildMap(agentId → entry)fromthis.workflowRuns.get(runId).agentsand look each agent up byagent.agentId; render the small chip via thegetTeammateBadgeHtmlpattern. (If the run object hasn't arrived yet, fall back to the agent's owndescription— the run:updatedevent will fill it in on the next render.)findParentSessionForSubagentis unchanged — workflow agentsessionId === session.claudeSessionId. Do not conflateworkflowRunIdwithsessionId.
4d. src/web/public/subagent-windows.js
Decision: REUSE .subagent-window per agent + a group sub-header — do NOT build a cluster class. A cluster path duplicates Map/z-index/drag/cleanup/persistence for no functional gain; reuse keeps connection lines, minimize-to-tab, and localStorage persistence. In openSubagentWindow, where the optional .subagent-window-parent sub-header is built: when agent.workflowRunId is set, inject a .subagent-workflow-header showing this.workflowRuns.get(runId)?.workflowName + the joined agent's label/phase (look up by agentId), mirroring the from <session> sub-header. Respect the existing skip guards (teammate-terminal windows, minimized/_lazyTerminal).
Do NOT auto-open windows for workflow agents — a multi-phase run can spawn many, against the 50-window/60fps budget + MAX_TRACKED_AGENTS=500. They render collapsed in the grouped panel; the user expands via the existing panel buttons.
4e. src/web/public/settings-ui.js + index.html
index.htmlPanels block: add asettings-itemcheckboxid="appSettingsShowUltracodeAgents"("Show ULTRACODE / Workflow Agents").openAppSettings: loadsettings.showUltracodeAgentswithfalsefallback (mirrorshowPlanUsageLimits).saveAppSettings: collectshowUltracodeAgentsinto the fresh settings literal (uncollected keys reset to default every save).- Live-apply on toggle: re-run
renderSubagentPanel()(show/hide group sections) — a panel re-render, not a CSS-class strip. - SYNCED, not per-device: do NOT add
showUltracodeAgentstodisplayKeysand do NOT strip it in the per-device block. A synced value gives the server-side gate (isWorkflowAgentTrackingEnabled, §3e) one canonical truth to decide whether to run the watcher; a per-device value can't gate a process-wide watcher. (ContrastshowResponseViewer, pure client display.) styles.css+mobile.css: add.subagent-workflow-headerand.subagent-group-badgenext to.subagent-window-parent; mirror device overrides inmobile.css.
5. Settings / opt-in wiring
- Key:
showUltracodeAgents(boolean, default OFF). FallbackfalseinopenAppSettings; "absent ⇒ off" inisWorkflowAgentTrackingEnabled(). Schemaz.boolean().optional()in the.strict()update schema, kept OUT ofdisplayKeys(synced). - Runtime gating:
workflowRunWatcher.start()and the subagent-watcherworkflows/descent run only when the boot-timesettings.jsonread reportsshowUltracodeAgents === true(mirroringisSubagentTrackingEnabled). The frontend additionally gates display. Toggling at runtime gates display immediately (panel re-render); the watcher branch picks up on next boot — matches existingsubagentTrackingEnabledsemantics. (Optional polish: restart just the workflow watcher on toggle for instant on/off.)
6. SSE events
Reused (no change): subagent:discovered|updated|tool_call|tool_result|progress|message|completed. Workflow agents flow through these; payloads now carry the optional isWorkflowAgent/workflowRunId fields on SubagentInfo. SSE payloads aren't schema-gated (typed only at broadcast() call sites), so the new fields propagate with zero friction.
New (3 events, run-level metadata):
| Event (backend const / frontend key) | Payload |
|---|---|
workflow:run_discovered / WORKFLOW_RUN_DISCOVERED |
WorkflowRunInfo |
workflow:run_updated / WORKFLOW_RUN_UPDATED |
WorkflowRunInfo |
workflow:run_removed / WORKFLOW_RUN_REMOVED |
{ runId: string } |
Sync requirement (CLAUDE.md): each must appear in both sse-events.ts (§3d) and constants.js SSE_EVENTS (§4a), be emitted via broadcast() in setupWorkflowRunWatcherListeners() (§3e), and have a dispatch-table row + _on* handler (§4b/§4c).
7. Edge cases & cleanup
journal.jsonlphantom-agent trap — owned by §3a-4: run-dir registration requires theagent-prefix and excludesjournal.jsonl.isInternalAgentover-filtering — owned by §3a-6: bypass at BOTH drop sites; titled from the frontend join (or the description fallback).- No workflow agents in the flat list —
renderSubagentPanelpartitions onagent.workflowRunId(§4c). When the toggle is OFF, the descent never ran, so they aren't inthis.subagentsat all. - Completion/idle — keep the existing per-agent mtime/PID/pgrep liveness as the per-card source of truth. Optionally render a group-level "workflow done" badge from run-state
status==='completed'. - Limits —
MAX_TRACKED_AGENTS=500LRU-evicts workflow agents in the same flat map; no auto-open (50-window budget); the 4hSTARTUP_MAX_FILE_AGE_MSskip means a run completed >4h ago won't reload after restart (acceptable — live monitoring). - Reconnect/replay — agents via
getRecentSubagents(15); headers viaworkflowRuns: getRecentRuns(15)ingetLightState.handleInitclearsthis.workflowRunsalongside the subagent Maps. - Watcher teardown — every per-run
fs.watchand the chokidar watcher closes instop()and onerror;CleanupManagerfor the new watcher (24h runs create many run dirs). - CLAUDE.md discipline — read-only
~/.claude/...artifacts; no new~/.codeman/...paths, no env-var prefixes touched. Claude-mode-only by nature (external CLIs don't write workflow transcripts).
8. Testing & verification
- Unit (pure):
test/workflow-run-watcher.test.ts: feed a scrubbed fixturewf_<runId>.json→ assertWorkflowRunInfoextraction (name/summary/phases,workflowProgress→agents[]keyed byagentId), mtime-skip, removal-by-set-diff.- Extend
subagent-watchercoverage: tempsubagents/workflows/wf_X/agent-Y.jsonl+ a strayjournal.jsonl→ assertagent-Yregistered withisWorkflowAgent/workflowRunIdandjournal.jsonlNOT registered; assert a short-description workflow agent is NOT dropped at eitherisInternalAgentsite.
- Route/inject (
app.inject):GET /api/workflows+:runIdreturn theApiResponseenvelope;GET /api/subagentsincludes a tagged agent. - Frontend (vm-sandbox, like
test/run-mode-ui.test.ts): dispatchsubagent:discoveredwithworkflowRunId+workflow:run_discovered→ assertrenderSubagentPanelproduces a group section under the workflow name with the agent inside it (label sourced from the join, not flat); assert order-independence (agent before run, and run before agent both resolve); assert OFF hides the section. - REQUIRED real end-to-end (the always-end-to-end-test rule — the plan-usage chip shipped dead from a gate mismatch): on dev/beta with
showUltracodeAgentsON, drive a real ultracode/workflow run, then (1)curl …/api/workflows | jqshows the live run withagents[]; (2)curl …/api/subagents | jq '.data[]|select(.isWorkflowAgent)'shows tagged agents; (3) watch/api/eventsforworkflow:run_discovered+subagent:discoveredwith the workflow fields; (4) Playwright (waitUntil:'domcontentloaded', wait 3–4s) asserts the grouped DOM cluster renders with the workflow-name header and live status. Verify path gates againstGET /api/sessionsworkingDir. Test against a LIVE run — all at-rest runs arecompleted/done;running/queuedstates only exist mid-run.
9. Phased rollout
| Phase | Scope | Done-check | Size |
|---|---|---|---|
| P1 — Backend discovery + tagging (gated, no UI) | §3a (nested descent, journal.jsonl skip, 2 SubagentInfo fields, isInternalAgent bypass ×2) + §3f schema key + §3e gate read. No run watcher yet. |
With showUltracodeAgents forced on, curl /api/subagents | jq '.data[]|select(.isWorkflowAgent)' lists real workflow agents during a live run; flat subagents unchanged; tsc --noEmit + targeted watcher test green. |
S–M |
| P2 — Run-state metadata + SSE | §3b (workflow-run-watcher.ts) + §3c types + §3d/§3e (SSE, bridge, getLightState replay) + §3g routes. |
curl /api/workflows | jq returns runs with agents[]/phases; SSE emits workflow:run_discovered; reconnect snapshot carries workflowRuns. |
M |
| P3 — Frontend grouped UI | §4a–§4d (constants, app.js state/dispatch/init, panels-ui grouped render + agent→label join, subagent-windows group sub-header). Reuse .subagent-window; no auto-open. |
Playwright: live run renders a group section under the workflow name with per-agent rows + live status + joined labels; flat subagents stay flat; expand opens a window with the workflow sub-header. | M |
| P4 — Settings toggle + polish + docs | §4e (checkbox, settings-ui load/save/live-apply, SYNCED), styles/mobile, phase chips, CLAUDE.md "Key Patterns" entry + this doc's status → SHIPPED. | Toggling the checkbox shows/hides the cluster live (no reload for display); OFF by default on a fresh install; CI green. | S |
Each phase is independently shippable: P1 is invisible (gated, no UI), P2 adds an API with no UI dependency, P3 lights up the UI for flag-enablers, P4 exposes the toggle and finalizes defaults/docs.
10. Effort & risk
Size: P1 = S–M, P2 = M, P3 = M, P4 = S. Total ≈ M (one focused engineer, ~2–4 days incl. the real end-to-end run — down from the first draft's M-L now that the backend join/coupling is gone).
Top 3 risks:
- Non-recursive watch on Linux misses live writes.
fs.watchis non-recursive and{recursive:true}is unreliable on Linux → per-wf_<runId>watchers (§3a-4) are correct, but the 5s full scan must re-readdir(wfRoot)to catch new run dirs mid-session, and each watcher must be torn down to avoid FSWatcher leaks in 24h runs. Mitigation: explicit per-run-dir registration + verifieddirWatchersteardown; chokidar (withCleanupManager) only in the new run watcher, whereteam-watcheralready proves the pattern. - Discovery cost / over-registration. A user with hundreds of historical workflow agents could flood
agentInfoon boot. Mitigation: the 4hSTARTUP_MAX_FILE_AGE_MSskip drops old files on the initial scan, the descent only runs when the toggle is on, andMAX_TRACKED_AGENTS=500LRU-evicts. Verify boot scan time doesn't regress with the corpus present. - Shipping-dead-on-a-gate (the repo's recurring failure mode — the plan-usage chip shipped dead because injection was gated on
CASES_DIRwhile real sessions ran elsewhere). Same trap here if the path/mode gate is wrong (e.g.projects/*/workflowsinstead ofprojects/*/*/workflows, or correlation via the wrong session key). Mitigation: the mandatory live ultracode end-to-end run in §8 against a real session'sworkingDir, observing the real SSE event + real DOM cluster — not the at-rest corpus, not unit tests alone.