feat(ultracode): master-detail tab for Workflow/ultracode run visualization

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>
This commit is contained in:
Codeman maintainer
2026-06-15 08:40:05 +02:00
parent f6a30d7335
commit c15c19fab7
17 changed files with 1800 additions and 1 deletions
+271
View File
@@ -0,0 +1,271 @@
# 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)
> 1. **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-panel` master-detail CSS) + `src/web/public/ultracode-panel.js` — NOT via `openSubagentWindow`/grouped windows.
> 2. **STANDALONE — zero edits to `subagent-watcher.ts`.** w16-claudeman's commit `f6a30d7` already 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-new `src/workflow-run-watcher.ts` (globs the disjoint `…/workflows/wf_*.json` tree). No shared files with w16.
> 3. **No per-agent transcript streaming needed for v1.** The run-state JSON already carries `tokens`/`toolCalls`/`state`/`label`/`phase` per agent, so the whole view reads from `wf_<runId>.json` alone. (Phase 4 will optionally link a card to its already-tracked transcript via `agentId` — no watcher edits.)
> 4. **Agent states are `start | progress | done`** (verified on disk) — NOT running/queued. `start`=queued (no agentId/tokens/toolCalls yet), `done` has `durationMs`/`resultPreview`.
> 5. **The run JSON's `script` (15–660KB embedded JS), `scriptPath`, `result`, `logs` are STRIPPED in the watcher** before caching/broadcast (a 28-agent run drops 174KB → ~25KB; `promptPreview`/`resultPreview` truncated).
> 6. **SSE/snapshot ship lightweight run SUMMARIES (no `agents[]`); the RIGHT pane fetches the full run** via `GET /api/workflows/:runId` on 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)
1. **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 deletes `subagent-watcher`'s backward dependency on `workflow-run-watcher` (`getAgentLabel()` + its TTL cache), removes the registration-vs-run-state **race** (labels always track the latest `workflow:run_updated`), and drops the per-agent `meta.json` read from the hot path.
2. **`SubagentInfo` grows by 2 fields, not 4** (`isWorkflowAgent`, `workflowRunId`) — both derivable from the file path alone at registration, zero extra I/O. `agentType`/`label`/`phase`/`state` come from the run object on the frontend.
3. **The `isInternalAgent` bypass covers BOTH drop sites** — `registerAgentFile` *and* the late re-resolution in `processEntry`. The first draft named only one.
4. **De-duplicated.** Each trap (`journal.jsonl`, the `projects/*/*/workflows` depth, 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:
```jsonc
{ "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 existing `subagent:*` events is free and reconnect-safe (those agents land in `agentInfo`, replayed by `getRecentSubagents(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 to `subagents/`). A small `WorkflowRunWatcher` watching `projects/*/*/workflows/wf_*.json` (mtime-skip, like `team-watcher`'s `configMtimes`) is the clean home; folding it into `subagent-watcher` would 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 `agentId` at 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):
```ts
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:
```ts
// 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 skipping `journal.jsonl`** (it ends in `.jsonl` but 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; on `error` and `stop()`, reuse the existing teardown (close + delete from `dirWatchers`/`knownSubagentDirs`/`dirWatcherErrorHandlers`).
- Guard re-registration **per run dir** in `knownSubagentDirs`, **not** `wfRoot` — the 5s full scan must still re-`readdir(wfRoot)` to pick up *new* `wf_<runId>` dirs created mid-session.
**(5) `registerAgentFile()` — accept + apply `runId`.** Add a trailing optional `runId?: string`. When set, the whole change is:
```ts
if (runId) { info.isWorkflowAgent = true; info.workflowRunId = runId; }
```
No `meta.json` read, no run-state lookup, no description override. `agentId`s 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:
- `registerAgentFile` initial check (`isInternalAgent(description)`),
- `processEntry`'s late re-resolution (the second `isInternalAgent` call).
**(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 under `projects` (verified: `projects/*/workflows` is empty; must be `projects/*/*/workflows/`). chokidar `depth:3` + a poll fallback, mirroring `team-watcher`'s dual discovery + interval.
- **mtime-skip:** `runMtimes: Map<absPath, number>` (mirror `team-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 embedded `script` string** — name/phases/summary are already top-level; the script's `export const meta` is redundant and costly. Derive `sessionUuid` from the dir name, `projectHash` from the dir above; expose `getProjectHash(workingDir)` for Codeman-session correlation.
- **`workflowProgress[] → agents[]`:** filter `type === 'workflow_agent'`, map each to a `WorkflowAgentEntry` (§3c) keyed by `agentId`. **This array is the join source the frontend uses** — no backend `getAgentLabel()` API, no TTL cache, no import from `subagent-watcher`.
- **Emit** `workflow:run_discovered|updated|removed` carrying `WorkflowRunInfo`; removal by set-diff (mirror `team-watcher`).
- **Lifecycle:** `start()`/`stop()` with `CleanupManager` teardown of chokidar + interval + caches; `LRUMap`-bounded run cache (24h memory rule).
### 3c. `src/types/` — workflow run types
```ts
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()`** (beside `setupSubagentWatcherListeners`): map the three run events → `this.broadcast(...)`. Add `cleanupWorkflowRunWatcherListeners()` (store handler refs).
- **Start/stop:** call `workflowRunWatcher.start()`/`.stop()` beside `subagentWatcher`, **gated on the same enable condition** (§3f).
- **`getLightState()`:** add `workflowRuns: workflowRunWatcher.getRecentRuns(15)` beside `subagents: subagentWatcher.getRecentSubagents(15)` so headers replay on reconnect (agents already replay via `subagents`). Keep the `LIGHT_STATE_CACHE_TTL_MS` memoization.
- **Gating read:** add `isWorkflowAgentTrackingEnabled()` mirroring `isSubagentTrackingEnabled()` (boot-time `dataPath('settings.json')` read). Gate `workflowRunWatcher.start()` **and** the subagent-watcher `workflows/` descent (§3a-3) on `showUltracodeAgents` so 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/subagents` and `GET /api/sessions/:id/subagents` include workflow agents once registered — **no change** (they carry `isWorkflowAgent`/`workflowRunId`; a consumer joins to `/api/workflows/:runId` for labels).
- Add `GET /api/workflows` → `workflowRunWatcher.getRecentRuns()` and `GET /api/workflows/:runId` (uniform `ApiResponse` contract; headers are also in `getLightState`).
- `GET /api/subagents/:agentId/transcript` works for workflow agents (they're in `agentInfo`) — 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=1000` for 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 -> WorkflowRunInfo` beside `this.subagents`.
- `_SSE_HANDLER_MAP`: add three rows → `_onWorkflowRunDiscovered/Updated/Removed` (must exist before `connectSSE` builds the wrappers).
- `handleInit`: after seeding `data.subagents`, seed `this.workflowRuns` from `data.workflowRuns` (clear-then-set). **Clear `this.workflowRuns` everywhere 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`: when `showUltracodeAgents` is on, **partition `this.subagents` into flat (no `workflowRunId`) vs grouped-by-`workflowRunId`**. Flat agents render exactly as today. For each group: build the header from `this.workflowRuns.get(runId)` (`workflowName` + phase/status chip from `phases[]`), then render that run's agents reusing the existing per-agent row markup. **Per-agent label/phase/agentType come from the JOIN** — build `Map(agentId → entry)` from `this.workflowRuns.get(runId).agents` and look each agent up by `agent.agentId`; render the small chip via the `getTeammateBadgeHtml` pattern. (If the run object hasn't arrived yet, fall back to the agent's own `description` — the run `:updated` event will fill it in on the next render.)
- `findParentSessionForSubagent` is unchanged — workflow agent `sessionId === session.claudeSessionId`. **Do not conflate `workflowRunId` with `sessionId`.**
### 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.html` Panels block: add a `settings-item` checkbox `id="appSettingsShowUltracodeAgents"` ("Show ULTRACODE / Workflow Agents").
- `openAppSettings`: load `settings.showUltracodeAgents` with `false` fallback (mirror `showPlanUsageLimits`).
- `saveAppSettings`: collect `showUltracodeAgents` into 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 `showUltracodeAgents` to `displayKeys` and 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. (Contrast `showResponseViewer`, pure client display.)
- `styles.css` + `mobile.css`: add `.subagent-workflow-header` and `.subagent-group-badge` next to `.subagent-window-parent`; mirror device overrides in `mobile.css`.
## 5. Settings / opt-in wiring
- **Key:** `showUltracodeAgents` (boolean, **default OFF**). Fallback `false` in `openAppSettings`; "absent ⇒ off" in `isWorkflowAgentTrackingEnabled()`. Schema `z.boolean().optional()` in the `.strict()` update schema, kept OUT of `displayKeys` (synced).
- **Runtime gating:** `workflowRunWatcher.start()` and the subagent-watcher `workflows/` descent run only when the boot-time `settings.json` read reports `showUltracodeAgents === true` (mirroring `isSubagentTrackingEnabled`). The frontend additionally gates display. Toggling at runtime gates **display** immediately (panel re-render); the **watcher branch** picks up on next boot — matches existing `subagentTrackingEnabled` semantics. (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.jsonl` phantom-agent trap** — owned by §3a-4: run-dir registration requires the `agent-` prefix and excludes `journal.jsonl`.
- **`isInternalAgent` over-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** — `renderSubagentPanel` partitions on `agent.workflowRunId` (§4c). When the toggle is OFF, the descent never ran, so they aren't in `this.subagents` at 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=500` LRU-evicts workflow agents in the same flat map; no auto-open (50-window budget); the 4h `STARTUP_MAX_FILE_AGE_MS` skip means a run completed >4h ago won't reload after restart (acceptable — live monitoring).
- **Reconnect/replay** — agents via `getRecentSubagents(15)`; headers via `workflowRuns: getRecentRuns(15)` in `getLightState`. `handleInit` clears `this.workflowRuns` alongside the subagent Maps.
- **Watcher teardown** — every per-run `fs.watch` and the chokidar watcher closes in `stop()` and on `error`; `CleanupManager` for 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 fixture `wf_<runId>.json` → assert `WorkflowRunInfo` extraction (name/summary/phases, `workflowProgress`→`agents[]` keyed by `agentId`), mtime-skip, removal-by-set-diff.
- Extend `subagent-watcher` coverage: temp `subagents/workflows/wf_X/agent-Y.jsonl` + a stray `journal.jsonl` → assert `agent-Y` registered with `isWorkflowAgent`/`workflowRunId` and `journal.jsonl` NOT registered; assert a short-description workflow agent is NOT dropped at **either** `isInternalAgent` site.
- **Route/inject (`app.inject`):** `GET /api/workflows` + `:runId` return the `ApiResponse` envelope; `GET /api/subagents` includes a tagged agent.
- **Frontend (vm-sandbox, like `test/run-mode-ui.test.ts`):** dispatch `subagent:discovered` with `workflowRunId` + `workflow:run_discovered` → assert `renderSubagentPanel` produces 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 `showUltracodeAgents` ON, **drive a real ultracode/workflow run**, then (1) `curl …/api/workflows | jq` shows the live run with `agents[]`; (2) `curl …/api/subagents | jq '.data[]|select(.isWorkflowAgent)'` shows tagged agents; (3) watch `/api/events` for `workflow:run_discovered` + `subagent:discovered` with 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 against `GET /api/sessions` `workingDir`. **Test against a LIVE run** — all at-rest runs are `completed`/`done`; `running`/`queued` states 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:**
1. **Non-recursive watch on Linux misses live writes.** `fs.watch` is 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 + verified `dirWatchers` teardown; chokidar (with `CleanupManager`) only in the new run watcher, where `team-watcher` already proves the pattern.
2. **Discovery cost / over-registration.** A user with hundreds of historical workflow agents could flood `agentInfo` on boot. Mitigation: the 4h `STARTUP_MAX_FILE_AGE_MS` skip drops old files on the initial scan, the descent only runs when the toggle is on, and `MAX_TRACKED_AGENTS=500` LRU-evicts. Verify boot scan time doesn't regress with the corpus present.
3. **Shipping-dead-on-a-gate** (the repo's recurring failure mode — the plan-usage chip shipped dead because injection was gated on `CASES_DIR` while real sessions ran elsewhere). Same trap here if the path/mode gate is wrong (e.g. `projects/*/workflows` instead of `projects/*/*/workflows`, or correlation via the wrong session key). Mitigation: the **mandatory live ultracode end-to-end run** in §8 against a real session's `workingDir`, observing the real SSE event + real DOM cluster — not the at-rest corpus, not unit tests alone.
+26
View File
@@ -0,0 +1,26 @@
/**
* @fileoverview Workflow (ultracode) run-watcher polling and cache configuration.
*
* Controls how frequently WorkflowRunWatcher polls
* ~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json
* and how many runs are cached in memory.
*
* Distinct from the Agent-Teams config (team-config.ts). The run-state JSON is
* rewritten on every agent tick across a whole run (28+ agents), so the watcher
* relies on a per-file mtime skip; the poll itself is just N stat() calls.
*
* @module config/workflow-config
*/
/** Workflow run-state poll interval (ms). Short because a poll is just N mtime stats. */
export const WORKFLOW_RUN_POLL_INTERVAL_MS = 10_000;
/** Max cached workflow runs (LRU eviction). */
export const MAX_CACHED_WORKFLOW_RUNS = 100;
/**
* Default recency window (minutes) for getRecentRuns(). Generous enough that a
* recently-finished long run still appears in the LEFT-pane list — filtered on
* last-activity, not start time, so multi-hour runs don't vanish.
*/
export const WORKFLOW_RUN_RECENT_WINDOW_MIN = 240;
+1
View File
@@ -67,3 +67,4 @@ export * from './push.js';
export * from './plan.js';
export * from './orchestrator.js';
export * from './update.js';
export * from './workflow-run.js';
+135
View File
@@ -0,0 +1,135 @@
/**
* @fileoverview Types for ultracode / Workflow-tool run visualization.
*
* A Workflow run persists its state to
* `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_<runId>.json`
* (a sibling of the deeper `subagents/workflows/wf_<runId>/agent-*.jsonl`
* transcript tree that subagent-watcher tracks). This file is the single source
* for the master-detail "working agents" view: a run's tasks/phases on the LEFT
* and per-agent stats (tokens burned, tool calls) on the RIGHT.
*
* Field presence is STATE-DRIVEN and verified against real runs on disk:
* - state 'start' (queued): no agentId/tokens/toolCalls/startedAt/durationMs/...
* - state 'progress' (running): has agentId/tokens/toolCalls, no durationMs/resultPreview
* - state 'done' (finished): all fields, incl. durationMs/resultPreview
* Absent fields are genuinely ABSENT (never explicit null) — use `?:`, not null.
*
* @module types/workflow-run
*/
/** One declared phase of a run (from the run JSON's top-level `phases[]`, 0-indexed). */
export interface WorkflowRunPhase {
/** Phase title; equals each member agent's `phaseTitle`. Always present. */
title: string;
/** Human description of the phase. Always present in `phases[]`. */
detail: string;
}
/**
* One agent slot in a run, derived from `workflowProgress[]` entries where
* `type === 'workflow_agent'`. Optional fields are absent until the agent
* reaches the relevant lifecycle state (see module doc).
*/
export interface WorkflowAgentInfo {
/** 1-based stable slot index, unique within the run. Always present. */
index: number;
/** Agent label, e.g. "probe:dompurify-config". Always present. */
label: string;
/** 1-based phase number; join via `run.phases[phaseIndex - 1]`. Always present. */
phaseIndex: number;
/** Phase title (=== run.phases[phaseIndex-1].title). Always present. */
phaseTitle: string;
/** Model id, e.g. "claude-opus-4-8[1m]". Always present. */
model: string;
/** Lifecycle state. Real on-disk values: 'start' | 'progress' | 'done'. Open union. */
state: 'start' | 'progress' | 'done' | (string & {});
/** Epoch ms the slot was queued. Always present. */
queuedAt?: number;
/** Epoch ms of the last progress tick. Always present once any progress occurs. */
lastProgressAt?: number;
/** Truncated prompt the agent was given. Always present. */
promptPreview?: string;
/**
* Globally-unique agent id; equals the `agent-<agentId>.jsonl` transcript stem
* (the Phase-4 correlation key). ABSENT while state === 'start'.
*/
agentId?: string;
/** Epoch ms the agent began. Absent while 'start'. */
startedAt?: number;
/** Attempt counter. Absent while 'start'. */
attempt?: number;
/** Tokens burned so far (RIGHT pane). Absent while 'start'. */
tokens?: number;
/** Tool calls made so far (RIGHT pane). Absent while 'start'. */
toolCalls?: number;
/** Name of the most recent tool. Present for progress/done (occasionally absent). */
lastToolName?: string;
/** Short summary of the most recent tool call. May be absent even when 'done'. */
lastToolSummary?: string;
/** Total run time (ms). Present ONLY when 'done' — the live-vs-finished discriminator. */
durationMs?: number;
/** Truncated final result. Present ONLY when 'done'. */
resultPreview?: string;
}
/**
* Run-level info shipped to the browser.
*
* IMPORTANT: the on-disk JSON also carries `script` (15–660KB of embedded JS),
* `scriptPath`, `result`, and `logs`. The watcher STRIPS all four before the
* object is ever cached/broadcast — never let them reach SSE/getLightState/route.
*/
export interface WorkflowRunInfo {
/** Run id (=== the wf_<runId>.json filename stem). Always present. */
runId: string;
/** Workflow name from `meta.name`. Always present. */
workflowName?: string;
/**
* Run status. Real on-disk values seen: 'completed' | 'killed'.
* 'running' | 'failed' are inferred (parse defensively; keep open union).
*/
status?: 'completed' | 'killed' | 'running' | 'failed' | (string & {});
/** Concise human description (best LEFT-pane label). Always present. */
summary?: string;
/** Total agent slots, INCLUDING not-yet-started 'start' agents. */
agentCount?: number;
/** Total tokens across the run (partial mid-run). */
totalTokens?: number;
/** Total tool calls across the run (partial mid-run). */
totalToolCalls?: number;
/** Total run duration (ms). */
durationMs?: number;
/** Run start time (epoch MILLIS). */
startTime?: number;
/** ISO end/write timestamp. */
timestamp?: string;
/** Default model for the run. */
defaultModel?: string;
/** Background-task id that owns the run. */
taskId?: string;
/** Declared phases (0-indexed). */
phases: WorkflowRunPhase[];
/** Agents, derived from `workflowProgress` filtered to `type === 'workflow_agent'`. */
agents: WorkflowAgentInfo[];
/** Error message, present when status is 'killed'/'failed'. */
error?: string;
// ----- Watcher-derived (NOT in the JSON body — captured from the file path) -----
/** `<sessionUuid>` path segment (for per-session scoping). */
sessionUuid: string;
/** `<projHash>` path segment. */
projectHash: string;
/**
* Most recent activity (epoch ms): max agent `lastProgressAt`, else `startTime`.
* Drives recency filtering/sorting so finished long runs still surface.
*/
lastActivityAt: number;
}
/**
* Lightweight run projection (no `agents[]`) for the LEFT-pane list and the
* getLightState reconnect snapshot. A full run with 28 agents serializes to
* ~36KB; the snapshot ships dozens of runs, so it carries summaries only and the
* RIGHT pane fetches the full run (`GET /api/workflows/:runId`) on selection.
*/
export type WorkflowRunSummary = Omit<WorkflowRunInfo, 'agents'>;
+21
View File
@@ -221,6 +221,11 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SUBAGENT_TOOL_RESULT, '_onSubagentToolResult'],
[SSE_EVENTS.SUBAGENT_COMPLETED, '_onSubagentCompleted'],
// Workflow runs (ultracode)
[SSE_EVENTS.WORKFLOW_RUN_DISCOVERED, '_onWorkflowRunDiscovered'],
[SSE_EVENTS.WORKFLOW_RUN_UPDATED, '_onWorkflowRunUpdated'],
[SSE_EVENTS.WORKFLOW_RUN_REMOVED, '_onWorkflowRunRemoved'],
// Images
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
@@ -339,6 +344,12 @@ class CodemanApp {
this.subagentToolResults = new Map(); // Map<agentId, Map<toolUseId, result>> - tool results by toolUseId
this.activeSubagentId = null; // Currently selected subagent for detail view
this.subagentPanelVisible = false;
// Ultracode / Workflow run visualization (master-detail tab)
this.workflowRuns = new Map(); // runId -> run summary (LEFT list)
this.workflowRunDetails = new Map(); // runId -> full run with agents[] (RIGHT pane)
this.activeWorkflowRunId = null;
this.activeWorkflowPhaseIndex = null;
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE;
this.minimizedSubagents = new Map(); // Map<sessionId, Set<agentId>> - minimized to tab
@@ -2228,6 +2239,11 @@ class CodemanApp {
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
this.subagentActivity.clear();
this.subagentToolResults.clear();
// Clear ultracode workflow run state (re-seeded from data.workflowRuns below)
if (this.workflowRuns) this.workflowRuns.clear();
if (this.workflowRunDetails) this.workflowRunDetails.clear();
this.activeWorkflowRunId = null;
this.activeWorkflowPhaseIndex = null;
// Clean up mobile/keyboard handlers and re-init (prevents listener accumulation on reconnect)
MobileDetection.cleanup();
KeyboardHandler.cleanup();
@@ -2402,6 +2418,11 @@ class CodemanApp {
});
}
// Seed ultracode workflow runs (LEFT-pane summaries) from the snapshot
if (data.workflowRuns) {
this.seedWorkflowRuns(data.workflowRuns);
}
// Restore previously active session (survives page reload + SSE reconnect)
// Must always re-select because handleInit clears terminal state above.
// Reset activeSessionId so selectSession doesn't early-return.
+5
View File
@@ -350,6 +350,11 @@ const SSE_EVENTS = {
SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
SUBAGENT_COMPLETED: 'subagent:completed',
// Workflow runs (ultracode / Workflow tool)
WORKFLOW_RUN_DISCOVERED: 'workflow:run_discovered',
WORKFLOW_RUN_UPDATED: 'workflow:run_updated',
WORKFLOW_RUN_REMOVED: 'workflow:run_removed',
// Images
IMAGE_DETECTED: 'image:detected',
ATTACHMENT_DETECTED: 'attachment:detected',
+32
View File
@@ -122,6 +122,7 @@
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
</button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
@@ -600,6 +601,29 @@
</div>
</div>
<!-- Ultracode / Workflow agents master-detail panel (opt-in via showUltracodeAgents) -->
<div class="subagents-panel ultracode-agents-panel hidden" id="ultracodeAgentsPanel">
<div class="subagents-panel-header">
<div class="subagents-panel-title">
Ultracode Agents <span id="ultracodeCountBadge" class="subagent-badge"></span>
</div>
<div class="subagents-panel-actions">
<button class="btn-icon-sm" onclick="app.toggleUltracodeAgentsPanel()" title="Toggle panel">&#x25B2;</button>
<button class="btn-icon-sm" onclick="app.closeUltracodeAgentsPanel()" title="Close">&times;</button>
</div>
</div>
<div class="subagents-panel-body">
<div class="subagent-container">
<div class="subagent-list ultracode-run-list" id="ultracodeRunList">
<div class="subagent-empty">No ultracode runs detected</div>
</div>
<div class="subagent-detail ultracode-agent-grid" id="ultracodeAgentGrid">
<div class="subagent-empty">Select a run to view its agents</div>
</div>
</div>
</div>
</div>
<!-- Session Options Modal (includes Respawn Settings) -->
<div class="modal" id="sessionOptionsModal">
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
@@ -1075,6 +1099,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show ultracode / Workflow runs as a master-detail tab (tasks on the left, agents with tokens + tool calls on the right)">
<span class="settings-item-label">Ultracode Agents</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowUltracodeAgents">
<span class="slider"></span>
</label>
</div>
<!-- Subagent Options Section -->
<div class="settings-section-header">Subagent Options</div>
@@ -1927,6 +1958,7 @@
<script defer src="orchestrator-panel.js"></script>
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="ultracode-panel.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+24
View File
@@ -313,6 +313,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
@@ -1368,6 +1369,7 @@ Object.assign(CodemanApp.prototype, {
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
@@ -1682,6 +1684,7 @@ Object.assign(CodemanApp.prototype, {
showProjectInsights: false,
showFileBrowser: false,
showSubagents: false,
showUltracodeAgents: false,
showMultiMonitorButton: false,
showPlanUsageLimits: false,
showAttachmentsButton: false,
@@ -1805,6 +1808,15 @@ Object.assign(CodemanApp.prototype, {
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
}
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
// from the mobile-header-buttons-policy guard.
const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
const ultracodeBtn = document.querySelector('.btn-ultracode-agents');
if (ultracodeBtn) {
ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents);
}
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
// Limits"). Server renders the initial state on reload; this handles a live
// toggle from a settings save. Marker class (base is display:inline-flex
@@ -1876,6 +1888,18 @@ Object.assign(CodemanApp.prototype, {
}
}
// Ultracode agents panel visibility (SYNCED setting — not in displayKeys)
const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
const ultracodePanel = document.getElementById('ultracodeAgentsPanel');
if (ultracodePanel) {
if (showUltracodeAgents) {
ultracodePanel.classList.remove('hidden');
} else {
ultracodePanel.classList.remove('open');
ultracodePanel.classList.add('hidden');
}
}
// File browser panel visibility
const fileBrowserPanel = document.getElementById('fileBrowserPanel');
if (fileBrowserPanel) {
+194
View File
@@ -8355,6 +8355,200 @@ kbd {
display: none !important;
}
/* "Ultracode Agents" header launcher — opt-in (App Settings → Display), hidden by
default everywhere (so the mobile-header-buttons-policy guard auto-excludes it).
Base inline-flex !important + a more-specific marker rule to hide. */
.btn-ultracode-agents {
display: inline-flex !important;
}
.btn-ultracode-agents.btn-ultracode-agents--hidden {
display: none !important;
}
/* ===== Ultracode / Workflow agents master-detail panel ===== */
/* Reuses .subagents-panel dock behavior but anchors LEFT so it never overlaps the
bottom-right subagents panel when both are open. */
.ultracode-agents-panel {
right: auto;
left: 0.5rem;
width: 640px;
}
@media (max-width: 700px) {
.ultracode-agents-panel {
left: 0;
right: 0;
width: 100%;
max-width: 100%;
}
}
/* LEFT pane: run rows */
.ultracode-run-item {
padding: 0.45rem 0.5rem;
border-radius: 6px;
background: var(--bg-input);
margin-bottom: 0.4rem;
cursor: pointer;
transition: background 0.15s;
}
.ultracode-run-item:hover {
background: var(--bg-card);
}
.ultracode-run-item.selected {
background: var(--bg-card);
border: 1px solid var(--accent);
}
.ultracode-run-head {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.2rem;
}
.ultracode-run-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ultracode-run-stats {
font-size: 0.65rem;
color: var(--text-dim);
}
.ultracode-status {
font-size: 0.58rem;
padding: 0.05rem 0.3rem;
border-radius: 4px;
margin-left: auto;
text-transform: lowercase;
background: var(--text-muted);
color: white;
flex-shrink: 0;
}
.ultracode-status.completed {
background: var(--success);
}
.ultracode-status.active {
background: var(--warning);
color: black;
}
.ultracode-status.failed {
background: var(--error, #b3261e);
color: white;
}
/* LEFT pane: phase chips for the selected run */
.ultracode-phase-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.4rem;
}
.ultracode-phase-chip {
font-size: 0.62rem;
padding: 0.1rem 0.4rem;
border-radius: 10px;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
}
.ultracode-phase-chip:hover {
color: var(--text);
}
.ultracode-phase-chip.selected {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* RIGHT pane: per-phase header + agent cards */
.ultracode-phase-header {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 0.7rem;
font-weight: 600;
color: var(--text);
margin: 0.5rem 0 0.3rem;
padding-bottom: 0.2rem;
border-bottom: 1px solid var(--border);
}
.ultracode-phase-sub {
font-size: 0.62rem;
font-weight: 400;
color: var(--text-dim);
}
.ultracode-agent-card {
background: var(--bg-input);
border-radius: 6px;
padding: 0.4rem 0.5rem;
margin-bottom: 0.35rem;
}
.ultracode-agent-top {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.25rem;
}
.ultracode-agent-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--text);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ultracode-agent-state {
font-size: 0.56rem;
padding: 0.05rem 0.3rem;
border-radius: 4px;
margin-left: auto;
flex-shrink: 0;
background: var(--text-muted);
color: white;
}
.ultracode-agent-state.completed {
background: var(--success);
}
.ultracode-agent-state.active {
background: var(--warning);
color: black;
}
.ultracode-agent-state.idle {
background: var(--bg-card);
color: var(--text-muted);
}
.ultracode-agent-meta {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.ultracode-chip {
font-size: 0.62rem;
padding: 0.05rem 0.35rem;
border-radius: 4px;
background: var(--bg-card);
color: var(--text-muted);
font-family: var(--font-mono);
}
.ultracode-chip-tok {
color: var(--accent);
}
.ultracode-chip-tool {
color: var(--text);
}
.ultracode-agent-sub {
font-size: 0.62rem;
color: var(--text-dim);
margin-top: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
default via the marker class below; the server strips it at render when the
+263
View File
@@ -0,0 +1,263 @@
/**
* @fileoverview Ultracode / Workflow run visualization — master-detail dock panel.
*
* Mirrors Claude Code's "working agents" TUI: LEFT pane = runs and their phases
* (selectable "tasks"), RIGHT pane = the selected run's agents with model, live
* state, TOKENS burned, and TOOL CALLS. Opt-in via the `showUltracodeAgents`
* setting; the launcher button + panel are hidden until enabled.
*
* Data: run SUMMARIES arrive via getLightState (`data.workflowRuns`) and the
* `workflow:run_*` SSE events (LEFT list). The full run (with agents[]) is fetched
* per-run from GET /api/workflows/:runId when a run is selected (RIGHT pane).
*
* Standalone: reads only the workflow-run endpoints; never touches subagent state.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @loadorder 11.5 (after panels-ui.js, before session-ui.js)
*/
/* global CodemanApp, SSE_EVENTS, escapeHtml */
Object.assign(CodemanApp.prototype, {
/** Ensure workflow state maps exist (lazy — constructor also seeds them). */
_ensureWorkflowState() {
if (!this.workflowRuns) this.workflowRuns = new Map(); // runId -> summary
if (!this.workflowRunDetails) this.workflowRunDetails = new Map(); // runId -> full run (with agents)
if (this.activeWorkflowRunId === undefined) this.activeWorkflowRunId = null;
if (this.activeWorkflowPhaseIndex === undefined) this.activeWorkflowPhaseIndex = null;
},
/** Seed the LEFT list from a getLightState snapshot (array of run summaries). */
seedWorkflowRuns(summaries) {
this._ensureWorkflowState();
this.workflowRuns.clear();
(summaries || []).forEach((s) => this.workflowRuns.set(s.runId, s));
this.renderUltracodeAgentsPanel();
},
// ----- SSE handlers (wired in app.js _SSE_HANDLER_MAP) -----
_onWorkflowRunDiscovered(data) {
this._upsertWorkflowRun(data);
},
_onWorkflowRunUpdated(data) {
this._upsertWorkflowRun(data);
},
_onWorkflowRunRemoved(data) {
this._ensureWorkflowState();
if (!data || !data.runId) return;
this.workflowRuns.delete(data.runId);
this.workflowRunDetails.delete(data.runId);
if (this.activeWorkflowRunId === data.runId) this.activeWorkflowRunId = null;
this.renderUltracodeAgentsPanel();
},
_upsertWorkflowRun(summary) {
this._ensureWorkflowState();
if (!summary || !summary.runId) return;
this.workflowRuns.set(summary.runId, summary);
// If the live-updating run is the one open in the detail pane, refresh its agents.
if (this.activeWorkflowRunId === summary.runId) {
this._fetchWorkflowRunDetail(summary.runId);
}
this.renderUltracodeAgentsPanel();
},
// ----- Panel open/close -----
toggleUltracodeAgentsPanel() {
const panel = document.getElementById('ultracodeAgentsPanel');
if (!panel) return;
panel.classList.remove('hidden');
panel.classList.toggle('open');
if (panel.classList.contains('open')) this.renderUltracodeAgentsPanel();
},
closeUltracodeAgentsPanel() {
const panel = document.getElementById('ultracodeAgentsPanel');
if (panel) panel.classList.remove('open');
},
// ----- Selection -----
selectWorkflowRun(runId) {
this._ensureWorkflowState();
this.activeWorkflowRunId = runId;
this.activeWorkflowPhaseIndex = null; // reset phase filter on run change
this._fetchWorkflowRunDetail(runId);
this.renderUltracodeAgentsPanel();
},
selectWorkflowPhase(phaseIndex) {
this._ensureWorkflowState();
// phaseIndex null => show all phases
this.activeWorkflowPhaseIndex = phaseIndex === null || phaseIndex === undefined ? null : Number(phaseIndex);
this._renderUltracodeDetail();
},
async _fetchWorkflowRunDetail(runId) {
try {
const res = await fetch(`/api/workflows/${encodeURIComponent(runId)}`);
const env = await res.json();
const run = env && env.success ? env.data : null;
if (run) {
this.workflowRunDetails.set(runId, run);
if (this.activeWorkflowRunId === runId) this._renderUltracodeDetail();
}
} catch {
/* transient — next update retries */
}
},
// ----- Render (debounced) -----
renderUltracodeAgentsPanel() {
clearTimeout(this._ultracodeRenderTimer);
this._ultracodeRenderTimer = setTimeout(() => this._renderUltracodeAgentsPanelImmediate(), 150);
},
_renderUltracodeAgentsPanelImmediate() {
this._ensureWorkflowState();
const panel = document.getElementById('ultracodeAgentsPanel');
if (!panel) return;
const badge = document.getElementById('ultracodeCountBadge');
if (badge) badge.textContent = this.workflowRuns.size ? String(this.workflowRuns.size) : '';
this._renderUltracodeRunList();
this._renderUltracodeDetail();
},
_renderUltracodeRunList() {
const list = document.getElementById('ultracodeRunList');
if (!list) return;
const runs = Array.from(this.workflowRuns.values()).sort(
(a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0)
);
if (!runs.length) {
list.innerHTML = '<div class="subagent-empty">No ultracode runs detected</div>';
return;
}
list.innerHTML = runs.map((r) => this._workflowRunRowHtml(r)).join('');
},
_workflowRunRowHtml(r) {
const active = r.runId === this.activeWorkflowRunId;
const name = escapeHtml(r.workflowName || r.summary || r.runId);
const status = String(r.status || '');
const statusCls = this._workflowStatusClass(status);
const stats = `${r.agentCount ?? 0} agents · ${this._fmtNum(r.totalTokens)} tok · ${r.totalToolCalls ?? 0} tools`;
let phasesHtml = '';
if (active && Array.isArray(r.phases) && r.phases.length) {
const allActive = this.activeWorkflowPhaseIndex === null ? ' selected' : '';
const chips = [
`<div class="ultracode-phase-chip${allActive}" onclick="event.stopPropagation();app.selectWorkflowPhase(null)">All</div>`,
];
r.phases.forEach((p, i) => {
const sel = this.activeWorkflowPhaseIndex === i + 1 ? ' selected' : '';
chips.push(
`<div class="ultracode-phase-chip${sel}" title="${escapeHtml(p.detail || '')}" onclick="event.stopPropagation();app.selectWorkflowPhase(${i + 1})">${escapeHtml(p.title || 'Phase ' + (i + 1))}</div>`
);
});
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
}
return (
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun('${escapeHtml(r.runId)}')">` +
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
phasesHtml +
`</div>`
);
},
_renderUltracodeDetail() {
const detail = document.getElementById('ultracodeAgentGrid');
if (!detail) return;
const runId = this.activeWorkflowRunId;
if (!runId) {
detail.innerHTML = '<div class="subagent-empty">Select a run to view its agents</div>';
return;
}
const run = this.workflowRunDetails.get(runId);
if (!run) {
detail.innerHTML = '<div class="subagent-empty">Loading agents…</div>';
return;
}
const phases = Array.isArray(run.phases) ? run.phases : [];
let agents = Array.isArray(run.agents) ? run.agents : [];
if (this.activeWorkflowPhaseIndex !== null) {
agents = agents.filter((a) => a.phaseIndex === this.activeWorkflowPhaseIndex);
}
if (!agents.length) {
detail.innerHTML = '<div class="subagent-empty">No agents in this view</div>';
return;
}
// Group agents by phaseIndex, in phase order.
const groups = new Map();
agents.forEach((a) => {
const key = a.phaseIndex || 0;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(a);
});
const orderedKeys = Array.from(groups.keys()).sort((a, b) => a - b);
const html = orderedKeys
.map((key) => {
const group = groups.get(key);
const title = (phases[key - 1] && phases[key - 1].title) || `Phase ${key}`;
const tok = group.reduce((s, a) => s + (a.tokens || 0), 0);
const tools = group.reduce((s, a) => s + (a.toolCalls || 0), 0);
const header =
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
})
.join('');
detail.innerHTML = html;
},
_workflowAgentCardHtml(a) {
const state = String(a.state || 'start');
const stateCls = this._workflowAgentStateClass(state);
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state;
const model = this._modelShort(a.model);
const tokens = a.tokens === undefined ? '—' : this._fmtNum(a.tokens);
const tools = a.toolCalls === undefined ? '—' : String(a.toolCalls);
let secondary = '';
if (state === 'done' && a.resultPreview) {
secondary = escapeHtml(a.resultPreview);
} else if (a.lastToolName) {
secondary = escapeHtml(a.lastToolName + (a.lastToolSummary ? ' · ' + a.lastToolSummary : ''));
}
return (
`<div class="ultracode-agent-card">` +
`<div class="ultracode-agent-top">` +
`<span class="ultracode-agent-label">${escapeHtml(a.label || 'agent')}</span>` +
`<span class="ultracode-agent-state ${stateCls}">${escapeHtml(stateLabel)}</span>` +
`</div>` +
`<div class="ultracode-agent-meta">` +
`<span class="ultracode-chip" title="model">${escapeHtml(model)}</span>` +
`<span class="ultracode-chip ultracode-chip-tok" title="tokens burned">${tokens} tok</span>` +
`<span class="ultracode-chip ultracode-chip-tool" title="tool calls">${tools} tools</span>` +
`</div>` +
(secondary ? `<div class="ultracode-agent-sub">${secondary}</div>` : '') +
`</div>`
);
},
// ----- helpers -----
_workflowStatusClass(status) {
if (status === 'completed') return 'completed';
if (status === 'running') return 'active';
if (status === 'killed' || status === 'failed') return 'failed';
return '';
},
_workflowAgentStateClass(state) {
if (state === 'done') return 'completed';
if (state === 'progress') return 'active';
return 'idle'; // start / queued
},
_modelShort(model) {
if (!model) return '';
return String(model)
.replace(/^claude-/, '')
.replace(/-\d{8}$/, '');
},
_fmtNum(n) {
if (n === undefined || n === null) return '0';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
},
});
+25
View File
@@ -26,6 +26,7 @@ import {
} from '../schemas.js';
import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js';
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
import { applyStatusLineConfig } from '../../hooks-config.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import {
@@ -540,6 +541,9 @@ export function registerSystemRoutes(
// Handle subagent tracking toggle dynamically
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
// Handle ultracode/workflow run watcher toggle dynamically (default OFF)
toggleService((settings.showUltracodeAgents as boolean) ?? false, workflowRunWatcher, 'Workflow run watcher');
// Handle image watcher toggle dynamically
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled
@@ -691,6 +695,27 @@ export function registerSystemRoutes(
}
});
// ========== Workflow Run Monitoring (ultracode) ==========
// LEFT-pane list: lightweight run summaries (no agents[]).
app.get('/api/workflows', async (req) => {
const { minutes } = req.query as { minutes?: string };
const runs = minutes
? workflowRunWatcher.getRecentRunSummaries(parseInt(minutes, 10))
: workflowRunWatcher.getAllRunSummaries();
return { success: true, data: runs };
});
// RIGHT-pane detail: full run incl. agents[] (tokens/toolCalls/state per agent).
app.get('/api/workflows/:runId', async (req) => {
const { runId } = req.params as { runId: string };
const run = workflowRunWatcher.getRun(runId);
if (!run) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Workflow run ${runId} not found`);
}
return { success: true, data: run };
});
// ========== Subagent Monitoring ==========
app.get('/api/subagents', async (req) => {
+2
View File
@@ -348,6 +348,8 @@ export const SettingsUpdateSchema = z
ralphTrackerEnabled: z.boolean().optional(),
subagentTrackingEnabled: z.boolean().optional(),
subagentActiveTabOnly: z.boolean().optional(),
/** Ultracode/Workflow run visualization (default OFF). Gates workflowRunWatcher + the master-detail tab. SYNCED. */
showUltracodeAgents: z.boolean().optional(),
imageWatcherEnabled: z.boolean().optional(),
tunnelEnabled: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
+63 -1
View File
@@ -44,7 +44,7 @@ import { dataPath } from '../config/instance.js';
import { getHookSecret } from '../config/hook-secret.js';
import { EventEmitter } from 'node:events';
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState } from '../types.js';
import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState, WorkflowRunInfo } from '../types.js';
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import { createMultiplexer } from '../mux-factory.js';
@@ -60,6 +60,7 @@ import {
type SubagentToolResult,
} from '../subagent-watcher.js';
import { imageWatcher } from '../image-watcher.js';
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import {
buildDetectedAttachmentHistoryItem,
@@ -259,6 +260,11 @@ export class WebServer extends EventEmitter {
attachmentDetected: (event: AttachmentDetectedEvent) => void;
error: (error: Error, sessionId?: string) => void;
} | null = null;
private workflowRunWatcherHandlers: {
discovered: (info: WorkflowRunInfo) => void;
updated: (info: WorkflowRunInfo) => void;
removed: (data: { runId: string }) => void;
} | null = null;
private tunnelManager: TunnelManager = new TunnelManager();
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
private authFailures: StaleExpirationMap<string, number> | null = null;
@@ -338,6 +344,7 @@ export class WebServer extends EventEmitter {
// Set up subagent watcher listeners
this.setupSubagentWatcherListeners();
this.setupWorkflowRunWatcherListeners();
// Set up image watcher listeners
this.setupImageWatcherListeners();
@@ -437,6 +444,31 @@ export class WebServer extends EventEmitter {
}
}
/**
* Bridge WorkflowRunWatcher events → SSE. Broadcasts run SUMMARIES (no agents[])
* to keep payloads small; the full agents[] is fetched per-run via
* GET /api/workflows/:runId when the user selects a run.
*/
private setupWorkflowRunWatcherListeners(): void {
this.workflowRunWatcherHandlers = {
discovered: (info: WorkflowRunInfo) => this.broadcast(SseEvent.WorkflowRunDiscovered, summarizeRun(info)),
updated: (info: WorkflowRunInfo) => this.broadcast(SseEvent.WorkflowRunUpdated, summarizeRun(info)),
removed: (data: { runId: string }) => this.broadcast(SseEvent.WorkflowRunRemoved, data),
};
workflowRunWatcher.on('run_discovered', this.workflowRunWatcherHandlers.discovered);
workflowRunWatcher.on('run_updated', this.workflowRunWatcherHandlers.updated);
workflowRunWatcher.on('run_removed', this.workflowRunWatcherHandlers.removed);
}
private cleanupWorkflowRunWatcherListeners(): void {
if (this.workflowRunWatcherHandlers) {
workflowRunWatcher.off('run_discovered', this.workflowRunWatcherHandlers.discovered);
workflowRunWatcher.off('run_updated', this.workflowRunWatcherHandlers.updated);
workflowRunWatcher.off('run_removed', this.workflowRunWatcherHandlers.removed);
this.workflowRunWatcherHandlers = null;
}
}
/**
* Set up event listeners for image watcher.
* Broadcasts image detection events to SSE clients for auto-popup.
@@ -1669,6 +1701,7 @@ export class WebServer extends EventEmitter {
respawnStatus,
globalStats: this.store.getAggregateStats(activeSessionTokens),
subagents: subagentWatcher.getRecentSubagents(15), // 15 min to avoid stale agents
workflowRuns: workflowRunWatcher.getAllRunSummaries(), // ultracode run summaries (no agents[]) for the LEFT list
timestamp: now,
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
@@ -1937,6 +1970,14 @@ export class WebServer extends EventEmitter {
console.log('Subagent watcher disabled by user settings');
}
// Start workflow run watcher for ultracode / Workflow run visualization (if enabled)
if (await this.isWorkflowAgentTrackingEnabled()) {
workflowRunWatcher.start();
console.log('Workflow run watcher started - monitoring ~/.claude/projects for ultracode run activity');
} else {
console.log('Workflow run watcher disabled by user settings (showUltracodeAgents off)');
}
// Start image watcher for auto-popup of screenshots (if enabled)
if (await this.isImageWatcherEnabled()) {
imageWatcher.start();
@@ -1983,6 +2024,23 @@ export class WebServer extends EventEmitter {
return true; // Default enabled
}
/**
* Check if ultracode/workflow run tracking is enabled in settings (default: FALSE — opt-in).
*/
private async isWorkflowAgentTrackingEnabled(): Promise<boolean> {
const settingsPath = dataPath('settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const settings = JSON.parse(content);
return settings.showUltracodeAgents ?? false;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to read showUltracodeAgents setting:', err);
}
}
return false; // Default disabled (opt-in)
}
/**
* Check if image watcher is enabled in settings (default: false)
*/
@@ -2337,12 +2395,16 @@ export class WebServer extends EventEmitter {
// Clean up watcher listeners to prevent memory leaks
this.cleanupSubagentWatcherListeners();
this.cleanupWorkflowRunWatcherListeners();
this.cleanupImageWatcherListeners();
this.cleanupTeamWatcherListeners();
// Stop subagent watcher
subagentWatcher.stop();
// Stop workflow run watcher
workflowRunWatcher.stop();
// Stop image watcher
imageWatcher.stop();
+15
View File
@@ -15,6 +15,7 @@
* - **Mux** (4): created, killed, died, statsUpdated
* - **Respawn** (24): stateChanged, cycleStarted/Completed, step*, aiCheck*, planCheck*, timer*, log, ...
* - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed
* - **Workflow runs** (3): run_discovered, run_updated, run_removed (ultracode / Workflow tool)
* - **Scheduled** (6): created, updated, completed, stopped, log, deleted
* - **Teams** (4): created, updated, removed, taskUpdated
* - **Transcript** (4): complete, plan_mode, tool_start, tool_end
@@ -215,6 +216,15 @@ export const SubagentMessage = 'subagent:message' as const;
/** Subagent finished. */
export const SubagentCompleted = 'subagent:completed' as const;
// ─── Workflow Runs (ultracode / Workflow tool) ───────────────────────────────
/** A workflow run was discovered (first time seen). Payload: WorkflowRunInfo. */
export const WorkflowRunDiscovered = 'workflow:run_discovered' as const;
/** A workflow run changed (agent state/token tick). Payload: WorkflowRunInfo. */
export const WorkflowRunUpdated = 'workflow:run_updated' as const;
/** A workflow run's file disappeared. Payload: { runId: string }. */
export const WorkflowRunRemoved = 'workflow:run_removed' as const;
// ─── Scheduled Runs ──────────────────────────────────────────────────────────
/** Scheduled run created. */
@@ -446,6 +456,11 @@ export const SseEvent = {
SubagentMessage,
SubagentCompleted,
// Workflow runs (ultracode)
WorkflowRunDiscovered,
WorkflowRunUpdated,
WorkflowRunRemoved,
// Scheduled runs
ScheduledCreated,
ScheduledUpdated,
+383
View File
@@ -0,0 +1,383 @@
/**
* @fileoverview Workflow (ultracode) Run Watcher
*
* Watches `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` —
* the run-state JSON the Workflow tool writes for each ultracode run — and emits
* events powering the master-detail "working agents" view (tasks/phases on the
* LEFT, per-agent tokens/tool-calls on the RIGHT).
*
* Deliberately STANDALONE: it never imports from or touches subagent-watcher.ts.
* It globs the run-state tree (`.../workflows/wf_*.json`); subagent-watcher globs
* the disjoint, deeper transcript tree (`.../subagents/workflows/wf_<id>/agent-*`).
* Separate singletons, disjoint directories, no shared mutable state.
*
* Discovery is dual: a periodic poll (catches new run dirs + removals) plus a
* per-run-dir chokidar watcher (live within-run updates). A per-file mtime skip
* keeps the hot path cheap — the run JSON is rewritten on every agent tick.
*
* @module workflow-run-watcher
*/
import { EventEmitter } from 'node:events';
import { readdir, readFile, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { watch as chokidarWatch, type FSWatcher as ChokidarWatcher } from 'chokidar';
import type { WorkflowRunInfo, WorkflowRunSummary, WorkflowAgentInfo, WorkflowRunPhase } from './types/workflow-run.js';
import { LRUMap } from './utils/lru-map.js';
import {
WORKFLOW_RUN_POLL_INTERVAL_MS,
MAX_CACHED_WORKFLOW_RUNS,
WORKFLOW_RUN_RECENT_WINDOW_MIN,
} from './config/workflow-config.js';
const WORKFLOWS_SUBDIR = 'workflows';
const RUN_FILE_PREFIX = 'wf_';
const RUN_FILE_SUFFIX = '.json';
/** Hard caps on the largest per-agent strings so a 28-agent run stays compact. */
const PROMPT_PREVIEW_MAX = 200;
const RESULT_PREVIEW_MAX = 240;
function truncate(value: string | undefined, max: number): string | undefined {
if (typeof value !== 'string') return undefined;
return value.length > max ? `${value.slice(0, max)}…` : value;
}
/** Drop the heavy `agents[]` for list/snapshot use. */
export function summarizeRun(info: WorkflowRunInfo): WorkflowRunSummary {
const { agents: _agents, ...summary } = info;
void _agents;
return summary;
}
interface DiscoveredRun {
filePath: string;
projectHash: string;
sessionUuid: string;
runId: string;
}
/** A `workflowProgress[]` entry as it appears on disk (loosely typed for defensive parsing). */
interface RawProgressEntry {
type?: string;
index?: number;
label?: string;
phaseIndex?: number;
phaseTitle?: string;
model?: string;
state?: string;
queuedAt?: number;
lastProgressAt?: number;
promptPreview?: string;
agentId?: string;
startedAt?: number;
attempt?: number;
tokens?: number;
toolCalls?: number;
lastToolName?: string;
lastToolSummary?: string;
durationMs?: number;
resultPreview?: string;
}
export class WorkflowRunWatcher extends EventEmitter {
private projectsDir: string;
private pollTimer: NodeJS.Timeout | null = null;
private _isRunning = false;
/** runId -> latest parsed run info (LRU-bounded). */
private runs = new LRUMap<string, WorkflowRunInfo>({ maxSize: MAX_CACHED_WORKFLOW_RUNS });
/** absolute run-file path -> last seen mtimeMs (skip unchanged files). */
private fileMtimes = new Map<string, number>();
/** runId -> absolute run-file path (for mtime cleanup on removal). */
private runIdToPath = new Map<string, string>();
/** workflows-dir absolute path -> chokidar watcher (one per live run dir). */
private dirWatchers = new Map<string, ChokidarWatcher>();
constructor(projectsDir?: string) {
super();
this.projectsDir = projectsDir || join(homedir(), '.claude', 'projects');
this.setMaxListeners(50);
}
// ========== Public API ==========
isRunning(): boolean {
return this._isRunning;
}
start(): void {
if (this._isRunning) return;
this._isRunning = true;
this.poll();
this.pollTimer = setInterval(() => this.poll(), WORKFLOW_RUN_POLL_INTERVAL_MS);
}
stop(): void {
this._isRunning = false;
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
for (const watcher of this.dirWatchers.values()) {
watcher.close().catch(() => {}); // best-effort teardown
}
this.dirWatchers.clear();
this.runs.clear();
this.fileMtimes.clear();
this.runIdToPath.clear();
}
/** All cached runs (no recency filter), most-recently-active first. */
getAllRuns(): WorkflowRunInfo[] {
return Array.from(this.runs.values()).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
}
/** Runs active within the last `minutes`, most-recently-active first. */
getRecentRuns(minutes: number = WORKFLOW_RUN_RECENT_WINDOW_MIN): WorkflowRunInfo[] {
const cutoff = Date.now() - minutes * 60_000;
return this.getAllRuns().filter((r) => r.lastActivityAt >= cutoff);
}
/**
* Lightweight summaries (no agents[]) of ALL cached runs, most-recently-active
* first. This is the LEFT-pane list + getLightState snapshot source: the cache
* is LRU-bounded (MAX_CACHED_WORKFLOW_RUNS), so it's already size-capped, and a
* run-browser should show past runs — NOT hide everything older than a window.
*/
getAllRunSummaries(): WorkflowRunSummary[] {
return this.getAllRuns().map(summarizeRun);
}
/** Summaries filtered to the last `minutes` of activity (opt-in via ?minutes). */
getRecentRunSummaries(minutes: number = WORKFLOW_RUN_RECENT_WINDOW_MIN): WorkflowRunSummary[] {
return this.getRecentRuns(minutes).map(summarizeRun);
}
getRun(runId: string): WorkflowRunInfo | undefined {
return this.runs.get(runId);
}
getStats(): { runCount: number; running: number; agentCount: number } {
let running = 0;
let agentCount = 0;
for (const run of this.runs.values()) {
if (run.status === 'running') running++;
agentCount += run.agents.length;
}
return { runCount: this.runs.size, running, agentCount };
}
// ========== Private ==========
private poll(): void {
this.pollAsync().catch(() => {
// Filesystem may be transiently unavailable; the next poll retries.
});
}
private async pollAsync(): Promise<void> {
const { files, dirs } = await this.discover();
// Install a live watcher for each run dir; tear down watchers for dirs that vanished.
for (const dir of dirs) this.ensureDirWatcher(dir);
for (const dir of Array.from(this.dirWatchers.keys())) {
if (!dirs.has(dir)) this.removeDirWatcher(dir);
}
const seenRunIds = new Set<string>();
for (const file of files) {
seenRunIds.add(file.runId);
await this.maybeParse(file);
}
// Removal by set-diff: a cached run whose file disappeared.
for (const runId of Array.from(this.runs.keys())) {
if (!seenRunIds.has(runId)) {
this.runs.delete(runId);
const path = this.runIdToPath.get(runId);
if (path) this.fileMtimes.delete(path);
this.runIdToPath.delete(runId);
this.emit('run_removed', { runId });
}
}
}
/** Walk projects/<projHash>/<sessionUuid>/workflows/ for wf_*.json files. */
private async discover(): Promise<{ files: DiscoveredRun[]; dirs: Set<string> }> {
const files: DiscoveredRun[] = [];
const dirs = new Set<string>();
let projectHashes: string[];
try {
projectHashes = await readdir(this.projectsDir);
} catch {
return { files, dirs };
}
for (const projectHash of projectHashes) {
let sessions: string[];
try {
sessions = await readdir(join(this.projectsDir, projectHash));
} catch {
continue;
}
for (const sessionUuid of sessions) {
const workflowsDir = join(this.projectsDir, projectHash, sessionUuid, WORKFLOWS_SUBDIR);
let names: string[];
try {
names = await readdir(workflowsDir);
} catch {
continue; // no workflows dir for this session — normal
}
let hasRun = false;
for (const name of names) {
if (!name.startsWith(RUN_FILE_PREFIX) || !name.endsWith(RUN_FILE_SUFFIX)) continue;
hasRun = true;
files.push({
filePath: join(workflowsDir, name),
projectHash,
sessionUuid,
runId: name.slice(0, -RUN_FILE_SUFFIX.length),
});
}
if (hasRun) dirs.add(workflowsDir);
}
}
return { files, dirs };
}
private async maybeParse(file: DiscoveredRun): Promise<void> {
let mtime: number;
try {
mtime = (await stat(file.filePath)).mtimeMs;
} catch {
return; // vanished between discover and stat
}
if (this.fileMtimes.get(file.filePath) === mtime) return;
this.fileMtimes.set(file.filePath, mtime);
const info = await this.parseFile(file);
if (!info) return;
const existed = this.runs.has(info.runId);
this.runs.set(info.runId, info);
this.runIdToPath.set(info.runId, file.filePath);
this.emit(existed ? 'run_updated' : 'run_discovered', info);
}
/**
* Parse a wf_<runId>.json into WorkflowRunInfo, STRIPPING the heavyweight
* `script`/`scriptPath`/`result`/`logs` fields (the embedded script alone is
* 15–660KB) so they never reach the cache, SSE, or routes.
*/
private async parseFile(file: DiscoveredRun): Promise<WorkflowRunInfo | null> {
let raw: Record<string, unknown>;
try {
raw = JSON.parse(await readFile(file.filePath, 'utf-8')) as Record<string, unknown>;
} catch {
return null; // mid-write or malformed — next mtime change re-parses
}
if (!raw || typeof raw !== 'object') return null;
const progress = Array.isArray(raw.workflowProgress) ? (raw.workflowProgress as RawProgressEntry[]) : [];
const agents: WorkflowAgentInfo[] = progress
.filter((e) => e && e.type === 'workflow_agent')
.map((e) => this.toAgent(e));
const phases: WorkflowRunPhase[] = Array.isArray(raw.phases)
? (raw.phases as Array<Record<string, unknown>>).map((p) => ({
title: typeof p.title === 'string' ? p.title : '',
detail: typeof p.detail === 'string' ? p.detail : '',
}))
: [];
let lastProgress = 0;
for (const a of agents) {
if (typeof a.lastProgressAt === 'number' && a.lastProgressAt > lastProgress) lastProgress = a.lastProgressAt;
}
const startTime = typeof raw.startTime === 'number' ? raw.startTime : undefined;
const lastActivityAt = lastProgress || startTime || 0;
return {
runId: typeof raw.runId === 'string' ? raw.runId : file.runId,
workflowName: typeof raw.workflowName === 'string' ? raw.workflowName : undefined,
status: typeof raw.status === 'string' ? raw.status : undefined,
summary: typeof raw.summary === 'string' ? raw.summary : undefined,
agentCount: typeof raw.agentCount === 'number' ? raw.agentCount : undefined,
totalTokens: typeof raw.totalTokens === 'number' ? raw.totalTokens : undefined,
totalToolCalls: typeof raw.totalToolCalls === 'number' ? raw.totalToolCalls : undefined,
durationMs: typeof raw.durationMs === 'number' ? raw.durationMs : undefined,
startTime,
timestamp: typeof raw.timestamp === 'string' ? raw.timestamp : undefined,
defaultModel: typeof raw.defaultModel === 'string' ? raw.defaultModel : undefined,
taskId: typeof raw.taskId === 'string' ? raw.taskId : undefined,
error: typeof raw.error === 'string' ? raw.error : undefined,
phases,
agents,
sessionUuid: file.sessionUuid,
projectHash: file.projectHash,
lastActivityAt,
};
}
private toAgent(e: RawProgressEntry): WorkflowAgentInfo {
return {
index: typeof e.index === 'number' ? e.index : 0,
label: typeof e.label === 'string' ? e.label : '',
phaseIndex: typeof e.phaseIndex === 'number' ? e.phaseIndex : 0,
phaseTitle: typeof e.phaseTitle === 'string' ? e.phaseTitle : '',
model: typeof e.model === 'string' ? e.model : '',
state: typeof e.state === 'string' ? e.state : 'start',
queuedAt: e.queuedAt,
lastProgressAt: e.lastProgressAt,
promptPreview: truncate(e.promptPreview, PROMPT_PREVIEW_MAX),
agentId: e.agentId,
startedAt: e.startedAt,
attempt: e.attempt,
tokens: e.tokens,
toolCalls: e.toolCalls,
lastToolName: e.lastToolName,
lastToolSummary: truncate(e.lastToolSummary, RESULT_PREVIEW_MAX),
durationMs: e.durationMs,
resultPreview: truncate(e.resultPreview, RESULT_PREVIEW_MAX),
};
}
private ensureDirWatcher(workflowsDir: string): void {
if (this.dirWatchers.has(workflowsDir)) return;
try {
const watcher = chokidarWatch(workflowsDir, {
depth: 0,
awaitWriteFinish: { stabilityThreshold: 200 },
ignoreInitial: true,
persistent: false,
});
const handler = () => this.poll();
watcher.on('add', handler);
watcher.on('change', handler);
watcher.on('unlink', handler);
watcher.on('error', () => {
// chokidar surfaced an error for this dir — drop the watcher; poll still covers it.
this.removeDirWatcher(workflowsDir);
});
this.dirWatchers.set(workflowsDir, watcher);
} catch {
// Watch setup failed — periodic poll still discovers changes.
}
}
private removeDirWatcher(workflowsDir: string): void {
const watcher = this.dirWatchers.get(workflowsDir);
if (watcher) {
watcher.close().catch(() => {});
this.dirWatchers.delete(workflowsDir);
}
}
}
/** Process-wide singleton (mirrors subagentWatcher / imageWatcher). */
export const workflowRunWatcher = new WorkflowRunWatcher();
+128
View File
@@ -0,0 +1,128 @@
/**
* Route tests for the ultracode workflow endpoints:
* GET /api/workflows → run summaries (no agents[])
* GET /api/workflows/:runId → full run (with agents[]) or 404
*
* Uses app.inject() — no real ports. The workflow-run-watcher singleton is mocked
* so we control the returned data (positive + not-found) without touching ~/.claude.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { vi } from 'vitest';
// ── Mocks required by registerSystemRoutes ──────────────────────────
vi.mock('node:fs/promises', () => ({
default: { readFile: vi.fn(async () => '{}'), writeFile: vi.fn(async () => undefined) },
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
});
vi.mock('../../src/subagent-watcher.js', () => ({
subagentWatcher: {
getSubagents: vi.fn(() => []),
getRecentSubagents: vi.fn(() => []),
isRunning: vi.fn(() => true),
start: vi.fn(),
stop: vi.fn(),
},
}));
vi.mock('../../src/image-watcher.js', () => ({
imageWatcher: { isRunning: vi.fn(() => false), start: vi.fn(), stop: vi.fn(), watchSession: vi.fn() },
}));
vi.mock('../../src/session-lifecycle-log.js', () => ({
getLifecycleLog: vi.fn(() => ({ log: vi.fn(), query: vi.fn(async () => []) })),
}));
vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({
isOpenCodeAvailable: vi.fn(() => false),
resolveOpenCodeDir: vi.fn(() => null),
}));
const SUMMARY = {
runId: 'wf_abc123',
workflowName: 'review-open-prs',
status: 'completed',
summary: 'Deep review',
agentCount: 2,
totalTokens: 1000,
totalToolCalls: 10,
phases: [{ title: 'Review', detail: 'one per PR' }],
sessionUuid: 'sess-1',
projectHash: 'proj-1',
lastActivityAt: 123,
};
const FULL_RUN = {
...SUMMARY,
agents: [
{
index: 1,
label: 'review:pr-1',
phaseIndex: 1,
phaseTitle: 'Review',
model: 'opus',
state: 'done',
tokens: 500,
toolCalls: 5,
},
{
index: 2,
label: 'review:pr-2',
phaseIndex: 1,
phaseTitle: 'Review',
model: 'opus',
state: 'done',
tokens: 500,
toolCalls: 5,
},
],
};
vi.mock('../../src/workflow-run-watcher.js', () => ({
workflowRunWatcher: {
getAllRunSummaries: vi.fn(() => [SUMMARY]),
getRecentRunSummaries: vi.fn(() => [SUMMARY]),
getRun: vi.fn((runId: string) => (runId === 'wf_abc123' ? FULL_RUN : undefined)),
isRunning: vi.fn(() => false),
start: vi.fn(),
stop: vi.fn(),
},
}));
describe('workflow routes', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
});
afterEach(async () => {
await harness.app.close();
});
it('GET /api/workflows returns the envelope with summaries (no agents[])', async () => {
const res = await harness.app.inject({ method: 'GET', url: '/api/workflows' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
expect(body.data).toHaveLength(1);
expect(body.data[0].runId).toBe('wf_abc123');
expect('agents' in body.data[0]).toBe(false);
});
it('GET /api/workflows/:runId returns the full run with agents[]', async () => {
const res = await harness.app.inject({ method: 'GET', url: '/api/workflows/wf_abc123' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.agents).toHaveLength(2);
expect(body.data.agents[0].tokens).toBe(500);
});
it('GET /api/workflows/:runId 404s for an unknown run', async () => {
const res = await harness.app.inject({ method: 'GET', url: '/api/workflows/wf_nope' });
const body = res.json();
expect(body.success).toBe(false);
expect(body.errorCode).toBe('NOT_FOUND');
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Tests for WorkflowRunWatcher — parses wf_<runId>.json run-state into
* WorkflowRunInfo for the ultracode master-detail view.
*
* Drives the real discover→parse path against a synthetic on-disk fixture in a
* temp projects dir (never the shared singleton, never ~/.claude).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { WorkflowRunWatcher } from '../src/workflow-run-watcher.js';
import type { WorkflowRunInfo } from '../src/types/workflow-run.js';
const PROJECT_HASH = '-home-arkon-default-claudeman';
const SESSION_UUID = '388113c8-cd01-4e80-93a8-3be66ab1519b';
const RUN_ID = 'wf_test1234-abc';
/** A run JSON shaped like a real (killed) run: all three agent states + the bloat fields. */
function sampleRunJson() {
return {
runId: RUN_ID,
timestamp: '2026-06-15T00:00:00.000Z',
taskId: 'task_abc',
// --- bloat fields that MUST be stripped ---
script: 'export const meta = {};\n'.repeat(5000), // ~110KB
scriptPath: '/tmp/whatever.js',
result: { plan: { huge: 'object' } },
logs: ['line1', 'line2'],
// --- real fields ---
agentCount: 3,
durationMs: 795173,
summary: 'Deep adversarial review of open PRs',
workflowName: 'review-open-prs',
status: 'killed',
error: 'user stopped the task',
startTime: 1781466999000,
defaultModel: 'claude-opus-4-8[1m]',
totalTokens: 109703,
totalToolCalls: 44,
phases: [
{ title: 'Review', detail: 'one deep reviewer per PR' },
{ title: 'Probe', detail: 'targeted security/correctness probes' },
{ title: 'Verify', detail: 'adversarially verify each finding' },
],
workflowProgress: [
{ type: 'workflow_phase', index: 0, phaseIndex: 1, phaseTitle: 'Review' },
{
type: 'workflow_agent',
index: 1,
label: 'probe:dompurify-config',
phaseIndex: 2,
phaseTitle: 'Probe',
agentId: 'a6c0e282c3f5ac0bf',
model: 'claude-opus-4-8[1m]',
state: 'done',
startedAt: 1781467000002,
queuedAt: 1781466999962,
attempt: 1,
lastToolName: 'StructuredOutput',
lastToolSummary: 'Does the profile setting make the allowlist dead config',
promptPreview: 'You are reviewing a pull request...',
lastProgressAt: 1781467524143,
tokens: 104703,
toolCalls: 41,
durationMs: 524140,
resultPreview: '{"verdict":"concern"}',
},
{
type: 'workflow_agent',
index: 2,
label: 'review:pr-127',
phaseIndex: 1,
phaseTitle: 'Review',
agentId: 'a1234567890abcdef',
model: 'claude-opus-4-8[1m]',
state: 'progress',
startedAt: 1781467010000,
queuedAt: 1781466999970,
attempt: 1,
lastToolName: 'Read',
promptPreview: 'Review PR 127...',
lastProgressAt: 1781467600000,
tokens: 5000,
toolCalls: 3,
},
{
type: 'workflow_agent',
index: 3,
label: 'verify:finding-x',
phaseIndex: 3,
phaseTitle: 'Verify',
model: 'claude-opus-4-8[1m]',
state: 'start',
queuedAt: 1781466999980,
promptPreview: 'Verify finding x...',
lastProgressAt: 1781466999980,
},
],
};
}
describe('WorkflowRunWatcher', () => {
let projectsDir: string;
let watcher: WorkflowRunWatcher;
beforeEach(async () => {
projectsDir = await mkdtemp(join(tmpdir(), 'wfw-test-'));
const workflowsDir = join(projectsDir, PROJECT_HASH, SESSION_UUID, 'workflows');
await mkdir(workflowsDir, { recursive: true });
await writeFile(join(workflowsDir, `${RUN_ID}.json`), JSON.stringify(sampleRunJson()), 'utf-8');
watcher = new WorkflowRunWatcher(projectsDir);
});
afterEach(async () => {
watcher.stop();
await rm(projectsDir, { recursive: true, force: true });
});
/** Start the watcher and resolve with the first discovered run. */
function firstRun(): Promise<WorkflowRunInfo> {
return new Promise<WorkflowRunInfo>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timed out waiting for run_discovered')), 5000);
watcher.once('run_discovered', (info: WorkflowRunInfo) => {
clearTimeout(timer);
resolve(info);
});
watcher.start();
});
}
it('discovers and parses a run, deriving session/project from the path', async () => {
const info = await firstRun();
expect(info.runId).toBe(RUN_ID);
expect(info.workflowName).toBe('review-open-prs');
expect(info.status).toBe('killed');
expect(info.error).toBe('user stopped the task');
expect(info.sessionUuid).toBe(SESSION_UUID);
expect(info.projectHash).toBe(PROJECT_HASH);
expect(info.totalTokens).toBe(109703);
expect(info.totalToolCalls).toBe(44);
});
it('keeps only workflow_agent entries (drops workflow_phase markers)', async () => {
const info = await firstRun();
expect(info.agents).toHaveLength(3);
expect(info.phases).toHaveLength(3);
});
it('STRIPS the heavyweight script/scriptPath/result/logs fields', async () => {
const info = await firstRun();
const asAny = info as unknown as Record<string, unknown>;
expect('script' in asAny).toBe(false);
expect('scriptPath' in asAny).toBe(false);
expect('result' in asAny).toBe(false);
expect('logs' in asAny).toBe(false);
// The serialized run that reaches a client must be small.
expect(JSON.stringify(info).length).toBeLessThan(5000);
});
it('carries tokens/toolCalls/durationMs on a done agent', async () => {
const info = await firstRun();
const done = info.agents.find((a) => a.state === 'done')!;
expect(done.agentId).toBe('a6c0e282c3f5ac0bf');
expect(done.tokens).toBe(104703);
expect(done.toolCalls).toBe(41);
expect(done.durationMs).toBe(524140);
expect(done.resultPreview).toBeDefined();
});
it('omits agentId/tokens/toolCalls/durationMs on a start (queued) agent', async () => {
const info = await firstRun();
const queued = info.agents.find((a) => a.state === 'start')!;
expect(queued.agentId).toBeUndefined();
expect(queued.tokens).toBeUndefined();
expect(queued.toolCalls).toBeUndefined();
expect(queued.durationMs).toBeUndefined();
expect(queued.label).toBe('verify:finding-x');
});
it('a progress agent has tokens but no durationMs (live discriminator)', async () => {
const info = await firstRun();
const running = info.agents.find((a) => a.state === 'progress')!;
expect(running.tokens).toBe(5000);
expect(running.toolCalls).toBe(3);
expect(running.durationMs).toBeUndefined();
});
it('phase join: agent.phaseIndex-1 indexes run.phases', async () => {
const info = await firstRun();
for (const agent of info.agents) {
expect(info.phases[agent.phaseIndex - 1].title).toBe(agent.phaseTitle);
}
});
it('exposes the run via getAllRuns/getRun after discovery', async () => {
await firstRun();
expect(watcher.getAllRuns()).toHaveLength(1);
expect(watcher.getRun(RUN_ID)?.runId).toBe(RUN_ID);
expect(watcher.getStats().agentCount).toBe(3);
});
it('getRecentRunSummaries omits agents[] (lightweight snapshot)', async () => {
await firstRun();
const summaries = watcher.getRecentRunSummaries(100000);
expect(summaries).toHaveLength(1);
expect('agents' in summaries[0]).toBe(false);
expect(summaries[0].runId).toBe(RUN_ID);
expect(summaries[0].agentCount).toBe(3);
});
});