mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29ffc62536 | ||
|
|
4cb3a4aac8 | ||
|
|
b6531cbf79 | ||
|
|
d16bf34e34 |
@@ -1,5 +1,43 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: ultracode floating run windows now pop on a fresh device/browser that loads while a run is already active.
|
||||
|
||||
`ultracodeFloatingWindows` syncs from the server (it's a non-display setting), but on a first-time device the SSE `getLightState` run snapshot can seed the run list BEFORE the async settings load resolves — so the floating-window gate read `false` at that instant and skipped any already-active run, leaving the window un-popped until the next ~10s watcher tick. The app now re-runs `syncAllUltracodeFloatingWindows()` once server settings finish loading (in the `loadAppSettingsFromServer().then()` callback), so an in-flight run pops its window immediately. Idempotent: open windows are left as-is, and if the setting is off any premature windows are torn down. Verified end-to-end against a real in-flight run on an isolated instance — a pristine browser (empty localStorage) seeds the setting from the server and pops the active run's window ~0.4s after first paint.
|
||||
|
||||
Also corrected a stale `@fileoverview` comment in `ultracode-windows.js` that claimed the floating windows are gated on `showUltracodeAgents`; they are gated on the dedicated `ultracodeFloatingWindows` toggle (only the docked "Ultracode Agents" panel uses `showUltracodeAgents`).
|
||||
|
||||
## 1.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: the Ultracode Agents panel's (×) Close button now fully hides the panel.
|
||||
|
||||
`closeUltracodeAgentsPanel()` only removed the `open` class, which drops the bottom-docked drawer to its collapsed _peek_ state (the 36px header strip stays visible) rather than closing it — so clicking (×) looked like it did nothing. It now also adds the `hidden` class (`display:none`), mirroring `closeSubagentsPanel()`. It deliberately does NOT flip the `showUltracodeAgents` setting (that also gates the run watcher and floating windows); the header launcher button reopens the panel. Verified in a real browser: after (×) the panel computes `display:none`.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.2 (must match `package.json`)
|
||||
**Version**: 1.1.6 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -124,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` | |
|
||||
@@ -132,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) + 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) + 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).
|
||||
|
||||
@@ -174,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) → `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) → `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).
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.6",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -350,6 +350,11 @@ class CodemanApp {
|
||||
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
|
||||
@@ -660,6 +665,16 @@ class CodemanApp {
|
||||
this.applySkin();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
|
||||
// FRESH device the getLightState run snapshot can seed workflowRuns BEFORE this
|
||||
// async settings load resolves — so the floating-window gate read false then and
|
||||
// skipped any already-active run. Re-sync now that the real setting is loaded so
|
||||
// an in-flight run pops its window immediately instead of waiting for the next
|
||||
// ~10s SSE tick. Idempotent: open windows are left as-is; if the setting is off
|
||||
// it tears any premature windows down.
|
||||
if (typeof this.syncAllUltracodeFloatingWindows === 'function') {
|
||||
this.syncAllUltracodeFloatingWindows();
|
||||
}
|
||||
});
|
||||
// Hide loading skeleton now that the app shell is ready
|
||||
document.body.classList.add('app-loaded');
|
||||
|
||||
@@ -1106,6 +1106,13 @@
|
||||
<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>
|
||||
@@ -1963,6 +1970,7 @@
|
||||
<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>
|
||||
|
||||
@@ -314,6 +314,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
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)
|
||||
@@ -1370,6 +1372,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
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,
|
||||
@@ -1685,6 +1688,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showFileBrowser: false,
|
||||
showSubagents: false,
|
||||
showUltracodeAgents: false,
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
@@ -1899,6 +1903,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
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');
|
||||
|
||||
@@ -8556,6 +8556,129 @@ kbd {
|
||||
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;
|
||||
|
||||
@@ -31,6 +31,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -47,6 +51,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -58,6 +64,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -70,8 +78,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (panel.classList.contains('open')) this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
closeUltracodeAgentsPanel() {
|
||||
// The X must FULLY hide the panel. Removing only `open` drops it to the
|
||||
// collapsed peek state (header strip still visible), so add `hidden`
|
||||
// (display:none) too — mirrors closeSubagentsPanel. Not the showUltracodeAgents
|
||||
// setting: that also gates the watcher + floating windows; the launcher reopens.
|
||||
const panel = document.getElementById('ultracodeAgentsPanel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
if (panel) {
|
||||
panel.classList.remove('open');
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
// ----- Selection -----
|
||||
@@ -97,6 +112,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
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 */
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* @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 `ultracodeFloatingWindows` setting is on (a DEDICATED toggle, separate
|
||||
* from the dock panel's `showUltracodeAgents` — see `_ultracodeFloatingEnabled`),
|
||||
* 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -541,8 +541,14 @@ export function registerSystemRoutes(
|
||||
// Handle subagent tracking toggle dynamically
|
||||
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
|
||||
// Handle ultracode/workflow run watcher toggle dynamically (default OFF)
|
||||
toggleService((settings.showUltracodeAgents as boolean) ?? false, workflowRunWatcher, 'Workflow run watcher');
|
||||
// Handle 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', () => {
|
||||
|
||||
@@ -350,6 +350,8 @@ export const SettingsUpdateSchema = z
|
||||
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(),
|
||||
|
||||
+3
-1
@@ -2026,13 +2026,15 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return (settings.showUltracodeAgents ?? false) || (settings.ultracodeFloatingWindows ?? false);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('Failed to read showUltracodeAgents setting:', err);
|
||||
|
||||
+210
-36
@@ -1,19 +1,33 @@
|
||||
/**
|
||||
* @fileoverview Workflow (ultracode) Run Watcher
|
||||
*
|
||||
* Watches `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` —
|
||||
* the run-state JSON the Workflow tool writes for each ultracode run — and emits
|
||||
* events powering the master-detail "working agents" view (tasks/phases on the
|
||||
* LEFT, per-agent tokens/tool-calls on the RIGHT).
|
||||
* 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:
|
||||
*
|
||||
* Deliberately STANDALONE: it never imports from or touches subagent-watcher.ts.
|
||||
* It globs the run-state tree (`.../workflows/wf_*.json`); subagent-watcher globs
|
||||
* the disjoint, deeper transcript tree (`.../subagents/workflows/wf_<id>/agent-*`).
|
||||
* Separate singletons, disjoint directories, no shared mutable state.
|
||||
* 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-run-dir chokidar watcher (live within-run updates). A per-file mtime skip
|
||||
* keeps the hot path cheap — the run JSON is rewritten on every agent tick.
|
||||
* 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
|
||||
*/
|
||||
@@ -34,8 +48,11 @@ import {
|
||||
} 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;
|
||||
@@ -60,6 +77,14 @@ interface DiscoveredRun {
|
||||
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;
|
||||
@@ -94,7 +119,11 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
private fileMtimes = new Map<string, number>();
|
||||
/** runId -> absolute run-file path (for mtime cleanup on removal). */
|
||||
private runIdToPath = new Map<string, string>();
|
||||
/** workflows-dir absolute path -> chokidar watcher (one per live run dir). */
|
||||
/** 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) {
|
||||
@@ -129,6 +158,8 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
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. */
|
||||
@@ -180,42 +211,65 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
private async pollAsync(): Promise<void> {
|
||||
const { files, dirs } = await this.discover();
|
||||
const { files, liveDirs, watchDirs } = await this.discover();
|
||||
|
||||
// Install a live watcher for each run dir; tear down watchers for dirs that vanished.
|
||||
for (const dir of dirs) this.ensureDirWatcher(dir);
|
||||
// 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 (!dirs.has(dir)) this.removeDirWatcher(dir);
|
||||
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);
|
||||
}
|
||||
|
||||
// Removal by set-diff: a cached run whose file disappeared.
|
||||
// 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>/workflows/ for wf_*.json files. */
|
||||
private async discover(): Promise<{ files: DiscoveredRun[]; dirs: Set<string> }> {
|
||||
/**
|
||||
* 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 dirs = new Set<string>();
|
||||
const liveDirs: DiscoveredLiveRun[] = [];
|
||||
const watchDirs = new Set<string>();
|
||||
|
||||
let projectHashes: string[];
|
||||
try {
|
||||
projectHashes = await readdir(this.projectsDir);
|
||||
} catch {
|
||||
return { files, dirs };
|
||||
return { files, liveDirs, watchDirs };
|
||||
}
|
||||
|
||||
for (const projectHash of projectHashes) {
|
||||
@@ -226,28 +280,50 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
continue;
|
||||
}
|
||||
for (const sessionUuid of sessions) {
|
||||
const workflowsDir = join(this.projectsDir, projectHash, sessionUuid, WORKFLOWS_SUBDIR);
|
||||
let names: string[];
|
||||
const sessionDir = join(this.projectsDir, projectHash, sessionUuid);
|
||||
|
||||
// (1) Completion artifacts: workflows/wf_*.json
|
||||
const workflowsDir = join(sessionDir, WORKFLOWS_SUBDIR);
|
||||
try {
|
||||
names = await readdir(workflowsDir);
|
||||
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 {
|
||||
continue; // no workflows dir for this session — normal
|
||||
// no workflows dir for this session — normal
|
||||
}
|
||||
let hasRun = false;
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(RUN_FILE_PREFIX) || !name.endsWith(RUN_FILE_SUFFIX)) continue;
|
||||
hasRun = true;
|
||||
files.push({
|
||||
filePath: join(workflowsDir, name),
|
||||
projectHash,
|
||||
sessionUuid,
|
||||
runId: name.slice(0, -RUN_FILE_SUFFIX.length),
|
||||
});
|
||||
|
||||
// (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
|
||||
}
|
||||
if (hasRun) dirs.add(workflowsDir);
|
||||
}
|
||||
}
|
||||
return { files, dirs };
|
||||
return { files, liveDirs, watchDirs };
|
||||
}
|
||||
|
||||
private async maybeParse(file: DiscoveredRun): Promise<void> {
|
||||
@@ -269,6 +345,101 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
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
|
||||
@@ -360,6 +531,9 @@ export class WorkflowRunWatcher extends EventEmitter {
|
||||
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);
|
||||
|
||||
@@ -210,3 +210,110 @@ describe('WorkflowRunWatcher', () => {
|
||||
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