Compare commits

...
Author SHA1 Message Date
Codeman maintainer a0ac10a07c feat(tunnel,ui): purple tunnel button + opt-in unauthenticated tunnel with warning (v1.1.9)
- Daylight Blue: Cloudflare Tunnel welcome button is now purple (was orange),
  keeping Claude blue / Tunnel purple / OpenCode green distinct.
- Allow enabling the Cloudflare tunnel with no CODEMAN_PASSWORD via the UI: the
  toggle now pops a security confirm dialog and, on confirm, sends an explicit
  per-request acknowledgeUnauthTunnel:true (new action field, never persisted).
  Server logs a loud warning whenever a passwordless public tunnel starts.
  curl/API/CLI stay refused unless password/env/flag — no accidental exposure.

Tests: extend test/routes/system-routes-tunnel-guard.test.ts (ack allows + not
persisted; ack:false still refuses). Verified e2e on an isolated instance
(purple button, confirm dialog, retry carries the flag, no real tunnel opened).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:44:03 +02:00
Codeman maintainer f7814ad364 feat(ui): distinct colors for welcome action buttons on Daylight Blue (v1.1.8)
On the default daylight-blue skin the three welcome buttons all read blue.
Give each its own identity: Run Claude Code keeps the blue accent, Cloudflare
Tunnel takes Cloudflare brand orange, Run OpenCode takes emerald green (with
matching hover/active states + dark ink for contrast). Scoped to daylight-blue
only; daylight-green and OG unchanged. Verified in-browser (blue/orange/green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:22:33 +02:00
Codeman maintainer 3172befd5d fix(terminal): keep Claude scrollback reachable — strip alt-screen/3J/mouse for claude mode (v1.1.7)
Terminal scroll-up intermittently broke for Claude sessions (most visible on
iPhone). Claude Code periodically emits alt-screen switches (?1049h/?47h/?1047h),
scrollback-erase (3J), and mouse-tracking enables for full-screen UIs, which move
xterm.js to the scrollback-less alt buffer / wipe saved lines / hijack the wheel.
Codeman stripped these but only for codex mode.

Share the strip via isAltScreenStripMode(mode) = codex || claude, applied at both
sites that were codex-only: the live PTY stream (Session._handleTerminalOutput,
incl. the chunk-boundary carry) and the /terminal buffer replay. shell stays
excluded (vim/less/htop need the alt screen); opencode unchanged.

Tests: test/claude-scrollback-strip.test.ts (8 new); codex strip tests unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:08:45 +02:00
Codeman maintainer 29ffc62536 fix(ultracode): pop floating windows on fresh devices loading mid-run (v1.1.6)
Re-run syncAllUltracodeFloatingWindows() after server settings load so a
first-time device whose getLightState run snapshot arrives before the async
settings fetch resolves still pops an already-active run's window immediately,
instead of waiting for the next ~10s watcher tick. Also fixes a stale
@fileoverview comment that named the wrong gating setting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:27:10 +02:00
Codeman maintainer 4cb3a4aac8 fix(ultracode): (x) Close fully hides the Ultracode Agents panel
closeUltracodeAgentsPanel() only removed `open`, leaving the drawer in its
collapsed peek state (header strip still visible) — so (x) looked like a no-op.
Now also adds `hidden` (display:none), mirroring closeSubagentsPanel; does NOT
flip showUltracodeAgents (that gates the watcher + floating windows). Verified in
a real browser (post-close computed display:none). Bumps 1.1.4 -> 1.1.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:46:53 +02:00
Codeman maintainer b6531cbf79 fix(ultracode): floating windows pop for LIVE runs (watch transcript tree)
The Workflow runtime writes workflows/wf_<id>.json only at completion (always
terminal), so workflow-run-watcher never saw a run until it was already done and
the ACTIVE-gated floating window never popped. The watcher now also scans
subagents/workflows/wf_<id>/ and synthesizes a minimal running record (agentId
slots preserved for the transcript-click join, lastActivityAt from mtimes,
done/running from the journal), superseded by the real wf_<id>.json at
completion. Standalone (no subagent-watcher import). Verified e2e on a real
in-flight run; +6 unit tests. Bumps 1.1.3 -> 1.1.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:20:52 +02:00
Codeman maintainer d16bf34e34 feat(ultracode): floating run windows with tab connector lines + dedicated toggle
Auto-popping draggable window per active ultracode/Workflow run, connected by a
glowing line to its originating session tab (resolved via claudeSessionId ===
sessionUuid). Mirrors the live agent grid; auto-closes after a run finishes;
dismissals are remembered. Additional to the existing docked panel.

New "Ultracode Floating Windows" setting (default OFF), independent of the
"Ultracode Agents" panel toggle; either toggle starts the workflow-run watcher.

Also bumps version to 1.1.3 and brings CLAUDE.md up to date for the ultracode
subsystem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:33:54 +02:00
20 changed files with 1256 additions and 89 deletions
+64
View File
@@ -1,5 +1,69 @@
# aicodeman
## 1.1.9
### Patch Changes
- Two welcome-screen tunnel changes:
- **UI (Daylight Blue skin):** the **Cloudflare Tunnel** button is now purple (was orange/yellow), keeping the three welcome buttons visually distinct — Claude blue, Tunnel purple, OpenCode green.
- **Enable a tunnel without `CODEMAN_PASSWORD`, with a warning.** Previously enabling the Cloudflare tunnel with no password set was hard-refused unless you set `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`. Now you can opt in straight from the browser: clicking the tunnel toggle without a password pops a **security confirm dialog** ("publishes this machine to a public URL with no login — effectively remote code execution; set CODEMAN_PASSWORD instead"), and only on confirm does it enable, sending an explicit per-request `acknowledgeUnauthTunnel:true`. The server logs a loud warning whenever a passwordless public tunnel starts. curl/API/CLI callers are unchanged — still refused unless they set a password, set the env var, or pass `acknowledgeUnauthTunnel:true` — so nothing gets exposed accidentally. The acknowledgment is an action field and is never persisted to settings.json.
## 1.1.8
### Patch Changes
- UI (Daylight Blue skin): give the welcome-screen action buttons distinct colors instead of all reading blue. **Run Claude Code** keeps the blue accent, **Cloudflare Tunnel** now uses Cloudflare's brand orange, and **Run OpenCode** uses an emerald green — so the three are visually distinguishable at a glance. Scoped to the default `daylight-blue` skin only (daylight-green and OG are unchanged), with matching hover/active states and dark ink for contrast. Verified in a real browser: the three buttons compute to blue / orange / green gradients on the welcome overlay.
## 1.1.7
### Patch Changes
- Fix: terminal scroll-up (scrollback) intermittently breaking for **Claude** sessions — most visible on iPhone, where you suddenly "can't scroll up the Claude console."
Root cause: Claude Code periodically emits alternate-screen switches (`\x1b[?1049h`/`\x1b[?47h`/`\x1b[?1047h`), scrollback-erase (`\x1b[3J`), and mouse-tracking enables — typically when it draws a full-screen UI (pickers/dialogs, the boot welcome). xterm.js obeys these by moving to the scrollback-less alternate buffer (or wiping saved lines / hijacking the wheel), so the conversation history becomes unreachable until Claude returns to its normal view. Codeman already stripped these sequences so history stays scrollable, but the strip was gated to **Codex mode only** — Claude (and the equivalent buffer-replay path) let them through.
The strip is now shared via a single `isAltScreenStripMode(mode)` predicate (`codex || claude`) applied at BOTH sites that were Codex-only: the live PTY stream (`Session._handleTerminalOutput`, including the split-across-chunks carry reassembly) and the `/terminal` buffer replay used on tab-switch/reconnect. `shell` is deliberately excluded so full-screen TUIs run from a shell (vim/less/htop) keep their alternate screen; `opencode` is also unchanged.
Verified end-to-end on an isolated instance against a real Claude session: the replayed buffer and live stream now carry zero alt-screen/scrollback-erase/mouse sequences, the terminal stays in the normal buffer with scrollback intact, and touch swipe-up scrolls correctly. Covered by new unit tests (`test/claude-scrollback-strip.test.ts`); the existing Codex strip tests are unchanged.
## 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
+8 -6
View File
@@ -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.9 (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).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.1.2",
"version": "1.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.1.2",
"version": "1.1.9",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.1.2",
"version": "1.1.9",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+34 -13
View File
@@ -136,6 +136,21 @@ export function isExternalCliMode(mode: SessionMode): boolean {
return mode === 'opencode' || mode === 'codex';
}
/**
* Modes whose TUI emits alt-screen / scrollback-erase / mouse-tracking sequences
* that we strip so the browser keeps everything in the main buffer with scrollback
* reachable (the strip runs on both the live stream and the buffer replay).
*
* Codex and Claude Code are known, controlled TUIs that repaint via cursor
* positioning, so dropping the alt-screen switch is safe — content stays in the
* normal buffer. Excluded: `shell` (arbitrary programs like vim/less/htop
* legitimately need the alt screen) and `opencode` (renders its own TUI that
* may rely on it). Keep parity with the replay-side strip in session-routes.ts.
*/
export function isAltScreenStripMode(mode: SessionMode): boolean {
return mode === 'codex' || mode === 'claude';
}
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
@@ -265,9 +280,10 @@ export class Session extends EventEmitter {
private _messages: ClaudeMessage[] = [];
private _lineBuffer: string = '';
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
// Codex only: trailing partial CSI held back so sequences split across PTY
// chunks can't slip past the alt-screen/scrollback strip (see _handleTerminalOutput)
private _codexSeqCarry: string = '';
// Alt-screen-strip modes (Codex/Claude): trailing partial CSI held back so
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
// strip (see _handleTerminalOutput / isAltScreenStripMode)
private _altScreenSeqCarry: string = '';
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
@@ -1134,35 +1150,40 @@ export class Session extends EventEmitter {
}
private _handleTerminalOutput(data: string): void {
// Codex emits sequences that wipe xterm.js scrollback, plus mouse-tracking
// enables that hijack the scroll wheel so the user can't reach scrollback:
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
// scrollback. Claude Code does this intermittently (e.g. full-screen pickers /
// dialogs), which is why terminal scroll-up "randomly" breaks for Claude
// sessions on mobile and desktop until the dialog closes:
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
// scrollback) — \x1b[?...l switches back.
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
// the visible viewport — are left intact; the TUI repaints those rows.)
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
// xterm.js forwards wheel events to codex instead of scrolling the
// xterm.js forwards wheel events to the CLI instead of scrolling the
// viewport, so the conversation is in scrollback but unreachable.
// (Focus events at ?1004 are left alone — codeman uses them for
// active-tab detection.)
// Strip them at the source so neither the persisted buffer nor the live
// SSE/WS stream carries them, keeping everything in the main buffer with
// scrollback intact. Codex's cursor-positioned redraws overwrite only the
// cells they actually target, so the non-erased rows keep their content.
if (this.mode === 'codex') {
// scrollback intact. These are controlled TUIs whose cursor-positioned
// redraws overwrite only the cells they target, so non-erased rows keep
// their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must
// keep the alt screen for vim/less/htop.
if (isAltScreenStripMode(this.mode)) {
// Reassemble sequences split across PTY chunk boundaries first: a chunk
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
// strip below and leave xterm stuck in the scrollback-less alt buffer
// until the next buffer replay. Hold back an incomplete digit-only CSI
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
// prepend it to the next chunk; complete sequences are never held.
data = this._codexSeqCarry + data;
this._codexSeqCarry = '';
data = this._altScreenSeqCarry + data;
this._altScreenSeqCarry = '';
// eslint-disable-next-line no-control-regex
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
if (splitTail) {
this._codexSeqCarry = splitTail[0];
this._altScreenSeqCarry = splitTail[0];
data = data.slice(0, -splitTail[0].length);
if (!data) return;
}
@@ -1809,7 +1830,7 @@ export class Session extends EventEmitter {
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._codexSeqCarry = '';
this._altScreenSeqCarry = '';
this._lastActivityAt = Date.now();
}
+15
View File
@@ -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');
+8
View File
@@ -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>
+55
View File
@@ -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)
@@ -1174,6 +1176,48 @@ Object.assign(CodemanApp.prototype, {
} catch {
/* non-JSON body — use the default message */
}
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if (res.status === 403) {
const confirmed = confirm(
'⚠️ SECURITY WARNING — no password set\n\n' +
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
'code execution on your computer.\n\n' +
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
'Enable the unauthenticated public tunnel anyway?'
);
if (!confirmed) {
this._dismissTunnelConnecting?.();
this.showToast('Tunnel not enabled', 'info');
return true;
}
try {
const retry = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
});
if (retry.ok) {
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
return false; // proceed with the caller's success/connecting path
}
let m = 'Failed to enable tunnel.';
try {
const b = await retry.json();
if (b && b.error) m = b.error;
} catch {
/* non-JSON */
}
this._dismissTunnelConnecting?.();
this.showToast(m, 'error');
return true;
} catch {
this._dismissTunnelConnecting?.();
this.showToast('Failed to enable tunnel', 'error');
return true;
}
}
this._dismissTunnelConnecting?.();
this.showToast(message, 'error');
return true;
@@ -1370,6 +1414,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 +1730,7 @@ Object.assign(CodemanApp.prototype, {
showFileBrowser: false,
showSubagents: false,
showUltracodeAgents: false,
ultracodeFloatingWindows: false,
showMultiMonitorButton: false,
showPlanUsageLimits: false,
showAttachmentsButton: false,
@@ -1899,6 +1945,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');
+158
View File
@@ -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
@@ -10076,3 +10199,38 @@ html:not([data-skin="og"]) {
/* ---- Focus rings (driven by --accent, now emerald) stay accessible ---- */
:focus-visible { outline-color: var(--accent); }
}
/* ---- Daylight Blue: distinct identity per welcome action button ----
Run Claude Code keeps the blue accent; Cloudflare Tunnel is purple; Run
OpenCode is emerald green — so the three are clearly different colors instead
of all reading blue. Scoped to daylight-blue only; placed after the shared
daylight block to win on equal specificity. ---- */
html[data-skin="daylight-blue"] .welcome-btn-opencode {
background: linear-gradient(135deg, #0d9f6e, #2fbf85);
border-color: rgba(16, 185, 129, 0.5);
color: #04291b;
}
html[data-skin="daylight-blue"] .welcome-btn-opencode:hover {
background: linear-gradient(135deg, #10b981, #34d399);
border-color: rgba(52, 211, 153, 0.6);
box-shadow: 0 0 28px -4px rgba(16, 185, 129, 0.4);
}
html[data-skin="daylight-blue"] .welcome-btn-tunnel {
background: linear-gradient(135deg, #7c3aed, #a855f7);
border-color: rgba(168, 85, 247, 0.5);
color: #f5f3ff;
}
html[data-skin="daylight-blue"] .welcome-btn-tunnel:hover {
background: linear-gradient(135deg, #8b5cf6, #c084fc);
border-color: rgba(192, 132, 252, 0.6);
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.45);
}
html[data-skin="daylight-blue"] .welcome-btn-tunnel.active {
background: linear-gradient(135deg, #6d28d9, #7c3aed);
border-color: rgba(124, 58, 237, 0.6);
color: #f5f3ff;
}
html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
background: linear-gradient(135deg, #7c3aed, #8b5cf6);
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5);
}
+9
View File
@@ -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;
+18 -1
View File
@@ -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 */
+384
View File
@@ -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">&times;</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);
}
},
});
+13 -11
View File
@@ -18,7 +18,7 @@ import {
type ApiResponse,
type SessionColor,
} from '../../types.js';
import { Session } from '../../session.js';
import { Session, isAltScreenStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
import {
CreateSessionSchema,
@@ -79,12 +79,13 @@ const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
* (1049 also saves cursor and clears the alt buffer).
* - CSI 3 J = erase saved lines (scrollback).
*
* Codex emits `\x1b[?1049h` and clear-scrollback sequences during startup and
* on repaint. xterm.js obeys them by switching to the alt buffer (no native
* scrollback) and wiping saved lines, so the user's conversation history
* disappears on every tab switch / pane refresh. Stripping these from the
* replayed byte stream keeps everything in the main buffer with scrollback
* intact. Mirrors the live-stream strip in Session._handleTerminalOutput.
* Codex AND Claude Code emit `\x1b[?1049h` and clear-scrollback sequences (the
* latter intermittently, e.g. full-screen pickers/dialogs). xterm.js obeys them
* by switching to the alt buffer (no native scrollback) and wiping saved lines,
* so the user's conversation history disappears on every tab switch / pane
* refresh (and scroll-up breaks live). Stripping these from the replayed byte
* stream keeps everything in the main buffer with scrollback intact. Mirrors the
* live-stream strip in Session._handleTerminalOutput (isAltScreenStripMode).
*/
// eslint-disable-next-line no-control-regex
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
@@ -989,10 +990,11 @@ export function registerSessionRoutes(
// the terminal appears empty when switching tabs.
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from codex byte streams.
// xterm.js obeys them by switching to its scrollback-less alt buffer and
// wiping saved lines, so conversation history disappears on tab switch.
if (session.mode === 'codex') {
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
// streams. xterm.js obeys them by switching to its scrollback-less alt
// buffer and wiping saved lines, so conversation history disappears on tab
// switch. Same gate as the live-stream strip in session.ts.
if (isAltScreenStripMode(session.mode)) {
strippedBuffer = strippedBuffer
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
.replace(ERASE_SCROLLBACK_PATTERN, '')
+38 -18
View File
@@ -505,20 +505,34 @@ export function registerSystemRoutes(
// control = effectively RCE) to a public *.trycloudflare.com URL. Because the
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
// unauthenticated. Refuse to start a tunnel unless auth is configured OR the
// operator has acknowledged unauthenticated-network exposure. A public tunnel is
// higher-stakes than a LAN bind, so this is REFUSE (vs the bind guard's warn).
// unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an
// explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a
// confirm dialog). This keeps curl/API/CLI callers protected by default while
// letting an operator opt in from the browser without setting the env var.
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning() && !isUnauthenticatedNetworkAcknowledged()) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, or set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 to acknowledge an ' +
'unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
const acknowledged = isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
if (!acknowledged) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' +
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
// Loud warning whenever a public tunnel is started with no password — whether
// acknowledged via env var or the per-request UI confirmation.
if (!process.env.CODEMAN_PASSWORD) {
console.warn(
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
'Set CODEMAN_PASSWORD to require login.'
);
}
}
try {
@@ -532,17 +546,23 @@ export function registerSystemRoutes(
} catch {
/* ignore */
}
// statusLineTelemetry is an ACTION field (reconcile the plan-usage exporter),
// not a stored setting — strip it before persisting so settings.json stays clean.
const { statusLineTelemetry, ...settingsToStore } = settings;
// statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored
// settings) — strip them before persisting so settings.json stays clean.
const { statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings;
const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
// 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', () => {
+7
View File
@@ -350,8 +350,15 @@ 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(),
// Action field (NOT persisted): explicit per-request acknowledgment that the
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
// Lets the UI enable a tunnel after a confirm dialog without the
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
agentTeamsEnabled: z.boolean().optional(),
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
+3 -1
View File
@@ -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
View File
@@ -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);
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { Session, isAltScreenStripMode } from '../src/session.js';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
};
function handleOutput(session: Session, data: string): void {
(session as unknown as SessionInternals)._handleTerminalOutput(data);
}
describe('isAltScreenStripMode', () => {
it('strips for the controlled TUIs (codex + claude), not shell/opencode', () => {
expect(isAltScreenStripMode('codex')).toBe(true);
expect(isAltScreenStripMode('claude')).toBe(true);
expect(isAltScreenStripMode('shell')).toBe(false);
expect(isAltScreenStripMode('opencode')).toBe(false);
});
});
describe('Claude terminal scrollback strip', () => {
it('strips alt-screen toggles, scrollback-erase, and mouse-tracking', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(session, '\x1b[?1049h\x1b[55;1Hdialog\x1b[3J\x1b[?1006h\x1b[?1049l');
expect(emitted[0]).toBe('\x1b[55;1Hdialog');
expect(session.terminalBuffer).toBe('\x1b[55;1Hdialog');
});
it('keeps the visible-screen erase (2J / [J) — only scrollback-erase (3J) is dropped', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
handleOutput(session, '\x1b[?1049h\x1b[2Jvisible\x1b[3Jscrollback\x1b[?1049l');
expect(session.terminalBuffer).toBe('\x1b[2Jvisiblescrollback');
});
it('preserves an ordinary erase-display redraw (no scrollback sequences)', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
handleOutput(session, '\x1b[H\x1b[Jclaude redraw');
expect(session.terminalBuffer).toBe('\x1b[H\x1b[Jclaude redraw');
});
it('strips sequences split across PTY chunk boundaries (carry reassembly)', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(session, 'before\x1b[?104');
handleOutput(session, '9h\x1b[2Jafter\x1b[3');
handleOutput(session, 'Jtail');
expect(session.terminalBuffer).toBe('before\x1b[2Jaftertail');
expect(emitted).toEqual(['before', '\x1b[2Jafter', 'tail']);
});
it('emits nothing for a chunk that is only a partial CSI, then completes it', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const emitted: string[] = [];
session.on('terminal', (data) => emitted.push(data));
handleOutput(session, '\x1b[?100'); // pure partial — held, nothing emitted
handleOutput(session, '6h done'); // completes ?1006h (stripped); rest passes
expect(emitted).toEqual([' done']);
expect(session.terminalBuffer).toBe(' done');
});
it('does not touch ordinary Claude conversation output', () => {
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const text = 'Here is line one\r\nHere is line two\r\n\x1b[2mdim status\x1b[0m';
handleOutput(session, text);
expect(session.terminalBuffer).toBe(text);
});
});
describe('Shell terminal output is NOT stripped (vim/less/htop need the alt screen)', () => {
it('leaves alt-screen toggles, scrollback-erase, and mouse-tracking intact for shell', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const vimLike = '\x1b[?1049h\x1b[?1002h\x1b[2J~ editing\x1b[3J\x1b[?1002l\x1b[?1049l';
handleOutput(session, vimLike);
expect(session.terminalBuffer).toBe(vimLike);
});
});
@@ -117,6 +117,35 @@ describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () =>
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
it('ALLOWS tunnel-enable with per-request acknowledgeUnauthTunnel:true (start called, 200, flag not persisted)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
// The action flag must NOT be persisted to settings.json.
expect(mockedWriteFile).toHaveBeenCalled();
const persisted = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
expect(persisted.acknowledgeUnauthTunnel).toBeUndefined();
expect(persisted.tunnelEnabled).toBe(true);
});
it('still REFUSES when acknowledgeUnauthTunnel is false (4xx, start not called)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: false },
});
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
expect(tunnel.start).not.toHaveBeenCalled();
expect(mockedWriteFile).not.toHaveBeenCalled();
});
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
tunnel = makeTunnelManager(true);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
+107
View File
@@ -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);
});
});