mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6531cbf79 | ||
|
|
d16bf34e34 | ||
|
|
e6989bdb40 | ||
|
|
6ab6bbbbd4 | ||
|
|
c15c19fab7 | ||
|
|
f6a30d7335 | ||
|
|
db93491dd1 | ||
|
|
b7ff54b2ec | ||
|
|
dc63d1f1a6 |
@@ -1,5 +1,35 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: ultracode floating run windows (and the live dock panel) now appear DURING an in-flight Workflow/ultracode run, not only after it finishes.
|
||||
|
||||
The Workflow runtime writes the run-state file `…/workflows/wf_<id>.json` only at completion (always a terminal status); while a run is live, its only on-disk state is the sibling `…/subagents/workflows/wf_<id>/` transcript tree. `workflow-run-watcher` previously scanned only the completion file, so it never observed a run until it was already terminal — and the floating-window auto-pop is gated on an ACTIVE run, so it never fired for a live run (the feature was effectively dead for in-flight runs).
|
||||
|
||||
The watcher now ALSO scans the `subagents/workflows/wf_<id>/` transcript tree and synthesizes a minimal ACTIVE run (status `running`, agent slots keyed by their `agentId` so the agent-card → live-transcript click still works, `lastActivityAt` from the newest agent/journal mtime, per-agent done/running derived from the run journal's `result` events) when no completion file exists yet. When the run finishes, the real `wf_<id>.json` supersedes the synthesized record (same runId), restoring full phase/token detail and the normal finish → 8s-grace auto-close flow. The watcher stays standalone (it never imports subagent-watcher). Verified end-to-end against a real in-flight run; adds unit coverage for live synthesis, agentId preservation, journal-derived state, empty-dir skipping, and completion-file precedence.
|
||||
|
||||
## 1.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Ultracode floating run windows + a dedicated toggle to control them.
|
||||
- **New: floating ultracode run windows.** When enabled, each active ultracode / Workflow run pops a small draggable window (like the file browser) connected by a glowing line to its originating session tab — the same connector-line idiom as subagent windows. The tab is resolved by matching the run's `sessionUuid` to a session's `claudeSessionId`. The window mirrors the live agent grid (phases, per-agent model / tokens burned / tool calls / state), auto-closes a few seconds after its run finishes, and remembers windows you explicitly dismiss so they don't re-pop. These windows are **additional to** the existing docked "Ultracode Agents" master-detail panel, which is unchanged.
|
||||
- **New setting "Ultracode Floating Windows"** (App Settings → Display), **default OFF**, independent of the "Ultracode Agents" panel toggle. Either toggle now starts the server-side workflow-run watcher (at boot and on live settings change), so the floating windows work even with the docked panel off.
|
||||
- Internals: new frontend module `ultracode-windows.js` (load order 15.5); ultracode connector lines are appended into the shared `#connectionLines` SVG within the existing batched read/write reflow pass in `subagent-windows.js`; new `ultracodeFloatingWindows` app-settings key in `schemas.ts`; watcher gating in `server.ts` + `system-routes.ts` now ORs both ultracode toggles.
|
||||
- Docs: `CLAUDE.md` brought up to date for the 1.1.2 ultracode/workflow-run subsystem (Agents / Frontend / Types / Config inventories, JS load order, a Key Patterns entry) and the new floating-windows feature.
|
||||
|
||||
## 1.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Ultracode/Workflow run visualization + subagent discovery fixes.
|
||||
- **Ultracode / Workflow run visualization** (new, opt-in): App Settings → Display → "Ultracode Agents" (`showUltracodeAgents`, default OFF) adds a master-detail tab that shows ultracode / Workflow-tool runs like Claude Code's "working agents" view — the LEFT pane lists runs and their phases (selectable tasks), the RIGHT pane shows each run's agents with model, live state, tokens burned, and tool calls. Clicking an agent opens its live transcript. Backed by a new standalone workflow-run watcher that reads the per-run state JSON (stripping the heavy embedded script/result/logs so payloads stay small), exposes `GET /api/workflows` and `GET /api/workflows/:runId`, and broadcasts `workflow:run_discovered/updated/removed` SSE events. The header launcher and panel stay hidden until the setting is enabled (the setting is synced across devices, not per-device).
|
||||
- **Subagent tracking discovery fix**: restored subagent tracking after Claude Code changed the on-disk format from `agent-*.jsonl` to `agent-*.meta.json` (background agents were showing 0). Also discovers workflow-nested subagents under `subagents/workflows/<wf>/` and hardens the meta→transcript upgrade path so an agent re-points to its `.jsonl` transcript once it appears.
|
||||
- **File viewer**: opens audio, SVG, and other binary files the same way the attachments viewer does.
|
||||
- **Tooling**: hardened the real-overview screenshot capture script and documented the `deviceScaleFactor` / static-cache gotchas.
|
||||
|
||||
## 1.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -56,7 +56,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.1.1 (must match `package.json`)
|
||||
**Version**: 1.1.4 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -108,6 +108,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
|
||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.**
|
||||
- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
|
||||
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — `scripts/capture-real-overview.mjs` (drives a live session in headless Chromium → overview PNG). Two traps, both observed 2026-06-14: **(1) DSF=2 doubles the console font.** xterm's WebGL renderer draws terminal glyphs at ~2× their nominal size under `deviceScaleFactor: 2`, while STILL reporting nominal cell dims (`terminal.cols`/`_renderService.dimensions.css.cell` say 8px/187cols — they lie), so it's invisible to any internal measurement and only the pixels reveal it. The HTML chrome (header/toolbar) is unaffected → ONLY the console font looks comically large. Default to **DSF=1** (script does); the image is 1× res but the font is true-to-browser. **(2) Stable filenames → stale renders.** Overwriting a fixed path (`claude-overview.png`) in place leaves OS image viewers (eog/feh) — and any HTTP client behind a long/`immutable` cache — showing the OLD render; the user reads it as "the fix didn't work". The script now mints a timestamped `claude-overview-<ts>.png` per run. ⚠️ This was a LOCAL image-viewer cache, NOT a Codeman serving bug: `file-routes` previews send `Cache-Control: no-cache` and `/api/screenshots/:name` sends none. The one real Codeman-side footgun: `server.ts` serves non-content-hashed static assets `public, max-age=31536000, immutable`, and `cacheBustAssets()` only rewrites `.js`/`.css` refs — a stable-named **image** referenced from public/ would go stale on overwrite. Reflect the per-device UI to match a real device when capturing: seed `localStorage` `codeman:skin`, `codeman-font-size`, and the desktop `codeman-app-settings` blob (the plan-usage chip is a per-device display key deleted from the server payload — a fresh browser hides it unless seeded; close side panels for a full-width terminal).
|
||||
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
|
||||
@@ -123,7 +124,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
|
||||
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
|
||||
| **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first |
|
||||
| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts` | |
|
||||
| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts`, `src/workflow-run-watcher.ts` | `workflow-run-watcher` is STANDALONE (never touches `subagent-watcher`) — see Key Patterns |
|
||||
| **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | |
|
||||
| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | |
|
||||
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
|
||||
@@ -131,14 +132,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
|
||||
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (16 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.9K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.9K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 8 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 16 domain files (incl. `workflow-run.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
|
||||
**Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; single-source, bundled to the gitignored `vendor/xterm-zerolag-input.js` and consumed by `app.js` (see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control).
|
||||
|
||||
**Config**: `src/config/` — 12 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
**Config**: `src/config/` — 14 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
@@ -173,11 +174,13 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups *identical* in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals.
|
||||
|
||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a run-state JSON per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json`. `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; disjoint directory tree, separate singleton) globs that tree via periodic poll + per-run chokidar watcher with per-file mtime skip, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is only started when the setting is on (`server.ts` gates on `showUltracodeAgents`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on the same `showUltracodeAgents` setting), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||
|
||||
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried; bug fixed in `b8cb467`), log viewers (2000), image popups (3000), local echo overlay (7).
|
||||
|
||||
|
||||
@@ -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.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.4",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* capture-readme-real.mjs
|
||||
*
|
||||
* Captures README desktop scenes (multi-session dashboard, monitor, subagent
|
||||
* windows) from a REAL Codeman instance — intended to run against an ISOLATED
|
||||
* dev/beta instance (CODEMAN_INSTANCE=beta on :5000) seeded from prod's settings,
|
||||
* NOT prod itself (never touch prod's live sessions).
|
||||
*
|
||||
* Reuses the high-quality capture recipe proven in capture-real-overview.mjs:
|
||||
* - DSF=2 + ?nowebgl → crisp retina at the TRUE font size (WebGL doubles
|
||||
* glyphs under DSF=2; the DOM renderer respects devicePixelRatio).
|
||||
* - per-device localStorage seeding so the capture matches a real device.
|
||||
*
|
||||
* SCENE=dashboard|monitor|subagent|all BASE=http://localhost:5000 \
|
||||
* OUT=screenshots-readme-real/desktop node scripts/capture-readme-real.mjs
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const OUT = process.env.OUT || 'screenshots-readme-real/desktop';
|
||||
const SKIN = process.env.SKIN || 'daylight-blue';
|
||||
const SCENE = process.env.SCENE || 'all';
|
||||
const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 13)));
|
||||
const VIEWPORT = { width: Number(process.env.VW || 1280), height: Number(process.env.VH || 720) };
|
||||
const DSF = Number(process.env.DSF || 2);
|
||||
const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const url = (extra = '') => {
|
||||
const sep = BASE.includes('?') ? '&' : '?';
|
||||
const params = [];
|
||||
if (DSF > 1) params.push('nowebgl'); // DOM renderer → correct font size at DSF>1
|
||||
if (extra) params.push(extra);
|
||||
return params.length ? `${BASE}${sep}${params.join('&')}` : BASE;
|
||||
};
|
||||
|
||||
async function newCtx(browser) {
|
||||
const context = await browser.newContext({
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: DSF,
|
||||
ignoreHTTPSErrors: BASE.startsWith('https'),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
await page.addInitScript(
|
||||
([skin, planUsage, font]) => {
|
||||
try {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
localStorage.setItem('codeman-font-size', String(font));
|
||||
const blob = { skin, showFileBrowser: false, showProjectInsights: false };
|
||||
if (planUsage) blob.showPlanUsageLimits = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[SKIN, PLAN_USAGE, FONT]
|
||||
);
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
async function bootstrap(page) {
|
||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
async function listSessions(page) {
|
||||
return page.evaluate(() =>
|
||||
Array.from(window.app.sessions.values()).map((s) => ({ id: s.id, name: s.name, mode: s.mode }))
|
||||
);
|
||||
}
|
||||
|
||||
async function shoot(page, name) {
|
||||
const out = join(OUT, name);
|
||||
await page.evaluate((f) => {
|
||||
try {
|
||||
if (window.app.setFontSize) window.app.setFontSize(f);
|
||||
} catch {}
|
||||
try {
|
||||
window.app.fitAddon && window.app.fitAddon.fit();
|
||||
} catch {}
|
||||
try {
|
||||
window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
|
||||
} catch {}
|
||||
}, FONT);
|
||||
await sleep(1500);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
console.log(' Saved: ' + out);
|
||||
}
|
||||
|
||||
async function sceneDashboard(browser) {
|
||||
console.log('Scene: dashboard');
|
||||
const { context, page } = await newCtx(browser);
|
||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||
await bootstrap(page);
|
||||
const sessions = await listSessions(page);
|
||||
// Select a claude session so the active terminal shows rich content; all tabs render.
|
||||
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
|
||||
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
|
||||
await sleep(4000);
|
||||
await shoot(page, 'multi-session-dashboard.png');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function sceneMonitor(browser) {
|
||||
console.log('Scene: monitor');
|
||||
const { context, page } = await newCtx(browser);
|
||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||
await bootstrap(page);
|
||||
const sessions = await listSessions(page);
|
||||
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
|
||||
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
|
||||
await sleep(2500);
|
||||
// toggleMonitorPanel() opens the panel, clears the hidden state, loads REAL
|
||||
// mux sessions (/api/mux), starts stats, and renders the task panel.
|
||||
await page.evaluate(async () => {
|
||||
try {
|
||||
await window.app.toggleMonitorPanel();
|
||||
} catch {}
|
||||
});
|
||||
await sleep(3000);
|
||||
await shoot(page, 'multi-session-monitor.png');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function sceneSubagent(browser) {
|
||||
console.log('Scene: subagent');
|
||||
const { context, page } = await newCtx(browser);
|
||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||
await bootstrap(page);
|
||||
// Select the session whose subagents we want (subagentActiveTabOnly means
|
||||
// app.subagents only fills for the active tab). Prefer SUBAGENT_SID env.
|
||||
const sessions = await listSessions(page);
|
||||
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
|
||||
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
|
||||
// Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents.
|
||||
let agents = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
agents = await page.evaluate(() =>
|
||||
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
|
||||
);
|
||||
if (agents.length >= 1) break;
|
||||
await sleep(1000);
|
||||
}
|
||||
console.log(' live in-browser subagents:', JSON.stringify(agents));
|
||||
if (agents.length === 0) {
|
||||
console.log(' NO live subagents — skipping (stage a longer subagent task and run this while it runs).');
|
||||
await context.close();
|
||||
return;
|
||||
}
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
try {
|
||||
window.app.openSubagentWindow(id);
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
agents.map((a) => a.id)
|
||||
);
|
||||
await sleep(2000);
|
||||
await page.evaluate(() => {
|
||||
const wins = Array.from(window.app.subagentWindows.values());
|
||||
const place = [
|
||||
{ left: 360, top: 60, w: 430, h: 330 },
|
||||
{ left: 810, top: 60, w: 430, h: 330 },
|
||||
];
|
||||
wins.slice(0, 2).forEach((win, i) => {
|
||||
const el = win.element;
|
||||
const p = place[i];
|
||||
el.style.left = p.left + 'px';
|
||||
el.style.top = p.top + 'px';
|
||||
el.style.width = p.w + 'px';
|
||||
el.style.height = p.h + 'px';
|
||||
});
|
||||
});
|
||||
await sleep(1500);
|
||||
await shoot(page, 'subagent-spawn.png');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
console.log(`BASE=${BASE} SKIN=${SKIN} DSF=${DSF} VIEWPORT=${VIEWPORT.width}x${VIEWPORT.height} SCENE=${SCENE}`);
|
||||
if (SCENE === 'dashboard' || SCENE === 'all') await sceneDashboard(browser);
|
||||
if (SCENE === 'monitor' || SCENE === 'all') await sceneMonitor(browser);
|
||||
if (SCENE === 'subagent' || SCENE === 'all') await sceneSubagent(browser);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('FATAL', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* capture-real-overview.mjs
|
||||
*
|
||||
* Captures a REAL claude-overview screenshot from a LIVE Codeman server
|
||||
* (no mock injection). Drive a real session to do real work, then run:
|
||||
*
|
||||
* SID=<sessionId> BASE=http://localhost:5000 OUT=screenshots-real \
|
||||
* node scripts/capture-real-overview.mjs
|
||||
*
|
||||
* Skin defaults to daylight-blue (prod default) via the localStorage pre-paint
|
||||
* contract in index.html. Output: <OUT>/claude-overview.png at 1280x720 (DSF 2).
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const SID = process.env.SID;
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const OUT = process.env.OUT || 'screenshots-real';
|
||||
const SKIN = process.env.SKIN || 'daylight-blue';
|
||||
// Unique filename per run (timestamped) so a viewer holding an old render of a
|
||||
// fixed path can never shadow a fresh capture. Override with NAME=… if needed.
|
||||
const STAMP = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
||||
const NAME = process.env.NAME || `claude-overview-${STAMP}.png`;
|
||||
const VIEWPORT = { width: Number(process.env.VW || 1512), height: Number(process.env.VH || 812) };
|
||||
// IMPORTANT: default deviceScaleFactor is 1, NOT 2. xterm's WebGL renderer in
|
||||
// headless Chromium draws terminal glyphs at ~2× their nominal size when DSF=2
|
||||
// (while still reporting nominal 8px cell dims internally, so it can't be caught
|
||||
// by measuring terminal.cols/cell — only the pixels reveal it). The HTML chrome
|
||||
// is unaffected, so DSF=2 makes ONLY the console font look comically large. DSF=1
|
||||
// renders the console at its true size, matching a real (non-headless) browser.
|
||||
const DSF = Number(process.env.DSF || 1);
|
||||
|
||||
if (!SID) {
|
||||
console.error('SID env var required (the live session id to screenshot)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const main = async () => {
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: DSF,
|
||||
ignoreHTTPSErrors: BASE.startsWith('https'),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
// Force the skin before any page script runs (pre-paint <head> contract), and
|
||||
// seed the PER-DEVICE display blob so the capture reflects what prod actually
|
||||
// shows on the user's real device — notably the plan-usage chip, which is a
|
||||
// per-device setting (default OFF) deleted from the server payload, so a fresh
|
||||
// browser would otherwise hide it. PLAN_USAGE=0 disables.
|
||||
const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
|
||||
// Terminal console font size. App default is 14px; a fresh headless browser has
|
||||
// no saved codeman-font-size, so it renders at 14 — much larger than a real
|
||||
// device where the console has been zoomed down. Seed a smaller value (clamped
|
||||
// to the app's [10,24] range) so the console font looks normal in the capture.
|
||||
const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 14)));
|
||||
await page.addInitScript(
|
||||
([skin, planUsage, font]) => {
|
||||
try {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
localStorage.setItem('codeman-font-size', String(font));
|
||||
// Desktop app-settings blob (settings-ui.js getSettingsStorageKey()).
|
||||
// Present these display keys explicitly so the server merge won't seed
|
||||
// side panels open (display keys only seed from server when absent from
|
||||
// localStorage). Matches the clean full-width-terminal reference look.
|
||||
const blob = {
|
||||
skin,
|
||||
showFileBrowser: false,
|
||||
showMonitor: false,
|
||||
showSubagents: false,
|
||||
showProjectInsights: false,
|
||||
};
|
||||
if (planUsage) blob.showPlanUsageLimits = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[SKIN, PLAN_USAGE, FONT]
|
||||
);
|
||||
|
||||
// At DSF>1, xterm's WebGL renderer draws glyphs at ~2x (see DSF comment above).
|
||||
// The app honors a `?nowebgl` URL param that switches to xterm's DOM renderer,
|
||||
// which respects devicePixelRatio correctly — so DSF=2 + nowebgl yields a crisp
|
||||
// 2x (retina) capture at the TRUE font size. Auto-enable it whenever DSF>1.
|
||||
const url = DSF > 1 ? `${BASE}${BASE.includes('?') ? '&' : '?'}nowebgl` : BASE;
|
||||
console.log(`Loading ${url} (DSF=${DSF}) ...`);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
|
||||
await sleep(1500);
|
||||
|
||||
console.log(`Selecting session ${SID} ...`);
|
||||
await page.evaluate((sid) => window.app.selectSession(sid), SID);
|
||||
|
||||
// Let the terminal buffer stream in + xterm render + any Ink redraw settle.
|
||||
await sleep(2000);
|
||||
|
||||
// Force a clean fit (avoids capturing a transient pre-fit frame where the
|
||||
// terminal renders at the wrong column count) and re-apply per-device header
|
||||
// visibility so the seeded plan-usage chip is shown.
|
||||
await page.evaluate((font) => {
|
||||
// Force the console font explicitly (setFontSize also re-fits) in case
|
||||
// loadFontSize didn't pick up the seeded value before the session rendered.
|
||||
try {
|
||||
if (window.app.setFontSize) window.app.setFontSize(font);
|
||||
else window.app.terminal.options.fontSize = font;
|
||||
} catch {}
|
||||
try {
|
||||
window.app.fitAddon && window.app.fitAddon.fit();
|
||||
} catch {}
|
||||
try {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
} catch {}
|
||||
try {
|
||||
window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
|
||||
} catch {}
|
||||
}, FONT);
|
||||
await sleep(3000);
|
||||
|
||||
// Optionally scroll the terminal up to frame the rich tool-call region
|
||||
// (Read/Write/Bash + green test results) instead of the trailing summary.
|
||||
const SCROLL = Number(process.env.SCROLL || 0);
|
||||
if (SCROLL) {
|
||||
await page.evaluate((n) => {
|
||||
const t = window.app && window.app.terminal;
|
||||
if (t && t.scrollLines) t.scrollLines(-n);
|
||||
}, SCROLL);
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
const outPath = join(OUT, NAME);
|
||||
await page.screenshot({ path: outPath, fullPage: false });
|
||||
console.log(`Saved: ${outPath}`);
|
||||
|
||||
await context.close();
|
||||
await browser.close();
|
||||
};
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('FATAL', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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;
|
||||
+152
-5
@@ -13,6 +13,8 @@
|
||||
* - `SubagentEvents` — typed event map
|
||||
*
|
||||
* Watched patterns: `~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl`
|
||||
* plus the `agent-{id}.meta.json` discovery sidecar (2026-06 format) and nested
|
||||
* workflow agents under `subagents/workflows/{workflowId}/agent-{id}.jsonl`.
|
||||
* Parses JSONL entries: user/assistant messages, tool_use/tool_result blocks, progress events.
|
||||
* Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID).
|
||||
*
|
||||
@@ -30,7 +32,7 @@ import { watch, existsSync, FSWatcher } from 'node:fs';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, basename } from 'node:path';
|
||||
import { join, basename, dirname } from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
|
||||
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js';
|
||||
@@ -1150,6 +1152,10 @@ export class SubagentWatcher extends EventEmitter {
|
||||
try {
|
||||
await statAsync(subagentDir);
|
||||
await this.watchSubagentDir(subagentDir, project, session);
|
||||
// Workflow agents nest one level deeper under subagents/workflows/{wf}/
|
||||
// (each holds its own agent-{id}.jsonl/.meta.json) — the flat watcher
|
||||
// above never sees them, so discover and watch each workflow dir too.
|
||||
await this.watchWorkflowDirs(subagentDir, project, session);
|
||||
} catch {
|
||||
// subagent dir doesn't exist - skip
|
||||
}
|
||||
@@ -1179,8 +1185,12 @@ export class SubagentWatcher extends EventEmitter {
|
||||
try {
|
||||
const files = await readdir(dir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.jsonl')) {
|
||||
if (file.startsWith('agent-') && file.endsWith('.jsonl')) {
|
||||
await this.registerAgentFile(join(dir, file), projectHash, sessionId, true);
|
||||
} else if (file.startsWith('agent-') && file.endsWith('.meta.json')) {
|
||||
// Claude Code (2026-06) writes a `agent-{id}.meta.json` sidecar for TUI
|
||||
// Task subagents and no longer always writes a per-agent `.jsonl` here.
|
||||
await this.registerAgentMeta(join(dir, file), projectHash, sessionId, true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -1190,13 +1200,26 @@ export class SubagentWatcher extends EventEmitter {
|
||||
// Single directory watcher handles both new files and file content changes
|
||||
try {
|
||||
const watcher = watch(dir, (_eventType, filename) => {
|
||||
if (!filename?.endsWith('.jsonl')) return;
|
||||
const filePath = join(dir, filename);
|
||||
// Only agent-{id}.jsonl / agent-{id}.meta.json — ignore siblings like a
|
||||
// workflow dir's journal.jsonl (would otherwise register a bogus "journal" agent).
|
||||
const isAgent = !!filename && filename.startsWith('agent-');
|
||||
const isJsonl = isAgent && filename.endsWith('.jsonl');
|
||||
const isMeta = isAgent && filename.endsWith('.meta.json');
|
||||
if (!isJsonl && !isMeta) return;
|
||||
const filePath = join(dir, filename as string);
|
||||
|
||||
// Debounce 100ms to batch rapid writes
|
||||
this.fileDeb.schedule(filePath, () => {
|
||||
if (!existsSync(filePath)) return;
|
||||
|
||||
if (isMeta) {
|
||||
// Meta sidecar — discovery only (not a transcript; never tail it).
|
||||
if (!this.fileAgentContext.has(filePath)) {
|
||||
this.registerAgentMeta(filePath, projectHash, sessionId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.fileAgentContext.has(filePath)) {
|
||||
// Known file — handle content change
|
||||
this.handleFileChange(filePath).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
|
||||
@@ -1225,6 +1248,40 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and watch nested workflow agent directories.
|
||||
*
|
||||
* The Workflow tool runs its subagents under
|
||||
* `subagents/workflows/{workflowId}/agent-{id}.jsonl` (+ `.meta.json`, alongside
|
||||
* a `journal.jsonl` of orchestration events). The flat `subagents/` watcher does
|
||||
* not recurse, and Node's `fs.watch({ recursive: true })` is unsupported on Linux,
|
||||
* so each workflow dir gets its own watcher here. Idempotent via `knownSubagentDirs`
|
||||
* and re-driven by the periodic scan, so newly created workflows are picked up
|
||||
* within one scan cycle (~5s) — the same latency as a new session's `subagents/`.
|
||||
*/
|
||||
private async watchWorkflowDirs(subagentDir: string, projectHash: string, sessionId: string): Promise<void> {
|
||||
const workflowsRoot = join(subagentDir, 'workflows');
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(workflowsRoot);
|
||||
} catch {
|
||||
return; // no workflows for this session
|
||||
}
|
||||
for (const name of names) {
|
||||
// Workflow ids are directories (e.g. `wf_<id>`); skip any stray files that
|
||||
// share the root (a workflow id never carries a file extension).
|
||||
if (name.endsWith('.jsonl') || name.endsWith('.json')) continue;
|
||||
const wfDir = join(workflowsRoot, name);
|
||||
try {
|
||||
const st = await statAsync(wfDir);
|
||||
if (!st.isDirectory()) continue;
|
||||
await this.watchSubagentDir(wfDir, projectHash, sessionId);
|
||||
} catch {
|
||||
// workflow dir vanished mid-scan — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a file content change for an already-registered agent file.
|
||||
* Tails from last known position, updates info, retries description if missing.
|
||||
@@ -1291,6 +1348,16 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
||||
|
||||
// Meta→transcript upgrade: the agent may already be registered from its
|
||||
// `.meta.json` sidecar (discovery-only — nothing to tail). Now that the real
|
||||
// `.jsonl` transcript has appeared, re-point to it and drop the stale sidecar
|
||||
// context, emitting `updated` below rather than a duplicate `discovered`.
|
||||
const priorEntry = this.agentInfo.get(agentId);
|
||||
const isMetaUpgrade = !!priorEntry && priorEntry.filePath.endsWith('.meta.json');
|
||||
if (isMetaUpgrade && priorEntry) {
|
||||
this.fileAgentContext.delete(priorEntry.filePath);
|
||||
}
|
||||
|
||||
// Initial info - handle race condition where file may be deleted between discovery and stat
|
||||
let fileStat;
|
||||
try {
|
||||
@@ -1341,7 +1408,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
// Track file context for directory watcher change handling
|
||||
this.fileAgentContext.set(filePath, { projectHash, sessionId });
|
||||
this.agentInfo.set(agentId, info);
|
||||
this.emit('subagent:discovered', info);
|
||||
this.emit(isMetaUpgrade ? 'subagent:updated' : 'subagent:discovered', info);
|
||||
|
||||
// Read existing content
|
||||
this.tailFile(filePath, agentId, sessionId, 0)
|
||||
@@ -1356,6 +1423,86 @@ export class SubagentWatcher extends EventEmitter {
|
||||
this.resetIdleTimer(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a subagent discovered via its `agent-{id}.meta.json` sidecar.
|
||||
*
|
||||
* As of the 2026-06 Claude Code format change, TUI Task subagents write the
|
||||
* `agent-{id}.meta.json` sidecar (`{ agentType, description, toolUseId }`) at
|
||||
* spawn, a beat *before* the `agent-{id}.jsonl` transcript appears in the same
|
||||
* dir. The legacy `.jsonl`-only discovery saw nothing in that window ("0
|
||||
* tracked"); this surfaces the agent from the sidecar immediately. The
|
||||
* transcript then lands within ~1s at the standard path and grows incrementally
|
||||
* (empirically verified — it is fully tailable, NOT a dead end), so:
|
||||
* - if the `.jsonl` already exists, defer to registerAgentFile (richer); else
|
||||
* - register meta-only now, and when the sibling `.jsonl` arrives the dir
|
||||
* watcher routes it to registerAgentFile, which detects the prior meta-only
|
||||
* entry and *upgrades* it in place (re-points filePath, starts tailing).
|
||||
*
|
||||
* Edge case: if the transcript never materializes (e.g. an agent that dies
|
||||
* before writing one), the agent stays meta-only — no live feed, status ages
|
||||
* out via the idle timer / stale cleanup.
|
||||
*/
|
||||
private async registerAgentMeta(
|
||||
metaPath: string,
|
||||
projectHash: string,
|
||||
sessionId: string,
|
||||
isInitialScan: boolean = false
|
||||
): Promise<void> {
|
||||
if (this.fileAgentContext.has(metaPath)) return;
|
||||
const agentId = basename(metaPath).replace('agent-', '').replace('.meta.json', '');
|
||||
if (this.agentInfo.has(agentId)) return;
|
||||
|
||||
// Prefer a real transcript if one was written alongside the sidecar.
|
||||
const jsonlPath = join(dirname(metaPath), `agent-${agentId}.jsonl`);
|
||||
if (existsSync(jsonlPath)) {
|
||||
await this.registerAgentFile(jsonlPath, projectHash, sessionId, isInitialScan);
|
||||
return;
|
||||
}
|
||||
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = await statAsync(metaPath);
|
||||
} catch {
|
||||
return; // deleted between discovery and stat
|
||||
}
|
||||
if (isInitialScan && Date.now() - fileStat.mtime.getTime() > STARTUP_MAX_FILE_AGE_MS) {
|
||||
return; // skip stale historical agents on startup
|
||||
}
|
||||
|
||||
let description: string | undefined;
|
||||
try {
|
||||
const meta = JSON.parse(await readFile(metaPath, 'utf8')) as { agentType?: string; description?: string };
|
||||
description = meta.description || meta.agentType;
|
||||
} catch {
|
||||
return; // unreadable / not yet fully written — a later watch event retries
|
||||
}
|
||||
if (this.isInternalAgent(description)) return;
|
||||
|
||||
const info: SubagentInfo = {
|
||||
agentId,
|
||||
sessionId,
|
||||
projectHash,
|
||||
filePath: metaPath,
|
||||
startedAt: fileStat.birthtime.toISOString(),
|
||||
lastActivityAt: fileStat.mtime.getTime(),
|
||||
status: 'active',
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: fileStat.size,
|
||||
description,
|
||||
};
|
||||
|
||||
if (this.agentInfo.size >= MAX_TRACKED_AGENTS) {
|
||||
const oldestId = this.findOldestInactiveAgent();
|
||||
if (oldestId) this.removeAgent(oldestId);
|
||||
}
|
||||
|
||||
this.fileAgentContext.set(metaPath, { projectHash, sessionId });
|
||||
this.agentInfo.set(agentId, info);
|
||||
this.emit('subagent:discovered', info);
|
||||
this.resetIdleTimer(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail a file from a specific position
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'>;
|
||||
@@ -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,17 @@ 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;
|
||||
// Ultracode floating run windows (additional to the dock panel — ultracode-windows.js)
|
||||
this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed }
|
||||
this.ultracodeWindowsClosed = new Set(); // runIds the user explicitly dismissed (don't re-pop)
|
||||
this.ultracodeWindowCloseTimers = new Map(); // runId -> auto-close timeout
|
||||
this.ultracodeWindowZIndex = 1000;
|
||||
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 +2244,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 +2423,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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">▲</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeUltracodeAgentsPanel()" title="Close">×</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,20 @@
|
||||
<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>
|
||||
<div class="settings-item" title="Pop a floating window for each active ultracode / Workflow run, connected by a line to its session tab (additional to the Ultracode Agents panel)">
|
||||
<span class="settings-item-label">Ultracode Floating Windows</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsUltracodeFloatingWindows">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Subagent Options Section -->
|
||||
<div class="settings-section-header">Subagent Options</div>
|
||||
@@ -1927,10 +1965,12 @@
|
||||
<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>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
<script defer src="ultracode-windows.js"></script>
|
||||
<script defer src="image-input.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2503,6 +2503,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
// SVG renders as an image, but file-raw deliberately serves SVG as an
|
||||
// untrusted octet-stream attachment (XSS hardening), so a direct
|
||||
// <img src=file-raw> would break. Fetch the bytes and render via a
|
||||
// same-origin blob typed image/svg+xml — <img> never executes scripts in
|
||||
// the referenced SVG, so this is safe while still rendering the graphic.
|
||||
if (ext === 'svg') {
|
||||
footerEl.textContent = 'SVG';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
if (!res.ok) throw new Error('Failed to load image');
|
||||
const blobUrl = URL.createObjectURL(new Blob([await res.text()], { type: 'image/svg+xml' }));
|
||||
bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`;
|
||||
const img = bodyEl.querySelector('img');
|
||||
if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||
@@ -2519,8 +2538,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else if (data.type === 'video') {
|
||||
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'audio') {
|
||||
bodyEl.innerHTML = `<audio src="${data.url}" controls autoplay></audio>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'binary') {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview</div>`;
|
||||
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||
footerEl.textContent = data.extension || 'binary';
|
||||
} else {
|
||||
// Text content
|
||||
|
||||
@@ -313,6 +313,9 @@ 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('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? 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 +1371,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
|
||||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||||
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
@@ -1682,6 +1687,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
showProjectInsights: false,
|
||||
showFileBrowser: false,
|
||||
showSubagents: false,
|
||||
showUltracodeAgents: false,
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
@@ -1805,6 +1812,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 +1892,27 @@ 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');
|
||||
}
|
||||
}
|
||||
// Floating ultracode run windows have their OWN opt-in (default OFF), independent of the
|
||||
// docked panel above: pop active runs when enabled, tear them all down when disabled
|
||||
// (additional layer — ultracode-windows.js).
|
||||
const ultracodeFloatingWindows = settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
if (ultracodeFloatingWindows) {
|
||||
if (typeof this.syncAllUltracodeFloatingWindows === 'function') this.syncAllUltracodeFloatingWindows();
|
||||
} else if (typeof this.removeAllUltracodeWindows === 'function') {
|
||||
this.removeAllUltracodeWindows();
|
||||
}
|
||||
|
||||
// File browser panel visibility
|
||||
const fileBrowserPanel = document.getElementById('fileBrowserPanel');
|
||||
if (fileBrowserPanel) {
|
||||
|
||||
@@ -7239,12 +7239,22 @@ kbd {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.file-preview-body audio {
|
||||
display: block;
|
||||
width: 90%;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.file-preview-body .binary-message {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-preview-body .binary-message a {
|
||||
color: var(--accent, #4ea1ff);
|
||||
}
|
||||
|
||||
.file-preview-footer {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
@@ -8345,6 +8355,330 @@ 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-card--clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ultracode-agent-card--clickable:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ========== Ultracode floating run windows (additional to the dock panel) ========== */
|
||||
/* A draggable window that auto-pops while a Workflow/ultracode run is active and is
|
||||
tied by a glowing connector line to the originating session tab (ultracode-windows.js). */
|
||||
.ultracode-window {
|
||||
position: fixed;
|
||||
width: 380px;
|
||||
height: 320px;
|
||||
min-width: 260px;
|
||||
min-height: 140px;
|
||||
max-width: 92vw;
|
||||
max-height: 80vh;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid #a855f7;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(168, 85, 247, 0.15);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
contain: layout paint;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
.ultracode-window.spawning {
|
||||
transform: scale(0.92);
|
||||
opacity: 0;
|
||||
}
|
||||
.ultracode-window.collapsed {
|
||||
height: auto !important;
|
||||
resize: none;
|
||||
}
|
||||
.ultracode-window.collapsed .ultracode-window-body {
|
||||
display: none;
|
||||
}
|
||||
.ultracode-window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: move;
|
||||
}
|
||||
.ultracode-window-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ultracode-window-title .icon {
|
||||
font-size: 0.95rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ultracode-window-title .uw-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ultracode-window-title .uw-status {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
.ultracode-window-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ultracode-window-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.ultracode-window-actions button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.ultracode-window-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg);
|
||||
min-height: 0;
|
||||
}
|
||||
.ultracode-window-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.ultracode-window-body::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ultracode-window-body .uw-summary {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* Ultracode connector line — distinct purple to set it apart from blue subagent lines. */
|
||||
.connection-line.ultracode-connection {
|
||||
stroke: #a855f7;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 6 3;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
|
||||
drop-shadow(0 0 5px rgba(168, 85, 247, 0.85))
|
||||
drop-shadow(0 0 10px rgba(168, 85, 247, 0.5));
|
||||
animation: ultracode-conn-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.connection-line.ultracode-connection:hover {
|
||||
stroke-width: 3.5;
|
||||
}
|
||||
@keyframes ultracode-conn-pulse {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 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
|
||||
|
||||
@@ -455,6 +455,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
svg.appendChild(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Ultracode floating run windows → parent tab (additional layer, ultracode-windows.js).
|
||||
// Drawn into the same SVG and same batched read/write pass; the tab-rect cache is shared.
|
||||
if (typeof this._appendUltracodeConnectionLines === 'function') {
|
||||
this._appendUltracodeConnectionLines(svg, rects);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -975,6 +981,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
this.imagePopups.clear();
|
||||
|
||||
// Clean up ultracode floating run windows (re-seeded from data.workflowRuns on reconnect)
|
||||
if (typeof this.removeAllUltracodeWindows === 'function') this.removeAllUltracodeWindows();
|
||||
|
||||
// Clear orphaned plan generation state
|
||||
this.activePlanOrchestratorId = null;
|
||||
this._planProgressHandler = null;
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* @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));
|
||||
// Restore floating windows for runs that are still active & recent (additional layer).
|
||||
if (typeof this._syncUltracodeFloatingWindow === 'function') {
|
||||
(summaries || []).forEach((s) => this._syncUltracodeFloatingWindow(s, { fromSeed: true }));
|
||||
}
|
||||
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;
|
||||
// Retire the floating run window too (additional layer — ultracode-windows.js).
|
||||
if (typeof this.closeUltracodeWindow === 'function') this.closeUltracodeWindow(data.runId, false);
|
||||
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);
|
||||
}
|
||||
// Auto-pop / refresh the floating run window for active runs (additional layer).
|
||||
if (typeof this._syncUltracodeFloatingWindow === 'function') this._syncUltracodeFloatingWindow(summary);
|
||||
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();
|
||||
// Refresh the floating window (if one is open for this run) with the fetched agents[].
|
||||
if (this.ultracodeWindows && this.ultracodeWindows.has(runId)) this.renderUltracodeWindowContent(runId);
|
||||
}
|
||||
} catch {
|
||||
/* transient — next update retries */
|
||||
}
|
||||
},
|
||||
|
||||
// Phase 4: open an agent's live transcript by agentId. The workflow agent's
|
||||
// agentId is byte-identical to the agent-<id>.jsonl stem already tracked by
|
||||
// subagent-watcher, so we reuse the existing transcript route — no watcher edits.
|
||||
// Graceful when the agent isn't tracked yet / aged out / tracking disabled.
|
||||
async openWorkflowAgentTranscript(agentId) {
|
||||
if (!agentId) return;
|
||||
let data = null;
|
||||
try {
|
||||
const res = await fetch(`/api/subagents/${encodeURIComponent(agentId)}/transcript?format=formatted`);
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
const ok = data && data.success && data.data;
|
||||
const formatted = ok ? data.data.formatted : null;
|
||||
const entryCount = ok ? data.data.entryCount || 0 : 0;
|
||||
if (!formatted || !entryCount) {
|
||||
alert(
|
||||
'No transcript available for this agent yet — it may be queued, aged out of tracking, or subagent tracking is disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const win = window.open('', '_blank', 'width=860,height=640');
|
||||
if (!win) return; // popup blocked
|
||||
win.document.write(
|
||||
`<html><head><title>Workflow agent ${escapeHtml(agentId)} transcript</title>` +
|
||||
`<style>body{background:#1a1a2e;color:#eee;font-family:monospace;padding:20px}pre{white-space:pre-wrap;word-wrap:break-word}</style>` +
|
||||
`</head><body><h2>Workflow agent ${escapeHtml(agentId)} (${entryCount} entries)</h2>` +
|
||||
`<pre>${escapeHtml(formatted.join('\n'))}</pre></body></html>`
|
||||
);
|
||||
win.document.close();
|
||||
},
|
||||
|
||||
// ----- 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 : ''));
|
||||
}
|
||||
// Phase 4: cards with an agentId open the live transcript (the agentId is byte-identical
|
||||
// to the agent-<id>.jsonl stem already tracked by subagent-watcher). 'start' agents have
|
||||
// no agentId yet, so they stay non-clickable.
|
||||
const clickable = !!a.agentId;
|
||||
const cardAttrs = clickable
|
||||
? ` class="ultracode-agent-card ultracode-agent-card--clickable" role="button" tabindex="0"` +
|
||||
` title="View transcript" onclick="app.openWorkflowAgentTranscript('${escapeHtml(a.agentId)}')"`
|
||||
: ` class="ultracode-agent-card"`;
|
||||
return (
|
||||
`<div${cardAttrs}>` +
|
||||
`<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);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* @fileoverview Ultracode floating run windows — auto-popping draggable windows
|
||||
* with a connector line to the originating session tab.
|
||||
*
|
||||
* This is the "floating thing" companion to the docked master-detail panel in
|
||||
* `ultracode-panel.js` (the dock panel stays — these windows are ADDITIONAL).
|
||||
* When the `showUltracodeAgents` setting is on, a small floating window pops up
|
||||
* for each ACTIVE ultracode/Workflow run (status not completed/killed/failed),
|
||||
* mirroring the live agent grid, and is connected by a glowing line to the
|
||||
* Codeman tab whose `claudeSessionId` matches the run's `sessionUuid` — the same
|
||||
* line idiom subagent windows use. The window auto-closes a few seconds after
|
||||
* its run finishes; an explicitly-closed run is remembered and never re-pops.
|
||||
*
|
||||
* Reuses, rather than duplicates:
|
||||
* - `makeWindowDraggable` + the shared `#connectionLines` SVG (subagent-windows.js)
|
||||
* - `_workflowAgentCardHtml`, `_fmtNum`, `_workflowStatusClass`, `_fetchWorkflowRunDetail`,
|
||||
* and the `workflowRuns` / `workflowRunDetails` maps (ultracode-panel.js)
|
||||
*
|
||||
* The connector-line draw is appended to the shared SVG from inside
|
||||
* `_updateConnectionLinesImmediate` (subagent-windows.js calls
|
||||
* `_appendUltracodeConnectionLines` at the end of its render pass), so both the
|
||||
* subagent and ultracode lines live in one batched read→write reflow pass.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency subagent-windows.js (makeWindowDraggable, updateConnectionLines, #connectionLines)
|
||||
* @dependency ultracode-panel.js (workflowRuns/workflowRunDetails, _workflowAgentCardHtml, _fmtNum)
|
||||
* @loadorder 15.5 (after subagent-windows.js — needs makeWindowDraggable at runtime)
|
||||
*/
|
||||
/* global CodemanApp, escapeHtml */
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Lazily seed the floating-window state maps (constructor also seeds them). */
|
||||
_ensureUltracodeWindowState() {
|
||||
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed }
|
||||
if (!this.ultracodeWindowsClosed) this.ultracodeWindowsClosed = new Set(); // runIds the user dismissed
|
||||
if (!this.ultracodeWindowCloseTimers) this.ultracodeWindowCloseTimers = new Map(); // runId -> setTimeout id
|
||||
if (this.ultracodeWindowZIndex === undefined) this.ultracodeWindowZIndex = 1000;
|
||||
},
|
||||
|
||||
/** Floating windows have their own opt-in (default OFF), independent of the dock panel. */
|
||||
_ultracodeFloatingEnabled() {
|
||||
const settings = this.loadAppSettingsFromStorage ? this.loadAppSettingsFromStorage() : {};
|
||||
return !!(settings && settings.ultracodeFloatingWindows);
|
||||
},
|
||||
|
||||
/** A run is "working" until it reaches a terminal status. Mid-run status is absent. */
|
||||
_isWorkflowRunActive(run) {
|
||||
const s = String((run && run.status) || '');
|
||||
return !(s === 'completed' || s === 'killed' || s === 'failed');
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve which Codeman tab a run belongs to: the session whose
|
||||
* `claudeSessionId` equals the run's `sessionUuid` (the path segment the watcher
|
||||
* captured). Falls back to the active session so the line still lands somewhere.
|
||||
*/
|
||||
_resolveUltracodeParentSession(run) {
|
||||
const uuid = run && run.sessionUuid;
|
||||
if (uuid && this.sessions) {
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
if (session && session.claudeSessionId === uuid) return sessionId;
|
||||
}
|
||||
}
|
||||
if (this.activeSessionId && this.sessions && this.sessions.has(this.activeSessionId)) {
|
||||
return this.activeSessionId;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto-pop driver — called for every run discovered/updated and on reconnect seed.
|
||||
* Creates a floating window for active runs, refreshes existing ones, and schedules
|
||||
* an auto-close once a run finishes.
|
||||
*/
|
||||
_syncUltracodeFloatingWindow(run, opts) {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (!run || !run.runId) return;
|
||||
if (!this._ultracodeFloatingEnabled()) return;
|
||||
const runId = run.runId;
|
||||
if (this.ultracodeWindowsClosed.has(runId)) return; // respect explicit dismissal
|
||||
|
||||
const active = this._isWorkflowRunActive(run);
|
||||
const existing = this.ultracodeWindows.get(runId);
|
||||
|
||||
if (active) {
|
||||
// Run is alive — cancel any pending auto-close.
|
||||
const pending = this.ultracodeWindowCloseTimers.get(runId);
|
||||
if (pending) {
|
||||
clearTimeout(pending);
|
||||
this.ultracodeWindowCloseTimers.delete(runId);
|
||||
}
|
||||
if (existing) {
|
||||
this.renderUltracodeWindowContent(runId);
|
||||
this._fetchWorkflowRunDetail(runId); // refresh agents[]; re-renders window on land
|
||||
} else {
|
||||
// On a reconnect snapshot, only restore windows for genuinely recent runs so
|
||||
// a backlog of stale undefined-status runs doesn't carpet the screen.
|
||||
if (opts && opts.fromSeed) {
|
||||
const FLOAT_SEED_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
const age = Date.now() - (run.lastActivityAt || 0);
|
||||
if (!(run.lastActivityAt && age < FLOAT_SEED_MAX_AGE_MS)) return;
|
||||
}
|
||||
this.createUltracodeWindow(run);
|
||||
}
|
||||
} else if (existing) {
|
||||
// Finished — refresh to the final state (status + final agent states), show it
|
||||
// briefly, then retire the floating window.
|
||||
this._fetchWorkflowRunDetail(runId);
|
||||
this.renderUltracodeWindowContent(runId);
|
||||
if (!this.ultracodeWindowCloseTimers.has(runId)) {
|
||||
const FLOAT_FINISH_GRACE_MS = 8000;
|
||||
const timer = setTimeout(() => {
|
||||
this.ultracodeWindowCloseTimers.delete(runId);
|
||||
this.closeUltracodeWindow(runId, false);
|
||||
}, FLOAT_FINISH_GRACE_MS);
|
||||
this.ultracodeWindowCloseTimers.set(runId, timer);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Build and mount a floating window for a run, positioned near its parent tab. */
|
||||
createUltracodeWindow(run) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const runId = run.runId;
|
||||
if (this.ultracodeWindows.has(runId)) return;
|
||||
const parentSessionId = this._resolveUltracodeParentSession(run);
|
||||
const titleText = run.workflowName || run.summary || runId;
|
||||
|
||||
const win = document.createElement('div');
|
||||
win.className = 'ultracode-window spawning';
|
||||
win.id = `ultracode-window-${runId}`;
|
||||
win.style.zIndex = ++this.ultracodeWindowZIndex;
|
||||
win.innerHTML = `
|
||||
<div class="ultracode-window-header">
|
||||
<div class="ultracode-window-title" title="${escapeHtml(titleText)}">
|
||||
<span class="icon">🧬</span>
|
||||
<span class="uw-name">${escapeHtml(titleText)}</span>
|
||||
<span class="uw-status"></span>
|
||||
</div>
|
||||
<div class="ultracode-window-actions">
|
||||
<button class="uw-min" type="button" title="Collapse">─</button>
|
||||
<button class="uw-close" type="button" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ultracode-window-body" id="ultracode-window-body-${runId}">
|
||||
<div class="subagent-empty">Loading agents…</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Position: spawn from the parent tab if we can find it, else cascade.
|
||||
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
|
||||
if (parentTab) {
|
||||
const r = parentTab.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(r.left, window.innerWidth - 392));
|
||||
win.style.left = `${left}px`;
|
||||
win.style.top = `${r.bottom + 14}px`;
|
||||
} else {
|
||||
const n = this.ultracodeWindows.size;
|
||||
win.style.left = `${24 + n * 26}px`;
|
||||
win.style.top = `${96 + n * 26}px`;
|
||||
}
|
||||
|
||||
document.body.appendChild(win);
|
||||
// Drop the spawn class on the next frame so the transition runs.
|
||||
requestAnimationFrame(() => win.classList.remove('spawning'));
|
||||
|
||||
const header = win.querySelector('.ultracode-window-header');
|
||||
const dragListeners = this.makeWindowDraggable(win, header);
|
||||
|
||||
win.querySelector('.uw-min').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleUltracodeWindowCollapse(runId);
|
||||
});
|
||||
win.querySelector('.uw-close').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeUltracodeWindow(runId, true);
|
||||
});
|
||||
const nameEl = win.querySelector('.uw-name');
|
||||
if (parentSessionId) {
|
||||
nameEl.style.cursor = 'pointer';
|
||||
nameEl.title = 'Go to session';
|
||||
nameEl.addEventListener('click', () => this.selectSession(parentSessionId));
|
||||
}
|
||||
|
||||
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners, collapsed: false });
|
||||
|
||||
this.renderUltracodeWindowContent(runId);
|
||||
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
/** Collapse/expand the window to header-only (line stays connected). */
|
||||
toggleUltracodeWindowCollapse(runId) {
|
||||
const data = this.ultracodeWindows.get(runId);
|
||||
if (!data) return;
|
||||
data.collapsed = !data.collapsed;
|
||||
data.element.classList.toggle('collapsed', data.collapsed);
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
/** Remove a floating window. `userInitiated` records a dismissal so it won't re-pop. */
|
||||
closeUltracodeWindow(runId, userInitiated) {
|
||||
this._ensureUltracodeWindowState();
|
||||
const pending = this.ultracodeWindowCloseTimers.get(runId);
|
||||
if (pending) {
|
||||
clearTimeout(pending);
|
||||
this.ultracodeWindowCloseTimers.delete(runId);
|
||||
}
|
||||
const data = this.ultracodeWindows.get(runId);
|
||||
if (userInitiated) this.ultracodeWindowsClosed.add(runId);
|
||||
if (!data) return;
|
||||
this._teardownUltracodeDrag(data.dragListeners);
|
||||
data.element.remove();
|
||||
this.ultracodeWindows.delete(runId);
|
||||
this.updateConnectionLines();
|
||||
},
|
||||
|
||||
/** Detach the document-level drag listeners returned by makeWindowDraggable. */
|
||||
_teardownUltracodeDrag(dl) {
|
||||
if (!dl) return;
|
||||
document.removeEventListener('mousemove', dl.move);
|
||||
document.removeEventListener('mouseup', dl.up);
|
||||
if (dl.touchMove) {
|
||||
document.removeEventListener('touchmove', dl.touchMove);
|
||||
document.removeEventListener('touchend', dl.up);
|
||||
document.removeEventListener('touchcancel', dl.up);
|
||||
}
|
||||
if (dl.handle) {
|
||||
dl.handle.removeEventListener('mousedown', dl.handleMouseDown);
|
||||
dl.handle.removeEventListener('touchstart', dl.handleTouchStart);
|
||||
}
|
||||
},
|
||||
|
||||
/** Tear down every floating window (called on SSE reconnect; keeps user dismissals). */
|
||||
removeAllUltracodeWindows() {
|
||||
this._ensureUltracodeWindowState();
|
||||
const had = this.ultracodeWindows.size > 0;
|
||||
for (const [, data] of this.ultracodeWindows) {
|
||||
this._teardownUltracodeDrag(data.dragListeners);
|
||||
if (data.element) data.element.remove();
|
||||
}
|
||||
this.ultracodeWindows.clear();
|
||||
for (const t of this.ultracodeWindowCloseTimers.values()) clearTimeout(t);
|
||||
this.ultracodeWindowCloseTimers.clear();
|
||||
// Redraw so the now-orphaned connector lines are cleared from the shared SVG.
|
||||
if (had) this.updateConnectionLines();
|
||||
},
|
||||
|
||||
/** When the feature is toggled on, pop windows for any currently-active runs. */
|
||||
syncAllUltracodeFloatingWindows() {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (!this._ultracodeFloatingEnabled()) {
|
||||
this.removeAllUltracodeWindows();
|
||||
return;
|
||||
}
|
||||
if (!this.workflowRuns) return;
|
||||
for (const run of this.workflowRuns.values()) {
|
||||
this._syncUltracodeFloatingWindow(run, { fromSeed: true });
|
||||
}
|
||||
},
|
||||
|
||||
/** Refresh a floating window's header + body from the latest summary/detail. */
|
||||
renderUltracodeWindowContent(runId) {
|
||||
const data = this.ultracodeWindows.get(runId);
|
||||
if (!data) return;
|
||||
const summary = this.workflowRuns && this.workflowRuns.get(runId);
|
||||
const detail = this.workflowRunDetails && this.workflowRunDetails.get(runId);
|
||||
// Summary is the freshest run-level info (every SSE tick); detail supplies agents[]
|
||||
// but is fetched less often. Merge so a completed summary isn't masked by stale detail.
|
||||
const run = summary && detail ? { ...detail, ...summary, agents: detail.agents } : detail || summary;
|
||||
if (!run) return;
|
||||
|
||||
const nameEl = data.element.querySelector('.uw-name');
|
||||
if (nameEl) nameEl.textContent = run.workflowName || run.summary || runId;
|
||||
|
||||
const statusEl = data.element.querySelector('.uw-status');
|
||||
if (statusEl) {
|
||||
const finished = !this._isWorkflowRunActive(run);
|
||||
const label = run.status ? String(run.status) : finished ? '—' : 'running';
|
||||
const clsKey = run.status ? run.status : finished ? '' : 'running';
|
||||
statusEl.textContent = label;
|
||||
statusEl.className = 'uw-status ultracode-status ' + this._workflowStatusClass(clsKey);
|
||||
}
|
||||
|
||||
const body = data.element.querySelector('.ultracode-window-body');
|
||||
if (body) body.innerHTML = this._ultracodeWindowBodyHtml(run);
|
||||
},
|
||||
|
||||
/** Compact body: a stats line + agent cards grouped by phase (reuses panel helpers). */
|
||||
_ultracodeWindowBodyHtml(run) {
|
||||
const phases = Array.isArray(run.phases) ? run.phases : [];
|
||||
const agents = Array.isArray(run.agents) ? run.agents : null;
|
||||
const agentCount = run.agentCount ?? (agents ? agents.length : 0);
|
||||
const head = `<div class="uw-summary">${this._fmtNum(run.totalTokens)} tok · ${run.totalToolCalls ?? 0} tools · ${agentCount} agents</div>`;
|
||||
|
||||
if (!agents) {
|
||||
// Summary-only (detail not fetched yet): show phase chips as a teaser.
|
||||
if (phases.length) {
|
||||
const chips = phases
|
||||
.map(
|
||||
(p) =>
|
||||
`<span class="ultracode-phase-chip" title="${escapeHtml(p.detail || '')}">${escapeHtml(p.title || '')}</span>`
|
||||
)
|
||||
.join('');
|
||||
return (
|
||||
head + `<div class="ultracode-phase-list">${chips}</div><div class="subagent-empty">Loading agents…</div>`
|
||||
);
|
||||
}
|
||||
return head + '<div class="subagent-empty">Loading agents…</div>';
|
||||
}
|
||||
if (!agents.length) return head + '<div class="subagent-empty">No agents yet</div>';
|
||||
|
||||
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 grid = 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('');
|
||||
return head + grid;
|
||||
},
|
||||
|
||||
/**
|
||||
* Append ultracode-window → parent-tab connector lines into the shared SVG.
|
||||
* Invoked at the tail of `_updateConnectionLinesImmediate` (subagent-windows.js),
|
||||
* so it shares that pass's batched read/write discipline. `rects` is the tab-rect
|
||||
* cache already populated for subagent lines — reuse it, fill any gaps.
|
||||
*/
|
||||
_appendUltracodeConnectionLines(svg, rects) {
|
||||
this._ensureUltracodeWindowState();
|
||||
if (!svg || !this.ultracodeWindows.size) return;
|
||||
if (!rects) rects = new Map();
|
||||
|
||||
// PHASE 1: layout reads (resolve parents, batch getBoundingClientRect).
|
||||
const winList = [];
|
||||
for (const [runId, data] of this.ultracodeWindows) {
|
||||
if (!data.element) continue;
|
||||
if (!data.parentSessionId) {
|
||||
const summary = this.workflowRuns && this.workflowRuns.get(runId);
|
||||
if (summary) data.parentSessionId = this._resolveUltracodeParentSession(summary);
|
||||
}
|
||||
const parentSessionId = data.parentSessionId;
|
||||
if (!parentSessionId) continue;
|
||||
const tabKey = 'tab:' + parentSessionId;
|
||||
if (!rects.has(tabKey)) {
|
||||
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
|
||||
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
|
||||
}
|
||||
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
|
||||
}
|
||||
|
||||
// PHASE 2: writes (curve from tab bottom-center to window top-center).
|
||||
for (const { runId, parentSessionId, winRect } of winList) {
|
||||
const tabRect = rects.get('tab:' + parentSessionId);
|
||||
if (!tabRect) continue;
|
||||
const x1 = tabRect.left + tabRect.width / 2;
|
||||
const y1 = tabRect.bottom;
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (y1 + y2) / 2;
|
||||
const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('class', 'connection-line ultracode-connection');
|
||||
line.setAttribute('data-run-id', runId);
|
||||
line.setAttribute('data-parent-tab', parentSessionId);
|
||||
svg.appendChild(line);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -511,49 +511,73 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
|
||||
// Check if it's a binary/media file
|
||||
// Classify by extension. Known media types render with a dedicated player;
|
||||
// other known-binary types are flagged so the client offers a download
|
||||
// affordance instead of trying to decode the bytes as text. Matches the
|
||||
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
|
||||
// so the file viewer can open the same files.
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const binaryExts = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'ico',
|
||||
'svg',
|
||||
'bmp',
|
||||
'mp4',
|
||||
'webm',
|
||||
'mov',
|
||||
'avi',
|
||||
'mp3',
|
||||
'wav',
|
||||
'ogg',
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
||||
const videoExts = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
|
||||
const audioExts = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
|
||||
const otherBinaryExts = new Set([
|
||||
'pdf',
|
||||
'zip',
|
||||
'tar',
|
||||
'gz',
|
||||
'bz2',
|
||||
'xz',
|
||||
'7z',
|
||||
'rar',
|
||||
'exe',
|
||||
'dll',
|
||||
'so',
|
||||
'dylib',
|
||||
'bin',
|
||||
'wasm',
|
||||
'class',
|
||||
'o',
|
||||
'a',
|
||||
'woff',
|
||||
'woff2',
|
||||
'ttf',
|
||||
'eot',
|
||||
'otf',
|
||||
'xlsx',
|
||||
'xls',
|
||||
'doc',
|
||||
'docx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'odt',
|
||||
'ods',
|
||||
'odp',
|
||||
'avi',
|
||||
'mkv',
|
||||
'wmv',
|
||||
'flv',
|
||||
]);
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
||||
const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']);
|
||||
|
||||
if (raw === 'true' || binaryExts.has(ext)) {
|
||||
// Return metadata for binary files
|
||||
const mediaType = imageExts.has(ext)
|
||||
? 'image'
|
||||
: videoExts.has(ext)
|
||||
? 'video'
|
||||
: audioExts.has(ext)
|
||||
? 'audio'
|
||||
: null;
|
||||
|
||||
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
|
||||
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
|
||||
// Return metadata for media/binary files (no text body)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: filePath,
|
||||
size: stat.size,
|
||||
type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary',
|
||||
type: mediaType ?? 'binary',
|
||||
extension: ext,
|
||||
url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`,
|
||||
url: fileRawUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -567,10 +591,39 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
);
|
||||
}
|
||||
|
||||
// Read as raw bytes so we can sniff for binary content before decoding. An
|
||||
// unrecognized extension (none at all, or a format not listed above) that
|
||||
// is actually binary would otherwise be dumped to the viewer as UTF-8
|
||||
// mojibake; a NUL byte in the first 8KB is a reliable binary signal that
|
||||
// (unlike a static extension list) catches arbitrary binary formats.
|
||||
const fileBuffer = await fs.readFile(resolvedPath);
|
||||
const buf = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(String(fileBuffer));
|
||||
const sniffLength = Math.min(buf.length, 8192);
|
||||
let looksBinary = false;
|
||||
for (let i = 0; i < sniffLength; i++) {
|
||||
if (buf[i] === 0) {
|
||||
looksBinary = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (looksBinary) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: filePath,
|
||||
size: stat.size,
|
||||
type: 'binary',
|
||||
extension: ext,
|
||||
url: fileRawUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Read text file with line limit (bounded to prevent DoS)
|
||||
const MAX_LINES_LIMIT = 10000;
|
||||
const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
|
||||
const content = await fs.readFile(resolvedPath, 'utf-8');
|
||||
const content = buf.toString('utf-8');
|
||||
const allLines = content.split('\n');
|
||||
const truncatedContent = allLines.length > maxLines;
|
||||
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
|
||||
@@ -638,9 +691,16 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
m4v: 'video/mp4',
|
||||
ogv: 'video/ogg',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
oga: 'audio/ogg',
|
||||
opus: 'audio/ogg',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
pdf: 'application/pdf',
|
||||
json: 'application/json',
|
||||
};
|
||||
|
||||
@@ -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,15 @@ 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).
|
||||
// Either the docked panel OR the floating windows keep the watcher running.
|
||||
toggleService(
|
||||
((settings.showUltracodeAgents as boolean) ?? false) ||
|
||||
((settings.ultracodeFloatingWindows 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 +701,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) => {
|
||||
|
||||
@@ -348,6 +348,10 @@ 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(),
|
||||
/** Floating ultracode run windows w/ tab connector lines (default OFF). Also starts workflowRunWatcher. SYNCED. */
|
||||
ultracodeFloatingWindows: z.boolean().optional(),
|
||||
imageWatcherEnabled: z.boolean().optional(),
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
|
||||
+65
-1
@@ -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,25 @@ export class WebServer extends EventEmitter {
|
||||
return true; // Default enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ultracode/workflow run tracking is enabled in settings (default: FALSE — opt-in).
|
||||
* The watcher feeds BOTH the docked Ultracode Agents panel (`showUltracodeAgents`) and the
|
||||
* floating run windows (`ultracodeFloatingWindows`), so either toggle starts it.
|
||||
*/
|
||||
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) || (settings.ultracodeFloatingWindows ?? 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 +2397,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,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,
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* @fileoverview Workflow (ultracode) Run Watcher
|
||||
*
|
||||
* Emits events powering the master-detail "working agents" view (tasks/phases on
|
||||
* the LEFT, per-agent tokens/tool-calls on the RIGHT) AND the floating run
|
||||
* windows, from TWO disk sources per run:
|
||||
*
|
||||
* 1. COMPLETION artifact — `…/workflows/wf_<id>.json`. The Workflow runtime
|
||||
* writes this with the FULL run state (phases, per-agent tokens/tool-calls,
|
||||
* result), but — as of the mid-2026 runtime — only when the run FINISHES
|
||||
* (always a terminal status). It is the authoritative, detailed record.
|
||||
* 2. LIVE transcript dir — `…/subagents/workflows/wf_<id>/` (agent-*.jsonl +
|
||||
* journal.jsonl). This appears WHILE a run is in flight, before any
|
||||
* `wf_<id>.json` exists. From it we synthesize a minimal ACTIVE run
|
||||
* (status 'running', agent slots keyed by agentId, lastActivityAt from file
|
||||
* mtimes) so the floating window pops DURING the run instead of only after.
|
||||
*
|
||||
* Precedence: when a completion `wf_<id>.json` exists it ALWAYS supersedes the
|
||||
* synthesized live record (same runId), so a finished run shows full detail and
|
||||
* the normal finish→auto-close flow runs. Without source 2 the floating-window
|
||||
* feature is dead for live runs (the completion file only lands at the end, so
|
||||
* the watcher would never see a run while it is active).
|
||||
*
|
||||
* Still STANDALONE: it never imports from or touches subagent-watcher.ts. It
|
||||
* independently reads the same `subagents/workflows/` tree subagent-watcher uses,
|
||||
* but as a separate singleton with no shared mutable state.
|
||||
*
|
||||
* Discovery is dual: a periodic poll (catches new run dirs + removals) plus a
|
||||
* per-dir chokidar watcher (live updates). A per-source mtime skip keeps the hot
|
||||
* path cheap — the completion JSON / live dir is re-read only when its mtime moves.
|
||||
*
|
||||
* @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 SUBAGENTS_SUBDIR = 'subagents';
|
||||
const RUN_FILE_PREFIX = 'wf_';
|
||||
const RUN_FILE_SUFFIX = '.json';
|
||||
const LIVE_JOURNAL_FILE = 'journal.jsonl';
|
||||
const LIVE_AGENT_PREFIX = 'agent-';
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** An in-flight run discovered from its `subagents/workflows/wf_<id>/` transcript dir. */
|
||||
interface DiscoveredLiveRun {
|
||||
dirPath: 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>();
|
||||
/** absolute live transcript-dir path -> newest member mtimeMs (skip unchanged live runs). */
|
||||
private liveDirMtimes = new Map<string, number>();
|
||||
/** runId -> absolute live transcript-dir path (for mtime cleanup on removal). */
|
||||
private runIdToLiveDir = new Map<string, string>();
|
||||
/** watched-dir absolute path -> chokidar watcher (workflows/ + subagents/workflows/). */
|
||||
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();
|
||||
this.liveDirMtimes.clear();
|
||||
this.runIdToLiveDir.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, liveDirs, watchDirs } = await this.discover();
|
||||
|
||||
// Install a live watcher for each watched dir; tear down watchers for dirs that vanished.
|
||||
for (const dir of watchDirs) this.ensureDirWatcher(dir);
|
||||
for (const dir of Array.from(this.dirWatchers.keys())) {
|
||||
if (!watchDirs.has(dir)) this.removeDirWatcher(dir);
|
||||
}
|
||||
|
||||
const seenRunIds = new Set<string>();
|
||||
const realRunIds = new Set<string>();
|
||||
for (const file of files) {
|
||||
seenRunIds.add(file.runId);
|
||||
realRunIds.add(file.runId);
|
||||
await this.maybeParse(file);
|
||||
}
|
||||
|
||||
// In-flight runs: synthesize from the transcript tree ONLY while no completion
|
||||
// wf_*.json exists yet — the real file (full detail + terminal status) supersedes.
|
||||
for (const live of liveDirs) {
|
||||
if (realRunIds.has(live.runId)) continue;
|
||||
seenRunIds.add(live.runId);
|
||||
await this.maybeParseLive(live);
|
||||
}
|
||||
|
||||
// Removal by set-diff: a cached run discoverable from neither source.
|
||||
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);
|
||||
const liveDir = this.runIdToLiveDir.get(runId);
|
||||
if (liveDir) this.liveDirMtimes.delete(liveDir);
|
||||
this.runIdToLiveDir.delete(runId);
|
||||
this.emit('run_removed', { runId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk projects/<projHash>/<sessionUuid>/ for both run sources:
|
||||
* - completion files: `workflows/wf_*.json`
|
||||
* - in-flight runs: `subagents/workflows/wf_<id>/` (transcript dirs)
|
||||
* Returns the dirs to chokidar-watch (so a new run/file is caught sub-poll).
|
||||
*/
|
||||
private async discover(): Promise<{
|
||||
files: DiscoveredRun[];
|
||||
liveDirs: DiscoveredLiveRun[];
|
||||
watchDirs: Set<string>;
|
||||
}> {
|
||||
const files: DiscoveredRun[] = [];
|
||||
const liveDirs: DiscoveredLiveRun[] = [];
|
||||
const watchDirs = new Set<string>();
|
||||
|
||||
let projectHashes: string[];
|
||||
try {
|
||||
projectHashes = await readdir(this.projectsDir);
|
||||
} catch {
|
||||
return { files, liveDirs, watchDirs };
|
||||
}
|
||||
|
||||
for (const projectHash of projectHashes) {
|
||||
let sessions: string[];
|
||||
try {
|
||||
sessions = await readdir(join(this.projectsDir, projectHash));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const sessionUuid of sessions) {
|
||||
const sessionDir = join(this.projectsDir, projectHash, sessionUuid);
|
||||
|
||||
// (1) Completion artifacts: workflows/wf_*.json
|
||||
const workflowsDir = join(sessionDir, WORKFLOWS_SUBDIR);
|
||||
try {
|
||||
const names = await readdir(workflowsDir);
|
||||
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) watchDirs.add(workflowsDir);
|
||||
} catch {
|
||||
// no workflows dir for this session — normal
|
||||
}
|
||||
|
||||
// (2) In-flight runs: subagents/workflows/wf_*/
|
||||
const liveParent = join(sessionDir, SUBAGENTS_SUBDIR, WORKFLOWS_SUBDIR);
|
||||
try {
|
||||
const names = await readdir(liveParent);
|
||||
let hasLive = false;
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(RUN_FILE_PREFIX) || name.endsWith(RUN_FILE_SUFFIX)) continue; // wf_<id> dir, not a file
|
||||
hasLive = true;
|
||||
liveDirs.push({
|
||||
dirPath: join(liveParent, name),
|
||||
projectHash,
|
||||
sessionUuid,
|
||||
runId: name,
|
||||
});
|
||||
}
|
||||
if (hasLive) watchDirs.add(liveParent);
|
||||
} catch {
|
||||
// no subagents/workflows dir for this session — normal
|
||||
}
|
||||
}
|
||||
}
|
||||
return { files, liveDirs, watchDirs };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-synthesize an in-flight run from its transcript dir when its newest member
|
||||
* mtime moved (skip otherwise so we don't re-emit run_updated on idle polls).
|
||||
*/
|
||||
private async maybeParseLive(live: DiscoveredLiveRun): Promise<void> {
|
||||
const info = await this.parseLiveDir(live);
|
||||
if (!info) return;
|
||||
if (this.liveDirMtimes.get(live.dirPath) === info.lastActivityAt) return;
|
||||
this.liveDirMtimes.set(live.dirPath, info.lastActivityAt);
|
||||
|
||||
const existed = this.runs.has(info.runId);
|
||||
this.runs.set(info.runId, info);
|
||||
this.runIdToLiveDir.set(info.runId, live.dirPath);
|
||||
this.emit(existed ? 'run_updated' : 'run_discovered', info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal ACTIVE WorkflowRunInfo from `subagents/workflows/wf_<id>/`.
|
||||
* The transcript tree carries no phases/tokens — those arrive with the
|
||||
* completion wf_*.json — so we expose: the agent slots (keyed by agentId, so the
|
||||
* card→transcript click still works), each marked done/running from journal
|
||||
* `result` lines, and lastActivityAt from the newest agent/journal mtime.
|
||||
*/
|
||||
private async parseLiveDir(live: DiscoveredLiveRun): Promise<WorkflowRunInfo | null> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(live.dirPath);
|
||||
} catch {
|
||||
return null; // vanished between discover and read
|
||||
}
|
||||
|
||||
const agentIds = new Set<string>();
|
||||
let newestMtime = 0;
|
||||
for (const name of entries) {
|
||||
if (name.startsWith(LIVE_AGENT_PREFIX)) {
|
||||
const stem = name.slice(LIVE_AGENT_PREFIX.length).replace(/\.(meta\.json|jsonl)$/, '');
|
||||
if (stem) agentIds.add(stem);
|
||||
}
|
||||
if (name === LIVE_JOURNAL_FILE || name.startsWith(LIVE_AGENT_PREFIX)) {
|
||||
try {
|
||||
const m = (await stat(join(live.dirPath, name))).mtimeMs;
|
||||
if (m > newestMtime) newestMtime = m;
|
||||
} catch {
|
||||
// entry vanished — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
if (agentIds.size === 0) return null; // nothing to show yet
|
||||
|
||||
const doneIds = await this.readJournalDoneAgents(join(live.dirPath, LIVE_JOURNAL_FILE));
|
||||
const agents: WorkflowAgentInfo[] = Array.from(agentIds)
|
||||
.sort()
|
||||
.map((id, i) => ({
|
||||
index: i + 1,
|
||||
label: `agent ${i + 1}`,
|
||||
phaseIndex: 1,
|
||||
phaseTitle: '',
|
||||
model: '',
|
||||
state: doneIds.has(id) ? 'done' : 'progress',
|
||||
agentId: id,
|
||||
}));
|
||||
|
||||
return {
|
||||
runId: live.runId,
|
||||
status: 'running',
|
||||
agentCount: agents.length,
|
||||
phases: [],
|
||||
agents,
|
||||
sessionUuid: live.sessionUuid,
|
||||
projectHash: live.projectHash,
|
||||
lastActivityAt: newestMtime || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Agent ids that already emitted a `result` event in the run journal. */
|
||||
private async readJournalDoneAgents(journalPath: string): Promise<Set<string>> {
|
||||
const done = new Set<string>();
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(journalPath, 'utf-8');
|
||||
} catch {
|
||||
return done; // journal not written yet — all agents still in progress
|
||||
}
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line) as { type?: string; agentId?: string };
|
||||
if (ev && ev.type === 'result' && typeof ev.agentId === 'string') done.add(ev.agentId);
|
||||
} catch {
|
||||
// tolerate a partially-written trailing line
|
||||
}
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
// subagents/workflows/ children are wf_<id>/ DIRS — catch their add/remove too.
|
||||
watcher.on('addDir', handler);
|
||||
watcher.on('unlinkDir', 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();
|
||||
@@ -215,6 +215,50 @@ describe('file-routes', () => {
|
||||
expect(body.data.url).toContain('file-raw');
|
||||
});
|
||||
|
||||
it('returns audio metadata for audio files', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 2048 } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=clip.mp3`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.type).toBe('audio');
|
||||
expect(body.data.url).toContain('file-raw');
|
||||
});
|
||||
|
||||
it('flags known-binary extensions (e.g. xlsx) instead of dumping mojibake', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 4096 } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.type).toBe('binary');
|
||||
expect(body.data.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sniffs NUL bytes and flags binary content for unknown extensions', async () => {
|
||||
const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]);
|
||||
mockedReadFile.mockResolvedValue(binary as never);
|
||||
mockedStat.mockResolvedValue({ size: binary.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=mystery.dat`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.type).toBe('binary');
|
||||
expect(body.data.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects path traversal attempts', async () => {
|
||||
// realpathSync resolves the symlink to a path outside workingDir
|
||||
mockedRealpathSync.mockReturnValue('/etc/passwd' as never);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -404,6 +404,136 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meta-sidecar discovery (2026-06 Claude Code format)', () => {
|
||||
it('should discover a subagent from agent-{id}.meta.json when no .jsonl exists', async () => {
|
||||
// New format: TUI Task subagents write only a meta sidecar (no per-agent .jsonl).
|
||||
mockExistsSync.mockImplementation((p: string) => !String(p).endsWith('.jsonl'));
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
if (path.includes('subagents')) return ['agent-meta1.meta.json'];
|
||||
if (path.includes('session1')) return ['subagents'];
|
||||
if (path.includes('project1')) return ['session1'];
|
||||
return ['project1'];
|
||||
});
|
||||
mockStatSync.mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 120,
|
||||
});
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({ agentType: 'general-purpose', description: 'Audit server.js', toolUseId: 'toolu_x' })
|
||||
);
|
||||
|
||||
const discoveredHandler = vi.fn();
|
||||
watcher.on('subagent:discovered', discoveredHandler);
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
|
||||
expect(discoveredHandler).toHaveBeenCalled();
|
||||
const info = discoveredHandler.mock.calls[0][0] as SubagentInfo;
|
||||
expect(info.agentId).toBe('meta1');
|
||||
expect(info.description).toBe('Audit server.js');
|
||||
expect(info.status).toBe('active');
|
||||
expect(watcher.getSubagents()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should prefer the real .jsonl transcript when one exists alongside the meta', async () => {
|
||||
// Both sidecar and transcript present → defer to the richer .jsonl path.
|
||||
mockExistsSync.mockReturnValue(true); // sibling agent-both.jsonl exists
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
if (path.includes('subagents')) return ['agent-both.meta.json'];
|
||||
if (path.includes('session1')) return ['subagents'];
|
||||
if (path.includes('project1')) return ['session1'];
|
||||
return ['project1'];
|
||||
});
|
||||
mockStatSync.mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 100,
|
||||
});
|
||||
// Mirror the passing discovery tests: leave createReadStream unmocked so
|
||||
// _resolveDescription fails gracefully (undefined) and discovery still fires.
|
||||
mockReadFileSync.mockReturnValue('');
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
|
||||
const discoveredHandler = vi.fn();
|
||||
watcher.on('subagent:discovered', discoveredHandler);
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(discoveredHandler).toHaveBeenCalled();
|
||||
const info = discoveredHandler.mock.calls[0][0] as SubagentInfo;
|
||||
expect(info.agentId).toBe('both');
|
||||
// filePath points at the transcript, not the sidecar.
|
||||
expect(info.filePath.endsWith('.jsonl')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow-nested subagents (subagents/workflows/{wf}/)', () => {
|
||||
// The Workflow tool nests its agents one level deeper than TUI Task subagents:
|
||||
// subagents/workflows/{workflowId}/agent-{id}.jsonl — plus a sibling journal.jsonl.
|
||||
// The flat subagents/ scan misses them; watchWorkflowDirs() descends one level.
|
||||
function mockWorkflowLayout(wfFiles: string[]) {
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
const p = String(path);
|
||||
if (p.endsWith('wf_test')) return wfFiles;
|
||||
if (p.endsWith('workflows')) return ['wf_test'];
|
||||
if (p.endsWith('subagents')) return ['workflows']; // no flat agents, just the workflows dir
|
||||
if (p.includes('session1')) return ['subagents'];
|
||||
if (p.includes('project1')) return ['session1'];
|
||||
return ['project1'];
|
||||
});
|
||||
mockStatSync.mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 100,
|
||||
});
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
return mockRl;
|
||||
}
|
||||
|
||||
it('should discover an agent nested under subagents/workflows/{wf}/', async () => {
|
||||
mockExistsSync.mockReturnValue(true); // sibling .jsonl exists for the meta
|
||||
const mockRl = mockWorkflowLayout(['agent-wf1.jsonl', 'agent-wf1.meta.json', 'journal.jsonl']);
|
||||
|
||||
const discoveredHandler = vi.fn();
|
||||
watcher.on('subagent:discovered', discoveredHandler);
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(discoveredHandler).toHaveBeenCalled();
|
||||
const info = discoveredHandler.mock.calls[0][0] as SubagentInfo;
|
||||
expect(info.agentId).toBe('wf1');
|
||||
expect(info.filePath.endsWith('.jsonl')).toBe(true);
|
||||
expect(info.filePath).toContain('workflows');
|
||||
});
|
||||
|
||||
it('should NOT register a workflow dir journal.jsonl as a bogus agent', async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
const mockRl = mockWorkflowLayout(['agent-wf1.jsonl', 'journal.jsonl']);
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const agents = watcher.getSubagents();
|
||||
expect(agents).toHaveLength(1); // only agent-wf1, never "journal"
|
||||
expect(agents.every((a) => a.agentId !== 'journal')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Lifecycle', () => {
|
||||
it('should start agents as active', async () => {
|
||||
const mockRl = createMockRl();
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* In-flight runs: the Workflow runtime writes the completion wf_<id>.json only when
|
||||
* a run FINISHES, so while it is live the only on-disk state is its
|
||||
* subagents/workflows/wf_<id>/ transcript dir. The watcher synthesizes a minimal
|
||||
* ACTIVE run from that dir so the floating window pops DURING the run.
|
||||
*/
|
||||
describe('WorkflowRunWatcher — in-flight (live) runs', () => {
|
||||
const LIVE_RUN_ID = 'wf_live5678-xyz';
|
||||
let projectsDir: string;
|
||||
let liveDir: string;
|
||||
let watcher: WorkflowRunWatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectsDir = await mkdtemp(join(tmpdir(), 'wfw-live-'));
|
||||
liveDir = join(projectsDir, PROJECT_HASH, SESSION_UUID, 'subagents', 'workflows', LIVE_RUN_ID);
|
||||
await mkdir(liveDir, { recursive: true });
|
||||
// Two agents started; one already produced a result (journal `result` line).
|
||||
await writeFile(
|
||||
join(liveDir, 'agent-aaa111.meta.json'),
|
||||
JSON.stringify({ agentType: 'workflow-subagent' }),
|
||||
'utf-8'
|
||||
);
|
||||
await writeFile(join(liveDir, 'agent-aaa111.jsonl'), '{"type":"assistant"}\n', 'utf-8');
|
||||
await writeFile(
|
||||
join(liveDir, 'agent-bbb222.meta.json'),
|
||||
JSON.stringify({ agentType: 'workflow-subagent' }),
|
||||
'utf-8'
|
||||
);
|
||||
await writeFile(join(liveDir, 'agent-bbb222.jsonl'), '{"type":"assistant"}\n', 'utf-8');
|
||||
await writeFile(
|
||||
join(liveDir, 'journal.jsonl'),
|
||||
'{"type":"started","agentId":"aaa111"}\n{"type":"started","agentId":"bbb222"}\n{"type":"result","agentId":"aaa111","result":{}}\n',
|
||||
'utf-8'
|
||||
);
|
||||
watcher = new WorkflowRunWatcher(projectsDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
watcher.stop();
|
||||
await rm(projectsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
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('synthesizes an ACTIVE run from the transcript dir when no completion file exists', async () => {
|
||||
const info = await firstRun();
|
||||
expect(info.runId).toBe(LIVE_RUN_ID);
|
||||
expect(info.status).toBe('running'); // active → frontend pops a floating window
|
||||
expect(info.sessionUuid).toBe(SESSION_UUID);
|
||||
expect(info.projectHash).toBe(PROJECT_HASH);
|
||||
expect(info.agents).toHaveLength(2);
|
||||
expect(info.agentCount).toBe(2);
|
||||
expect(info.lastActivityAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('preserves agentId per slot (so the card→transcript click join still works)', async () => {
|
||||
const info = await firstRun();
|
||||
const ids = info.agents.map((a) => a.agentId).sort();
|
||||
expect(ids).toEqual(['aaa111', 'bbb222']);
|
||||
});
|
||||
|
||||
it('marks an agent done/progress from journal result lines', async () => {
|
||||
const info = await firstRun();
|
||||
expect(info.agents.find((a) => a.agentId === 'aaa111')!.state).toBe('done'); // has a result line
|
||||
expect(info.agents.find((a) => a.agentId === 'bbb222')!.state).toBe('progress'); // started, no result yet
|
||||
});
|
||||
|
||||
it('counts the live run as running in getStats', async () => {
|
||||
await firstRun();
|
||||
expect(watcher.getStats().running).toBe(1);
|
||||
});
|
||||
|
||||
it('does NOT surface a live dir that has no agent files yet', async () => {
|
||||
const empty = join(projectsDir, PROJECT_HASH, SESSION_UUID, 'subagents', 'workflows', 'wf_empty0000-noo');
|
||||
await mkdir(empty, { recursive: true });
|
||||
await firstRun(); // resolves on the real (populated) live run
|
||||
// The empty run id must never enter the cache.
|
||||
expect(watcher.getRun('wf_empty0000-noo')).toBeUndefined();
|
||||
expect(watcher.getAllRuns().map((r) => r.runId)).toEqual([LIVE_RUN_ID]);
|
||||
});
|
||||
|
||||
it('a completion wf_*.json supersedes the live dir for the same runId (real status wins)', async () => {
|
||||
const workflowsDir = join(projectsDir, PROJECT_HASH, SESSION_UUID, 'workflows');
|
||||
await mkdir(workflowsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(workflowsDir, `${LIVE_RUN_ID}.json`),
|
||||
JSON.stringify({ runId: LIVE_RUN_ID, status: 'completed', durationMs: 1234, phases: [], workflowProgress: [] }),
|
||||
'utf-8'
|
||||
);
|
||||
const info = await firstRun();
|
||||
expect(info.runId).toBe(LIVE_RUN_ID);
|
||||
expect(info.status).toBe('completed'); // real completion file wins, not synthesized 'running'
|
||||
expect(info.durationMs).toBe(1234);
|
||||
// Only one cached entry for the runId — no live/real duplication.
|
||||
expect(watcher.getAllRuns()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user