mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3172befd5d | ||
|
|
29ffc62536 | ||
|
|
4cb3a4aac8 | ||
|
|
b6531cbf79 | ||
|
|
d16bf34e34 | ||
|
|
e6989bdb40 | ||
|
|
6ab6bbbbd4 | ||
|
|
c15c19fab7 | ||
|
|
f6a30d7335 | ||
|
|
db93491dd1 | ||
|
|
b7ff54b2ec | ||
|
|
dc63d1f1a6 | ||
|
|
7c5920d3b9 | ||
|
|
e1e670594b | ||
|
|
2e28e17834 | ||
|
|
90f18438ff | ||
|
|
5b62f397ec | ||
|
|
1e54ebcdf4 | ||
|
|
0364bea166 | ||
|
|
c7e8ff616f | ||
|
|
21fbff4d8a | ||
|
|
8ffb2b0644 | ||
|
|
80ebf8b549 | ||
|
|
c101cc8716 | ||
|
|
cceb24ed8f | ||
|
|
60dab7ce3f | ||
|
|
a5263b3252 | ||
|
|
90cd481b9f | ||
|
|
787e5e2a03 | ||
|
|
097433c86f | ||
|
|
e738c776c1 | ||
|
|
e10f0dabdb | ||
|
|
661c89cefd | ||
|
|
a122e867ef | ||
|
|
a68f23e647 | ||
|
|
f0f43ddbad | ||
|
|
ea53916adc | ||
|
|
585127deb2 | ||
|
|
e742d00c98 | ||
|
|
1a363a3e62 | ||
|
|
577b6d7384 | ||
|
|
5eacb1cf03 | ||
|
|
5fbe451c26 | ||
|
|
99e537ef1a | ||
|
|
67c7973aa5 | ||
|
|
534712e50f | ||
|
|
f69cd4874c | ||
|
|
1ac3c09054 | ||
|
|
95fb5fc226 | ||
|
|
eae225bf9a | ||
|
|
4d9d93dfff | ||
|
|
c82f6c802e | ||
|
|
41a209e96d | ||
|
|
7102fdb23a | ||
|
|
49c92e4723 |
@@ -1,5 +1,89 @@
|
||||
# aicodeman
|
||||
|
||||
## 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
|
||||
|
||||
- Ultracode/Workflow run visualization + subagent discovery fixes.
|
||||
- **Ultracode / Workflow run visualization** (new, opt-in): App Settings → Display → "Ultracode Agents" (`showUltracodeAgents`, default OFF) adds a master-detail tab that shows ultracode / Workflow-tool runs like Claude Code's "working agents" view — the LEFT pane lists runs and their phases (selectable tasks), the RIGHT pane shows each run's agents with model, live state, tokens burned, and tool calls. Clicking an agent opens its live transcript. Backed by a new standalone workflow-run watcher that reads the per-run state JSON (stripping the heavy embedded script/result/logs so payloads stay small), exposes `GET /api/workflows` and `GET /api/workflows/:runId`, and broadcasts `workflow:run_discovered/updated/removed` SSE events. The header launcher and panel stay hidden until the setting is enabled (the setting is synced across devices, not per-device).
|
||||
- **Subagent tracking discovery fix**: restored subagent tracking after Claude Code changed the on-disk format from `agent-*.jsonl` to `agent-*.meta.json` (background agents were showing 0). Also discovers workflow-nested subagents under `subagents/workflows/<wf>/` and hardens the meta→transcript upgrade path so an agent re-points to its `.jsonl` transcript once it appears.
|
||||
- **File viewer**: opens audio, SVG, and other binary files the same way the attachments viewer does.
|
||||
- **Tooling**: hardened the real-overview screenshot capture script and documented the `deviceScaleFactor` / static-cache gotchas.
|
||||
|
||||
## 1.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Six reviewed contributor PRs (all adversarially reviewed and fixed before merge):
|
||||
- **Markdown sanitizer hardened against mutation-XSS (#126).** The denylist `_sanitizeHtml` is replaced with vendored DOMPurify 3.4.8 (authentic, byte-matched to the official dist) wired via a new `sanitize-html.js` allowlist, with a fail-closed escape fallback. The curated allowlist is genuinely enforced (no `USE_PROFILES` override) so non-markdown tags and svg/math/style/script/event-handler/`javascript:` vectors are stripped while legitimate markdown survives.
|
||||
- **Hook-event secret now required unconditionally (#127).** The `/api/hook-event` + `/api/status-telemetry` localhost bypass requires the per-instance hook secret whether or not a managed tunnel is running, closing the own-loopback-reverse-proxy gap. A self-heal refreshes pre-secret hook configs in existing cases on spawn so password-protected installs don't silently 401 their hooks. No-password loopback installs are unaffected.
|
||||
- **`codeman doctor` dependency checker (#125).** New `doctor`/`check-deps` command probes Node, the agent CLIs, tmux, and document converters per environment (linux/darwin/win32/wsl), with grouped or `--json` output and a non-zero exit when a required tool is missing. Requires Node 22+, reports `pdftoppm` (used for PDF/Office thumbnails), and validates `--category`.
|
||||
- **macOS Option / physical-key session shortcuts (#129).** Tab switching matches physical key codes (`e.code`) so Option+1–9 works on macOS layouts that remap Option, plus Option/Alt+`[`/`]` for previous/next session — without leaking escape sequences into the focused terminal.
|
||||
- **Desktop session tabs auto-wrap to a second row on overflow (#128)** instead of horizontal scrolling (off when the manual two-row layout is pinned; mobile/tablet unchanged), re-evaluated on window resize.
|
||||
- **CJK input textarea hidden on the welcome screen (#123)** so it no longer floats over the welcome overlay, and re-shown on session entry; vertical centering fixed.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- **Plan Usage Limits chip (new).** A header chip now shows your live Claude plan usage — the 5-hour and weekly windows as a percentage — parsed from Claude Code's statusLine telemetry (CLI v2.1.80+). It's opt-in via **App Settings → Display → "Plan Usage Limits"** (default OFF). The toggle is **per-device**: turn it on at your desk without it appearing on your phone. Telemetry collection is decoupled from display, so one device's preference never affects another's, and the last-known value replays instantly on reconnect. Distinct from auto-resume (which reacts to the limit _message_) — this proactively shows the live %.
|
||||
|
||||
**Attachments.** New attachment history drawer to browse files referenced by a session (COD-39), plus document previews and thumbnails on attachment cards (COD-38). The header **Attachments button is now opt-in** (default OFF) via **App Settings → Display → "Attachments Button"**, per-device like the Response Viewer button.
|
||||
|
||||
**Settings & models.** Added Opus 4.6 options to the Claude Model picker. Removed the legacy Token Count / Show Cost header toggles and moved Plan Usage Limits to the top of the Display settings. Slimmed the Skin picker control to match its row.
|
||||
|
||||
**Mobile & header polish.** Restored the response-viewer (eye) button on phones; kept the phone header minimal (settings gear + lifecycle log stay in the toolbar). Added two regression guards so header controls can't silently leak onto the mobile header again — a CI-runnable static policy check plus a real-browser E2E test.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major 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.0.0 (must match `package.json`)
|
||||
**Version**: 1.1.7 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -108,6 +108,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
|
||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.**
|
||||
- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
|
||||
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — `scripts/capture-real-overview.mjs` (drives a live session in headless Chromium → overview PNG). Two traps, both observed 2026-06-14: **(1) DSF=2 doubles the console font.** xterm's WebGL renderer draws terminal glyphs at ~2× their nominal size under `deviceScaleFactor: 2`, while STILL reporting nominal cell dims (`terminal.cols`/`_renderService.dimensions.css.cell` say 8px/187cols — they lie), so it's invisible to any internal measurement and only the pixels reveal it. The HTML chrome (header/toolbar) is unaffected → ONLY the console font looks comically large. Default to **DSF=1** (script does); the image is 1× res but the font is true-to-browser. **(2) Stable filenames → stale renders.** Overwriting a fixed path (`claude-overview.png`) in place leaves OS image viewers (eog/feh) — and any HTTP client behind a long/`immutable` cache — showing the OLD render; the user reads it as "the fix didn't work". The script now mints a timestamped `claude-overview-<ts>.png` per run. ⚠️ This was a LOCAL image-viewer cache, NOT a Codeman serving bug: `file-routes` previews send `Cache-Control: no-cache` and `/api/screenshots/:name` sends none. The one real Codeman-side footgun: `server.ts` serves non-content-hashed static assets `public, max-age=31536000, immutable`, and `cacheBustAssets()` only rewrites `.js`/`.css` refs — a stable-named **image** referenced from public/ would go stale on overwrite. Reflect the per-device UI to match a real device when capturing: seed `localStorage` `codeman:skin`, `codeman-font-size`, and the desktop `codeman-app-settings` blob (the plan-usage chip is a per-device display key deleted from the server payload — a fresh browser hides it unless seeded; close side panels for a full-width terminal).
|
||||
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
|
||||
@@ -118,26 +119,27 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Domain | Key files | Notes |
|
||||
|--------|-----------|-------|
|
||||
| **Entry** | `src/index.ts`, `src/cli.ts` | |
|
||||
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts`, `src/usage-limit-patterns.ts` | |
|
||||
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts`, `src/usage-limit-patterns.ts`, `src/usage-telemetry.ts` | |
|
||||
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
|
||||
| **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` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||
| **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` (15 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` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.7K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
| **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) + 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/` — 10 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).
|
||||
|
||||
@@ -156,6 +158,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Auto-resume on usage limit** ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time from cleaned output; `SessionAutoOps` arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + `continue`. Still-limited responses re-arm the loop (5-min retry on stale times); a `working` transition cancels it. Claude-mode only (detection rides `_processExpensiveParsers`). Persists/recovers via `SessionState.autoResumeEnabled`/`autoResumeAt`; respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected` — prevents `/clear` from wiping the paused conversation). Endpoint: `POST /api/sessions/:id/auto-resume`; SSE: `session:limitPauseScheduled`/`limitResume`/`limitResumeCancelled`. Tests: `test/usage-limit-patterns.test.ts`, `test/session-auto-resume.test.ts`.
|
||||
|
||||
**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is *ours*, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated under a tunnel) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, toggled by `showPlanUsageLimits` in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit *message*; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
**External CLI modes (OpenCode, Codex)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). Both modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv`, never on the spawn command line: OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars` in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode); tmux exports `COLORTERM=truecolor` + unsets `NO_COLOR` (other modes unset `COLORTERM`); availability via `GET /api/codex/status` — session/quick-start routes fail with `OPERATION_FAILED` and an install hint (`npm install -g @openai/codex`) when the binary is missing. Frontend: run-mode dropdown → `runCodex()` in `session-ui.js` ("Run CX" label), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. Tests: `test/run-mode-ui.test.ts` (vm-sandbox harness, no real DOM).
|
||||
@@ -168,11 +172,15 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
|
||||
|
||||
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups *identical* in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals.
|
||||
|
||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a run-state JSON per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json`. `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; disjoint directory tree, separate singleton) globs that tree via periodic poll + per-run chokidar watcher with per-file mtime skip, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is only started when the setting is on (`server.ts` gates on `showUltracodeAgents`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on the same `showUltracodeAgents` setting), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||
|
||||
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried; bug fixed in `b8cb467`), log viewers (2000), image popups (3000), local echo overlay (7).
|
||||
|
||||
@@ -184,6 +192,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman *consumer* that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture *feel* in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
|
||||
|
||||
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applyTheme()`/`applyTerminalSkin()` apply it live on save and keep the standalone `codeman:skin` key + the settings object in sync. `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font).
|
||||
@@ -201,7 +211,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
|
||||
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
||||
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
|
||||
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
|
||||
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
@@ -212,7 +222,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~136 handlers across 15 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~146 handlers across 16 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
|
||||
@@ -483,7 +483,8 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
|----------|--------|
|
||||
| `Ctrl/Cmd+W` | Kill active session |
|
||||
| `Ctrl/Cmd+Tab` | Next session |
|
||||
| `Alt+1`–`Alt+9` | Switch to tab N |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
|
||||
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# Ultracode / Workflow Agent Visualization — Design & Implementation Plan
|
||||
|
||||
> **Status: IMPLEMENTED (2026-06-15, rev. 3) — Phases 1–3 shipped & verified; Phase 4 (live-transcript link) deferred.** A dedicated, opt-in **master-detail tab** (`showUltracodeAgents`, default OFF) shows ultracode/Workflow runs as Claude Code's "working agents" TUI: LEFT = runs + phases (selectable tasks), RIGHT = each run's agents with model, live state, **tokens burned**, and **tool calls**.
|
||||
>
|
||||
> ### What rev. 3 changed vs. rev. 2 (decided during implementation against on-disk truth)
|
||||
> 1. **UI is a master-detail TAB, not grouped floating subagent windows.** The user asked for the CC "working agents" view (left task picker, right agent stats). Built as a new docked panel `#ultracodeAgentsPanel` (clones `.subagents-panel` master-detail CSS) + `src/web/public/ultracode-panel.js` — NOT via `openSubagentWindow`/grouped windows.
|
||||
> 2. **STANDALONE — zero edits to `subagent-watcher.ts`.** w16-claudeman's commit `f6a30d7` already discovers the per-agent workflow *transcripts* (`watchWorkflowDirs`). The data the view needs (run/phase/per-agent tokens+toolCalls) lives in the *run-state* JSON, read by a brand-new `src/workflow-run-watcher.ts` (globs the disjoint `…/workflows/wf_*.json` tree). No shared files with w16.
|
||||
> 3. **No per-agent transcript streaming needed for v1.** The run-state JSON already carries `tokens`/`toolCalls`/`state`/`label`/`phase` per agent, so the whole view reads from `wf_<runId>.json` alone. (Phase 4 will optionally link a card to its already-tracked transcript via `agentId` — no watcher edits.)
|
||||
> 4. **Agent states are `start | progress | done`** (verified on disk) — NOT running/queued. `start`=queued (no agentId/tokens/toolCalls yet), `done` has `durationMs`/`resultPreview`.
|
||||
> 5. **The run JSON's `script` (15–660KB embedded JS), `scriptPath`, `result`, `logs` are STRIPPED in the watcher** before caching/broadcast (a 28-agent run drops 174KB → ~25KB; `promptPreview`/`resultPreview` truncated).
|
||||
> 6. **SSE/snapshot ship lightweight run SUMMARIES (no `agents[]`); the RIGHT pane fetches the full run** via `GET /api/workflows/:runId` on selection. (A 25-run snapshot is ~20KB vs ~900KB if it carried every agent.) The LEFT list shows ALL cached runs (LRU-bounded), not a recency window — a run browser must show past runs.
|
||||
>
|
||||
> _Original rev. 2 proposal (grouped floating windows, extending subagent-watcher) preserved below for context; superseded by the above._
|
||||
|
||||
### What changed in rev. 2 (vs. the first draft)
|
||||
|
||||
1. **No backend cross-watcher coupling.** The per-agent label/phase/agentType/state **join moves to the frontend at render time** — the run object already carries every agent's entry keyed by `agentId`. This deletes `subagent-watcher`'s backward dependency on `workflow-run-watcher` (`getAgentLabel()` + its TTL cache), removes the registration-vs-run-state **race** (labels always track the latest `workflow:run_updated`), and drops the per-agent `meta.json` read from the hot path.
|
||||
2. **`SubagentInfo` grows by 2 fields, not 4** (`isWorkflowAgent`, `workflowRunId`) — both derivable from the file path alone at registration, zero extra I/O. `agentType`/`label`/`phase`/`state` come from the run object on the frontend.
|
||||
3. **The `isInternalAgent` bypass covers BOTH drop sites** — `registerAgentFile` *and* the late re-resolution in `processEntry`. The first draft named only one.
|
||||
4. **De-duplicated.** Each trap (`journal.jsonl`, the `projects/*/*/workflows` depth, the gate-mismatch lesson, reuse-not-rebuild) is stated once in its owning section.
|
||||
|
||||
### Code-reuse verified against the tree (2026-06-14)
|
||||
|
||||
Confirmed present and shaped as assumed: `subagent-watcher.ts` — `watchSubagentDir`/`registerAgentFile`/`tailFile`/`processEntry`, `getRecentSubagents`, `isInternalAgent` (drops on `MIN_DESCRIPTION_LENGTH=5`), `STARTUP_MAX_FILE_AGE_MS=4h`, `MAX_TRACKED_AGENTS`, `knownSubagentDirs`/`dirWatchers`. `team-watcher.ts` — `configMtimes` mtime-skip + chokidar + `setInterval` poll. `server.ts` — `setupSubagentWatcherListeners`, `getLightState()` (`subagents: getRecentSubagents(15)`, `LIGHT_STATE_CACHE_TTL_MS=1000`), `isSubagentTrackingEnabled()` (`settings.subagentTrackingEnabled ?? true`). Frontend — `_SSE_HANDLER_MAP`, `this.subagents` Map, `handleInit`/`cleanupAllFloatingWindows`, `renderSubagentPanel`/`_renderSubagentPanelImmediate`, `getTeammateBadgeHtml`, `openSubagentWindow` + `.subagent-window-parent` sub-header.
|
||||
|
||||
## 1. The enabling fact: on-disk artifacts
|
||||
|
||||
The Workflow tool (what `ultracode` drives) persists each workflow agent as a transcript under the **same `subagents/` directory Codeman already watches**, one level deeper. Empirically verified against a real run (`wf_a8e09f2c-550`); **re-confirm the shape against a fresh run at implementation time** (§8 mandates a live e2e pass anyway):
|
||||
|
||||
```
|
||||
~/.claude/projects/<projHash>/<sessionUuid>/
|
||||
├─ subagents/
|
||||
│ ├─ agent-XX.jsonl ← regular Task subagent (tracked today)
|
||||
│ └─ workflows/wf_<runId>/
|
||||
│ ├─ agent-YY.jsonl ← WORKFLOW agent — IDENTICAL line format
|
||||
│ ├─ agent-YY.meta.json ← {"agentType":"workflow-subagent"} (optional enrichment)
|
||||
│ └─ journal.jsonl ← run journal {type:"started",...} — MUST be skipped
|
||||
└─ workflows/wf_<runId>.json ← run state: runId, workflowName, summary, status,
|
||||
phases[], workflowProgress[], totals (DIFFERENT tree)
|
||||
```
|
||||
|
||||
The per-agent `.jsonl` line shape is identical to a regular subagent transcript:
|
||||
|
||||
```jsonc
|
||||
{ "parentUuid": null, "isSidechain": true, "agentId": "ac6a1d27012a64e38",
|
||||
"type": "user" | "assistant", "message": { "role": "...", "content": "..." }, ... }
|
||||
```
|
||||
|
||||
Because the line shape is identical, the entire existing parse→event→render pipeline works unchanged once discovery reaches those files. The only new data is the **run-level metadata** in `workflows/wf_<runId>.json` (name, summary, phases, and `workflowProgress[]` — the per-agent labels/state/tools), which supplies the group header and per-agent labels.
|
||||
|
||||
**Can show:** per-agent live transcript (tool calls, messages, results); per-agent status (active/idle/completed via the existing mtime/PID/pgrep liveness); per-agent model + running token totals (from each agent's JSONL `message.usage`, exactly as today); the run's `workflowName`/`summary`/`phases[]`; per-agent `label`/`phaseTitle`/`state`/`lastToolName` (from `workflowProgress[]`); grouping under `wf_<runId>`.
|
||||
|
||||
**Cannot show:** anything absent from the artifacts — a live phase cursor beyond `workflowProgress[].state`; an authoritative **budget/cost ceiling** (only consumed totals exist — `usage` + run-state `totalTokens`, no remaining-budget field); runs older than `STARTUP_MAX_FILE_AGE_MS` (4h) after a server restart (live monitoring only).
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
**Decision: EXTEND `subagent-watcher.ts` for per-agent discovery/streaming; ADD a thin `workflow-run-watcher.ts` (modeled on `team-watcher.ts`) for the group-header metadata ONLY. The agent→run-metadata join happens on the FRONTEND, so the two watchers stay decoupled.**
|
||||
|
||||
- The per-agent JSONL is identical in shape, so re-running it through `registerAgentFile()` → `tailFile()` → `processEntry()` and the existing `subagent:*` events is free and reconnect-safe (those agents land in `agentInfo`, replayed by `getRecentSubagents(15)`). A parallel per-agent watcher would duplicate the liveness/token/tool-call/SSE machinery for zero benefit.
|
||||
- Run metadata lives in a *different* file under a *different* tree (`workflows/wf_<runId>.json`, sibling to `subagents/`). A small `WorkflowRunWatcher` watching `projects/*/*/workflows/wf_*.json` (mtime-skip, like `team-watcher`'s `configMtimes`) is the clean home; folding it into `subagent-watcher` would entangle two unrelated watch roots and put a JSON re-read in the hot per-line path.
|
||||
- **The two watchers never call each other.** The frontend receives both streams and joins agent→label by `agentId` at render time (the run object carries every agent's entry). This removes the timing coupling entirely.
|
||||
|
||||
```
|
||||
~/.claude/projects/<projHash>/<sessionUuid>/
|
||||
├─ subagents/
|
||||
│ ├─ agent-XX.jsonl ──────────────► SubagentWatcher (EXTENDED: also descends
|
||||
│ └─ workflows/wf_<runId>/ workflows/wf_<runId>/, tags isWorkflowAgent+runId)
|
||||
│ ├─ agent-YY.jsonl ─┐ reuse registerAgentFile/tailFile/processEntry
|
||||
│ └─ journal.jsonl (SKIP) emits subagent:* (now w/ 2 workflow fields)
|
||||
└─ workflows/wf_<runId>.json ──────► WorkflowRunWatcher (NEW, team-watcher-shaped)
|
||||
{workflowName,phases,workflowProgress[]} emits workflow:run_discovered|updated|removed
|
||||
|
||||
server.ts
|
||||
setupSubagentWatcherListeners() ──► broadcast(subagent:*) ─┐
|
||||
setupWorkflowRunWatcherListeners() ──► broadcast(workflow:run_*) │ SSE
|
||||
getLightState(): subagents + workflowRuns ───────────────────────┘
|
||||
│
|
||||
▼ app.js dispatch table
|
||||
panels-ui: partition this.subagents by workflowRunId; header + per-agent
|
||||
labels JOINED from this.workflowRuns.get(runId).agents (by agentId)
|
||||
```
|
||||
|
||||
## 3. Backend changes (ordered, file-by-file)
|
||||
|
||||
### 3a. `src/subagent-watcher.ts` — nested discovery + 2 tag fields
|
||||
|
||||
**(1) Extend `SubagentInfo` with exactly two optional fields** (optional → regular subagents and the wire shape are unaffected):
|
||||
|
||||
```ts
|
||||
isWorkflowAgent?: boolean; // true when discovered under subagents/workflows/<wf_runId>/
|
||||
workflowRunId?: string; // e.g. "wf_23dbeab2-152" (parent dir name)
|
||||
```
|
||||
|
||||
Both are derived from the **file path alone** at registration — no extra reads. They ride existing `subagent:discovered|updated|completed` payloads (no new per-agent event). Do **not** add `agentType`/`label`/`phase`/`workflowName` here — those come from the run object on the frontend (§4c).
|
||||
|
||||
**(2) Constant.** `const WORKFLOWS_SUBDIR = 'workflows';` near the existing dir constants.
|
||||
|
||||
**(3) `watchSubagentDir()` — descend into `workflows/<wf_runId>/`.** After the existing direct-child registration loop:
|
||||
|
||||
```ts
|
||||
// Workflow agents live one level deeper: subagents/workflows/<wf_runId>/agent-*.jsonl
|
||||
const wfRoot = join(dir, WORKFLOWS_SUBDIR);
|
||||
try {
|
||||
for (const runId of await readdir(wfRoot)) {
|
||||
if (!runId.startsWith('wf_')) continue;
|
||||
await this.watchWorkflowRunDir(join(wfRoot, runId), projectHash, sessionId, runId);
|
||||
}
|
||||
} catch { /* no workflows subdir — normal for most sessions */ }
|
||||
```
|
||||
|
||||
The existing `fs.watch(dir, …)` on `subagents/` is **non-recursive on Linux** and won't fire for writes inside `workflows/<runId>/`, so each run dir needs its own watcher.
|
||||
|
||||
**(4) New private `watchWorkflowRunDir(runDir, projectHash, sessionId, runId)`** — clone `watchSubagentDir`'s structure, but:
|
||||
- Register only files matching `^agent-.*\.jsonl$`, **explicitly skipping `journal.jsonl`** (it ends in `.jsonl` but is `{type:'started',…}`, not a transcript — registering it would create a phantom agent).
|
||||
- Call `registerAgentFile(filePath, projectHash, sessionId, isInitialScan, runId)` so the agent is tagged.
|
||||
- Install one `watch(runDir, …)` per run dir; on `error` and `stop()`, reuse the existing teardown (close + delete from `dirWatchers`/`knownSubagentDirs`/`dirWatcherErrorHandlers`).
|
||||
- Guard re-registration **per run dir** in `knownSubagentDirs`, **not** `wfRoot` — the 5s full scan must still re-`readdir(wfRoot)` to pick up *new* `wf_<runId>` dirs created mid-session.
|
||||
|
||||
**(5) `registerAgentFile()` — accept + apply `runId`.** Add a trailing optional `runId?: string`. When set, the whole change is:
|
||||
|
||||
```ts
|
||||
if (runId) { info.isWorkflowAgent = true; info.workflowRunId = runId; }
|
||||
```
|
||||
|
||||
No `meta.json` read, no run-state lookup, no description override. `agentId`s are globally unique `a<16hex>` (verified: 0 collisions across a 370-agent corpus), so keep the flat `agentInfo` map keyed by `agentId` — do **not** switch to a composite key. Add a one-line dev-assert log if `agentInfo.has(agentId)` with a *different* `workflowRunId`, so a future collision is observable.
|
||||
|
||||
**(6) `isInternalAgent` bypass — BOTH drop sites.** Workflow agents have no Task-tool spawn record, so `_resolveDescription` yields only the first-user-message fallback (often a long phase prompt) or empty → `isInternalAgent` (`length < MIN_DESCRIPTION_LENGTH`) would wrongly drop them. They are real by construction (the `subagents/workflows/wf_*/` path is the discriminator). Gate the drop on `!info.isWorkflowAgent` at **both** places:
|
||||
- `registerAgentFile` initial check (`isInternalAgent(description)`),
|
||||
- `processEntry`'s late re-resolution (the second `isInternalAgent` call).
|
||||
|
||||
**(7) `stop()` teardown.** Per-run watchers live in `dirWatchers`, so the existing close-all loop covers them — verify no separate map was introduced (24h runs spawn many `wf_<runId>` dirs → FSWatcher leak risk).
|
||||
|
||||
### 3b. NEW `src/workflow-run-watcher.ts` (singleton, EventEmitter — model on `team-watcher.ts`)
|
||||
|
||||
- **Watch root:** `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` — **two** levels under `projects` (verified: `projects/*/workflows` is empty; must be `projects/*/*/workflows/`). chokidar `depth:3` + a poll fallback, mirroring `team-watcher`'s dual discovery + interval.
|
||||
- **mtime-skip:** `runMtimes: Map<absPath, number>` (mirror `team-watcher.configMtimes`).
|
||||
- **Parse:** read `wf_<runId>.json`, take the **top-level structured keys** (`runId`, `workflowName`, `summary`, `status`, `phases:[{title,detail}]`, `agentCount`, `defaultModel`, `durationMs`, `totalTokens`, `totalToolCalls`, `workflowProgress[]`). **Do NOT parse the embedded `script` string** — name/phases/summary are already top-level; the script's `export const meta` is redundant and costly. Derive `sessionUuid` from the dir name, `projectHash` from the dir above; expose `getProjectHash(workingDir)` for Codeman-session correlation.
|
||||
- **`workflowProgress[] → agents[]`:** filter `type === 'workflow_agent'`, map each to a `WorkflowAgentEntry` (§3c) keyed by `agentId`. **This array is the join source the frontend uses** — no backend `getAgentLabel()` API, no TTL cache, no import from `subagent-watcher`.
|
||||
- **Emit** `workflow:run_discovered|updated|removed` carrying `WorkflowRunInfo`; removal by set-diff (mirror `team-watcher`).
|
||||
- **Lifecycle:** `start()`/`stop()` with `CleanupManager` teardown of chokidar + interval + caches; `LRUMap`-bounded run cache (24h memory rule).
|
||||
|
||||
### 3c. `src/types/` — workflow run types
|
||||
|
||||
```ts
|
||||
export interface WorkflowAgentEntry { // one workflowProgress[type==='workflow_agent']
|
||||
agentId: string; label: string; phaseIndex?: number; phaseTitle?: string;
|
||||
agentType?: string; model?: string; state?: string; // 'done'|'running'|'queued'|...
|
||||
lastToolName?: string; lastToolSummary?: string; tokens?: number; toolCalls?: number;
|
||||
}
|
||||
export interface WorkflowRunInfo {
|
||||
runId: string; sessionUuid: string; projectHash: string;
|
||||
workflowName?: string; summary?: string; status?: string; // 'running'|'completed'|...
|
||||
phases: Array<{ title: string; detail?: string }>;
|
||||
agentCount?: number; defaultModel?: string;
|
||||
agents: WorkflowAgentEntry[]; // workflowProgress filtered to workflow_agent, keyed by agentId
|
||||
startedAt?: number; durationMs?: number; totalTokens?: number; totalToolCalls?: number;
|
||||
}
|
||||
```
|
||||
|
||||
The two `SubagentInfo` workflow fields stay inline in `subagent-watcher.ts` (matching the existing convention).
|
||||
|
||||
### 3d. `src/web/sse-events.ts` — register run events
|
||||
|
||||
Add `workflow:run_discovered`, `workflow:run_updated`, `workflow:run_removed` after the `subagent:*` block and to the `SseEvent` union. **No new per-agent event** — workflow agents reuse `subagent:*`.
|
||||
|
||||
### 3e. `src/web/server.ts` — bridge, snapshot, gating
|
||||
|
||||
- **`setupWorkflowRunWatcherListeners()`** (beside `setupSubagentWatcherListeners`): map the three run events → `this.broadcast(...)`. Add `cleanupWorkflowRunWatcherListeners()` (store handler refs).
|
||||
- **Start/stop:** call `workflowRunWatcher.start()`/`.stop()` beside `subagentWatcher`, **gated on the same enable condition** (§3f).
|
||||
- **`getLightState()`:** add `workflowRuns: workflowRunWatcher.getRecentRuns(15)` beside `subagents: subagentWatcher.getRecentSubagents(15)` so headers replay on reconnect (agents already replay via `subagents`). Keep the `LIGHT_STATE_CACHE_TTL_MS` memoization.
|
||||
- **Gating read:** add `isWorkflowAgentTrackingEnabled()` mirroring `isSubagentTrackingEnabled()` (boot-time `dataPath('settings.json')` read). Gate `workflowRunWatcher.start()` **and** the subagent-watcher `workflows/` descent (§3a-3) on `showUltracodeAgents` so non-opted-in users never register historical workflow agents.
|
||||
|
||||
### 3f. `src/web/schemas.ts` — settings key
|
||||
|
||||
Add `showUltracodeAgents: z.boolean().optional()` to the `.strict()` settings update schema near `showPlanUsageLimits` (required — `.strict()` 400s the whole PUT on an unknown key).
|
||||
|
||||
### 3g. `src/web/routes/system-routes.ts` — poll API
|
||||
|
||||
- `GET /api/subagents` and `GET /api/sessions/:id/subagents` include workflow agents once registered — **no change** (they carry `isWorkflowAgent`/`workflowRunId`; a consumer joins to `/api/workflows/:runId` for labels).
|
||||
- Add `GET /api/workflows` → `workflowRunWatcher.getRecentRuns()` and `GET /api/workflows/:runId` (uniform `ApiResponse` contract; headers are also in `getLightState`).
|
||||
- `GET /api/subagents/:agentId/transcript` works for workflow agents (they're in `agentInfo`) — no new route.
|
||||
|
||||
## 4. Frontend changes (file-by-file)
|
||||
|
||||
### 4a. `src/web/public/constants.js`
|
||||
- Add the three SSE strings to `SSE_EVENTS`, matching §3d exactly (`WORKFLOW_RUN_DISCOVERED: 'workflow:run_discovered'`, etc.).
|
||||
- Reuse `ZINDEX_SUBAGENT_BASE=1000` for the agent windows (they ARE subagent windows). The group **header/cluster** is in-flow panel DOM, not a floating window — no new z-index (1100 is plan-subagent).
|
||||
|
||||
### 4b. `src/web/public/app.js`
|
||||
- Constructor: `this.workflowRuns = new Map(); // runId -> WorkflowRunInfo` beside `this.subagents`.
|
||||
- `_SSE_HANDLER_MAP`: add three rows → `_onWorkflowRunDiscovered/Updated/Removed` (must exist before `connectSSE` builds the wrappers).
|
||||
- `handleInit`: after seeding `data.subagents`, seed `this.workflowRuns` from `data.workflowRuns` (clear-then-set). **Clear `this.workflowRuns` everywhere the subagent Maps are cleared** (incl. `cleanupAllFloatingWindows`) — 24h leak guard.
|
||||
|
||||
### 4c. `src/web/public/panels-ui.js` — the join lives here
|
||||
- `_onWorkflowRunDiscovered/Updated(data)` → `this.workflowRuns.set(data.runId, data)` + debounced re-render; `_onWorkflowRunRemoved` → delete + re-render.
|
||||
- **No change to `_onSubagentDiscovered/Updated`** — they already store the whole payload, so the 2 new fields ride along.
|
||||
- `renderSubagentPanel`/`_renderSubagentPanelImmediate`: when `showUltracodeAgents` is on, **partition `this.subagents` into flat (no `workflowRunId`) vs grouped-by-`workflowRunId`**. Flat agents render exactly as today. For each group: build the header from `this.workflowRuns.get(runId)` (`workflowName` + phase/status chip from `phases[]`), then render that run's agents reusing the existing per-agent row markup. **Per-agent label/phase/agentType come from the JOIN** — build `Map(agentId → entry)` from `this.workflowRuns.get(runId).agents` and look each agent up by `agent.agentId`; render the small chip via the `getTeammateBadgeHtml` pattern. (If the run object hasn't arrived yet, fall back to the agent's own `description` — the run `:updated` event will fill it in on the next render.)
|
||||
- `findParentSessionForSubagent` is unchanged — workflow agent `sessionId === session.claudeSessionId`. **Do not conflate `workflowRunId` with `sessionId`.**
|
||||
|
||||
### 4d. `src/web/public/subagent-windows.js`
|
||||
**Decision: REUSE `.subagent-window` per agent + a group sub-header — do NOT build a cluster class.** A cluster path duplicates Map/z-index/drag/cleanup/persistence for no functional gain; reuse keeps connection lines, minimize-to-tab, and `localStorage` persistence. In `openSubagentWindow`, where the optional `.subagent-window-parent` sub-header is built: when `agent.workflowRunId` is set, inject a `.subagent-workflow-header` showing `this.workflowRuns.get(runId)?.workflowName` + the joined agent's `label`/phase (look up by `agentId`), mirroring the `from <session>` sub-header. Respect the existing skip guards (teammate-terminal windows, minimized/`_lazyTerminal`).
|
||||
|
||||
**Do NOT auto-open windows** for workflow agents — a multi-phase run can spawn many, against the 50-window/60fps budget + `MAX_TRACKED_AGENTS=500`. They render collapsed in the grouped panel; the user expands via the existing panel buttons.
|
||||
|
||||
### 4e. `src/web/public/settings-ui.js` + `index.html`
|
||||
- `index.html` Panels block: add a `settings-item` checkbox `id="appSettingsShowUltracodeAgents"` ("Show ULTRACODE / Workflow Agents").
|
||||
- `openAppSettings`: load `settings.showUltracodeAgents` with `false` fallback (mirror `showPlanUsageLimits`).
|
||||
- `saveAppSettings`: collect `showUltracodeAgents` into the fresh settings literal (uncollected keys reset to default every save).
|
||||
- Live-apply on toggle: re-run `renderSubagentPanel()` (show/hide group sections) — a panel re-render, not a CSS-class strip.
|
||||
- **SYNCED, not per-device:** do NOT add `showUltracodeAgents` to `displayKeys` and do NOT strip it in the per-device block. A synced value gives the server-side gate (`isWorkflowAgentTrackingEnabled`, §3e) one canonical truth to decide whether to run the watcher; a per-device value can't gate a process-wide watcher. (Contrast `showResponseViewer`, pure client display.)
|
||||
- `styles.css` + `mobile.css`: add `.subagent-workflow-header` and `.subagent-group-badge` next to `.subagent-window-parent`; mirror device overrides in `mobile.css`.
|
||||
|
||||
## 5. Settings / opt-in wiring
|
||||
|
||||
- **Key:** `showUltracodeAgents` (boolean, **default OFF**). Fallback `false` in `openAppSettings`; "absent ⇒ off" in `isWorkflowAgentTrackingEnabled()`. Schema `z.boolean().optional()` in the `.strict()` update schema, kept OUT of `displayKeys` (synced).
|
||||
- **Runtime gating:** `workflowRunWatcher.start()` and the subagent-watcher `workflows/` descent run only when the boot-time `settings.json` read reports `showUltracodeAgents === true` (mirroring `isSubagentTrackingEnabled`). The frontend additionally gates display. Toggling at runtime gates **display** immediately (panel re-render); the **watcher branch** picks up on next boot — matches existing `subagentTrackingEnabled` semantics. (Optional polish: restart just the workflow watcher on toggle for instant on/off.)
|
||||
|
||||
## 6. SSE events
|
||||
|
||||
**Reused (no change):** `subagent:discovered|updated|tool_call|tool_result|progress|message|completed`. Workflow agents flow through these; payloads now carry the optional `isWorkflowAgent`/`workflowRunId` fields on `SubagentInfo`. SSE payloads aren't schema-gated (typed only at `broadcast()` call sites), so the new fields propagate with zero friction.
|
||||
|
||||
**New (3 events, run-level metadata):**
|
||||
|
||||
| Event (backend const / frontend key) | Payload |
|
||||
|---|---|
|
||||
| `workflow:run_discovered` / `WORKFLOW_RUN_DISCOVERED` | `WorkflowRunInfo` |
|
||||
| `workflow:run_updated` / `WORKFLOW_RUN_UPDATED` | `WorkflowRunInfo` |
|
||||
| `workflow:run_removed` / `WORKFLOW_RUN_REMOVED` | `{ runId: string }` |
|
||||
|
||||
Sync requirement (CLAUDE.md): each must appear in **both** `sse-events.ts` (§3d) and `constants.js` `SSE_EVENTS` (§4a), be emitted via `broadcast()` in `setupWorkflowRunWatcherListeners()` (§3e), and have a dispatch-table row + `_on*` handler (§4b/§4c).
|
||||
|
||||
## 7. Edge cases & cleanup
|
||||
|
||||
- **`journal.jsonl` phantom-agent trap** — owned by §3a-4: run-dir registration requires the `agent-` prefix and excludes `journal.jsonl`.
|
||||
- **`isInternalAgent` over-filtering** — owned by §3a-6: bypass at BOTH drop sites; titled from the frontend join (or the description fallback).
|
||||
- **No workflow agents in the flat list** — `renderSubagentPanel` partitions on `agent.workflowRunId` (§4c). When the toggle is OFF, the descent never ran, so they aren't in `this.subagents` at all.
|
||||
- **Completion/idle** — keep the existing per-agent mtime/PID/pgrep liveness as the per-card source of truth. Optionally render a group-level "workflow done" badge from run-state `status==='completed'`.
|
||||
- **Limits** — `MAX_TRACKED_AGENTS=500` LRU-evicts workflow agents in the same flat map; no auto-open (50-window budget); the 4h `STARTUP_MAX_FILE_AGE_MS` skip means a run completed >4h ago won't reload after restart (acceptable — live monitoring).
|
||||
- **Reconnect/replay** — agents via `getRecentSubagents(15)`; headers via `workflowRuns: getRecentRuns(15)` in `getLightState`. `handleInit` clears `this.workflowRuns` alongside the subagent Maps.
|
||||
- **Watcher teardown** — every per-run `fs.watch` and the chokidar watcher closes in `stop()` and on `error`; `CleanupManager` for the new watcher (24h runs create many run dirs).
|
||||
- **CLAUDE.md discipline** — read-only `~/.claude/...` artifacts; no new `~/.codeman/...` paths, no env-var prefixes touched. Claude-mode-only by nature (external CLIs don't write workflow transcripts).
|
||||
|
||||
## 8. Testing & verification
|
||||
|
||||
- **Unit (pure):**
|
||||
- `test/workflow-run-watcher.test.ts`: feed a scrubbed fixture `wf_<runId>.json` → assert `WorkflowRunInfo` extraction (name/summary/phases, `workflowProgress`→`agents[]` keyed by `agentId`), mtime-skip, removal-by-set-diff.
|
||||
- Extend `subagent-watcher` coverage: temp `subagents/workflows/wf_X/agent-Y.jsonl` + a stray `journal.jsonl` → assert `agent-Y` registered with `isWorkflowAgent`/`workflowRunId` and `journal.jsonl` NOT registered; assert a short-description workflow agent is NOT dropped at **either** `isInternalAgent` site.
|
||||
- **Route/inject (`app.inject`):** `GET /api/workflows` + `:runId` return the `ApiResponse` envelope; `GET /api/subagents` includes a tagged agent.
|
||||
- **Frontend (vm-sandbox, like `test/run-mode-ui.test.ts`):** dispatch `subagent:discovered` with `workflowRunId` + `workflow:run_discovered` → assert `renderSubagentPanel` produces a group section under the workflow name with the agent inside it (label sourced from the **join**, not flat); assert order-independence (agent before run, and run before agent both resolve); assert OFF hides the section.
|
||||
- **REQUIRED real end-to-end** (the always-end-to-end-test rule — the plan-usage chip shipped *dead* from a gate mismatch): on dev/beta with `showUltracodeAgents` ON, **drive a real ultracode/workflow run**, then (1) `curl …/api/workflows | jq` shows the live run with `agents[]`; (2) `curl …/api/subagents | jq '.data[]|select(.isWorkflowAgent)'` shows tagged agents; (3) watch `/api/events` for `workflow:run_discovered` + `subagent:discovered` with the workflow fields; (4) Playwright (`waitUntil:'domcontentloaded'`, wait 3–4s) asserts the grouped DOM cluster renders with the workflow-name header and live status. Verify path gates against `GET /api/sessions` `workingDir`. **Test against a LIVE run** — all at-rest runs are `completed`/`done`; `running`/`queued` states only exist mid-run.
|
||||
|
||||
## 9. Phased rollout
|
||||
|
||||
| Phase | Scope | Done-check | Size |
|
||||
|---|---|---|---|
|
||||
| **P1 — Backend discovery + tagging (gated, no UI)** | §3a (nested descent, `journal.jsonl` skip, 2 `SubagentInfo` fields, `isInternalAgent` bypass ×2) + §3f schema key + §3e gate read. No run watcher yet. | With `showUltracodeAgents` forced on, `curl /api/subagents \| jq '.data[]\|select(.isWorkflowAgent)'` lists real workflow agents during a live run; flat subagents unchanged; `tsc --noEmit` + targeted watcher test green. | S–M |
|
||||
| **P2 — Run-state metadata + SSE** | §3b (`workflow-run-watcher.ts`) + §3c types + §3d/§3e (SSE, bridge, `getLightState` replay) + §3g routes. | `curl /api/workflows \| jq` returns runs with `agents[]`/`phases`; SSE emits `workflow:run_discovered`; reconnect snapshot carries `workflowRuns`. | M |
|
||||
| **P3 — Frontend grouped UI** | §4a–§4d (constants, app.js state/dispatch/init, panels-ui grouped render + **agent→label join**, subagent-windows group sub-header). Reuse `.subagent-window`; no auto-open. | Playwright: live run renders a group section under the workflow name with per-agent rows + live status + joined labels; flat subagents stay flat; expand opens a window with the workflow sub-header. | M |
|
||||
| **P4 — Settings toggle + polish + docs** | §4e (checkbox, settings-ui load/save/live-apply, SYNCED), styles/mobile, phase chips, CLAUDE.md "Key Patterns" entry + this doc's status → SHIPPED. | Toggling the checkbox shows/hides the cluster live (no reload for display); OFF by default on a fresh install; CI green. | S |
|
||||
|
||||
Each phase is independently shippable: P1 is invisible (gated, no UI), P2 adds an API with no UI dependency, P3 lights up the UI for flag-enablers, P4 exposes the toggle and finalizes defaults/docs.
|
||||
|
||||
## 10. Effort & risk
|
||||
|
||||
**Size:** P1 = S–M, P2 = M, P3 = M, P4 = S. Total ≈ **M** (one focused engineer, ~2–4 days incl. the real end-to-end run — down from the first draft's M-L now that the backend join/coupling is gone).
|
||||
|
||||
**Top 3 risks:**
|
||||
|
||||
1. **Non-recursive watch on Linux misses live writes.** `fs.watch` is non-recursive and `{recursive:true}` is unreliable on Linux → per-`wf_<runId>` watchers (§3a-4) are correct, but the 5s full scan must re-`readdir(wfRoot)` to catch *new* run dirs mid-session, and each watcher must be torn down to avoid FSWatcher leaks in 24h runs. Mitigation: explicit per-run-dir registration + verified `dirWatchers` teardown; chokidar (with `CleanupManager`) only in the new run watcher, where `team-watcher` already proves the pattern.
|
||||
2. **Discovery cost / over-registration.** A user with hundreds of historical workflow agents could flood `agentInfo` on boot. Mitigation: the 4h `STARTUP_MAX_FILE_AGE_MS` skip drops old files on the initial scan, the descent only runs when the toggle is on, and `MAX_TRACKED_AGENTS=500` LRU-evicts. Verify boot scan time doesn't regress with the corpus present.
|
||||
3. **Shipping-dead-on-a-gate** (the repo's recurring failure mode — the plan-usage chip shipped dead because injection was gated on `CASES_DIR` while real sessions ran elsewhere). Same trap here if the path/mode gate is wrong (e.g. `projects/*/workflows` instead of `projects/*/*/workflows`, or correlation via the wrong session key). Mitigation: the **mandatory live ultracode end-to-end run** in §8 against a real session's `workingDir`, observing the real SSE event + real DOM cluster — not the at-rest corpus, not unit tests alone.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Plan Usage Limits Display — Design & As-Built
|
||||
|
||||
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** Opt-in via App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`, default OFF). Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||
>
|
||||
> Two surfaces from one `statusLine` callback:
|
||||
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
||||
> - **In-terminal statusline footer** — the **current session's** status: `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%`.
|
||||
>
|
||||
> The `rate_limits` JSON schema below was **empirically confirmed** against Claude Code 2.1.177 on a Claude Max account; see the Verification appendix to reproduce.
|
||||
|
||||
## Problem
|
||||
|
||||
Codeman had no proactive view of how much of the Claude subscription is left. It only learned about limits **reactively**: `usage-limit-patterns.ts` regex-scrapes ANSI-stripped terminal output for footer strings like `5-hour limit reached ∙ resets 8pm`, extracting only the **reset time**, and only *after* Claude has already stalled. There was no "73% of your 5-hour limit used" anywhere.
|
||||
|
||||
We wanted a live, always-visible gauge so the operator can see a wall coming and pace overnight/autonomous runs — without hijacking the in-terminal statusline, which should keep showing the current session's status.
|
||||
|
||||
## Data source: the statusline `rate_limits` JSON
|
||||
|
||||
Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a configured `statusLine.command` on stdin after each render. On Pro/Max subscriptions that blob includes `rate_limits`. **This is the only channel that exposes plan-limit data** (see rejected alternatives) — so the feature *must* set a statusLine command, which is why the footer is also reconstructed by it (below).
|
||||
|
||||
### Confirmed schema (real captured payload)
|
||||
|
||||
```jsonc
|
||||
"rate_limits": {
|
||||
"five_hour": { "used_percentage": 15, "resets_at": 1781409000 }, // → 2026-06-14T03:50:00Z
|
||||
"seven_day": { "used_percentage": 34, "resets_at": 1781827200 } // → 2026-06-19T00:00:00Z
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `rate_limits.five_hour.used_percentage` | `number` 0–100 | Integer-valued in practice; treat as `number`, don't assume decimals. |
|
||||
| `rate_limits.five_hour.resets_at` | `number` | **Epoch SECONDS** (10 digits). `×1000` for a JS `Date`. |
|
||||
| `rate_limits.seven_day.{used_percentage,resets_at}` | same | |
|
||||
|
||||
**Confirmed facts & gotchas:**
|
||||
|
||||
- **Only two windows exist: `five_hour` and `seven_day`.** There is **no separate Opus-weekly field**, even on a Max/Opus account.
|
||||
- `rate_limits` is **absent on the first render**, **present after the first API response**. UI degrades to "no chip yet."
|
||||
- statusLine fires **only in interactive TUI mode**, never `--print`. Fine — Codeman sessions are interactive TUIs (and so are Codeman-spawned ones in tmux).
|
||||
- **Subscriber-gated.** Absent for API-key / non-subscriber auth.
|
||||
|
||||
### Bonus telemetry in the same payload — used for the footer
|
||||
|
||||
The same stdin object also carries `model.display_name`, `context_window.{used_percentage, total_input_tokens, total_output_tokens, …}`, `cost.total_cost_usd`, `effort.level`, etc. The shipped feature uses **model + token totals + context %** to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts `contextUsedPercentage`/`costUsd`/`modelDisplayName` alongside the limits for future chip tooltips.
|
||||
|
||||
### Alternatives considered & rejected
|
||||
|
||||
| Source | Why not |
|
||||
|--------|---------|
|
||||
| OAuth endpoint `api.anthropic.com/api/oauth/usage` | Undocumented, aggressively rate-limited, needs the **encrypted** OAuth token. Only worth it for *dollar spend*. |
|
||||
| `/usage` slash command | Interactive-only, no programmatic output. |
|
||||
| On-disk `~/.claude/` files | No usage state persisted (only `daemon.status.json` = auto-updater supervisor). |
|
||||
| CLI flag (`claude usage` / `--check-usage`) | Does not exist. |
|
||||
| `StopFailure` hook | Carries only an `error_type` on *failure* — no live percentages. |
|
||||
|
||||
## As-built architecture
|
||||
|
||||
```
|
||||
Claude TUI (any Claude session, incl. linked-case/real-repo sessions)
|
||||
│ renders statusline after each assistant msg (+ /compact, mode change)
|
||||
▼
|
||||
statusLine.command (settings.local.json) ──reads stdin JSON──▶
|
||||
curl -sk POST $CODEMAN_API_URL/api/status-telemetry {sessionId, data}
|
||||
(X-Codeman-Hook-Secret: $(cat $CODEMAN_HOOK_SECRET_FILE))
|
||||
│ ◀── HTTP 200 text/plain = current-SESSION status string ──┘
|
||||
▼
|
||||
printf '%s' "$body" → in-terminal footer: "Opus 4.8 (1M context) in:… out:… ctx:…%"
|
||||
|
||||
server (status-telemetry-routes.ts):
|
||||
parse rate_limits → (if changed) store last-known + broadcast SSE session:statusTelemetry → header chip
|
||||
parse model/tokens/ctx → return the session-status footer string
|
||||
▼
|
||||
app.js: _onSessionStatusTelemetry → chip (per-window colors) + localStorage save
|
||||
handleInit → chip from init-snapshot planUsage (fresh-load replay)
|
||||
```
|
||||
|
||||
### 1. The exporter — `generateStatusLineCommand()` in `hooks-config.ts`
|
||||
|
||||
Mirrors the hook `curlCmd()`. Reads the stdin JSON, POSTs `{sessionId, data}` to a **fixed** loopback path, and prints the response body back to stdout (print-through, so the footer stays useful). The managed-session env carries `$CODEMAN_SESSION_ID` / `$CODEMAN_API_URL` / `$CODEMAN_HOOK_SECRET_FILE` (from `tmux-manager.buildEnvExports()`).
|
||||
|
||||
```bash
|
||||
INPUT=$(cat 2>/dev/null || echo '{}'); \
|
||||
printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | \
|
||||
curl -sk -X POST "$CODEMAN_API_URL/api/status-telemetry" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" \
|
||||
--data @- 2>/dev/null || echo codeman
|
||||
```
|
||||
|
||||
⚠️ **`curl -sk`, not `curl -s`.** Prod is loopback **HTTPS with a self-signed cert**; without `-k`, curl returns `000` and the statusline silently shows nothing. `-k` is safe (loopback only). *(The existing hook curls use `-s` without `-k` and have the same latent issue on HTTPS installs — a known, separate follow-up.)*
|
||||
|
||||
### 2. Endpoint — `POST /api/status-telemetry` (`status-telemetry-routes.ts`)
|
||||
|
||||
Fixed path (sessionId in the **body**, not the URL) so the auth exemption is an exact-match like `/api/hook-event` (`middleware/auth.ts`: loopback-only; `X-Codeman-Hook-Secret`-gated while a tunnel runs). Schema `StatusTelemetrySchema` in `schemas.ts` validates the subset; unknown keys are stripped. Pure parsing/formatting in `usage-telemetry.ts`:
|
||||
|
||||
- `parseStatusTelemetry(data)` → `{ fiveHour, sevenDay, … }` or `null`. On change (signature dedup; statusline fires often), store last-known (`plan-usage-latest.ts`) and `broadcast('session:statusTelemetry', { sessionId, …telemetry })`.
|
||||
- `parseSessionStatus(data)` + `formatSessionStatusText()` → the **footer** string `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` (returned as `text/plain`). Available from the first render, even before `rate_limits` appears.
|
||||
|
||||
### 3. SSE + frontend chip
|
||||
|
||||
`session:statusTelemetry` registered in `sse-events.ts` + `constants.js`. `app.js`:
|
||||
- `_onSessionStatusTelemetry` → `updatePlanUsageChip(data)` + save to `localStorage['codeman:planUsage']`.
|
||||
- `updatePlanUsageChip` renders two `5h`/`7d` windows; **per-window color by usage** — green `<60%`, yellow `60–84%`, red `≥85%` (`pu-green/pu-yellow/pu-red`); bold labels/values; reset times in the tooltip. `resets_at*1000 → Date`.
|
||||
- Chip element ships hidden (`header-plan-usage--hidden`); `applyHeaderVisibilitySettings()` reveals it client-side when the setting is on (response-viewer pattern — **no `renderIndexHtml` strip**, which kept the "title-only" render contract intact).
|
||||
|
||||
### 4. Chip data robustness — three layers
|
||||
|
||||
1. **Live:** `session:statusTelemetry` SSE on every distinct render.
|
||||
2. **Fresh load / reconnect:** server stores the latest in `plan-usage-latest.ts`; `getLightState()` includes it as `planUsage`; the per-connection **init snapshot** replays it; `handleInit` paints the chip immediately (authoritative over localStorage). Null until the first telemetry of the process.
|
||||
3. **Offline / cross-restart:** `restorePlanUsageChip()` reads `localStorage` on load (12h freshness guard).
|
||||
|
||||
### 5. Injection lifecycle — works for *any* user, never self-destructs
|
||||
|
||||
The setting `showPlanUsageLimits` is **synced** (in `settings.json`, not a per-device `displayKey`).
|
||||
|
||||
- **On toggle** (`PUT /api/settings`, `system-routes.ts`): reconcile the exporter across **all active Claude sessions' working dirs** — inject on enable, remove on disable. Server-side and authoritative, so existing sessions get the footer + feed the chip *immediately*, no new session needed, no dependency on a client's synced localStorage.
|
||||
- **On session create** (`session-routes.ts`): **ADD-ONLY** — inject when `statusLineTelemetry` is true; **never remove**. Sessions in a repo share one `settings.local.json`, so a single create-with-false (e.g. a client whose synced setting hadn't loaded) must not yank the statusLine out from under other live sessions. Removal happens only via the explicit toggle.
|
||||
- `applyStatusLineConfig()` is **`isOurs`-guarded** (matches `/api/status-telemetry`), so a user's own hand-authored statusLine is never touched, and it **updates an out-of-date ours-command** so fixes (e.g. `-k`) propagate. **No `CASES_DIR` gate** — runs for linked cases / real repos (where sessions actually run), mirroring `updateCaseModel`.
|
||||
|
||||
## Codeman-specific considerations
|
||||
|
||||
1. **Account-global limits.** The 5h/7d pools are shared across all sessions on the account → one shared header chip (freshest sample wins), not a per-tab bar.
|
||||
2. **The footer is owned, by necessity.** A statusLine command always replaces Claude's default footer. Since `rate_limits` *only* arrives via statusLine, we reconstruct a useful **session-status** footer (model · tokens · ctx %) from the same payload rather than showing the limits there.
|
||||
3. **`isOurs`-guarded.** Never removes/overwrites a user's own statusLine on disable; only manages the Codeman exporter.
|
||||
4. **Security envelope unchanged.** The exporter runs arbitrary shell every render — same trust model as the hook curls (localhost + `$CODEMAN_HOOK_SECRET_FILE`); reuses the hook-secret gate.
|
||||
5. **Claude-only.** OpenCode/Codex emit no `rate_limits` JSON; injection is gated to `mode === 'claude'`.
|
||||
6. **Future — auto-resume synergy.** Live percentages would let `SessionAutoOps` pre-arm *before* the wall instead of reacting to the stall footer. Not built.
|
||||
|
||||
## Files shipped
|
||||
|
||||
- `src/usage-telemetry.ts` — pure parse/format (`parseStatusTelemetry`, `parseSessionStatus`, `formatSessionStatusText`, `telemetrySignature`) + `test/usage-telemetry.test.ts`.
|
||||
- `src/hooks-config.ts` — `generateStatusLineCommand()` (`curl -sk`), `applyStatusLineConfig()` (add/update/remove, `isOurs`-guarded).
|
||||
- `src/web/routes/status-telemetry-routes.ts` — `POST /api/status-telemetry`.
|
||||
- `src/web/plan-usage-latest.ts` — process-wide last-known store for init replay.
|
||||
- `src/web/schemas.ts` — `StatusTelemetrySchema` + `showPlanUsageLimits` + create-payload `statusLineTelemetry`.
|
||||
- `src/web/middleware/auth.ts` — exemption extended to `/api/status-telemetry`.
|
||||
- `src/web/routes/session-routes.ts` — add-only create-time injection.
|
||||
- `src/web/routes/system-routes.ts` — settings-toggle reconcile.
|
||||
- `src/web/server.ts` — `getLightState().planUsage` (init snapshot).
|
||||
- `src/web/sse-events.ts` + `constants.js` — `session:statusTelemetry`.
|
||||
- Frontend: `app.js` (`_onSessionStatusTelemetry`, `updatePlanUsageChip`, `restorePlanUsageChip`, `handleInit`), `settings-ui.js` (toggle + `applyHeaderVisibilitySettings`), `index.html` (chip + toggle row), `styles.css` (chip + colors), `session-ui.js` (create payload).
|
||||
|
||||
## Bugs E2E testing caught (that unit tests didn't)
|
||||
|
||||
The first "shipped" build passed every test and was broken in practice. End-to-end testing on the real install (the lesson: drive a REAL session, observe the REAL output) surfaced:
|
||||
|
||||
1. **`CASES_DIR` injection gate** excluded the user's whole workflow — sessions run in linked cases / real repos, not under `~/codeman-cases`. → dropped the gate.
|
||||
2. **`curl -s` → `000`** on the loopback self-signed HTTPS cert; statusline silently empty. → `curl -sk`.
|
||||
3. **Remove-on-create-false + shared `settings.local.json`** let a single stale client yank the statusLine out from under all sessions in a repo. → add-only on create; removal only via the toggle reconcile.
|
||||
4. **Chip blank after reload** (localStorage-only, lost on restart/fresh browser). → server-side last-known in the init snapshot.
|
||||
|
||||
## Open questions / future
|
||||
|
||||
- **Schema stability.** `rate_limits` is officially shipped but undocumented in exact shape; the parser is tolerant (renders whatever windows exist, ignores unknown).
|
||||
- **Hook `curl -s` parity.** Hooks share the no-`-k` issue on HTTPS installs — worth fixing the hook curl too (separate change; covered by `cod54` tests).
|
||||
- **Disable cleanliness.** Disabling removes the statusLine from active sessions; a brand-new session created by a *stale* client could re-add it (chip still hidden, footer benign). Fully server-authoritative create-time injection (read the setting server-side instead of the payload flag) would close this — deferred.
|
||||
|
||||
## Verification appendix — how the schema was captured (reproducible)
|
||||
|
||||
Captured without touching global settings or any real session:
|
||||
|
||||
1. Throwaway dir `/tmp/sl-capture` with an exporter `dump.sh` that appends stdin to `payloads.jsonl` and prints `cap`; a `settings.json` pointing `statusLine.command` at it.
|
||||
2. `--print` mode does **not** render a statusline → no capture (confirms TUI-only). Must use interactive.
|
||||
3. Launch interactive Claude in an **isolated tmux socket** (`tmux -L slcap`, never `-L codeman`) inside the temp dir, `--settings /tmp/sl-capture/settings.json` (no global mutation). Confirm the workspace-trust dialog (appears even with `--dangerously-skip-permissions`), then send a one-line prompt (literal text + Enter separately, Ink-style).
|
||||
4. After the first response, `rate_limits` appears in the **second** captured record (absent in the first). Inspect with `jq '.rate_limits'`.
|
||||
5. Tear down: `tmux -L slcap kill-server` + `rm -rf /tmp/sl-capture`; verify the `codeman` socket is untouched.
|
||||
|
||||
Related: `docs/claude-code-hooks-reference.md` (hook callback pattern), `src/usage-limit-patterns.ts` (reactive fallback), `docs/respawn-state-machine.md` (auto-resume interplay).
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.7",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.7",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -67,6 +67,7 @@ appendFileSync(
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
|
||||
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
|
||||
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
|
||||
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
|
||||
@@ -90,6 +91,7 @@ console.log('\n[build] content-hash cache busting');
|
||||
'notification-manager.js',
|
||||
'keyboard-accessory.js',
|
||||
'input-cjk.js',
|
||||
'sanitize-html.js',
|
||||
'app.js',
|
||||
'terminal-ui.js',
|
||||
'respawn-ui.js',
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* capture-readme-real.mjs
|
||||
*
|
||||
* Captures README desktop scenes (multi-session dashboard, monitor, subagent
|
||||
* windows) from a REAL Codeman instance — intended to run against an ISOLATED
|
||||
* dev/beta instance (CODEMAN_INSTANCE=beta on :5000) seeded from prod's settings,
|
||||
* NOT prod itself (never touch prod's live sessions).
|
||||
*
|
||||
* Reuses the high-quality capture recipe proven in capture-real-overview.mjs:
|
||||
* - DSF=2 + ?nowebgl → crisp retina at the TRUE font size (WebGL doubles
|
||||
* glyphs under DSF=2; the DOM renderer respects devicePixelRatio).
|
||||
* - per-device localStorage seeding so the capture matches a real device.
|
||||
*
|
||||
* SCENE=dashboard|monitor|subagent|all BASE=http://localhost:5000 \
|
||||
* OUT=screenshots-readme-real/desktop node scripts/capture-readme-real.mjs
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const OUT = process.env.OUT || 'screenshots-readme-real/desktop';
|
||||
const SKIN = process.env.SKIN || 'daylight-blue';
|
||||
const SCENE = process.env.SCENE || 'all';
|
||||
const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 13)));
|
||||
const VIEWPORT = { width: Number(process.env.VW || 1280), height: Number(process.env.VH || 720) };
|
||||
const DSF = Number(process.env.DSF || 2);
|
||||
const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const url = (extra = '') => {
|
||||
const sep = BASE.includes('?') ? '&' : '?';
|
||||
const params = [];
|
||||
if (DSF > 1) params.push('nowebgl'); // DOM renderer → correct font size at DSF>1
|
||||
if (extra) params.push(extra);
|
||||
return params.length ? `${BASE}${sep}${params.join('&')}` : BASE;
|
||||
};
|
||||
|
||||
async function newCtx(browser) {
|
||||
const context = await browser.newContext({
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: DSF,
|
||||
ignoreHTTPSErrors: BASE.startsWith('https'),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
await page.addInitScript(
|
||||
([skin, planUsage, font]) => {
|
||||
try {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
localStorage.setItem('codeman-font-size', String(font));
|
||||
const blob = { skin, showFileBrowser: false, showProjectInsights: false };
|
||||
if (planUsage) blob.showPlanUsageLimits = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[SKIN, PLAN_USAGE, FONT]
|
||||
);
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
async function bootstrap(page) {
|
||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
async function listSessions(page) {
|
||||
return page.evaluate(() =>
|
||||
Array.from(window.app.sessions.values()).map((s) => ({ id: s.id, name: s.name, mode: s.mode }))
|
||||
);
|
||||
}
|
||||
|
||||
async function shoot(page, name) {
|
||||
const out = join(OUT, name);
|
||||
await page.evaluate((f) => {
|
||||
try {
|
||||
if (window.app.setFontSize) window.app.setFontSize(f);
|
||||
} catch {}
|
||||
try {
|
||||
window.app.fitAddon && window.app.fitAddon.fit();
|
||||
} catch {}
|
||||
try {
|
||||
window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
|
||||
} catch {}
|
||||
}, FONT);
|
||||
await sleep(1500);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
console.log(' Saved: ' + out);
|
||||
}
|
||||
|
||||
async function sceneDashboard(browser) {
|
||||
console.log('Scene: dashboard');
|
||||
const { context, page } = await newCtx(browser);
|
||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||
await bootstrap(page);
|
||||
const sessions = await listSessions(page);
|
||||
// Select a claude session so the active terminal shows rich content; all tabs render.
|
||||
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
|
||||
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
|
||||
await sleep(4000);
|
||||
await shoot(page, 'multi-session-dashboard.png');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function sceneMonitor(browser) {
|
||||
console.log('Scene: monitor');
|
||||
const { context, page } = await newCtx(browser);
|
||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||
await bootstrap(page);
|
||||
const sessions = await listSessions(page);
|
||||
const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
|
||||
if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
|
||||
await sleep(2500);
|
||||
// toggleMonitorPanel() opens the panel, clears the hidden state, loads REAL
|
||||
// mux sessions (/api/mux), starts stats, and renders the task panel.
|
||||
await page.evaluate(async () => {
|
||||
try {
|
||||
await window.app.toggleMonitorPanel();
|
||||
} catch {}
|
||||
});
|
||||
await sleep(3000);
|
||||
await shoot(page, 'multi-session-monitor.png');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function sceneSubagent(browser) {
|
||||
console.log('Scene: subagent');
|
||||
const { context, page } = await newCtx(browser);
|
||||
await page.goto(url(), { waitUntil: 'domcontentloaded' });
|
||||
await bootstrap(page);
|
||||
// Select the session whose subagents we want (subagentActiveTabOnly means
|
||||
// app.subagents only fills for the active tab). Prefer SUBAGENT_SID env.
|
||||
const sessions = await listSessions(page);
|
||||
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
|
||||
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
|
||||
// Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents.
|
||||
let agents = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
agents = await page.evaluate(() =>
|
||||
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
|
||||
);
|
||||
if (agents.length >= 1) break;
|
||||
await sleep(1000);
|
||||
}
|
||||
console.log(' live in-browser subagents:', JSON.stringify(agents));
|
||||
if (agents.length === 0) {
|
||||
console.log(' NO live subagents — skipping (stage a longer subagent task and run this while it runs).');
|
||||
await context.close();
|
||||
return;
|
||||
}
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
try {
|
||||
window.app.openSubagentWindow(id);
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
agents.map((a) => a.id)
|
||||
);
|
||||
await sleep(2000);
|
||||
await page.evaluate(() => {
|
||||
const wins = Array.from(window.app.subagentWindows.values());
|
||||
const place = [
|
||||
{ left: 360, top: 60, w: 430, h: 330 },
|
||||
{ left: 810, top: 60, w: 430, h: 330 },
|
||||
];
|
||||
wins.slice(0, 2).forEach((win, i) => {
|
||||
const el = win.element;
|
||||
const p = place[i];
|
||||
el.style.left = p.left + 'px';
|
||||
el.style.top = p.top + 'px';
|
||||
el.style.width = p.w + 'px';
|
||||
el.style.height = p.h + 'px';
|
||||
});
|
||||
});
|
||||
await sleep(1500);
|
||||
await shoot(page, 'subagent-spawn.png');
|
||||
await context.close();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
console.log(`BASE=${BASE} SKIN=${SKIN} DSF=${DSF} VIEWPORT=${VIEWPORT.width}x${VIEWPORT.height} SCENE=${SCENE}`);
|
||||
if (SCENE === 'dashboard' || SCENE === 'all') await sceneDashboard(browser);
|
||||
if (SCENE === 'monitor' || SCENE === 'all') await sceneMonitor(browser);
|
||||
if (SCENE === 'subagent' || SCENE === 'all') await sceneSubagent(browser);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('FATAL', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* capture-real-overview.mjs
|
||||
*
|
||||
* Captures a REAL claude-overview screenshot from a LIVE Codeman server
|
||||
* (no mock injection). Drive a real session to do real work, then run:
|
||||
*
|
||||
* SID=<sessionId> BASE=http://localhost:5000 OUT=screenshots-real \
|
||||
* node scripts/capture-real-overview.mjs
|
||||
*
|
||||
* Skin defaults to daylight-blue (prod default) via the localStorage pre-paint
|
||||
* contract in index.html. Output: <OUT>/claude-overview.png at 1280x720 (DSF 2).
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const SID = process.env.SID;
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const OUT = process.env.OUT || 'screenshots-real';
|
||||
const SKIN = process.env.SKIN || 'daylight-blue';
|
||||
// Unique filename per run (timestamped) so a viewer holding an old render of a
|
||||
// fixed path can never shadow a fresh capture. Override with NAME=… if needed.
|
||||
const STAMP = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
||||
const NAME = process.env.NAME || `claude-overview-${STAMP}.png`;
|
||||
const VIEWPORT = { width: Number(process.env.VW || 1512), height: Number(process.env.VH || 812) };
|
||||
// IMPORTANT: default deviceScaleFactor is 1, NOT 2. xterm's WebGL renderer in
|
||||
// headless Chromium draws terminal glyphs at ~2× their nominal size when DSF=2
|
||||
// (while still reporting nominal 8px cell dims internally, so it can't be caught
|
||||
// by measuring terminal.cols/cell — only the pixels reveal it). The HTML chrome
|
||||
// is unaffected, so DSF=2 makes ONLY the console font look comically large. DSF=1
|
||||
// renders the console at its true size, matching a real (non-headless) browser.
|
||||
const DSF = Number(process.env.DSF || 1);
|
||||
|
||||
if (!SID) {
|
||||
console.error('SID env var required (the live session id to screenshot)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const main = async () => {
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: DSF,
|
||||
ignoreHTTPSErrors: BASE.startsWith('https'),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
// Force the skin before any page script runs (pre-paint <head> contract), and
|
||||
// seed the PER-DEVICE display blob so the capture reflects what prod actually
|
||||
// shows on the user's real device — notably the plan-usage chip, which is a
|
||||
// per-device setting (default OFF) deleted from the server payload, so a fresh
|
||||
// browser would otherwise hide it. PLAN_USAGE=0 disables.
|
||||
const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
|
||||
// Terminal console font size. App default is 14px; a fresh headless browser has
|
||||
// no saved codeman-font-size, so it renders at 14 — much larger than a real
|
||||
// device where the console has been zoomed down. Seed a smaller value (clamped
|
||||
// to the app's [10,24] range) so the console font looks normal in the capture.
|
||||
const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 14)));
|
||||
await page.addInitScript(
|
||||
([skin, planUsage, font]) => {
|
||||
try {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
localStorage.setItem('codeman-font-size', String(font));
|
||||
// Desktop app-settings blob (settings-ui.js getSettingsStorageKey()).
|
||||
// Present these display keys explicitly so the server merge won't seed
|
||||
// side panels open (display keys only seed from server when absent from
|
||||
// localStorage). Matches the clean full-width-terminal reference look.
|
||||
const blob = {
|
||||
skin,
|
||||
showFileBrowser: false,
|
||||
showMonitor: false,
|
||||
showSubagents: false,
|
||||
showProjectInsights: false,
|
||||
};
|
||||
if (planUsage) blob.showPlanUsageLimits = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[SKIN, PLAN_USAGE, FONT]
|
||||
);
|
||||
|
||||
// At DSF>1, xterm's WebGL renderer draws glyphs at ~2x (see DSF comment above).
|
||||
// The app honors a `?nowebgl` URL param that switches to xterm's DOM renderer,
|
||||
// which respects devicePixelRatio correctly — so DSF=2 + nowebgl yields a crisp
|
||||
// 2x (retina) capture at the TRUE font size. Auto-enable it whenever DSF>1.
|
||||
const url = DSF > 1 ? `${BASE}${BASE.includes('?') ? '&' : '?'}nowebgl` : BASE;
|
||||
console.log(`Loading ${url} (DSF=${DSF}) ...`);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
|
||||
await sleep(1500);
|
||||
|
||||
console.log(`Selecting session ${SID} ...`);
|
||||
await page.evaluate((sid) => window.app.selectSession(sid), SID);
|
||||
|
||||
// Let the terminal buffer stream in + xterm render + any Ink redraw settle.
|
||||
await sleep(2000);
|
||||
|
||||
// Force a clean fit (avoids capturing a transient pre-fit frame where the
|
||||
// terminal renders at the wrong column count) and re-apply per-device header
|
||||
// visibility so the seeded plan-usage chip is shown.
|
||||
await page.evaluate((font) => {
|
||||
// Force the console font explicitly (setFontSize also re-fits) in case
|
||||
// loadFontSize didn't pick up the seeded value before the session rendered.
|
||||
try {
|
||||
if (window.app.setFontSize) window.app.setFontSize(font);
|
||||
else window.app.terminal.options.fontSize = font;
|
||||
} catch {}
|
||||
try {
|
||||
window.app.fitAddon && window.app.fitAddon.fit();
|
||||
} catch {}
|
||||
try {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
} catch {}
|
||||
try {
|
||||
window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
|
||||
} catch {}
|
||||
}, FONT);
|
||||
await sleep(3000);
|
||||
|
||||
// Optionally scroll the terminal up to frame the rich tool-call region
|
||||
// (Read/Write/Bash + green test results) instead of the trailing summary.
|
||||
const SCROLL = Number(process.env.SCROLL || 0);
|
||||
if (SCROLL) {
|
||||
await page.evaluate((n) => {
|
||||
const t = window.app && window.app.terminal;
|
||||
if (t && t.scrollLines) t.scrollLines(-n);
|
||||
}, SCROLL);
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
const outPath = join(OUT, NAME);
|
||||
await page.screenshot({ path: outPath, fullPage: false });
|
||||
console.log(`Saved: ${outPath}`);
|
||||
|
||||
await context.close();
|
||||
await browser.close();
|
||||
};
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('FATAL', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+30
@@ -626,4 +626,34 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.alias('check-deps')
|
||||
.description('Check Codeman tool dependencies (Node, Claude CLI, tmux, LibreOffice, MS Office)')
|
||||
.option('--json', 'Output structured JSON instead of a table')
|
||||
.option('--category <name>', 'Only check one category (core|office|other)')
|
||||
.action(async (options) => {
|
||||
const { createRealHost, checkAll } = await import('./utils/dependency-checker.js');
|
||||
const { renderTable, renderJson, computeExitCode } = await import('./utils/dependency-report.js');
|
||||
const { DEPENDENCY_REGISTRY, TOOL_CATEGORIES } = await import('./config/dependency-registry.js');
|
||||
|
||||
if (options.category && !(TOOL_CATEGORIES as readonly string[]).includes(options.category)) {
|
||||
console.error(`Unknown category "${options.category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const host = createRealHost();
|
||||
const registry = options.category
|
||||
? DEPENDENCY_REGISTRY.filter((t) => t.category === options.category)
|
||||
: DEPENDENCY_REGISTRY;
|
||||
const results = checkAll(registry, host);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(renderJson(results, host.environment), null, 2));
|
||||
} else {
|
||||
console.log(renderTable(results, host.environment));
|
||||
}
|
||||
process.exit(computeExitCode(results));
|
||||
});
|
||||
|
||||
export { program };
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @fileoverview Static registry of downstream tool dependencies probed by
|
||||
* `codeman doctor`. Each entry declares per-environment resolvers and the
|
||||
* skills that use it. EXTENSION POINT: skill-manifest-driven discovery
|
||||
* (COD follow-up) will merge dynamically-found entries into this list.
|
||||
*
|
||||
* @module config/dependency-registry
|
||||
*/
|
||||
|
||||
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
|
||||
|
||||
/** The valid `--category` filter values; single source of truth for the type, the CLI
|
||||
* help text, and CLI input validation. */
|
||||
export const TOOL_CATEGORIES = ['core', 'office', 'other'] as const;
|
||||
export type ToolCategory = (typeof TOOL_CATEGORIES)[number];
|
||||
|
||||
/** Resolve a binary on the PATH and read its version. */
|
||||
export interface PathResolver {
|
||||
kind: 'path';
|
||||
bins: string[];
|
||||
versionArg?: string; // default '--version'
|
||||
versionRegex?: RegExp; // default matches first \d+.\d+(.\d+)?
|
||||
}
|
||||
|
||||
/** Resolve a Windows-installed app reachable from win32 or WSL. */
|
||||
export interface WindowsSideResolver {
|
||||
kind: 'windows-side';
|
||||
appDirs: string[]; // relative to a Program Files root
|
||||
exes: string[]; // candidate executables; first found wins
|
||||
}
|
||||
|
||||
export interface ResolverSpec {
|
||||
match: ProbeEnvironment[];
|
||||
resolver: PathResolver | WindowsSideResolver;
|
||||
}
|
||||
|
||||
export interface ToolDependency {
|
||||
id: string;
|
||||
label: string;
|
||||
category: ToolCategory;
|
||||
required: boolean;
|
||||
usedBy?: string[];
|
||||
minVersion?: string;
|
||||
resolvers: ResolverSpec[];
|
||||
installHint?: Partial<Record<ProbeEnvironment, string>>;
|
||||
}
|
||||
|
||||
const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
|
||||
|
||||
export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
||||
{
|
||||
id: 'node',
|
||||
label: 'Node.js',
|
||||
category: 'core',
|
||||
required: true,
|
||||
minVersion: '22.0.0',
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }],
|
||||
installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' },
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
label: 'Claude CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Claude Code sessions (default backend)'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }],
|
||||
installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' },
|
||||
},
|
||||
{
|
||||
id: 'tmux',
|
||||
label: 'tmux',
|
||||
category: 'core',
|
||||
required: true,
|
||||
resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
|
||||
installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' },
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'OpenCode CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['OpenCode sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
label: 'Codex CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Codex sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'thumbnails'],
|
||||
resolvers: [
|
||||
{
|
||||
match: ['linux', 'darwin', 'wsl'],
|
||||
resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' },
|
||||
},
|
||||
],
|
||||
installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' },
|
||||
},
|
||||
{
|
||||
id: 'pdftoppm',
|
||||
label: 'pdftoppm',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'PDF/Office first-page thumbnails'],
|
||||
// poppler's pdftoppm prints its version to stderr; presence is what matters here.
|
||||
resolvers: [
|
||||
{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } },
|
||||
],
|
||||
installHint: {
|
||||
linux: 'sudo apt install poppler-utils',
|
||||
darwin: 'brew install poppler',
|
||||
wsl: 'sudo apt install poppler-utils',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'msoffice',
|
||||
label: 'MS Office',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'thumbnails'],
|
||||
resolvers: [
|
||||
{
|
||||
match: ['wsl', 'win32'],
|
||||
resolver: {
|
||||
kind: 'windows-side',
|
||||
appDirs: ['Microsoft Office/root/Office16'],
|
||||
exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @fileoverview Workflow (ultracode) run-watcher polling and cache configuration.
|
||||
*
|
||||
* Controls how frequently WorkflowRunWatcher polls
|
||||
* ~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json
|
||||
* and how many runs are cached in memory.
|
||||
*
|
||||
* Distinct from the Agent-Teams config (team-config.ts). The run-state JSON is
|
||||
* rewritten on every agent tick across a whole run (28+ agents), so the watcher
|
||||
* relies on a per-file mtime skip; the poll itself is just N stat() calls.
|
||||
*
|
||||
* @module config/workflow-config
|
||||
*/
|
||||
|
||||
/** Workflow run-state poll interval (ms). Short because a poll is just N mtime stats. */
|
||||
export const WORKFLOW_RUN_POLL_INTERVAL_MS = 10_000;
|
||||
|
||||
/** Max cached workflow runs (LRU eviction). */
|
||||
export const MAX_CACHED_WORKFLOW_RUNS = 100;
|
||||
|
||||
/**
|
||||
* Default recency window (minutes) for getRecentRuns(). Generous enough that a
|
||||
* recently-finished long run still appears in the LEFT-pane list — filtered on
|
||||
* last-activity, not start time, so multi-hour runs don't vanish.
|
||||
*/
|
||||
export const WORKFLOW_RUN_RECENT_WINDOW_MIN = 240;
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @fileoverview Global concurrency limiter for spawning external document
|
||||
* converters (pdftoppm / LibreOffice `soffice` / Word-COM `powershell.exe`).
|
||||
*
|
||||
* Without a cap, N simultaneous thumbnail/preview requests for *distinct*
|
||||
* documents fork N converter processes at once — each held open for up to the
|
||||
* multi-minute conversion timeout. That is a localhost resource-exhaustion
|
||||
* (fork-bomb-shaped) vector: a handful of large PDFs detected at once can pin
|
||||
* CPU and RAM. This module serializes converter spawns down to a small fixed
|
||||
* pool; excess spawns queue (FIFO) until a slot frees. The in-flight cache in
|
||||
* `document-preview-cache.ts` already de-dups *identical* inputs; this bounds
|
||||
* the *distinct* case the cache can't.
|
||||
*
|
||||
* Permit accounting transfers the slot directly to the next waiter on release
|
||||
* (rather than decrement-then-reacquire) so the active count can never exceed
|
||||
* the cap even under interleaved async resumption.
|
||||
*
|
||||
* NOT re-entrant: never call `runWithConversionLimit` from inside a task that is
|
||||
* already holding a slot — a nested acquire under a full pool would deadlock.
|
||||
* The converter call sites only ever acquire once per request (the office path
|
||||
* acquires for `soffice` and `pdftoppm` sequentially, not nested).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Max converter processes allowed to run concurrently across the whole process.
|
||||
* Override with CODEMAN_MAX_DOCUMENT_CONVERSIONS (clamped to >= 1).
|
||||
*/
|
||||
const MAX_CONCURRENT_DOCUMENT_CONVERSIONS = (() => {
|
||||
const raw = Number(process.env.CODEMAN_MAX_DOCUMENT_CONVERSIONS);
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 3;
|
||||
})();
|
||||
|
||||
let active = 0;
|
||||
const waiters: Array<() => void> = [];
|
||||
|
||||
/** Test/diagnostic hook: converters currently holding a slot. */
|
||||
export function getActiveConversionCount(): number {
|
||||
return active;
|
||||
}
|
||||
|
||||
function acquire(): Promise<void> {
|
||||
if (active < MAX_CONCURRENT_DOCUMENT_CONVERSIONS) {
|
||||
active++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => waiters.push(resolve));
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
const next = waiters.shift();
|
||||
if (next) {
|
||||
// Hand the slot straight to the next waiter — `active` stays at the cap.
|
||||
next();
|
||||
} else {
|
||||
active--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `task` once a converter slot is free, releasing the slot afterward. */
|
||||
export async function runWithConversionLimit<T>(task: () => Promise<T>): Promise<T> {
|
||||
await acquire();
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @fileoverview Shared disk cache for expensive Office document previews.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, extname, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const OFFICE_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||
const DOCUMENT_PREVIEW_CACHE_DIR = join(tmpdir(), 'codeman-document-preview-cache');
|
||||
/**
|
||||
* Cap on persistent converted-PDF files kept in DOCUMENT_PREVIEW_CACHE_DIR.
|
||||
* The cache key embeds the source mtime, so every edit to a doc orphans its
|
||||
* prior PDF; without a cap the dir grows unbounded across long-running sessions.
|
||||
* Override with CODEMAN_MAX_PREVIEW_CACHE_FILES (clamped to >= 1).
|
||||
*/
|
||||
const MAX_PREVIEW_CACHE_FILES = (() => {
|
||||
const raw = Number(process.env.CODEMAN_MAX_PREVIEW_CACHE_FILES);
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 100;
|
||||
})();
|
||||
function buildWordExportPdfScript(sourcePath: string, outputPath: string): string {
|
||||
return `
|
||||
$ErrorActionPreference = "Stop"
|
||||
$source = ${toPowerShellSingleQuotedString(sourcePath)}
|
||||
$output = ${toPowerShellSingleQuotedString(outputPath)}
|
||||
$word = $null
|
||||
$doc = $null
|
||||
try {
|
||||
$word = New-Object -ComObject Word.Application
|
||||
$word.Visible = $false
|
||||
$word.DisplayAlerts = 0
|
||||
$doc = $word.Documents.Open($source)
|
||||
$doc.ExportAsFixedFormat($output, 17)
|
||||
} finally {
|
||||
if ($null -ne $doc) {
|
||||
$doc.Close($false) | Out-Null
|
||||
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
|
||||
}
|
||||
if ($null -ne $word) {
|
||||
$word.Quit() | Out-Null
|
||||
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
|
||||
}
|
||||
[System.GC]::Collect()
|
||||
[System.GC]::WaitForPendingFinalizers()
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
type OfficePreviewConverter = 'msword' | 'libreoffice';
|
||||
|
||||
const inFlightOfficeConversions = new Map<string, Promise<string | null>>();
|
||||
|
||||
export function clearDocumentPreviewCache(): void {
|
||||
inFlightOfficeConversions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort LRU-ish eviction for the persistent converted-PDF cache: keeps at
|
||||
* most MAX_PREVIEW_CACHE_FILES `*.pdf` files in `cacheDir`, deleting the oldest
|
||||
* by mtime once over the cap. Never throws — a pruning failure must not fail the
|
||||
* conversion that triggered it. Only `*.pdf` files are considered, so the
|
||||
* transient `work-*` mkdtemp dirs are ignored.
|
||||
*/
|
||||
export async function pruneDocumentPreviewCache(cacheDir: string): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(cacheDir);
|
||||
const pdfs = entries.filter((name) => name.toLowerCase().endsWith('.pdf'));
|
||||
if (pdfs.length <= MAX_PREVIEW_CACHE_FILES) return;
|
||||
|
||||
const stats = await Promise.all(
|
||||
pdfs.map(async (name) => {
|
||||
const fullPath = join(cacheDir, name);
|
||||
try {
|
||||
const stat = await fs.stat(fullPath);
|
||||
return { fullPath, mtimeMs: stat.mtimeMs ?? 0 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const sorted = stats.filter((s): s is { fullPath: string; mtimeMs: number } => s !== null);
|
||||
sorted.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
||||
const toRemove = sorted.slice(0, Math.max(0, sorted.length - MAX_PREVIEW_CACHE_FILES));
|
||||
await Promise.all(toRemove.map((entry) => fs.rm(entry.fullPath, { force: true }).catch(() => {})));
|
||||
} catch {
|
||||
// Best-effort: pruning must never break a conversion.
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOfficePreviewPdfPath(filePath: string, extension: string): Promise<string | null> {
|
||||
const ext = extension.toLowerCase().replace(/^\./, '');
|
||||
if (ext !== 'docx' && ext !== 'pptx') return null;
|
||||
|
||||
let sourceStat;
|
||||
try {
|
||||
sourceStat = await fs.stat(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const converter of getOfficePreviewConverters(filePath, ext)) {
|
||||
const cacheKey = createDocumentPreviewCacheKey(filePath, ext, sourceStat.size, sourceStat.mtimeMs ?? 0, converter);
|
||||
const cachePath = getOfficePreviewCachePath(filePath, cacheKey, converter);
|
||||
|
||||
if (await fileExists(cachePath)) {
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
const inFlightKey = `${converter}:${cacheKey}`;
|
||||
const inFlight = inFlightOfficeConversions.get(inFlightKey);
|
||||
if (inFlight) {
|
||||
const converted = await inFlight;
|
||||
if (converted) return converted;
|
||||
continue;
|
||||
}
|
||||
|
||||
const conversion =
|
||||
converter === 'msword'
|
||||
? convertWordDocumentToCachedPdf(filePath, cachePath)
|
||||
: convertLibreOfficeDocumentToCachedPdf(filePath, cachePath);
|
||||
inFlightOfficeConversions.set(inFlightKey, conversion);
|
||||
try {
|
||||
const converted = await conversion;
|
||||
if (converted) return converted;
|
||||
} finally {
|
||||
inFlightOfficeConversions.delete(inFlightKey);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOfficePreviewConverters(filePath: string, extension: string): OfficePreviewConverter[] {
|
||||
if (extension === 'docx' && wslMountPathToWindowsPath(filePath)) {
|
||||
return ['msword', 'libreoffice'];
|
||||
}
|
||||
return ['libreoffice'];
|
||||
}
|
||||
|
||||
function createDocumentPreviewCacheKey(
|
||||
filePath: string,
|
||||
extension: string,
|
||||
size: number,
|
||||
mtimeMs: number,
|
||||
converter: OfficePreviewConverter
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify({ cacheVersion: 2, converter, filePath, extension, size, mtimeMs }))
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function getOfficePreviewCachePath(filePath: string, cacheKey: string, converter: OfficePreviewConverter): string {
|
||||
if (converter === 'msword') {
|
||||
const windowsCacheDir = getWindowsUserTempCacheDir(filePath);
|
||||
if (windowsCacheDir) {
|
||||
return join(windowsCacheDir, `${cacheKey}.pdf`);
|
||||
}
|
||||
}
|
||||
|
||||
return join(DOCUMENT_PREVIEW_CACHE_DIR, `${cacheKey}.pdf`);
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
return typeof stat.isFile !== 'function' || stat.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function convertWordDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
|
||||
const outputPath = wslMountPathToWindowsPath(cachePath);
|
||||
if (!outputPath) return null;
|
||||
|
||||
let sourceCopyPath: string | undefined;
|
||||
|
||||
try {
|
||||
await fs.mkdir(dirname(cachePath), { recursive: true });
|
||||
sourceCopyPath = join(dirname(cachePath), `${basename(cachePath, '.pdf')}.docx`);
|
||||
await fs.copyFile(filePath, sourceCopyPath);
|
||||
|
||||
const sourcePath = wslMountPathToWindowsPath(sourceCopyPath);
|
||||
if (!sourcePath) return null;
|
||||
|
||||
await runWithConversionLimit(() =>
|
||||
execFileAsync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-EncodedCommand',
|
||||
encodePowerShellCommand(buildWordExportPdfScript(sourcePath, outputPath)),
|
||||
],
|
||||
{
|
||||
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (await fileExists(cachePath)) {
|
||||
await pruneDocumentPreviewCache(dirname(cachePath));
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
console.warn(`[DocumentPreviewCache] Microsoft Word did not produce PDF output for ${filePath}`);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[DocumentPreviewCache] Failed to convert DOCX with Microsoft Word (${filePath}):`,
|
||||
getCacheErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (sourceCopyPath) {
|
||||
await fs.rm(sourceCopyPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function convertLibreOfficeDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
|
||||
let workDir: string | undefined;
|
||||
try {
|
||||
await fs.mkdir(DOCUMENT_PREVIEW_CACHE_DIR, { recursive: true });
|
||||
const outDir = await fs.mkdtemp(join(DOCUMENT_PREVIEW_CACHE_DIR, 'work-'));
|
||||
workDir = outDir;
|
||||
const profileDir = join(outDir, 'profile');
|
||||
await fs.mkdir(profileDir, { recursive: true });
|
||||
|
||||
await runWithConversionLimit(() =>
|
||||
execFileAsync(
|
||||
'soffice',
|
||||
[
|
||||
'--headless',
|
||||
'--nologo',
|
||||
'--nofirststartwizard',
|
||||
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
'--outdir',
|
||||
outDir,
|
||||
filePath,
|
||||
],
|
||||
{
|
||||
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const converted = (await fs.readdir(outDir)).find((name) => name.toLowerCase().endsWith('.pdf'));
|
||||
if (!converted) return null;
|
||||
|
||||
await fs.rename(join(workDir, converted), cachePath);
|
||||
await pruneDocumentPreviewCache(DOCUMENT_PREVIEW_CACHE_DIR);
|
||||
return cachePath;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[DocumentPreviewCache] Failed to convert Office file to PDF (${filePath}):`,
|
||||
getCacheErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (workDir) {
|
||||
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wslMountPathToWindowsPath(filePath: string): string | null {
|
||||
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/(.+)$/);
|
||||
if (!match) return null;
|
||||
return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`;
|
||||
}
|
||||
|
||||
function getWindowsUserTempCacheDir(filePath: string): string | null {
|
||||
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/Users\/([^/]+)\//);
|
||||
if (!match) return null;
|
||||
return `/mnt/${match[1].toLowerCase()}/Users/${match[2]}/AppData/Local/Temp/codeman-document-preview-cache`;
|
||||
}
|
||||
|
||||
function toPowerShellSingleQuotedString(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function encodePowerShellCommand(script: string): string {
|
||||
return Buffer.from(script, 'utf16le').toString('base64');
|
||||
}
|
||||
|
||||
export function getPreviewPdfDownloadName(fileName: string, extension: string): string {
|
||||
return `${basename(fileName, extname(fileName) || `.${extension}`)}.pdf`;
|
||||
}
|
||||
|
||||
function getCacheErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @fileoverview Best-effort first-page thumbnails for attachment cards.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, extname, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { getOfficePreviewPdfPath } from './document-preview-cache.js';
|
||||
import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
export interface ThumbnailResult {
|
||||
content: Buffer;
|
||||
contentType: 'image/png';
|
||||
}
|
||||
|
||||
export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise<ThumbnailResult | null> {
|
||||
const ext = extension.toLowerCase().replace(/^\./, '');
|
||||
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
|
||||
if (ext === 'png') {
|
||||
return { content: await fs.readFile(filePath), contentType: 'image/png' };
|
||||
}
|
||||
|
||||
if (ext === 'pdf') {
|
||||
return renderPdfFirstPage(filePath);
|
||||
}
|
||||
|
||||
if (ext === 'docx' || ext === 'pptx') {
|
||||
return renderOfficeFirstPage(filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Thumbnailer] Failed to generate ${ext} thumbnail for ${filePath}:`, getThumbnailErrorMessage(err));
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderOfficeFirstPage(filePath: string): Promise<ThumbnailResult | null> {
|
||||
try {
|
||||
const previewPdfPath = await getOfficePreviewPdfPath(filePath, extname(filePath).toLowerCase().replace(/^\./, ''));
|
||||
if (!previewPdfPath) return null;
|
||||
return await renderPdfFirstPage(previewPdfPath);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Thumbnailer] Failed to convert Office file to PDF for thumbnail (${filePath}):`,
|
||||
getThumbnailErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPdfFirstPage(filePath: string): Promise<ThumbnailResult | null> {
|
||||
let previewDir: string | undefined;
|
||||
try {
|
||||
previewDir = await fs.mkdtemp(join(tmpdir(), 'codeman-thumb-pdf-'));
|
||||
const prefix = join(previewDir, basename(filePath, extname(filePath)));
|
||||
await runWithConversionLimit(() =>
|
||||
execFileAsync('pdftoppm', ['-png', '-singlefile', '-f', '1', '-l', '1', '-scale-to', '520', filePath, prefix], {
|
||||
timeout: THUMBNAIL_CONVERSION_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
);
|
||||
const content = await fs.readFile(`${prefix}.png`);
|
||||
return { content, contentType: 'image/png' };
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Thumbnailer] Failed to render PDF first page for thumbnail (${filePath}):`,
|
||||
getThumbnailErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (previewDir) {
|
||||
await fs.rm(previewDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getThumbnailErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
+202
-71
@@ -31,6 +31,30 @@ import { join } from 'node:path';
|
||||
import type { HookEventType } from './types.js';
|
||||
import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
|
||||
|
||||
/**
|
||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||
* writer in this module (hooks, env, model, statusLine) shares this map, so
|
||||
* concurrent updates to the SAME file — e.g. session-create writing hooks/model
|
||||
* while an App-Settings toggle injects the statusLine into the same repo — can't
|
||||
* lose each other's changes through interleaved read-then-write. Per-path chains
|
||||
* are independent; the map self-prunes when a path's chain goes idle.
|
||||
*/
|
||||
const settingsWriteLocks = new Map<string, Promise<unknown>>();
|
||||
function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = settingsWriteLocks.get(path) ?? Promise.resolve();
|
||||
const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome
|
||||
// Tail never rejects, so a failed write doesn't poison subsequent writers.
|
||||
const tail = run.then(
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
settingsWriteLocks.set(path, tail);
|
||||
void tail.then(() => {
|
||||
if (settingsWriteLocks.get(path) === tail) settingsWriteLocks.delete(path);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the hooks section for .claude/settings.local.json
|
||||
*
|
||||
@@ -103,29 +127,31 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
||||
if (keysToRemove.length === 0) return;
|
||||
|
||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||
if (!existsSync(settingsPath)) return;
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
if (!existsSync(settingsPath)) return;
|
||||
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return; // Malformed — don't rewrite it
|
||||
}
|
||||
|
||||
const env = existing.env as Record<string, string> | undefined;
|
||||
if (!env) return;
|
||||
|
||||
let changed = false;
|
||||
for (const key of keysToRemove) {
|
||||
if (key in env) {
|
||||
delete env[key];
|
||||
changed = true;
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return; // Malformed — don't rewrite it
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
|
||||
existing.env = env;
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
const env = existing.env as Record<string, string> | undefined;
|
||||
if (!env) return;
|
||||
|
||||
let changed = false;
|
||||
for (const key of keysToRemove) {
|
||||
if (key in env) {
|
||||
delete env[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
|
||||
existing.env = env;
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,30 +160,31 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
||||
*/
|
||||
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
|
||||
const currentEnv = (existing.env as Record<string, string>) || {};
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
if (value) {
|
||||
currentEnv[key] = value;
|
||||
} else {
|
||||
delete currentEnv[key];
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
existing.env = currentEnv;
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
|
||||
const currentEnv = (existing.env as Record<string, string>) || {};
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
if (value) {
|
||||
currentEnv[key] = value;
|
||||
} else {
|
||||
delete currentEnv[key];
|
||||
}
|
||||
}
|
||||
existing.env = currentEnv;
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,26 +193,27 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
|
||||
*/
|
||||
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
|
||||
if (model) {
|
||||
existing.model = model;
|
||||
} else {
|
||||
delete existing.model;
|
||||
}
|
||||
if (model) {
|
||||
existing.model = model;
|
||||
} else {
|
||||
delete existing.model;
|
||||
}
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,22 +222,125 @@ export async function updateCaseModel(casePath: string, model: string | null): P
|
||||
*/
|
||||
export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
// If file is malformed or doesn't exist, start fresh
|
||||
existing = {};
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
// If file is malformed or doesn't exist, start fresh
|
||||
existing = {};
|
||||
}
|
||||
|
||||
const hooksConfig = generateHooksConfig();
|
||||
const merged = { ...existing, ...hooksConfig };
|
||||
const hooksConfig = generateHooksConfig();
|
||||
const merged = { ...existing, ...hooksConfig };
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal a case's hooks block so the COD-91 unconditional hook-secret gate keeps
|
||||
* accepting its hook events.
|
||||
*
|
||||
* `writeHooksConfig` only runs when a case is first CREATED. Cases created before the
|
||||
* X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their
|
||||
* settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the
|
||||
* gate requires it unconditionally (COD-91), silently 401 on a password-protected
|
||||
* install. This refreshes the hooks block so those stale curls regain the header.
|
||||
*
|
||||
* Deliberately surgical: regenerates ONLY when settings.local.json already contains
|
||||
* Codeman's own hook curls (they target `/api/hook-event`) that lack the secret header.
|
||||
* No-op when the file/hooks are absent (we never impose hooks on a user who removed
|
||||
* them), when the hooks aren't ours, or when the secret is already present — so it never
|
||||
* clobbers a user's customizations and is cheap enough to call on every Claude spawn.
|
||||
*/
|
||||
export async function refreshStaleHookSecret(casePath: string): Promise<void> {
|
||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||
if (!existsSync(settingsPath)) return;
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return; // malformed — leave it untouched (case-create owns the happy path)
|
||||
}
|
||||
const hooksJson = JSON.stringify(existing.hooks ?? null);
|
||||
const isOurs = hooksJson.includes('/api/hook-event');
|
||||
// The generated curl carries this header literal (see generateHooksConfig); its
|
||||
// absence on our own hooks means they predate COD-54 and need regenerating.
|
||||
const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret');
|
||||
if (!isOurs || hasSecret) return;
|
||||
const merged = { ...existing, ...generateHooksConfig() };
|
||||
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
|
||||
const STATUSLINE_MARKER = '/api/status-telemetry';
|
||||
|
||||
/**
|
||||
* The plan-usage statusLine exporter command. Mirrors the hook `curlCmd` pattern:
|
||||
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
|
||||
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
|
||||
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
|
||||
* (present in every managed session via tmux setenv), so the config is static.
|
||||
*/
|
||||
export function generateStatusLineCommand(): string {
|
||||
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
|
||||
// production setup; without -k curl returns 000 and the statusline shows
|
||||
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
|
||||
// footer is never blank if Codeman is unreachable.
|
||||
return (
|
||||
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
||||
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||
`--data @- 2>/dev/null || echo codeman`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove Codeman's plan-usage statusLine exporter in
|
||||
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
|
||||
* (command targets `/api/status-telemetry`), so a user's hand-authored
|
||||
* statusLine is never removed OR overwritten — on both the enable and disable
|
||||
* paths we bail out when an existing statusLine isn't ours. Callers gate on
|
||||
* Claude mode. Merges, preserving all other keys (hooks, env, model).
|
||||
*/
|
||||
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return; // Malformed — don't rewrite it
|
||||
}
|
||||
}
|
||||
|
||||
const current = existing.statusLine as { command?: unknown } | undefined;
|
||||
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
|
||||
|
||||
if (enabled) {
|
||||
const desired = generateStatusLineCommand();
|
||||
if (isOurs && current?.command === desired) return; // already current — skip rewrite
|
||||
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
|
||||
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
|
||||
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
|
||||
} else {
|
||||
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
|
||||
delete existing.statusLine;
|
||||
}
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,12 +20,8 @@ import { KeyedDebouncer } from './utils/index.js';
|
||||
// ========== Constants ==========
|
||||
|
||||
/** Supported image file extensions (lowercase) */
|
||||
// PNG stays on the image-popup path: it's the dominant screenshot format and the
|
||||
// frontend only wires the `image:detected` popup today. The attachment-card UI
|
||||
// that would consume `attachment:detected` for images is out of scope for this
|
||||
// PR, so routing PNG to it would silently break the dropped-screenshot popup.
|
||||
const IMAGE_POPUP_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||
const ATTACHMENT_EXTENSIONS = new Set(['.pdf', '.docx', '.pptx']);
|
||||
const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||
const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']);
|
||||
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
|
||||
|
||||
/** Time to wait for file writes to stabilize (ms) */
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename, extname } from 'node:path';
|
||||
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
||||
import type { AttachmentDetectedEvent } from './types/tools.js';
|
||||
import { getAttachmentType } from './attachment-registry.js';
|
||||
|
||||
export const ATTACHMENT_HISTORY_LIMIT = 100;
|
||||
|
||||
export interface ExternalAttachmentHistoryInput {
|
||||
sessionId: string;
|
||||
externalPath: string;
|
||||
fileName?: string;
|
||||
extension?: string;
|
||||
size: number;
|
||||
mtimeMs?: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export function normalizeAttachmentExtension(extensionOrPath: string): string {
|
||||
const value = extensionOrPath.startsWith('.') ? extensionOrPath : extname(extensionOrPath) || extensionOrPath;
|
||||
return value.toLowerCase().replace(/^\./, '');
|
||||
}
|
||||
|
||||
function historyKey(item: SessionAttachmentHistoryItem): string {
|
||||
if (item.source === 'external' && item.externalPath) {
|
||||
return `external:${item.externalPath}`;
|
||||
}
|
||||
return `detected:${item.relativePath || item.fileName}`;
|
||||
}
|
||||
|
||||
function safeExternalHistoryId(item: SessionAttachmentHistoryItem): string {
|
||||
const source = item.externalPath || item.id || item.fileName;
|
||||
const digest = createHash('sha256').update(source).digest('hex').slice(0, 16);
|
||||
return `external:${digest}:${item.fileName}`;
|
||||
}
|
||||
|
||||
export function sanitizeAttachmentHistoryItem(item: SessionAttachmentHistoryItem): SessionAttachmentHistoryItem {
|
||||
const { externalPath: _externalPath, ...safe } = item;
|
||||
return {
|
||||
...safe,
|
||||
id: item.source === 'external' ? safeExternalHistoryId(item) : item.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeAttachmentHistory(
|
||||
history: readonly SessionAttachmentHistoryItem[]
|
||||
): SessionAttachmentHistoryItem[] {
|
||||
return history.map(sanitizeAttachmentHistoryItem);
|
||||
}
|
||||
|
||||
export function upsertAttachmentHistory(
|
||||
history: readonly SessionAttachmentHistoryItem[],
|
||||
item: SessionAttachmentHistoryItem
|
||||
): SessionAttachmentHistoryItem[] {
|
||||
const nextKey = historyKey(item);
|
||||
return [item, ...history.filter((existing) => historyKey(existing) !== nextKey)].slice(0, ATTACHMENT_HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
export function buildDetectedAttachmentHistoryItem(event: AttachmentDetectedEvent): SessionAttachmentHistoryItem {
|
||||
return {
|
||||
id: `detected:${event.relativePath || event.fileName}`,
|
||||
sessionId: event.sessionId,
|
||||
fileName: event.fileName,
|
||||
extension: normalizeAttachmentExtension(event.extension),
|
||||
attachmentType: event.attachmentType,
|
||||
size: event.size,
|
||||
mtimeMs: 0,
|
||||
timestamp: event.timestamp,
|
||||
source: 'detected',
|
||||
relativePath: event.relativePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExternalAttachmentHistoryItem(
|
||||
input: ExternalAttachmentHistoryInput
|
||||
): SessionAttachmentHistoryItem {
|
||||
const extension = normalizeAttachmentExtension(input.extension || input.fileName || input.externalPath);
|
||||
return {
|
||||
id: `external:${createHash('sha256').update(input.externalPath).digest('hex').slice(0, 16)}:${
|
||||
input.fileName || basename(input.externalPath)
|
||||
}`,
|
||||
sessionId: input.sessionId,
|
||||
fileName: input.fileName || basename(input.externalPath),
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: input.size,
|
||||
mtimeMs: input.mtimeMs ?? 0,
|
||||
timestamp: input.timestamp ?? Date.now(),
|
||||
source: 'external',
|
||||
externalPath: input.externalPath,
|
||||
};
|
||||
}
|
||||
+70
-13
@@ -81,6 +81,11 @@ import { SessionAutoOps } from './session-auto-ops.js';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { SessionTaskCache } from './session-task-cache.js';
|
||||
import { parseAttachmentMagicLinks } from './attachment-magic.js';
|
||||
import {
|
||||
sanitizeAttachmentHistory,
|
||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||
} from './session-attachment-history.js';
|
||||
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||
@@ -131,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). */
|
||||
@@ -260,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
|
||||
@@ -314,6 +335,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// Bounded dedup set for terminal attachment magic-links already requested.
|
||||
private _attachmentMagicSeen = new Set<string>();
|
||||
private _attachmentHistory: SessionAttachmentHistoryItem[] = [];
|
||||
|
||||
// Nice prioritying configuration
|
||||
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
|
||||
@@ -405,6 +427,8 @@ export class Session extends EventEmitter {
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** Restored per-session attachment history. May include server-private external paths. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -471,6 +495,9 @@ export class Session extends EventEmitter {
|
||||
if (config.effort && isEffortLevel(config.effort)) {
|
||||
this._effort = config.effort;
|
||||
}
|
||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||
}
|
||||
|
||||
// Initialize task tracker and forward events (store handlers for cleanup)
|
||||
this._taskTracker = new TaskTracker();
|
||||
@@ -897,6 +924,30 @@ export class Session extends EventEmitter {
|
||||
return this._status === 'idle' || this._status === 'busy';
|
||||
}
|
||||
|
||||
get attachmentHistory(): SessionAttachmentHistoryItem[] {
|
||||
return sanitizeAttachmentHistory(this._attachmentHistory);
|
||||
}
|
||||
|
||||
upsertAttachmentHistory(item: SessionAttachmentHistoryItem): void {
|
||||
this._attachmentHistory = upsertAttachmentHistoryList(this._attachmentHistory, item);
|
||||
}
|
||||
|
||||
restoreAttachmentHistory(history: SessionAttachmentHistoryItem[] | undefined): void {
|
||||
this._attachmentHistory = [];
|
||||
for (const item of [...(history ?? [])].reverse()) {
|
||||
// Guard against malformed/legacy on-disk entries (null, non-object, or
|
||||
// missing required fields). historyKey() dereferences source/fileName, so
|
||||
// a bad item would otherwise throw inside the constructor and abort the
|
||||
// entire mux-recovery loop.
|
||||
if (!item || typeof item !== 'object' || !item.source || !item.fileName) continue;
|
||||
this.upsertAttachmentHistory(item);
|
||||
}
|
||||
}
|
||||
|
||||
getAttachmentHistoryForPersist(): SessionAttachmentHistoryItem[] | undefined {
|
||||
return this._attachmentHistory.length > 0 ? this._attachmentHistory.map((item) => ({ ...item })) : undefined;
|
||||
}
|
||||
|
||||
toState(): SessionState {
|
||||
return {
|
||||
id: this.id,
|
||||
@@ -936,6 +987,7 @@ export class Session extends EventEmitter {
|
||||
codexConfig: this._codexConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
// envOverrides intentionally NOT on the public SessionState type — they must not
|
||||
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
|
||||
// can carry secrets). For disk persistence, session-manager calls
|
||||
@@ -1098,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;
|
||||
}
|
||||
@@ -1773,7 +1830,7 @@ export class Session extends EventEmitter {
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._codexSeqCarry = '';
|
||||
this._altScreenSeqCarry = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
}
|
||||
|
||||
|
||||
+152
-5
@@ -13,6 +13,8 @@
|
||||
* - `SubagentEvents` — typed event map
|
||||
*
|
||||
* Watched patterns: `~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl`
|
||||
* plus the `agent-{id}.meta.json` discovery sidecar (2026-06 format) and nested
|
||||
* workflow agents under `subagents/workflows/{workflowId}/agent-{id}.jsonl`.
|
||||
* Parses JSONL entries: user/assistant messages, tool_use/tool_result blocks, progress events.
|
||||
* Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID).
|
||||
*
|
||||
@@ -30,7 +32,7 @@ import { watch, existsSync, FSWatcher } from 'node:fs';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, basename } from 'node:path';
|
||||
import { join, basename, dirname } from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
|
||||
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js';
|
||||
@@ -1150,6 +1152,10 @@ export class SubagentWatcher extends EventEmitter {
|
||||
try {
|
||||
await statAsync(subagentDir);
|
||||
await this.watchSubagentDir(subagentDir, project, session);
|
||||
// Workflow agents nest one level deeper under subagents/workflows/{wf}/
|
||||
// (each holds its own agent-{id}.jsonl/.meta.json) — the flat watcher
|
||||
// above never sees them, so discover and watch each workflow dir too.
|
||||
await this.watchWorkflowDirs(subagentDir, project, session);
|
||||
} catch {
|
||||
// subagent dir doesn't exist - skip
|
||||
}
|
||||
@@ -1179,8 +1185,12 @@ export class SubagentWatcher extends EventEmitter {
|
||||
try {
|
||||
const files = await readdir(dir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.jsonl')) {
|
||||
if (file.startsWith('agent-') && file.endsWith('.jsonl')) {
|
||||
await this.registerAgentFile(join(dir, file), projectHash, sessionId, true);
|
||||
} else if (file.startsWith('agent-') && file.endsWith('.meta.json')) {
|
||||
// Claude Code (2026-06) writes a `agent-{id}.meta.json` sidecar for TUI
|
||||
// Task subagents and no longer always writes a per-agent `.jsonl` here.
|
||||
await this.registerAgentMeta(join(dir, file), projectHash, sessionId, true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -1190,13 +1200,26 @@ export class SubagentWatcher extends EventEmitter {
|
||||
// Single directory watcher handles both new files and file content changes
|
||||
try {
|
||||
const watcher = watch(dir, (_eventType, filename) => {
|
||||
if (!filename?.endsWith('.jsonl')) return;
|
||||
const filePath = join(dir, filename);
|
||||
// Only agent-{id}.jsonl / agent-{id}.meta.json — ignore siblings like a
|
||||
// workflow dir's journal.jsonl (would otherwise register a bogus "journal" agent).
|
||||
const isAgent = !!filename && filename.startsWith('agent-');
|
||||
const isJsonl = isAgent && filename.endsWith('.jsonl');
|
||||
const isMeta = isAgent && filename.endsWith('.meta.json');
|
||||
if (!isJsonl && !isMeta) return;
|
||||
const filePath = join(dir, filename as string);
|
||||
|
||||
// Debounce 100ms to batch rapid writes
|
||||
this.fileDeb.schedule(filePath, () => {
|
||||
if (!existsSync(filePath)) return;
|
||||
|
||||
if (isMeta) {
|
||||
// Meta sidecar — discovery only (not a transcript; never tail it).
|
||||
if (!this.fileAgentContext.has(filePath)) {
|
||||
this.registerAgentMeta(filePath, projectHash, sessionId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.fileAgentContext.has(filePath)) {
|
||||
// Known file — handle content change
|
||||
this.handleFileChange(filePath).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
|
||||
@@ -1225,6 +1248,40 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and watch nested workflow agent directories.
|
||||
*
|
||||
* The Workflow tool runs its subagents under
|
||||
* `subagents/workflows/{workflowId}/agent-{id}.jsonl` (+ `.meta.json`, alongside
|
||||
* a `journal.jsonl` of orchestration events). The flat `subagents/` watcher does
|
||||
* not recurse, and Node's `fs.watch({ recursive: true })` is unsupported on Linux,
|
||||
* so each workflow dir gets its own watcher here. Idempotent via `knownSubagentDirs`
|
||||
* and re-driven by the periodic scan, so newly created workflows are picked up
|
||||
* within one scan cycle (~5s) — the same latency as a new session's `subagents/`.
|
||||
*/
|
||||
private async watchWorkflowDirs(subagentDir: string, projectHash: string, sessionId: string): Promise<void> {
|
||||
const workflowsRoot = join(subagentDir, 'workflows');
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(workflowsRoot);
|
||||
} catch {
|
||||
return; // no workflows for this session
|
||||
}
|
||||
for (const name of names) {
|
||||
// Workflow ids are directories (e.g. `wf_<id>`); skip any stray files that
|
||||
// share the root (a workflow id never carries a file extension).
|
||||
if (name.endsWith('.jsonl') || name.endsWith('.json')) continue;
|
||||
const wfDir = join(workflowsRoot, name);
|
||||
try {
|
||||
const st = await statAsync(wfDir);
|
||||
if (!st.isDirectory()) continue;
|
||||
await this.watchSubagentDir(wfDir, projectHash, sessionId);
|
||||
} catch {
|
||||
// workflow dir vanished mid-scan — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a file content change for an already-registered agent file.
|
||||
* Tails from last known position, updates info, retries description if missing.
|
||||
@@ -1291,6 +1348,16 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
||||
|
||||
// Meta→transcript upgrade: the agent may already be registered from its
|
||||
// `.meta.json` sidecar (discovery-only — nothing to tail). Now that the real
|
||||
// `.jsonl` transcript has appeared, re-point to it and drop the stale sidecar
|
||||
// context, emitting `updated` below rather than a duplicate `discovered`.
|
||||
const priorEntry = this.agentInfo.get(agentId);
|
||||
const isMetaUpgrade = !!priorEntry && priorEntry.filePath.endsWith('.meta.json');
|
||||
if (isMetaUpgrade && priorEntry) {
|
||||
this.fileAgentContext.delete(priorEntry.filePath);
|
||||
}
|
||||
|
||||
// Initial info - handle race condition where file may be deleted between discovery and stat
|
||||
let fileStat;
|
||||
try {
|
||||
@@ -1341,7 +1408,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
// Track file context for directory watcher change handling
|
||||
this.fileAgentContext.set(filePath, { projectHash, sessionId });
|
||||
this.agentInfo.set(agentId, info);
|
||||
this.emit('subagent:discovered', info);
|
||||
this.emit(isMetaUpgrade ? 'subagent:updated' : 'subagent:discovered', info);
|
||||
|
||||
// Read existing content
|
||||
this.tailFile(filePath, agentId, sessionId, 0)
|
||||
@@ -1356,6 +1423,86 @@ export class SubagentWatcher extends EventEmitter {
|
||||
this.resetIdleTimer(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a subagent discovered via its `agent-{id}.meta.json` sidecar.
|
||||
*
|
||||
* As of the 2026-06 Claude Code format change, TUI Task subagents write the
|
||||
* `agent-{id}.meta.json` sidecar (`{ agentType, description, toolUseId }`) at
|
||||
* spawn, a beat *before* the `agent-{id}.jsonl` transcript appears in the same
|
||||
* dir. The legacy `.jsonl`-only discovery saw nothing in that window ("0
|
||||
* tracked"); this surfaces the agent from the sidecar immediately. The
|
||||
* transcript then lands within ~1s at the standard path and grows incrementally
|
||||
* (empirically verified — it is fully tailable, NOT a dead end), so:
|
||||
* - if the `.jsonl` already exists, defer to registerAgentFile (richer); else
|
||||
* - register meta-only now, and when the sibling `.jsonl` arrives the dir
|
||||
* watcher routes it to registerAgentFile, which detects the prior meta-only
|
||||
* entry and *upgrades* it in place (re-points filePath, starts tailing).
|
||||
*
|
||||
* Edge case: if the transcript never materializes (e.g. an agent that dies
|
||||
* before writing one), the agent stays meta-only — no live feed, status ages
|
||||
* out via the idle timer / stale cleanup.
|
||||
*/
|
||||
private async registerAgentMeta(
|
||||
metaPath: string,
|
||||
projectHash: string,
|
||||
sessionId: string,
|
||||
isInitialScan: boolean = false
|
||||
): Promise<void> {
|
||||
if (this.fileAgentContext.has(metaPath)) return;
|
||||
const agentId = basename(metaPath).replace('agent-', '').replace('.meta.json', '');
|
||||
if (this.agentInfo.has(agentId)) return;
|
||||
|
||||
// Prefer a real transcript if one was written alongside the sidecar.
|
||||
const jsonlPath = join(dirname(metaPath), `agent-${agentId}.jsonl`);
|
||||
if (existsSync(jsonlPath)) {
|
||||
await this.registerAgentFile(jsonlPath, projectHash, sessionId, isInitialScan);
|
||||
return;
|
||||
}
|
||||
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = await statAsync(metaPath);
|
||||
} catch {
|
||||
return; // deleted between discovery and stat
|
||||
}
|
||||
if (isInitialScan && Date.now() - fileStat.mtime.getTime() > STARTUP_MAX_FILE_AGE_MS) {
|
||||
return; // skip stale historical agents on startup
|
||||
}
|
||||
|
||||
let description: string | undefined;
|
||||
try {
|
||||
const meta = JSON.parse(await readFile(metaPath, 'utf8')) as { agentType?: string; description?: string };
|
||||
description = meta.description || meta.agentType;
|
||||
} catch {
|
||||
return; // unreadable / not yet fully written — a later watch event retries
|
||||
}
|
||||
if (this.isInternalAgent(description)) return;
|
||||
|
||||
const info: SubagentInfo = {
|
||||
agentId,
|
||||
sessionId,
|
||||
projectHash,
|
||||
filePath: metaPath,
|
||||
startedAt: fileStat.birthtime.toISOString(),
|
||||
lastActivityAt: fileStat.mtime.getTime(),
|
||||
status: 'active',
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: fileStat.size,
|
||||
description,
|
||||
};
|
||||
|
||||
if (this.agentInfo.size >= MAX_TRACKED_AGENTS) {
|
||||
const oldestId = this.findOldestInactiveAgent();
|
||||
if (oldestId) this.removeAgent(oldestId);
|
||||
}
|
||||
|
||||
this.fileAgentContext.set(metaPath, { projectHash, sessionId });
|
||||
this.agentInfo.set(agentId, info);
|
||||
this.emit('subagent:discovered', info);
|
||||
this.resetIdleTimer(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail a file from a specific position
|
||||
*/
|
||||
|
||||
@@ -67,3 +67,4 @@ export * from './push.js';
|
||||
export * from './plan.js';
|
||||
export * from './orchestrator.js';
|
||||
export * from './update.js';
|
||||
export * from './workflow-run.js';
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
import type { AttachmentDetectedType } from './tools.js';
|
||||
|
||||
/** Status of a Claude session */
|
||||
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
@@ -101,6 +102,40 @@ export interface SessionConfig {
|
||||
*/
|
||||
export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink';
|
||||
|
||||
export type SessionAttachmentHistorySource = 'detected' | 'external';
|
||||
|
||||
/**
|
||||
* Session-scoped attachment history entry.
|
||||
*
|
||||
* `externalPath` is server-private. It may be present in the internal persisted
|
||||
* history copy, but API-bound session state must sanitize it before returning
|
||||
* to the browser.
|
||||
*/
|
||||
export interface SessionAttachmentHistoryItem {
|
||||
/** Stable history identity used for dedupe and list rendering */
|
||||
id: string;
|
||||
/** Codeman session ID this item belongs to */
|
||||
sessionId: string;
|
||||
/** Display filename */
|
||||
fileName: string;
|
||||
/** Lowercase extension without a leading dot */
|
||||
extension: string;
|
||||
/** Viewer category used by the web UI */
|
||||
attachmentType: AttachmentDetectedType;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Last modified timestamp in milliseconds, if known */
|
||||
mtimeMs: number;
|
||||
/** Last time this attachment was seen or explicitly published */
|
||||
timestamp: number;
|
||||
/** How the attachment entered the session */
|
||||
source: SessionAttachmentHistorySource;
|
||||
/** Workspace-relative path for detected session files */
|
||||
relativePath?: string;
|
||||
/** Server-private absolute path for explicitly published external files */
|
||||
externalPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current state of a session
|
||||
*/
|
||||
@@ -183,6 +218,8 @@ export interface SessionState {
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** Sanitized per-session attachment history. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @fileoverview Types for ultracode / Workflow-tool run visualization.
|
||||
*
|
||||
* A Workflow run persists its state to
|
||||
* `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_<runId>.json`
|
||||
* (a sibling of the deeper `subagents/workflows/wf_<runId>/agent-*.jsonl`
|
||||
* transcript tree that subagent-watcher tracks). This file is the single source
|
||||
* for the master-detail "working agents" view: a run's tasks/phases on the LEFT
|
||||
* and per-agent stats (tokens burned, tool calls) on the RIGHT.
|
||||
*
|
||||
* Field presence is STATE-DRIVEN and verified against real runs on disk:
|
||||
* - state 'start' (queued): no agentId/tokens/toolCalls/startedAt/durationMs/...
|
||||
* - state 'progress' (running): has agentId/tokens/toolCalls, no durationMs/resultPreview
|
||||
* - state 'done' (finished): all fields, incl. durationMs/resultPreview
|
||||
* Absent fields are genuinely ABSENT (never explicit null) — use `?:`, not null.
|
||||
*
|
||||
* @module types/workflow-run
|
||||
*/
|
||||
|
||||
/** One declared phase of a run (from the run JSON's top-level `phases[]`, 0-indexed). */
|
||||
export interface WorkflowRunPhase {
|
||||
/** Phase title; equals each member agent's `phaseTitle`. Always present. */
|
||||
title: string;
|
||||
/** Human description of the phase. Always present in `phases[]`. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent slot in a run, derived from `workflowProgress[]` entries where
|
||||
* `type === 'workflow_agent'`. Optional fields are absent until the agent
|
||||
* reaches the relevant lifecycle state (see module doc).
|
||||
*/
|
||||
export interface WorkflowAgentInfo {
|
||||
/** 1-based stable slot index, unique within the run. Always present. */
|
||||
index: number;
|
||||
/** Agent label, e.g. "probe:dompurify-config". Always present. */
|
||||
label: string;
|
||||
/** 1-based phase number; join via `run.phases[phaseIndex - 1]`. Always present. */
|
||||
phaseIndex: number;
|
||||
/** Phase title (=== run.phases[phaseIndex-1].title). Always present. */
|
||||
phaseTitle: string;
|
||||
/** Model id, e.g. "claude-opus-4-8[1m]". Always present. */
|
||||
model: string;
|
||||
/** Lifecycle state. Real on-disk values: 'start' | 'progress' | 'done'. Open union. */
|
||||
state: 'start' | 'progress' | 'done' | (string & {});
|
||||
/** Epoch ms the slot was queued. Always present. */
|
||||
queuedAt?: number;
|
||||
/** Epoch ms of the last progress tick. Always present once any progress occurs. */
|
||||
lastProgressAt?: number;
|
||||
/** Truncated prompt the agent was given. Always present. */
|
||||
promptPreview?: string;
|
||||
/**
|
||||
* Globally-unique agent id; equals the `agent-<agentId>.jsonl` transcript stem
|
||||
* (the Phase-4 correlation key). ABSENT while state === 'start'.
|
||||
*/
|
||||
agentId?: string;
|
||||
/** Epoch ms the agent began. Absent while 'start'. */
|
||||
startedAt?: number;
|
||||
/** Attempt counter. Absent while 'start'. */
|
||||
attempt?: number;
|
||||
/** Tokens burned so far (RIGHT pane). Absent while 'start'. */
|
||||
tokens?: number;
|
||||
/** Tool calls made so far (RIGHT pane). Absent while 'start'. */
|
||||
toolCalls?: number;
|
||||
/** Name of the most recent tool. Present for progress/done (occasionally absent). */
|
||||
lastToolName?: string;
|
||||
/** Short summary of the most recent tool call. May be absent even when 'done'. */
|
||||
lastToolSummary?: string;
|
||||
/** Total run time (ms). Present ONLY when 'done' — the live-vs-finished discriminator. */
|
||||
durationMs?: number;
|
||||
/** Truncated final result. Present ONLY when 'done'. */
|
||||
resultPreview?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run-level info shipped to the browser.
|
||||
*
|
||||
* IMPORTANT: the on-disk JSON also carries `script` (15–660KB of embedded JS),
|
||||
* `scriptPath`, `result`, and `logs`. The watcher STRIPS all four before the
|
||||
* object is ever cached/broadcast — never let them reach SSE/getLightState/route.
|
||||
*/
|
||||
export interface WorkflowRunInfo {
|
||||
/** Run id (=== the wf_<runId>.json filename stem). Always present. */
|
||||
runId: string;
|
||||
/** Workflow name from `meta.name`. Always present. */
|
||||
workflowName?: string;
|
||||
/**
|
||||
* Run status. Real on-disk values seen: 'completed' | 'killed'.
|
||||
* 'running' | 'failed' are inferred (parse defensively; keep open union).
|
||||
*/
|
||||
status?: 'completed' | 'killed' | 'running' | 'failed' | (string & {});
|
||||
/** Concise human description (best LEFT-pane label). Always present. */
|
||||
summary?: string;
|
||||
/** Total agent slots, INCLUDING not-yet-started 'start' agents. */
|
||||
agentCount?: number;
|
||||
/** Total tokens across the run (partial mid-run). */
|
||||
totalTokens?: number;
|
||||
/** Total tool calls across the run (partial mid-run). */
|
||||
totalToolCalls?: number;
|
||||
/** Total run duration (ms). */
|
||||
durationMs?: number;
|
||||
/** Run start time (epoch MILLIS). */
|
||||
startTime?: number;
|
||||
/** ISO end/write timestamp. */
|
||||
timestamp?: string;
|
||||
/** Default model for the run. */
|
||||
defaultModel?: string;
|
||||
/** Background-task id that owns the run. */
|
||||
taskId?: string;
|
||||
/** Declared phases (0-indexed). */
|
||||
phases: WorkflowRunPhase[];
|
||||
/** Agents, derived from `workflowProgress` filtered to `type === 'workflow_agent'`. */
|
||||
agents: WorkflowAgentInfo[];
|
||||
/** Error message, present when status is 'killed'/'failed'. */
|
||||
error?: string;
|
||||
|
||||
// ----- Watcher-derived (NOT in the JSON body — captured from the file path) -----
|
||||
/** `<sessionUuid>` path segment (for per-session scoping). */
|
||||
sessionUuid: string;
|
||||
/** `<projHash>` path segment. */
|
||||
projectHash: string;
|
||||
/**
|
||||
* Most recent activity (epoch ms): max agent `lastProgressAt`, else `startTime`.
|
||||
* Drives recency filtering/sorting so finished long runs still surface.
|
||||
*/
|
||||
lastActivityAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight run projection (no `agents[]`) for the LEFT-pane list and the
|
||||
* getLightState reconnect snapshot. A full run with 28 agents serializes to
|
||||
* ~36KB; the snapshot ships dozens of runs, so it carries summaries only and the
|
||||
* RIGHT pane fetches the full run (`GET /api/workflows/:runId`) on selection.
|
||||
*/
|
||||
export type WorkflowRunSummary = Omit<WorkflowRunInfo, 'agents'>;
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview Pure parsing + formatting of Claude Code statusline telemetry.
|
||||
*
|
||||
* Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command`
|
||||
* on each render. On Pro/Max subscriptions that blob carries a `rate_limits`
|
||||
* object with the 5-hour rolling and 7-day weekly plan windows. The
|
||||
* Codeman-managed statusLine exporter (see `hooks-config.generateStatusLineCommand`)
|
||||
* POSTs that blob to `/api/status-telemetry`; these helpers normalize the subset
|
||||
* Codeman displays and format the compact in-terminal footer string.
|
||||
*
|
||||
* Confirmed schema (empirically captured, CC 2.1.177, Claude Max — see
|
||||
* `docs/usage-limits-display-plan.md`):
|
||||
* rate_limits.{five_hour,seven_day}.{used_percentage: number 0-100,
|
||||
* resets_at: number EPOCH-SECONDS}
|
||||
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
|
||||
* before the first API response and for non-subscriber auth — both yield null.
|
||||
*
|
||||
* All functions are pure for testability. See `test/usage-telemetry.test.ts`.
|
||||
*
|
||||
* @module usage-telemetry
|
||||
*/
|
||||
|
||||
/** A single normalized plan-usage window. */
|
||||
export interface UsageWindow {
|
||||
/** Percent of the window consumed, 0–100. */
|
||||
usedPercentage: number;
|
||||
/** Epoch MILLISECONDS when the window resets (statusline reports seconds). */
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
/** Normalized telemetry Codeman broadcasts to the UI. */
|
||||
export interface StatusTelemetry {
|
||||
fiveHour?: UsageWindow;
|
||||
sevenDay?: UsageWindow;
|
||||
/** Context-window percent used, 0–100 (bonus field from the same payload). */
|
||||
contextUsedPercentage?: number;
|
||||
/** Session cost in USD (bonus field). */
|
||||
costUsd?: number;
|
||||
/** Model display name, e.g. "Opus 4.8 (1M context)" (bonus field). */
|
||||
modelDisplayName?: string;
|
||||
}
|
||||
|
||||
/** Raw subset of the statusline stdin JSON (snake_case, as Claude emits it). */
|
||||
export interface RawStatuslinePayload {
|
||||
rate_limits?: {
|
||||
five_hour?: { used_percentage?: number; resets_at?: number };
|
||||
seven_day?: { used_percentage?: number; resets_at?: number };
|
||||
};
|
||||
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
|
||||
cost?: { total_cost_usd?: number };
|
||||
model?: { display_name?: string };
|
||||
}
|
||||
|
||||
function clampPct(n: number): number {
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function parseWindow(w?: { used_percentage?: number; resets_at?: number }): UsageWindow | undefined {
|
||||
if (!w || typeof w.used_percentage !== 'number' || typeof w.resets_at !== 'number') return undefined;
|
||||
if (!Number.isFinite(w.resets_at) || w.resets_at <= 0) return undefined;
|
||||
return { usedPercentage: clampPct(w.used_percentage), resetAt: Math.round(w.resets_at * 1000) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw statusline payload to the telemetry Codeman displays. Returns
|
||||
* null when there is no plan-limit data to show (pre-first-response or a
|
||||
* non-subscriber account) so the caller can skip broadcasting.
|
||||
*/
|
||||
export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): StatusTelemetry | null {
|
||||
if (!data) return null;
|
||||
const fiveHour = parseWindow(data.rate_limits?.five_hour);
|
||||
const sevenDay = parseWindow(data.rate_limits?.seven_day);
|
||||
if (!fiveHour && !sevenDay) return null;
|
||||
|
||||
const t: StatusTelemetry = {};
|
||||
if (fiveHour) t.fiveHour = fiveHour;
|
||||
if (sevenDay) t.sevenDay = sevenDay;
|
||||
if (typeof data.context_window?.used_percentage === 'number') {
|
||||
t.contextUsedPercentage = clampPct(data.context_window.used_percentage);
|
||||
}
|
||||
if (typeof data.cost?.total_cost_usd === 'number' && Number.isFinite(data.cost.total_cost_usd)) {
|
||||
t.costUsd = data.cost.total_cost_usd;
|
||||
}
|
||||
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
|
||||
t.modelDisplayName = data.model.display_name.slice(0, 60);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current-session status for the in-terminal statusline footer. This is the
|
||||
* "status of the current session" the user sees in Claude's footer — distinct
|
||||
* from the account-wide plan limits, which live ONLY in the Codeman header chip.
|
||||
*/
|
||||
export interface SessionStatus {
|
||||
modelDisplayName?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
contextUsedPercentage?: number;
|
||||
}
|
||||
|
||||
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
|
||||
function withCommas(n: number): string {
|
||||
return Math.max(0, Math.round(n))
|
||||
.toString()
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/** Extract current-session status (footer) from the raw payload. */
|
||||
export function parseSessionStatus(data: RawStatuslinePayload | undefined): SessionStatus | null {
|
||||
if (!data) return null;
|
||||
const s: SessionStatus = {};
|
||||
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
|
||||
s.modelDisplayName = data.model.display_name.slice(0, 60);
|
||||
}
|
||||
const cw = data.context_window;
|
||||
if (typeof cw?.total_input_tokens === 'number' && Number.isFinite(cw.total_input_tokens)) {
|
||||
s.inputTokens = Math.max(0, cw.total_input_tokens);
|
||||
}
|
||||
if (typeof cw?.total_output_tokens === 'number' && Number.isFinite(cw.total_output_tokens)) {
|
||||
s.outputTokens = Math.max(0, cw.total_output_tokens);
|
||||
}
|
||||
if (typeof cw?.used_percentage === 'number') {
|
||||
s.contextUsedPercentage = clampPct(cw.used_percentage);
|
||||
}
|
||||
return Object.keys(s).length ? s : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
||||
* which live in the Codeman header chip. Claude requires a statusLine command to
|
||||
* emit the rate_limits JSON at all, so this is what that command prints back.
|
||||
*/
|
||||
export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
if (!s) return 'codeman';
|
||||
const groups: string[] = [];
|
||||
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
||||
const tok: string[] = [];
|
||||
if (s.inputTokens != null) tok.push(`in:${withCommas(s.inputTokens)}`);
|
||||
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
||||
if (tok.length) groups.push(tok.join(' '));
|
||||
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
|
||||
return groups.length ? groups.join(' ') : 'codeman';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature for change-detection — the statusline fires on every
|
||||
* assistant message, so the route only rebroadcasts when this value changes.
|
||||
*
|
||||
* Keys on EXACTLY the values the header chip displays: the two windows' ROUNDED
|
||||
* percentages (the chip renders `Math.round`) + their reset times. Deliberately
|
||||
* excludes contextUsedPercentage / costUsd / modelDisplayName — none are shown
|
||||
* in the chip, and contextUsedPercentage in particular drifts on every assistant
|
||||
* message, which would defeat the dedup and fan out a redundant SSE broadcast +
|
||||
* localStorage write + identical chip re-render each time.
|
||||
*/
|
||||
export function telemetrySignature(t: StatusTelemetry): string {
|
||||
return JSON.stringify([
|
||||
t.fiveHour ? Math.round(t.fiveHour.usedPercentage) : null,
|
||||
t.fiveHour?.resetAt ?? null,
|
||||
t.sevenDay ? Math.round(t.sevenDay.usedPercentage) : null,
|
||||
t.sevenDay?.resetAt ?? null,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @fileoverview Probe engine for `codeman doctor`. Resolves each registry tool
|
||||
* against an injectable ProbeHost (real impl uses child_process/fs; tests inject
|
||||
* fakes) and returns structured results. Pure given the host — no global I/O.
|
||||
*
|
||||
* @module utils/dependency-checker
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js';
|
||||
|
||||
export interface EnvDetectionInputs {
|
||||
platform: NodeJS.Platform;
|
||||
procVersion: string;
|
||||
hasWindowsInterop: boolean;
|
||||
}
|
||||
|
||||
export function detectEnvironment(inputs: EnvDetectionInputs): ProbeEnvironment {
|
||||
if (inputs.platform === 'win32') return 'win32';
|
||||
if (inputs.platform === 'darwin') return 'darwin';
|
||||
const isWsl = /microsoft|wsl/i.test(inputs.procVersion) && inputs.hasWindowsInterop;
|
||||
return isWsl ? 'wsl' : 'linux';
|
||||
}
|
||||
|
||||
const DEFAULT_VERSION_RE = /(\d+\.\d+(?:\.\d+)?)/;
|
||||
|
||||
export function extractVersion(text: string, re?: RegExp): string | undefined {
|
||||
const m = (re ?? DEFAULT_VERSION_RE).exec(text);
|
||||
return m ? m[1] : undefined;
|
||||
}
|
||||
|
||||
/** Returns -1 if a < b, 0 if equal, 1 if a > b (numeric, component-wise). */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
||||
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
||||
const len = Math.max(pa.length, pb.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (d !== 0) return d < 0 ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export type ToolStatus = 'ok' | 'missing' | 'outdated' | 'skipped' | 'error';
|
||||
|
||||
export interface ToolResult {
|
||||
id: string;
|
||||
label: string;
|
||||
category: ToolCategory;
|
||||
required: boolean;
|
||||
usedBy: string[];
|
||||
status: ToolStatus;
|
||||
version?: string;
|
||||
path?: string;
|
||||
installHint?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ProbeHost {
|
||||
environment: ProbeEnvironment;
|
||||
which(bin: string): string | null;
|
||||
fileExists(path: string): boolean;
|
||||
runVersion(bin: string, args: string[]): string | null;
|
||||
windowsProgramRoots(): string[];
|
||||
windowsFileVersion(winPath: string): string | null;
|
||||
}
|
||||
|
||||
function finalize(
|
||||
base: Pick<ToolResult, 'id' | 'label' | 'category' | 'required' | 'usedBy'>,
|
||||
tool: ToolDependency,
|
||||
path: string,
|
||||
version: string | undefined
|
||||
): ToolResult {
|
||||
if (tool.minVersion) {
|
||||
if (!version) return { ...base, status: 'error', path, reason: 'version required but could not be parsed' };
|
||||
if (compareVersions(version, tool.minVersion) < 0) return { ...base, status: 'outdated', path, version };
|
||||
}
|
||||
return { ...base, status: 'ok', path, version };
|
||||
}
|
||||
|
||||
export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
|
||||
const base = {
|
||||
id: tool.id,
|
||||
label: tool.label,
|
||||
category: tool.category,
|
||||
required: tool.required,
|
||||
usedBy: tool.usedBy ?? [],
|
||||
};
|
||||
const installHint = tool.installHint?.[host.environment];
|
||||
|
||||
const spec = tool.resolvers.find((r) => r.match.includes(host.environment));
|
||||
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };
|
||||
|
||||
if (spec.resolver.kind === 'path') {
|
||||
const { bins, versionArg, versionRegex } = spec.resolver;
|
||||
for (const bin of bins) {
|
||||
const resolved = host.which(bin);
|
||||
if (resolved) {
|
||||
const out = host.runVersion(bin, [versionArg ?? '--version']);
|
||||
const version = out ? extractVersion(out, versionRegex) : undefined;
|
||||
return finalize(base, tool, resolved, version);
|
||||
}
|
||||
}
|
||||
return { ...base, status: 'missing', installHint };
|
||||
}
|
||||
|
||||
// windows-side
|
||||
const { appDirs, exes } = spec.resolver;
|
||||
for (const root of host.windowsProgramRoots()) {
|
||||
for (const dir of appDirs) {
|
||||
for (const exe of exes) {
|
||||
const winPath = `${root}/${dir}/${exe}`;
|
||||
if (host.fileExists(winPath)) {
|
||||
const raw = host.windowsFileVersion(winPath);
|
||||
const version = raw ? extractVersion(raw) : undefined;
|
||||
return finalize(base, tool, winPath, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...base, status: 'missing', installHint };
|
||||
}
|
||||
|
||||
export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResult[] {
|
||||
return registry.map((tool) => checkTool(tool, host));
|
||||
}
|
||||
|
||||
function safeWhich(bin: string): string | null {
|
||||
try {
|
||||
const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const first = out.split(/\r?\n/)[0]?.trim();
|
||||
return first && existsSync(first) ? first : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeRunVersion(bin: string, args: string[]): string | null {
|
||||
try {
|
||||
return execFileSync(bin, args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout
|
||||
const stdout = (err as { stdout?: Buffer | string })?.stdout;
|
||||
return stdout ? stdout.toString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
function readProcVersion(): string {
|
||||
try {
|
||||
return readFileSync('/proc/version', 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function listWindowsProgramRoots(): string[] {
|
||||
const roots: string[] = [];
|
||||
try {
|
||||
for (const entry of readdirSync('/mnt')) {
|
||||
for (const pf of ['Program Files', 'Program Files (x86)']) {
|
||||
const root = `/mnt/${entry}/${pf}`;
|
||||
if (existsSync(root)) roots.push(root);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /mnt absent (not WSL) -> no roots
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function readWindowsFileVersion(winPath: string): string | null {
|
||||
try {
|
||||
const windowsPath = execFileSync('wslpath', ['-w', winPath], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const out = execFileSync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`],
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
return out || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRealHost(): ProbeHost {
|
||||
const environment = detectEnvironment({
|
||||
platform: process.platform,
|
||||
procVersion: readProcVersion(),
|
||||
hasWindowsInterop: safeWhich('cmd.exe') !== null || safeWhich('powershell.exe') !== null,
|
||||
});
|
||||
return {
|
||||
environment,
|
||||
which: safeWhich,
|
||||
fileExists: existsSync,
|
||||
runVersion: safeRunVersion,
|
||||
windowsProgramRoots: listWindowsProgramRoots,
|
||||
windowsFileVersion: readWindowsFileVersion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @fileoverview Renders ToolResult[] from the dependency checker into a
|
||||
* human-readable grouped table or JSON, and computes the process exit code.
|
||||
* Plain text only (no color) so output is stable and snapshot-friendly; the
|
||||
* CLI layer may colorize.
|
||||
*
|
||||
* @module utils/dependency-report
|
||||
*/
|
||||
|
||||
import type { ProbeEnvironment, ToolCategory } from '../config/dependency-registry.js';
|
||||
import type { ToolResult, ToolStatus } from './dependency-checker.js';
|
||||
|
||||
const CATEGORY_ORDER: ToolCategory[] = ['core', 'office', 'other'];
|
||||
|
||||
function glyph(r: ToolResult): string {
|
||||
if (r.status === 'ok') return '✓';
|
||||
if (r.status === 'skipped') return '○';
|
||||
return r.required ? '✗' : '○';
|
||||
}
|
||||
|
||||
function statusText(r: ToolResult): string {
|
||||
if (r.status === 'ok') return r.version ?? 'installed';
|
||||
if (r.status === 'outdated') return `${r.version ?? '?'} (below minimum)`;
|
||||
if (r.status === 'skipped') return 'n/a';
|
||||
if (r.status === 'error') return 'version error';
|
||||
return 'not found';
|
||||
}
|
||||
|
||||
export function computeExitCode(results: ToolResult[]): number {
|
||||
const failed = results.some(
|
||||
(r) => r.required && (r.status === 'missing' || r.status === 'outdated' || r.status === 'error')
|
||||
);
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
export function renderTable(results: ToolResult[], environment: ProbeEnvironment): string {
|
||||
const lines: string[] = [`Codeman dependency check — ${environment}`, ''];
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const rows = results.filter((r) => r.category === category);
|
||||
if (rows.length === 0) continue;
|
||||
lines.push(category.toUpperCase());
|
||||
for (const r of rows) {
|
||||
const detail = r.path ? ` ${r.path}` : '';
|
||||
lines.push(` ${glyph(r)} ${r.label.padEnd(14)} ${statusText(r).padEnd(22)}${detail}`);
|
||||
if (r.usedBy.length) lines.push(` used by: ${r.usedBy.join(', ')}`);
|
||||
if (r.installHint) lines.push(` install: ${r.installHint}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
const ok = results.filter((r) => r.status === 'ok').length;
|
||||
const requiredMissing = results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length;
|
||||
const optionalMissing = results.filter((r) => !r.required && r.status === 'missing').length;
|
||||
lines.push(`Summary: ${ok} ok · ${requiredMissing} required missing · ${optionalMissing} optional missing`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export interface DependencyReportJson {
|
||||
platform: { environment: ProbeEnvironment };
|
||||
summary: { ok: number; requiredMissing: number; optionalMissing: number; exitCode: number };
|
||||
tools: ToolResult[];
|
||||
}
|
||||
|
||||
export function renderJson(results: ToolResult[], environment: ProbeEnvironment): DependencyReportJson {
|
||||
const byStatus = (s: ToolStatus) => results.filter((r) => r.status === s).length;
|
||||
return {
|
||||
platform: { environment },
|
||||
summary: {
|
||||
ok: byStatus('ok'),
|
||||
requiredMissing: results.filter((r) => r.required && r.status !== 'ok' && r.status !== 'skipped').length,
|
||||
optionalMissing: results.filter((r) => !r.required && r.status === 'missing').length,
|
||||
exitCode: computeExitCode(results),
|
||||
},
|
||||
tools: results,
|
||||
};
|
||||
}
|
||||
+18
-29
@@ -36,20 +36,12 @@ interface AuthState {
|
||||
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
|
||||
* Only active when CODEMAN_PASSWORD is set.
|
||||
*
|
||||
* @param getTunnelRunning - returns true while a managed tunnel is active. Used
|
||||
* to gate the `/api/hook-event` localhost bypass: when a tunnel is up, tunneled
|
||||
* internet traffic reaches the loopback origin with `req.ip === 127.0.0.1`, so
|
||||
* the bypass additionally requires the shared hook secret (COD-54). When no
|
||||
* tunnel is running (loopback-only, the normal case) the plain localhost bypass
|
||||
* is kept so already-deployed (pre-secret) hooks + the loop channel keep working.
|
||||
* Optional; defaults to "no tunnel" (unchanged behavior) when omitted.
|
||||
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass requires the
|
||||
* shared hook secret unconditionally (COD-91) — see the onRequest hook below.
|
||||
*
|
||||
* @returns AuthState for lifecycle management (dispose on server stop)
|
||||
*/
|
||||
export function registerAuthMiddleware(
|
||||
app: FastifyInstance,
|
||||
https: boolean,
|
||||
getTunnelRunning: () => boolean = () => false
|
||||
): AuthState {
|
||||
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
|
||||
const state: AuthState = {
|
||||
authSessions: null,
|
||||
authFailures: null,
|
||||
@@ -107,36 +99,33 @@ export function registerAuthMiddleware(
|
||||
}
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
// Hook events come from local Claude Code hooks (curl from localhost) — no
|
||||
// Basic-Auth credentials available. Validated downstream by HookEventSchema.
|
||||
// Hook events + statusline telemetry come from local Claude Code (curl from
|
||||
// localhost) — no Basic-Auth credentials available. Validated downstream by
|
||||
// HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate.
|
||||
//
|
||||
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
|
||||
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
|
||||
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
|
||||
// would pass. So:
|
||||
// - tunnel running → bypass requires the shared hook secret (local hooks present
|
||||
// it via the X-Codeman-Hook-Secret header; internet traffic can't know it),
|
||||
// - tunnel not running (loopback-only, the normal case) → keep the plain
|
||||
// localhost bypass so already-deployed (pre-secret) hooks + the loop's own
|
||||
// credential-less hook channel keep working.
|
||||
if (req.url === '/api/hook-event' && req.method === 'POST') {
|
||||
// would pass. COD-91: require the shared hook secret on the loopback bypass
|
||||
// UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
|
||||
// a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
|
||||
// serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
|
||||
// bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
|
||||
// from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it
|
||||
// always closes the gap without breaking the legitimate hook channel.
|
||||
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
|
||||
const ip = req.ip;
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
if (isLoopback) {
|
||||
if (!getTunnelRunning()) {
|
||||
// Loopback-only: unchanged behavior.
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// Tunnel up: require the shared secret (constant-time compare).
|
||||
// Always require the shared secret (constant-time compare).
|
||||
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
|
||||
const expected = Buffer.from(getHookSecret());
|
||||
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// Wrong/absent secret while tunneled — rate-limit per IP in the DEDICATED
|
||||
// hook bucket (never authFailures, which would lock out the login path).
|
||||
// Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket
|
||||
// (never authFailures, which would lock out the login path).
|
||||
const hookIp = req.ip;
|
||||
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
||||
if (hookFailures >= AUTH_FAILURE_MAX) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
|
||||
*
|
||||
* The status-telemetry route writes the latest broadcast value here; the SSE
|
||||
* init snapshot (`getLightState`) replays it so the header "Plan Usage Limits"
|
||||
* chip shows immediately on a fresh page load / SSE reconnect — before any new
|
||||
* statusline render arrives, and without relying on per-browser localStorage.
|
||||
*
|
||||
* Null until the first telemetry of the process; cleared naturally on restart.
|
||||
*
|
||||
* @module plan-usage-latest
|
||||
*/
|
||||
|
||||
let latest: Record<string, unknown> | null = null;
|
||||
|
||||
export function setLatestPlanUsage(value: Record<string, unknown>): void {
|
||||
latest = value;
|
||||
}
|
||||
|
||||
export function getLatestPlanUsage(): Record<string, unknown> | null {
|
||||
return latest;
|
||||
}
|
||||
+187
-28
@@ -157,6 +157,7 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
|
||||
|
||||
// Scheduled runs
|
||||
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
|
||||
@@ -220,8 +221,14 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SUBAGENT_TOOL_RESULT, '_onSubagentToolResult'],
|
||||
[SSE_EVENTS.SUBAGENT_COMPLETED, '_onSubagentCompleted'],
|
||||
|
||||
// Workflow runs (ultracode)
|
||||
[SSE_EVENTS.WORKFLOW_RUN_DISCOVERED, '_onWorkflowRunDiscovered'],
|
||||
[SSE_EVENTS.WORKFLOW_RUN_UPDATED, '_onWorkflowRunUpdated'],
|
||||
[SSE_EVENTS.WORKFLOW_RUN_REMOVED, '_onWorkflowRunRemoved'],
|
||||
|
||||
// Images
|
||||
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
|
||||
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
|
||||
|
||||
// Tunnel
|
||||
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
|
||||
@@ -337,6 +344,17 @@ class CodemanApp {
|
||||
this.subagentToolResults = new Map(); // Map<agentId, Map<toolUseId, result>> - tool results by toolUseId
|
||||
this.activeSubagentId = null; // Currently selected subagent for detail view
|
||||
this.subagentPanelVisible = false;
|
||||
|
||||
// Ultracode / Workflow run visualization (master-detail tab)
|
||||
this.workflowRuns = new Map(); // runId -> run summary (LEFT list)
|
||||
this.workflowRunDetails = new Map(); // runId -> full run with agents[] (RIGHT pane)
|
||||
this.activeWorkflowRunId = null;
|
||||
this.activeWorkflowPhaseIndex = null;
|
||||
// Ultracode floating run windows (additional to the dock panel — ultracode-windows.js)
|
||||
this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed }
|
||||
this.ultracodeWindowsClosed = new Set(); // runIds the user explicitly dismissed (don't re-pop)
|
||||
this.ultracodeWindowCloseTimers = new Map(); // runId -> auto-close timeout
|
||||
this.ultracodeWindowZIndex = 1000;
|
||||
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
|
||||
this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE;
|
||||
this.minimizedSubagents = new Map(); // Map<sessionId, Set<agentId>> - minimized to tab
|
||||
@@ -386,6 +404,11 @@ class CodemanApp {
|
||||
// Image popup windows (auto-open for detected screenshots/images)
|
||||
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
||||
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
||||
this.attachmentCards = new Map(); // Map<attachmentId, { element, sessionId, filePath }>
|
||||
this.attachmentCardStack = null;
|
||||
this.attachmentHistoryCounts = new Map(); // Map<sessionId, count>
|
||||
this.attachmentHistoryItems = [];
|
||||
this.attachmentHistoryDrawerOpen = false;
|
||||
|
||||
// File browser state (methods in panels-ui.js)
|
||||
this.fileBrowserData = null;
|
||||
@@ -580,6 +603,7 @@ class CodemanApp {
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.restorePlanUsageChip();
|
||||
this.applySkin();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
@@ -641,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');
|
||||
@@ -759,16 +793,34 @@ class CodemanApp {
|
||||
if (e.key === 'Escape') {
|
||||
this.closeAllPanels();
|
||||
this.closeHelp();
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
}
|
||||
|
||||
// Alt+1-9: switch to Codeman session by index
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
|
||||
const idx = parseInt(e.key) - 1;
|
||||
if (idx < this.sessionOrder.length) {
|
||||
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
|
||||
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
|
||||
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
|
||||
// of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
|
||||
const code = e.code || '';
|
||||
const digitMatch = code.match(/^Digit([1-9])$/);
|
||||
if (digitMatch) {
|
||||
const idx = parseInt(digitMatch[1], 10) - 1;
|
||||
if (idx < this.sessionOrder.length) {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.code === 'BracketLeft') {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
this.prevSession();
|
||||
return;
|
||||
}
|
||||
if (e.code === 'BracketRight') {
|
||||
e.preventDefault();
|
||||
this.nextSession();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Match against shortcut table
|
||||
@@ -1293,28 +1345,14 @@ class CodemanApp {
|
||||
|
||||
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
|
||||
_sanitizeHtml(html) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
const frag = tpl.content;
|
||||
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
|
||||
el.remove();
|
||||
if (typeof window !== 'undefined' && typeof window.sanitizeMarkdownHtml === 'function') {
|
||||
return window.sanitizeMarkdownHtml(html);
|
||||
}
|
||||
for (const el of frag.querySelectorAll('*')) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on')) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
|
||||
const val = attr.value.replace(/\s/g, '').toLowerCase();
|
||||
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(frag);
|
||||
return div.innerHTML;
|
||||
// Fail closed: DOMPurify unavailable — never return un-sanitized HTML.
|
||||
return String(html == null ? '' : html)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1812,6 +1850,58 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude plan usage limits (5-hour + weekly) — account-global, so the latest
|
||||
// sample from any session drives the shared header chip.
|
||||
_onSessionStatusTelemetry(data) {
|
||||
this.updatePlanUsageChip(data);
|
||||
// Persist last-known so the chip shows immediately on the next page load /
|
||||
// SSE reconnect, instead of staying blank until a session next renders.
|
||||
try {
|
||||
localStorage.setItem('codeman:planUsage', JSON.stringify({ t: Date.now(), data }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Repopulate the chip from the last-known value on page load (account-global,
|
||||
// slow-moving; ignored if older than 12h). Live events refresh it.
|
||||
restorePlanUsageChip() {
|
||||
try {
|
||||
const raw = localStorage.getItem('codeman:planUsage');
|
||||
if (!raw) return;
|
||||
const saved = JSON.parse(raw);
|
||||
if (saved?.data && Date.now() - (saved.t || 0) < 12 * 3600 * 1000) {
|
||||
this.updatePlanUsageChip(saved.data);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
updatePlanUsageChip(data) {
|
||||
const chip = document.getElementById('planUsageChip');
|
||||
if (!chip || !data) return;
|
||||
const pct = (w) => (w && typeof w.usedPercentage === 'number' ? Math.round(w.usedPercentage) : null);
|
||||
const five = pct(data.fiveHour);
|
||||
const seven = pct(data.sevenDay);
|
||||
if (five === null && seven === null) return;
|
||||
// Per-window color by how much is used up: green < 60%, yellow 60–84%, red ≥ 85%.
|
||||
const colorClass = (p) => (p >= 85 ? 'pu-red' : p >= 60 ? 'pu-yellow' : 'pu-green');
|
||||
// innerHTML here is XSS-safe ONLY because every interpolated value is a
|
||||
// coerced finite number and the labels/classes are fixed literals. If a
|
||||
// string field (e.g. modelDisplayName, which the route also broadcasts) is
|
||||
// ever shown in this chip, render it via textContent — never interpolate an
|
||||
// untrusted string into this template.
|
||||
const seg = (label, p) => {
|
||||
if (p === null) return '';
|
||||
const n = Math.round(Number(p));
|
||||
if (!Number.isFinite(n)) return '';
|
||||
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
|
||||
};
|
||||
chip.innerHTML = [seg('5h', five), seg('7d', seven)].filter(Boolean).join('<span class="pu-sep">·</span>');
|
||||
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
|
||||
chip.title =
|
||||
`Claude plan usage\n` +
|
||||
`5-hour limit: ${five ?? '—'}% used (resets ${resetStr(data.fiveHour)})\n` +
|
||||
`Weekly limit: ${seven ?? '—'}% used (resets ${resetStr(data.sevenDay)})`;
|
||||
}
|
||||
|
||||
// Scheduled runs
|
||||
_onScheduledCreated(data) {
|
||||
this.currentRun = data;
|
||||
@@ -2105,7 +2195,13 @@ class CodemanApp {
|
||||
// Mobile defaults ship cjkInputEnabled: false (native terminal input by
|
||||
// default on touch), but an explicit user enable is honored everywhere —
|
||||
// the App Settings toggle must not be a silent no-op on phones.
|
||||
const showCjk = this._serverCjkOverride || (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false);
|
||||
// The welcome/home screen (no active session) has nothing to type into.
|
||||
// Force-hide the CJK textarea there — otherwise the `position: fixed`
|
||||
// `.cjk-input-visible` rule floats it over the welcome overlay and blocks
|
||||
// content. Re-synced on session enter/leave via hideWelcome()/showWelcome().
|
||||
const cjkUserEnabled =
|
||||
this._serverCjkOverride || (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false);
|
||||
const showCjk = cjkUserEnabled && !!this.activeSessionId;
|
||||
cjkEl.classList.toggle('cjk-input-visible', !!showCjk);
|
||||
document.body.classList.toggle('cjk-input-visible', !!showCjk);
|
||||
cjkEl.style.display = showCjk ? 'block' : 'none';
|
||||
@@ -2158,6 +2254,11 @@ class CodemanApp {
|
||||
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
|
||||
this.subagentActivity.clear();
|
||||
this.subagentToolResults.clear();
|
||||
// Clear ultracode workflow run state (re-seeded from data.workflowRuns below)
|
||||
if (this.workflowRuns) this.workflowRuns.clear();
|
||||
if (this.workflowRunDetails) this.workflowRunDetails.clear();
|
||||
this.activeWorkflowRunId = null;
|
||||
this.activeWorkflowPhaseIndex = null;
|
||||
// Clean up mobile/keyboard handlers and re-init (prevents listener accumulation on reconnect)
|
||||
MobileDetection.cleanup();
|
||||
KeyboardHandler.cleanup();
|
||||
@@ -2165,6 +2266,7 @@ class CodemanApp {
|
||||
KeyboardHandler.init();
|
||||
// Clear tab alerts
|
||||
this.tabAlerts.clear();
|
||||
this.attachmentHistoryCounts.clear();
|
||||
// Clear shown completions (used for duplicate notification prevention)
|
||||
if (this._shownCompletions) {
|
||||
this._shownCompletions.clear();
|
||||
@@ -2210,6 +2312,10 @@ class CodemanApp {
|
||||
this._serverCjkOverride = data.inputCjkForm || false;
|
||||
this._updateCjkInputState();
|
||||
|
||||
// Plan-usage chip: server's last-known telemetry, so it shows immediately on
|
||||
// a fresh load / reconnect (authoritative; wins over the localStorage restore).
|
||||
if (data.planUsage) this.updatePlanUsageChip(data.planUsage);
|
||||
|
||||
// Update version displays (header and toolbar)
|
||||
if (data.version) {
|
||||
const versionEl = this.$('versionDisplay');
|
||||
@@ -2327,6 +2433,11 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
// Seed ultracode workflow runs (LEFT-pane summaries) from the snapshot
|
||||
if (data.workflowRuns) {
|
||||
this.seedWorkflowRuns(data.workflowRuns);
|
||||
}
|
||||
|
||||
// Restore previously active session (survives page reload + SSE reconnect)
|
||||
// Must always re-select because handleInit clears terminal state above.
|
||||
// Reset activeSessionId so selectSession doesn't early-return.
|
||||
@@ -2590,6 +2701,39 @@ class CodemanApp {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
this.updateTabOverflowMode();
|
||||
}
|
||||
|
||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||
// unless the user has pinned the manual two-row layout (tabTwoRows). Mobile/
|
||||
// tablet keep horizontal scroll. Policy lives in constants.js for unit testing.
|
||||
updateTabOverflowMode() {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
|
||||
const deviceType = MobileDetection.getDeviceType();
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false;
|
||||
|
||||
if (manualTwoRows || deviceType !== 'desktop') {
|
||||
container.classList.remove('tabs-auto-wrap');
|
||||
return;
|
||||
}
|
||||
|
||||
// Measure the natural one-row overflow, then enable wrapping only if needed.
|
||||
container.classList.remove('tabs-auto-wrap');
|
||||
const shouldWrap = window.CodemanTabOverflow?.shouldAutoWrapTabs
|
||||
? window.CodemanTabOverflow.shouldAutoWrapTabs({
|
||||
deviceType,
|
||||
manualTwoRows,
|
||||
tabCount: this.sessions.size,
|
||||
scrollWidth: container.scrollWidth,
|
||||
clientWidth: container.clientWidth,
|
||||
})
|
||||
: container.scrollWidth > container.clientWidth + 1;
|
||||
|
||||
container.classList.toggle('tabs-auto-wrap', shouldWrap);
|
||||
}
|
||||
|
||||
_fullRenderSessionTabs() {
|
||||
@@ -2666,6 +2810,12 @@ class CodemanApp {
|
||||
|
||||
// Update connection lines after tabs change (positions may have shifted)
|
||||
this.updateConnectionLines();
|
||||
|
||||
// Re-evaluate desktop auto-wrap for every full rebuild, including the incremental
|
||||
// branch's early `_fullRenderSessionTabs(); return;` paths and the manual two-rows
|
||||
// toggle (applyTabWrapSettings calls this) which would otherwise leave a stale
|
||||
// tabs-auto-wrap class until the next content render.
|
||||
this.updateTabOverflowMode();
|
||||
}
|
||||
|
||||
// Set up arrow key navigation for session tabs (accessibility)
|
||||
@@ -3164,6 +3314,10 @@ class CodemanApp {
|
||||
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
|
||||
this._updateActiveTabImmediate(sessionId);
|
||||
this.renderSessionTabs();
|
||||
this.updateAttachmentHistoryBadge?.();
|
||||
if (this.attachmentHistoryDrawerOpen) {
|
||||
this.loadAttachmentHistory?.(sessionId);
|
||||
}
|
||||
this._updateLocalEchoState();
|
||||
|
||||
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
|
||||
@@ -3534,10 +3688,15 @@ class CodemanApp {
|
||||
this.projectInsights.delete(sessionId);
|
||||
this.pendingHooks.delete(sessionId);
|
||||
this.tabAlerts.delete(sessionId);
|
||||
this.attachmentHistoryCounts.delete(sessionId);
|
||||
if (this.attachmentHistoryDrawerOpen && this.activeSessionId === sessionId) {
|
||||
this.closeAttachmentHistory?.();
|
||||
}
|
||||
this.terminalLoadStates.delete(sessionId);
|
||||
this.clearCountdownTimers(sessionId);
|
||||
this.closeSessionLogViewerWindows(sessionId);
|
||||
this.closeSessionImagePopups(sessionId);
|
||||
this.closeSessionAttachmentCards(sessionId);
|
||||
this.closeSessionSubagentWindows(sessionId, true);
|
||||
|
||||
// Clean up idle timer
|
||||
|
||||
@@ -114,9 +114,24 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK
|
||||
// Expose for tests. `const` declarations at the top of a non-module script
|
||||
// are global lexical bindings but not `window` properties, so explicit
|
||||
// assignment is the test-visible API surface.
|
||||
// Desktop tab-overflow policy: auto-wrap the session tabs to a second row when
|
||||
// they overflow one row (and the user hasn't pinned the manual two-row layout).
|
||||
function shouldAutoWrapTabs(input) {
|
||||
if (!input || input.deviceType !== 'desktop') return false;
|
||||
if (input.manualTwoRows) return false;
|
||||
if ((input.tabCount || 0) < 2) return false;
|
||||
|
||||
const scrollWidth = Number(input.scrollWidth) || 0;
|
||||
const clientWidth = Number(input.clientWidth) || 0;
|
||||
return scrollWidth > clientWidth + 1;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
window.CodemanTabOverflow = {
|
||||
shouldAutoWrapTabs,
|
||||
};
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
@@ -251,6 +266,7 @@ const SSE_EVENTS = {
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
SESSION_RUNNING: 'session:running',
|
||||
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
|
||||
|
||||
// Scheduled runs
|
||||
SCHEDULED_CREATED: 'scheduled:created',
|
||||
@@ -334,6 +350,11 @@ const SSE_EVENTS = {
|
||||
SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
|
||||
SUBAGENT_COMPLETED: 'subagent:completed',
|
||||
|
||||
// Workflow runs (ultracode / Workflow tool)
|
||||
WORKFLOW_RUN_DISCOVERED: 'workflow:run_discovered',
|
||||
WORKFLOW_RUN_UPDATED: 'workflow:run_updated',
|
||||
WORKFLOW_RUN_REMOVED: 'workflow:run_removed',
|
||||
|
||||
// Images
|
||||
IMAGE_DETECTED: 'image:detected',
|
||||
ATTACHMENT_DETECTED: 'attachment:detected',
|
||||
|
||||
+69
-16
@@ -39,6 +39,9 @@
|
||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
||||
<script defer src="vendor/marked.min.js"></script>
|
||||
<!-- DOMPurify (allowlist HTML sanitizer for rendered markdown).
|
||||
Must load before sanitize-html.js (which wires it) and app.js (which calls it). -->
|
||||
<script defer src="vendor/dompurify.min.js"></script>
|
||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
|
||||
@@ -83,7 +86,7 @@
|
||||
<!-- Detached single-session window title (shown only in solo mode) -->
|
||||
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
|
||||
|
||||
<div class="header-right mobile-collapsed" id="headerRight">
|
||||
<div class="header-right" id="headerRight">
|
||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
@@ -114,7 +117,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
@@ -500,7 +509,8 @@
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
@@ -591,6 +601,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ultracode / Workflow agents master-detail panel (opt-in via showUltracodeAgents) -->
|
||||
<div class="subagents-panel ultracode-agents-panel hidden" id="ultracodeAgentsPanel">
|
||||
<div class="subagents-panel-header">
|
||||
<div class="subagents-panel-title">
|
||||
Ultracode Agents <span id="ultracodeCountBadge" class="subagent-badge"></span>
|
||||
</div>
|
||||
<div class="subagents-panel-actions">
|
||||
<button class="btn-icon-sm" onclick="app.toggleUltracodeAgentsPanel()" title="Toggle panel">▲</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeUltracodeAgentsPanel()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subagents-panel-body">
|
||||
<div class="subagent-container">
|
||||
<div class="subagent-list ultracode-run-list" id="ultracodeRunList">
|
||||
<div class="subagent-empty">No ultracode runs detected</div>
|
||||
</div>
|
||||
<div class="subagent-detail ultracode-agent-grid" id="ultracodeAgentGrid">
|
||||
<div class="subagent-empty">Select a run to view its agents</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Options Modal (includes Respawn Settings) -->
|
||||
<div class="modal" id="sessionOptionsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
|
||||
@@ -976,6 +1009,13 @@
|
||||
|
||||
<!-- Header Displays Section -->
|
||||
<div class="settings-section-header">Header Displays</div>
|
||||
<div class="settings-item" title="Show Claude plan usage limits (5-hour & weekly) in the header. Applies to newly created sessions.">
|
||||
<span class="settings-item-label">Plan Usage Limits</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowPlanUsageLimits">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show A-/A+ font size buttons in header">
|
||||
<span class="settings-item-label">Font Controls</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -990,20 +1030,6 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show total token count in header">
|
||||
<span class="settings-item-label">Token Count</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowTokenCount" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show estimated cost next to token count">
|
||||
<span class="settings-item-label">Show Cost ($)</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowCost">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show session lifecycle log button in header">
|
||||
<span class="settings-item-label">Lifecycle Log</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1018,6 +1044,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
|
||||
<span class="settings-item-label">Attachments Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowAttachmentsButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
|
||||
<span class="settings-item-label">Multi-monitor Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1066,6 +1099,20 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show ultracode / Workflow runs as a master-detail tab (tasks on the left, agents with tokens + tool calls on the right)">
|
||||
<span class="settings-item-label">Ultracode Agents</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowUltracodeAgents">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Pop a floating window for each active ultracode / Workflow run, connected by a line to its session tab (additional to the Ultracode Agents panel)">
|
||||
<span class="settings-item-label">Ultracode Floating Windows</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsUltracodeFloatingWindows">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Subagent Options Section -->
|
||||
<div class="settings-section-header">Subagent Options</div>
|
||||
@@ -1178,6 +1225,8 @@
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="opus[1m]">Opus (1M context)</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-opus-4-6[1m]">Opus 4.6 (1M context)</option>
|
||||
<option value="claude-opus-4-6">Opus 4.6</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="haiku">Haiku</option>
|
||||
</select>
|
||||
@@ -1907,6 +1956,8 @@
|
||||
<script defer src="notification-manager.js"></script>
|
||||
<script defer src="keyboard-accessory.js"></script>
|
||||
<script defer src="input-cjk.js"></script>
|
||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||
<script defer src="sanitize-html.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
@@ -1914,10 +1965,12 @@
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
<script defer src="settings-ui.js"></script>
|
||||
<script defer src="panels-ui.js"></script>
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
<script defer src="ultracode-windows.js"></script>
|
||||
<script defer src="image-input.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -139,6 +139,9 @@ const MobileDetection = {
|
||||
resizeTimeout = setTimeout(() => {
|
||||
this.updateBodyClass();
|
||||
this.updateAppHeight();
|
||||
// Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only
|
||||
// other trigger is a tab content render. No-op on mobile/tablet (method bails).
|
||||
if (typeof app !== 'undefined') app.updateTabOverflowMode?.();
|
||||
}, 100);
|
||||
};
|
||||
window.addEventListener('resize', this._resizeHandler);
|
||||
|
||||
+52
-47
@@ -101,31 +101,12 @@ html.mobile-init .file-browser-panel {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Inline, always-visible header utilities (eye / multimonitor). The
|
||||
position:fixed collapsible tray (02fa3f3) had its expand toggle reverted on
|
||||
master but left this container permanently `mobile-collapsed` → the response
|
||||
viewer eye became unreachable on phones. Restored to the simple inline flow. */
|
||||
.header-right {
|
||||
position: fixed;
|
||||
top: calc(52px + var(--safe-area-top));
|
||||
left: calc(0.5rem + var(--safe-area-left));
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right));
|
||||
padding: 0.35rem;
|
||||
background: rgba(10, 10, 10, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.header-right::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right.mobile-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-icon-header:not(.btn-sm) {
|
||||
@@ -435,32 +416,10 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
.header-right {
|
||||
position: fixed;
|
||||
top: calc(40px + var(--safe-area-top));
|
||||
left: calc(0.3rem + var(--safe-area-left));
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 0.2rem;
|
||||
gap: 0.1rem;
|
||||
max-width: calc(100vw - 0.6rem - var(--safe-area-left) - var(--safe-area-right));
|
||||
padding: 0.25rem;
|
||||
background: rgba(10, 10, 10, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex-shrink: 0;
|
||||
border-left: none;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.header-right::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right.mobile-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Smaller header buttons on mobile */
|
||||
@@ -475,7 +434,9 @@ html.mobile-init .file-browser-panel {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar */
|
||||
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar.
|
||||
(The attachments button is opt-in / default-hidden everywhere via its own
|
||||
--hidden marker, so it needs no mobile-specific rule here.) */
|
||||
.btn-icon-header.btn-settings,
|
||||
.btn-icon-header.btn-lifecycle-log {
|
||||
display: none !important;
|
||||
@@ -2346,3 +2307,47 @@ html.mobile-init .file-browser-panel {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
/* Attachment history (COD-18): full-screen sheet on phones */
|
||||
.attachment-history-drawer {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
border-left: none;
|
||||
border-radius: 0;
|
||||
padding-top: var(--safe-area-top);
|
||||
padding-left: var(--safe-area-left);
|
||||
padding-right: var(--safe-area-right);
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
}
|
||||
|
||||
.attachment-history-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.attachment-history-list {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.attachment-history-item {
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.attachment-history-thumb {
|
||||
width: 104px;
|
||||
}
|
||||
|
||||
.attachment-history-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.attachment-history-actions button {
|
||||
padding: 4px 7px;
|
||||
}
|
||||
}
|
||||
|
||||
+507
-4
@@ -2443,8 +2443,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
},
|
||||
|
||||
async openFilePreview(filePath) {
|
||||
if (!this.activeSessionId || !filePath) return;
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
if (!sessionId || !filePath) return;
|
||||
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
const titleEl = this.$('filePreviewTitle');
|
||||
@@ -2459,8 +2459,72 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
|
||||
footerEl.textContent = '';
|
||||
|
||||
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||||
|
||||
// Registered attachment: render straight from its by-id routes — images and
|
||||
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||
if (attachmentId) {
|
||||
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||
footerEl.textContent = ext.toUpperCase();
|
||||
if (IMAGE_EXTS.has(ext)) {
|
||||
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
|
||||
} else if (ext === 'pdf') {
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
} else if (ext === 'docx' || ext === 'pptx') {
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(`${base}/raw`);
|
||||
if (!res.ok) throw new Error('Failed to load attachment');
|
||||
const text = await res.text();
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||||
} catch (err) {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Workspace-path (auto-detected, unregistered) attachments: Office docs are
|
||||
// converted to PDF server-side via the file-preview route; PDFs stream raw.
|
||||
// Both render inline in an iframe. Without this, docx/pptx/pdf fall through
|
||||
// to file-content below, which would dump the binary bytes as mojibake.
|
||||
if (ext === 'docx' || ext === 'pptx') {
|
||||
footerEl.textContent = ext.toUpperCase();
|
||||
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
if (ext === 'pdf') {
|
||||
footerEl.textContent = 'PDF';
|
||||
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
// SVG renders as an image, but file-raw deliberately serves SVG as an
|
||||
// untrusted octet-stream attachment (XSS hardening), so a direct
|
||||
// <img src=file-raw> would break. Fetch the bytes and render via a
|
||||
// same-origin blob typed image/svg+xml — <img> never executes scripts in
|
||||
// the referenced SVG, so this is safe while still rendering the graphic.
|
||||
if (ext === 'svg') {
|
||||
footerEl.textContent = 'SVG';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
if (!res.ok) throw new Error('Failed to load image');
|
||||
const blobUrl = URL.createObjectURL(new Blob([await res.text()], { type: 'image/svg+xml' }));
|
||||
bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`;
|
||||
const img = bodyEl.querySelector('img');
|
||||
if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||
if (!res.ok) throw new Error('Failed to load file');
|
||||
|
||||
const result = await res.json();
|
||||
@@ -2474,8 +2538,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else if (data.type === 'video') {
|
||||
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'audio') {
|
||||
bodyEl.innerHTML = `<audio src="${data.url}" controls autoplay></audio>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'binary') {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview</div>`;
|
||||
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||
footerEl.textContent = data.extension || 'binary';
|
||||
} else {
|
||||
// Text content
|
||||
@@ -2498,6 +2566,441 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.filePreviewContent = '';
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Attachment Cards (detected documents/images)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// SSE `attachment:detected` consumer: surface a dismissible card for the file
|
||||
// and bump the per-session history unread count (refreshing the open drawer).
|
||||
_onAttachmentDetected(data) {
|
||||
console.log('[Attachment Detected]', data);
|
||||
this.addAttachmentCard(data);
|
||||
if (data.sessionId) {
|
||||
const current =
|
||||
this.attachmentHistoryCounts.get(data.sessionId) ??
|
||||
this.sessions.get(data.sessionId)?.attachmentHistory?.length ??
|
||||
0;
|
||||
this.attachmentHistoryCounts.set(data.sessionId, Math.min(current + 1, 100));
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
this.updateAttachmentHistoryBadge();
|
||||
if (this.attachmentHistoryDrawerOpen) {
|
||||
this._debouncedCall(
|
||||
'attachmentHistoryRefresh',
|
||||
() => {
|
||||
// The drawer may have closed or the active session changed during
|
||||
// the debounce window — don't refresh for a stale session.
|
||||
if (this.attachmentHistoryDrawerOpen && this.activeSessionId === data.sessionId) {
|
||||
this.loadAttachmentHistory(data.sessionId);
|
||||
}
|
||||
},
|
||||
250
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Lazily create the floating stack the cards live in (appended to <body>).
|
||||
ensureAttachmentCardStack() {
|
||||
let stack = this.attachmentCardStack || document.getElementById('attachmentCardStack');
|
||||
if (!stack) {
|
||||
stack = document.createElement('div');
|
||||
stack.id = 'attachmentCardStack';
|
||||
stack.className = 'attachment-card-stack';
|
||||
document.body.appendChild(stack);
|
||||
}
|
||||
this.attachmentCardStack = stack;
|
||||
return stack;
|
||||
},
|
||||
|
||||
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
|
||||
const url = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
addAttachmentCard(attachmentEvent) {
|
||||
const {
|
||||
sessionId,
|
||||
relativePath,
|
||||
fileName,
|
||||
timestamp,
|
||||
size,
|
||||
attachmentType,
|
||||
extension,
|
||||
attachmentId,
|
||||
rawUrl,
|
||||
previewUrl,
|
||||
thumbnailUrl,
|
||||
} = attachmentEvent;
|
||||
const filePath = relativePath || fileName;
|
||||
const cardId = attachmentId || `${sessionId}-${timestamp}-${fileName}`;
|
||||
|
||||
if (this.attachmentCards.has(cardId)) {
|
||||
const existing = this.attachmentCards.get(cardId);
|
||||
existing.element.focus?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const MAX_ATTACHMENT_CARDS = 10;
|
||||
if (this.attachmentCards.size >= MAX_ATTACHMENT_CARDS) {
|
||||
const oldestId = this.attachmentCards.keys().next().value;
|
||||
if (oldestId) this.closeAttachmentCard(oldestId);
|
||||
}
|
||||
|
||||
const stack = this.ensureAttachmentCardStack();
|
||||
const session = this.sessions.get(sessionId);
|
||||
const sessionName = session?.name || sessionId.substring(0, 8);
|
||||
const attachmentRawUrl =
|
||||
rawUrl ||
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
const attachmentPreviewUrl =
|
||||
previewUrl ||
|
||||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
|
||||
const attachmentThumbnailUrl =
|
||||
thumbnailUrl ||
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
|
||||
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
|
||||
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
|
||||
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
|
||||
|
||||
const card = document.createElement('article');
|
||||
card.className = `attachment-card attachment-${escapeHtml(attachmentType || 'file')}`;
|
||||
card.tabIndex = 0;
|
||||
card.dataset.attachmentId = cardId;
|
||||
card.dataset.previewUrl = attachmentPreviewUrl || '';
|
||||
card.innerHTML = `
|
||||
<div class="attachment-thumbnail">
|
||||
${attachmentThumbnailUrl ? `<img class="attachment-thumbnail-img" src="${escapeHtml(attachmentThumbnailUrl)}" alt="">` : ''}
|
||||
<div class="attachment-thumbnail-fallback ${attachmentThumbnailUrl ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
|
||||
</div>
|
||||
<div class="attachment-card-main">
|
||||
<div class="attachment-file-name" title="${escapeHtml(filePath)}">${escapeHtml(fileName)}</div>
|
||||
<div class="attachment-file-meta">
|
||||
<span>${escapeHtml(sessionName)}</span>
|
||||
<span>${this.formatFileSize(size || 0)}</span>
|
||||
</div>
|
||||
<div class="attachment-actions">
|
||||
<button type="button" class="attachment-preview-btn">Preview</button>
|
||||
<a href="${escapeHtml(downloadUrl)}">Download</a>
|
||||
<button type="button" class="attachment-open-btn">Open</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="attachment-close-btn" title="Dismiss">×</button>
|
||||
`;
|
||||
|
||||
const attachmentThumbnailImg = card.querySelector('.attachment-thumbnail-img');
|
||||
if (attachmentThumbnailImg) {
|
||||
attachmentThumbnailImg.onerror = () => {
|
||||
attachmentThumbnailImg.remove();
|
||||
card.querySelector('.attachment-thumbnail-fallback')?.classList.add('visible');
|
||||
};
|
||||
}
|
||||
|
||||
card.querySelector('.attachment-preview-btn')?.addEventListener('click', () => {
|
||||
this.openFilePreview(filePath, sessionId, attachmentId || null);
|
||||
});
|
||||
card.querySelector('.attachment-open-btn')?.addEventListener('click', () => {
|
||||
this.openAttachmentInNewTab(sessionId, filePath, attachmentId || null);
|
||||
});
|
||||
card.querySelector('.attachment-close-btn')?.addEventListener('click', () => {
|
||||
this.closeAttachmentCard(cardId);
|
||||
});
|
||||
|
||||
stack.prepend(card);
|
||||
this.attachmentCards.set(cardId, { element: card, sessionId, filePath });
|
||||
this._refreshAttachmentClearAll();
|
||||
},
|
||||
|
||||
// Centralized show/hide for the stack's "Clear all" control. Both addAttachmentCard and
|
||||
// closeAttachmentCard call this so the control appears on the 2nd card and hides at <=1.
|
||||
_refreshAttachmentClearAll() {
|
||||
const stack = this.attachmentCardStack;
|
||||
if (!stack) return;
|
||||
let control = stack.querySelector('.attachment-clear-all');
|
||||
if (this.attachmentCards.size < 2) {
|
||||
if (control) control.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (!control) {
|
||||
control = document.createElement('button');
|
||||
control.type = 'button';
|
||||
control.className = 'attachment-clear-all';
|
||||
control.textContent = 'Clear all';
|
||||
control.title = 'Dismiss all attachment cards';
|
||||
control.addEventListener('click', () => this.closeAllAttachmentCards());
|
||||
stack.prepend(control);
|
||||
}
|
||||
control.hidden = false;
|
||||
},
|
||||
|
||||
closeAttachmentCard(attachmentId) {
|
||||
const cardData = this.attachmentCards.get(attachmentId);
|
||||
if (!cardData) return;
|
||||
cardData.element.remove();
|
||||
this.attachmentCards.delete(attachmentId);
|
||||
if (this.attachmentCardStack && this.attachmentCards.size === 0) {
|
||||
this.attachmentCardStack.remove();
|
||||
this.attachmentCardStack = null;
|
||||
} else {
|
||||
this._refreshAttachmentClearAll();
|
||||
}
|
||||
},
|
||||
|
||||
closeAllAttachmentCards() {
|
||||
for (const attachmentId of [...this.attachmentCards.keys()]) {
|
||||
this.closeAttachmentCard(attachmentId);
|
||||
}
|
||||
},
|
||||
|
||||
closeSessionAttachmentCards(sessionId) {
|
||||
const toClose = [];
|
||||
for (const [attachmentId, data] of this.attachmentCards) {
|
||||
if (data.sessionId === sessionId) toClose.push(attachmentId);
|
||||
}
|
||||
for (const attachmentId of toClose) {
|
||||
this.closeAttachmentCard(attachmentId);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Attachment History Drawer
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
updateAttachmentHistoryBadge(count = null) {
|
||||
const badge = document.getElementById('attachmentHistoryBadge');
|
||||
const button = document.getElementById('attachmentsHistoryBtn');
|
||||
const sessionId = this.activeSessionId;
|
||||
const nextCount = count ?? (sessionId ? this.attachmentHistoryCounts.get(sessionId) || 0 : 0);
|
||||
if (badge) {
|
||||
badge.textContent = nextCount > 99 ? '99+' : String(nextCount);
|
||||
badge.style.display = nextCount > 0 ? '' : 'none';
|
||||
}
|
||||
if (button) {
|
||||
button.classList.toggle('active', this.attachmentHistoryDrawerOpen);
|
||||
button.setAttribute('aria-expanded', this.attachmentHistoryDrawerOpen ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
|
||||
ensureAttachmentHistoryDrawer() {
|
||||
let drawer = document.getElementById('attachmentHistoryDrawer');
|
||||
if (drawer) return drawer;
|
||||
|
||||
drawer = document.createElement('aside');
|
||||
drawer.id = 'attachmentHistoryDrawer';
|
||||
drawer.className = 'attachment-history-drawer';
|
||||
drawer.setAttribute('aria-label', 'Attachment history');
|
||||
drawer.innerHTML = `
|
||||
<div class="attachment-history-header">
|
||||
<div>
|
||||
<div class="attachment-history-title">Attachments</div>
|
||||
<div class="attachment-history-subtitle" id="attachmentHistorySubtitle">0 files</div>
|
||||
</div>
|
||||
<div class="attachment-history-header-actions">
|
||||
<button type="button" class="btn-icon-sm" id="attachmentHistoryRefreshBtn" title="Refresh" aria-label="Refresh attachments">↻</button>
|
||||
<button type="button" class="btn-icon-sm" id="attachmentHistoryCloseBtn" title="Close" aria-label="Close attachments">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="attachment-history-list" id="attachmentHistoryList"></div>
|
||||
`;
|
||||
document.body.appendChild(drawer);
|
||||
drawer.querySelector('#attachmentHistoryRefreshBtn')?.addEventListener('click', () => {
|
||||
this.loadAttachmentHistory(this.activeSessionId);
|
||||
});
|
||||
drawer.querySelector('#attachmentHistoryCloseBtn')?.addEventListener('click', () => {
|
||||
this.closeAttachmentHistory();
|
||||
});
|
||||
return drawer;
|
||||
},
|
||||
|
||||
async toggleAttachmentHistory() {
|
||||
if (this.attachmentHistoryDrawerOpen) {
|
||||
this.closeAttachmentHistory();
|
||||
return;
|
||||
}
|
||||
await this.openAttachmentHistory();
|
||||
},
|
||||
|
||||
async openAttachmentHistory() {
|
||||
const drawer = this.ensureAttachmentHistoryDrawer();
|
||||
this.attachmentHistoryDrawerOpen = true;
|
||||
drawer.classList.add('open');
|
||||
this.updateAttachmentHistoryBadge();
|
||||
await this.loadAttachmentHistory(this.activeSessionId);
|
||||
},
|
||||
|
||||
closeAttachmentHistory() {
|
||||
const drawer = document.getElementById('attachmentHistoryDrawer');
|
||||
this.attachmentHistoryDrawerOpen = false;
|
||||
drawer?.classList.remove('open');
|
||||
// Cancel any pending debounced refresh so it can't fire against a closed drawer.
|
||||
if (this._debounceTimers?.attachmentHistoryRefresh) {
|
||||
clearTimeout(this._debounceTimers.attachmentHistoryRefresh);
|
||||
this._debounceTimers.attachmentHistoryRefresh = null;
|
||||
}
|
||||
this.updateAttachmentHistoryBadge();
|
||||
},
|
||||
|
||||
async loadAttachmentHistory(sessionId = this.activeSessionId) {
|
||||
const drawer = this.ensureAttachmentHistoryDrawer();
|
||||
const list = drawer.querySelector('#attachmentHistoryList');
|
||||
const subtitle = drawer.querySelector('#attachmentHistorySubtitle');
|
||||
if (!list || !subtitle) return;
|
||||
|
||||
if (!sessionId) {
|
||||
this.attachmentHistoryItems = [];
|
||||
subtitle.textContent = 'No session';
|
||||
list.innerHTML = '<div class="attachment-history-empty">No active session</div>';
|
||||
this.updateAttachmentHistoryBadge(0);
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = '<div class="attachment-history-empty">Loading...</div>';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/attachments`);
|
||||
if (!res.ok) throw new Error('Failed to load attachments');
|
||||
const result = await res.json();
|
||||
if (!result.success) throw new Error(result.error || 'Failed to load attachments');
|
||||
const items = result.data?.items || [];
|
||||
this.attachmentHistoryItems = items;
|
||||
this.attachmentHistoryCounts.set(sessionId, items.length);
|
||||
this.updateAttachmentHistoryBadge(items.length);
|
||||
this.renderAttachmentHistory(items);
|
||||
} catch (err) {
|
||||
console.error('Failed to load attachment history:', err);
|
||||
subtitle.textContent = 'Unavailable';
|
||||
list.innerHTML = `<div class="attachment-history-empty">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderAttachmentHistory(items = this.attachmentHistoryItems || []) {
|
||||
const drawer = this.ensureAttachmentHistoryDrawer();
|
||||
const list = drawer.querySelector('#attachmentHistoryList');
|
||||
const subtitle = drawer.querySelector('#attachmentHistorySubtitle');
|
||||
if (!list || !subtitle) return;
|
||||
|
||||
subtitle.textContent = `${items.length} ${items.length === 1 ? 'file' : 'files'}`;
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = `
|
||||
<div class="attachment-history-empty">
|
||||
<div class="attachment-history-empty-title">No attachments yet</div>
|
||||
<div>Show a file here by running:</div>
|
||||
<code>codeman attach /absolute/path/to/file.pptx</code>
|
||||
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map((item) => this.renderAttachmentHistoryItem(item)).join('');
|
||||
list.querySelectorAll('.attachment-history-thumb-img').forEach((img) => {
|
||||
img.onerror = () => {
|
||||
img.remove();
|
||||
const fallback = img.closest('.attachment-history-thumb')?.querySelector('.attachment-history-thumb-fallback');
|
||||
fallback?.classList.add('visible');
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('[data-attachment-action]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const id = button.getAttribute('data-history-id');
|
||||
const action = button.getAttribute('data-attachment-action');
|
||||
if (!id || !action) return;
|
||||
if (action === 'preview') this.previewAttachmentHistoryItem(id);
|
||||
if (action === 'download') this.downloadAttachmentHistoryItem(id);
|
||||
if (action === 'open') this.openAttachmentHistoryItem(id);
|
||||
if (action === 'reshow') this.reshowAttachmentCard(id);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
renderAttachmentHistoryItem(item) {
|
||||
const typeLabel = (item.extension || item.attachmentType || 'file').toUpperCase();
|
||||
const meta = [
|
||||
item.source === 'external' ? 'published' : 'workspace',
|
||||
this.formatFileSize(item.size || 0),
|
||||
item.missing ? 'missing' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
const thumb =
|
||||
item.thumbnailUrl && !item.missing
|
||||
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">`
|
||||
: '';
|
||||
const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
|
||||
return `
|
||||
<div class="attachment-history-item ${item.missing ? 'missing' : ''}" data-history-item="${escapeHtml(item.id)}">
|
||||
<div class="attachment-history-thumb">
|
||||
${thumb}
|
||||
<div class="attachment-history-thumb-fallback ${thumb ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
|
||||
</div>
|
||||
<div class="attachment-history-item-main">
|
||||
<div class="attachment-history-file-name" title="${escapeHtml(item.fileName)}">${escapeHtml(item.fileName)}</div>
|
||||
<div class="attachment-history-meta">${escapeHtml(meta)}</div>
|
||||
<div class="attachment-history-actions">
|
||||
<button type="button" data-attachment-action="preview" data-history-id="${escapeHtml(item.id)}" ${disabled}>Preview</button>
|
||||
<button type="button" data-attachment-action="download" data-history-id="${escapeHtml(item.id)}" ${disabled}>Download</button>
|
||||
<button type="button" data-attachment-action="open" data-history-id="${escapeHtml(item.id)}" ${disabled}>Open</button>
|
||||
<button type="button" data-attachment-action="reshow" data-history-id="${escapeHtml(item.id)}" ${disabled}>Card</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
getAttachmentHistoryItem(itemId) {
|
||||
return (this.attachmentHistoryItems || []).find((item) => item.id === itemId) || null;
|
||||
},
|
||||
|
||||
previewAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
const path = item.relativePath || item.fileName;
|
||||
this.openFilePreview(path, item.sessionId, item.attachmentId || null);
|
||||
// Close the drawer so the preview window is unobstructed.
|
||||
this.closeAttachmentHistory();
|
||||
},
|
||||
|
||||
openAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
if (item.rawUrl || item.url) {
|
||||
window.open(item.rawUrl || item.url, '_blank');
|
||||
return;
|
||||
}
|
||||
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
|
||||
},
|
||||
|
||||
downloadAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing || !item.downloadUrl) return;
|
||||
window.open(item.downloadUrl, '_blank');
|
||||
},
|
||||
|
||||
reshowAttachmentCard(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
this.addAttachmentCard({
|
||||
sessionId: item.sessionId,
|
||||
relativePath: item.relativePath,
|
||||
fileName: item.fileName,
|
||||
// Use the item's own timestamp (not Date.now()) so the derived cardId is
|
||||
// stable across clicks — re-showing focuses the existing card instead of
|
||||
// stacking a duplicate.
|
||||
timestamp: item.timestamp ?? Date.now(),
|
||||
size: item.size,
|
||||
attachmentType: item.attachmentType,
|
||||
extension: item.extension,
|
||||
attachmentId: item.attachmentId,
|
||||
rawUrl: item.rawUrl,
|
||||
previewUrl: item.previewUrl,
|
||||
thumbnailUrl: item.thumbnailUrl,
|
||||
});
|
||||
},
|
||||
|
||||
copyFilePreviewContent() {
|
||||
if (this.filePreviewContent) {
|
||||
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @fileoverview Allowlist-based HTML sanitizer for markdown-rendered, agent/transcript-derived
|
||||
* content that is subsequently assigned via innerHTML (response viewer, attachment markdown
|
||||
* preview, message bodies).
|
||||
*
|
||||
* Security (COD-56): the previous sanitizer was a hand-rolled DENYLIST — it removed a fixed
|
||||
* set of tags (script/iframe/object/embed/form/base/meta/link/style), stripped on* attrs and a
|
||||
* few dangerous URL schemes, then re-serialized. Denylists are mXSS-prone: they did not strip
|
||||
* `svg`/`math` (which carry their own foreign-namespace parsing rules and can smuggle script via
|
||||
* namespace confusion), did not strip `style` attributes (CSS `expression()`/`url(javascript:)`
|
||||
* on legacy engines), and had no positive allowlist, so any tag/attribute not explicitly named
|
||||
* survived. `marked` runs with raw-HTML passthrough, so crafted HTML echoed by an agent flows
|
||||
* straight into this function.
|
||||
*
|
||||
* This module replaces that with DOMPurify (Cure53), an allowlist sanitizer that is the
|
||||
* industry standard for mXSS defense. It is configured to allow exactly the tag/attribute set
|
||||
* that markdown rendering legitimately produces (headings, lists, code, blockquotes, links,
|
||||
* tables, images with safe src) and to FORBID `style`/`svg`/`math` plus all event handlers and
|
||||
* dangerous URL schemes.
|
||||
*
|
||||
* Cross-environment: in the browser this file runs as a classic <script> after
|
||||
* vendor/dompurify.min.js and wires `window.sanitizeMarkdownHtml`. The factory is also exported
|
||||
* (window/globalThis + CommonJS) so a jsdom unit test can build a sanitizer bound to a
|
||||
* jsdom-window DOMPurify instance and exercise the exact same config.
|
||||
*
|
||||
* @globals {function} sanitizeMarkdownHtml - (html:string) => string, sanitized HTML
|
||||
* @globals {function} createMarkdownSanitizer - (DOMPurify) => sanitizeMarkdownHtml (for tests)
|
||||
* @dependency vendor/dompurify.min.js (provides the global DOMPurify)
|
||||
* @loadorder 5.6 of 15 — after input-cjk.js(5.5), before app.js(6) (app.js calls it)
|
||||
*/
|
||||
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tags markdown rendering (marked, gfm) legitimately emits. Anything outside this set is
|
||||
* dropped by DOMPurify. Deliberately excludes svg/math (mXSS foreign-namespace vectors) and
|
||||
* form/embed/object/iframe/script/style (no place in rendered markdown).
|
||||
*/
|
||||
var ALLOWED_TAGS = [
|
||||
'a',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'caption',
|
||||
'code',
|
||||
'del',
|
||||
'div',
|
||||
'em',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'i',
|
||||
'img',
|
||||
'ins',
|
||||
'kbd',
|
||||
'li',
|
||||
'mark',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'q',
|
||||
's',
|
||||
'samp',
|
||||
'span',
|
||||
'strong',
|
||||
'sub',
|
||||
'sup',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'tfoot',
|
||||
'th',
|
||||
'thead',
|
||||
'tr',
|
||||
'ul',
|
||||
'var',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
|
||||
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
|
||||
* blocks may carry `language-*` classes from marked.
|
||||
*/
|
||||
var ALLOWED_ATTR = [
|
||||
'href',
|
||||
'src',
|
||||
'alt',
|
||||
'title',
|
||||
'class',
|
||||
'id',
|
||||
'name',
|
||||
'colspan',
|
||||
'rowspan',
|
||||
'align',
|
||||
'width',
|
||||
'height',
|
||||
'lang',
|
||||
'dir',
|
||||
'start',
|
||||
'reversed',
|
||||
'type',
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a sanitizer bound to a specific DOMPurify instance. The browser passes the global
|
||||
* DOMPurify; tests pass a jsdom-window-bound instance so the same config is exercised under
|
||||
* vitest without a real browser.
|
||||
*/
|
||||
function createMarkdownSanitizer(DOMPurify) {
|
||||
if (!DOMPurify || typeof DOMPurify.sanitize !== 'function') {
|
||||
throw new Error('createMarkdownSanitizer: a DOMPurify instance is required');
|
||||
}
|
||||
|
||||
var CONFIG = {
|
||||
ALLOWED_TAGS: ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: ALLOWED_ATTR,
|
||||
// Defense in depth even though style/svg/math are not in ALLOWED_TAGS: also forbid the
|
||||
// foreign-namespace roots and style so config drift can't silently re-admit them.
|
||||
FORBID_TAGS: ['style', 'svg', 'math', 'script', 'iframe', 'object', 'embed', 'form'],
|
||||
FORBID_ATTR: ['style'],
|
||||
// NOTE: do NOT set USE_PROFILES here. DOMPurify treats USE_PROFILES and
|
||||
// ALLOWED_TAGS/ALLOWED_ATTR as mutually exclusive — when a profile is set it
|
||||
// RESETS the allow-lists to the full profile and silently ignores the curated
|
||||
// lists above, widening the tag set far beyond what markdown emits. Relying on
|
||||
// the explicit ALLOWED_TAGS/ALLOWED_ATTR keeps the tight allowlist in force;
|
||||
// FORBID_TAGS/FORBID_ATTR remain as defense-in-depth. DOMPurify still applies
|
||||
// its default safe-URI handling (blocks javascript:/vbscript:, allows
|
||||
// http/https/mailto/tel + data: only on image tags).
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ADD_ATTR: [],
|
||||
RETURN_DOM: false,
|
||||
RETURN_DOM_FRAGMENT: false,
|
||||
// Keep text content of any removed element (so stripping a stray tag doesn't eat prose),
|
||||
// matching the previous serializer's behavior of dropping the element but not its text.
|
||||
KEEP_CONTENT: true,
|
||||
};
|
||||
|
||||
return function sanitizeMarkdownHtml(html) {
|
||||
return DOMPurify.sanitize(html == null ? '' : String(html), CONFIG);
|
||||
};
|
||||
}
|
||||
|
||||
// Expose the factory for tests (and any non-browser consumer).
|
||||
if (root) {
|
||||
root.createMarkdownSanitizer = createMarkdownSanitizer;
|
||||
// In the browser, vendor/dompurify.min.js has already defined the global DOMPurify.
|
||||
if (root.DOMPurify && typeof root.DOMPurify.sanitize === 'function') {
|
||||
root.sanitizeMarkdownHtml = createMarkdownSanitizer(root.DOMPurify);
|
||||
}
|
||||
}
|
||||
|
||||
// CommonJS export for the vitest/jsdom unit test.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
createMarkdownSanitizer: createMarkdownSanitizer,
|
||||
ALLOWED_TAGS: ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: ALLOWED_ATTR,
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
|
||||
@@ -370,6 +370,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(modelOverride !== undefined ? { modelOverride } : {}),
|
||||
// Plan-usage statusLine exporter (App Settings → Display). The server
|
||||
// ADDS our exporter on create when true; when false it intentionally
|
||||
// leaves any existing exporter in place (a per-repo settings.local.json
|
||||
// is shared by sibling sessions, so create-with-false must not yank it
|
||||
// — see the comment in session-routes create). Disabling the setting
|
||||
// removes it via the App Settings toggle path (system-routes), not here.
|
||||
statusLineTelemetry: globalSettings.showPlanUsageLimits === true,
|
||||
})
|
||||
}).then(r => r.json())
|
||||
);
|
||||
|
||||
@@ -305,16 +305,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? defaults.showTokenCount ?? true;
|
||||
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
|
||||
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
|
||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
|
||||
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -1352,7 +1355,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
async saveAppSettings() {
|
||||
// Gesture overlay is injected at page render (server-side), so a change to it
|
||||
// only takes effect on reload — remember the prior value to decide below.
|
||||
const _prevGestureEnabled = (this.loadAppSettingsFromStorage().gestureControlEnabled ?? false) === true;
|
||||
const _prev = this.loadAppSettingsFromStorage();
|
||||
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
|
||||
const settings = {
|
||||
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
|
||||
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
|
||||
@@ -1360,15 +1364,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
showTokenCount: document.getElementById('appSettingsShowTokenCount').checked,
|
||||
showCost: document.getElementById('appSettingsShowCost').checked,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
|
||||
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
|
||||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||||
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -1396,6 +1402,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
};
|
||||
|
||||
// The "Token Count" / "Show Cost ($)" header toggles were removed from the
|
||||
// UI, but their features still read settings.showTokenCount / settings.showCost
|
||||
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
|
||||
// rebuilds `settings` fresh from the DOM (a full replacement, not a merge), so
|
||||
// without this these keys would be DROPPED on every save and fall back to their
|
||||
// defaults — silently re-enabling the token chip for anyone who'd turned it off,
|
||||
// with no UI left to turn it back off. Preserve the prior stored preference.
|
||||
if (_prev.showTokenCount !== undefined) settings.showTokenCount = _prev.showTokenCount;
|
||||
if (_prev.showCost !== undefined) settings.showCost = _prev.showCost;
|
||||
|
||||
// Save to localStorage
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this._updateLocalEchoState();
|
||||
@@ -1506,12 +1522,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Apply keyboard bar mode
|
||||
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||
|
||||
// Save to server (includes notification prefs for cross-browser persistence)
|
||||
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
||||
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, ...serverSettings } = settings;
|
||||
// Save to server (includes notification prefs for cross-browser persistence).
|
||||
// Strip device-specific DISPLAY keys so they never sync across devices —
|
||||
// localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits
|
||||
// is per-device too (desktop can show the usage chip while mobile stays hidden).
|
||||
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
|
||||
// ENABLE only, so a device with the chip OFF never strips the exporter that
|
||||
// another device's chip depends on — see system-routes settings handler).
|
||||
const {
|
||||
localEchoEnabled: _leo,
|
||||
cjkInputEnabled: _cjk,
|
||||
extendedKeyboardBar: _ekb,
|
||||
skin: _skin,
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
try {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
|
||||
notificationPreferences: notifPrefsToSave,
|
||||
voiceSettings,
|
||||
});
|
||||
@@ -1657,7 +1687,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
showProjectInsights: false,
|
||||
showFileBrowser: false,
|
||||
showSubagents: false,
|
||||
showUltracodeAgents: false,
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
// Input
|
||||
gestureControlEnabled: false,
|
||||
// Feature toggles - keep tracking on even on mobile
|
||||
@@ -1760,6 +1794,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer);
|
||||
}
|
||||
|
||||
// Hide the attachments (history) button when disabled. Opt-in, default OFF —
|
||||
// marker class, base is display:inline-flex !important.
|
||||
const showAttachmentsButton = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
const attachmentsBtn = document.getElementById('attachmentsHistoryBtn');
|
||||
if (attachmentsBtn) {
|
||||
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
|
||||
}
|
||||
|
||||
// Multi-monitor button — hidden by default (App Settings → Display → "Header
|
||||
// Displays"). The server renders the correct initial state on every reload;
|
||||
// this handles a live toggle from a settings save (no reload). Toggle the
|
||||
@@ -1770,6 +1812,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
|
||||
}
|
||||
|
||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
// from the mobile-header-buttons-policy guard.
|
||||
const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
|
||||
const ultracodeBtn = document.querySelector('.btn-ultracode-agents');
|
||||
if (ultracodeBtn) {
|
||||
ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents);
|
||||
}
|
||||
|
||||
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
|
||||
// Limits"). Server renders the initial state on reload; this handles a live
|
||||
// toggle from a settings save. Marker class (base is display:inline-flex
|
||||
// !important), matching the response-viewer/multimonitor pattern.
|
||||
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
const planUsageChip = document.getElementById('planUsageChip');
|
||||
if (planUsageChip) {
|
||||
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
|
||||
}
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -1831,6 +1892,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
|
||||
// Ultracode agents panel visibility (SYNCED setting — not in displayKeys)
|
||||
const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
|
||||
const ultracodePanel = document.getElementById('ultracodeAgentsPanel');
|
||||
if (ultracodePanel) {
|
||||
if (showUltracodeAgents) {
|
||||
ultracodePanel.classList.remove('hidden');
|
||||
} else {
|
||||
ultracodePanel.classList.remove('open');
|
||||
ultracodePanel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
// Floating ultracode run windows have their OWN opt-in (default OFF), independent of the
|
||||
// docked panel above: pop active runs when enabled, tear them all down when disabled
|
||||
// (additional layer — ultracode-windows.js).
|
||||
const ultracodeFloatingWindows = settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
if (ultracodeFloatingWindows) {
|
||||
if (typeof this.syncAllUltracodeFloatingWindows === 'function') this.syncAllUltracodeFloatingWindows();
|
||||
} else if (typeof this.removeAllUltracodeWindows === 'function') {
|
||||
this.removeAllUltracodeWindows();
|
||||
}
|
||||
|
||||
// File browser panel visibility
|
||||
const fileBrowserPanel = document.getElementById('fileBrowserPanel');
|
||||
if (fileBrowserPanel) {
|
||||
@@ -1952,6 +2034,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async loadAppSettingsFromServer(settingsPromise = null) {
|
||||
// One-time migration: showPlanUsageLimits became a per-device display setting.
|
||||
// Before this, it synced from the server, so the (separate) mobile settings blob
|
||||
// may carry a stale `true` the user never enabled on this device. Clear it once
|
||||
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
|
||||
try {
|
||||
if (
|
||||
MobileDetection.getDeviceType() === 'mobile' &&
|
||||
!localStorage.getItem('codeman:planUsagePerDeviceMigrated')
|
||||
) {
|
||||
const s = this.loadAppSettingsFromStorage();
|
||||
if (s && s.showPlanUsageLimits) {
|
||||
s.showPlanUsageLimits = false;
|
||||
this.saveAppSettingsToStorage(s);
|
||||
}
|
||||
localStorage.setItem('codeman:planUsagePerDeviceMigrated', '1');
|
||||
}
|
||||
} catch {
|
||||
/* best-effort migration */
|
||||
}
|
||||
try {
|
||||
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env);
|
||||
if (settings) {
|
||||
@@ -1966,8 +2067,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showLifecycleLog', 'showResponseViewer',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
|
||||
// can show it while mobile stays hidden. It used to sync, so an older
|
||||
// server.json may still carry `true` — drop it so the server value is NEVER
|
||||
// seeded into a device that didn't explicitly enable it (collection is handled
|
||||
// separately via the statusLineTelemetry action, not this display flag).
|
||||
delete appSettings.showPlanUsageLimits;
|
||||
// Merge settings: non-display keys always sync from server,
|
||||
// display keys only seed from server when localStorage has no value
|
||||
// (prevents cross-device overwrite while fixing settings re-enabling on fresh loads)
|
||||
|
||||
@@ -312,6 +312,13 @@ body {
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.session-tabs.tabs-auto-wrap {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 96px;
|
||||
}
|
||||
|
||||
.session-tabs::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 0;
|
||||
@@ -1107,6 +1114,7 @@ body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
body.solo-mode .btn-multimonitor,
|
||||
body.solo-mode .header-plan-usage,
|
||||
body.solo-mode .btn-lifecycle-log {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -3943,6 +3951,12 @@ body.solo-mode .btn-lifecycle-log {
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
flex: 0 1 auto;
|
||||
/* Match the field to its settings row: the base .form-select (0.8rem / 0.5rem
|
||||
padding) renders bigger and taller than the row, and the daylight skins'
|
||||
Manrope font exaggerates it further. Slim it to the row's 0.75rem text. */
|
||||
font-size: 0.75rem;
|
||||
padding-top: 0.3rem;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.settings-item-multiline {
|
||||
@@ -7225,12 +7239,22 @@ kbd {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.file-preview-body audio {
|
||||
display: block;
|
||||
width: 90%;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.file-preview-body .binary-message {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-preview-body .binary-message a {
|
||||
color: var(--accent, #4ea1ff);
|
||||
}
|
||||
|
||||
.file-preview-footer {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
@@ -8321,6 +8345,396 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Attachments" header button — opt-in (App Settings → Display), hidden by
|
||||
default. Same pattern as the response viewer: a base inline-flex !important so
|
||||
an inline style can't override it, and a more-specific marker rule to hide. */
|
||||
.btn-attachments-history {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-attachments-history.btn-attachments-history--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Ultracode Agents" header launcher — opt-in (App Settings → Display), hidden by
|
||||
default everywhere (so the mobile-header-buttons-policy guard auto-excludes it).
|
||||
Base inline-flex !important + a more-specific marker rule to hide. */
|
||||
.btn-ultracode-agents {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-ultracode-agents.btn-ultracode-agents--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ===== Ultracode / Workflow agents master-detail panel ===== */
|
||||
/* Reuses .subagents-panel dock behavior but anchors LEFT so it never overlaps the
|
||||
bottom-right subagents panel when both are open. */
|
||||
.ultracode-agents-panel {
|
||||
right: auto;
|
||||
left: 0.5rem;
|
||||
width: 640px;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.ultracode-agents-panel {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* LEFT pane: run rows */
|
||||
.ultracode-run-item {
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input);
|
||||
margin-bottom: 0.4rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ultracode-run-item:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.ultracode-run-item.selected {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
.ultracode-run-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.ultracode-run-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ultracode-run-stats {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.ultracode-status {
|
||||
font-size: 0.58rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
text-transform: lowercase;
|
||||
background: var(--text-muted);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ultracode-status.completed {
|
||||
background: var(--success);
|
||||
}
|
||||
.ultracode-status.active {
|
||||
background: var(--warning);
|
||||
color: black;
|
||||
}
|
||||
.ultracode-status.failed {
|
||||
background: var(--error, #b3261e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* LEFT pane: phase chips for the selected run */
|
||||
.ultracode-phase-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.ultracode-phase-chip {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ultracode-phase-chip:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.ultracode-phase-chip.selected {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* RIGHT pane: per-phase header + agent cards */
|
||||
.ultracode-phase-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
padding-bottom: 0.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ultracode-phase-sub {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.ultracode-agent-card {
|
||||
background: var(--bg-input);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.ultracode-agent-card--clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ultracode-agent-card--clickable:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.ultracode-agent-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.ultracode-agent-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ultracode-agent-state {
|
||||
font-size: 0.56rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-muted);
|
||||
color: white;
|
||||
}
|
||||
.ultracode-agent-state.completed {
|
||||
background: var(--success);
|
||||
}
|
||||
.ultracode-agent-state.active {
|
||||
background: var(--warning);
|
||||
color: black;
|
||||
}
|
||||
.ultracode-agent-state.idle {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.ultracode-agent-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.ultracode-chip {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.ultracode-chip-tok {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ultracode-chip-tool {
|
||||
color: var(--text);
|
||||
}
|
||||
.ultracode-agent-sub {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ========== Ultracode floating run windows (additional to the dock panel) ========== */
|
||||
/* A draggable window that auto-pops while a Workflow/ultracode run is active and is
|
||||
tied by a glowing connector line to the originating session tab (ultracode-windows.js). */
|
||||
.ultracode-window {
|
||||
position: fixed;
|
||||
width: 380px;
|
||||
height: 320px;
|
||||
min-width: 260px;
|
||||
min-height: 140px;
|
||||
max-width: 92vw;
|
||||
max-height: 80vh;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid #a855f7;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(168, 85, 247, 0.15);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
contain: layout paint;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
.ultracode-window.spawning {
|
||||
transform: scale(0.92);
|
||||
opacity: 0;
|
||||
}
|
||||
.ultracode-window.collapsed {
|
||||
height: auto !important;
|
||||
resize: none;
|
||||
}
|
||||
.ultracode-window.collapsed .ultracode-window-body {
|
||||
display: none;
|
||||
}
|
||||
.ultracode-window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: move;
|
||||
}
|
||||
.ultracode-window-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ultracode-window-title .icon {
|
||||
font-size: 0.95rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ultracode-window-title .uw-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ultracode-window-title .uw-status {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
.ultracode-window-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ultracode-window-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.ultracode-window-actions button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.ultracode-window-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg);
|
||||
min-height: 0;
|
||||
}
|
||||
.ultracode-window-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.ultracode-window-body::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ultracode-window-body .uw-summary {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* Ultracode connector line — distinct purple to set it apart from blue subagent lines. */
|
||||
.connection-line.ultracode-connection {
|
||||
stroke: #a855f7;
|
||||
stroke-width: 3;
|
||||
stroke-dasharray: 6 3;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
|
||||
drop-shadow(0 0 5px rgba(168, 85, 247, 0.85))
|
||||
drop-shadow(0 0 10px rgba(168, 85, 247, 0.5));
|
||||
animation: ultracode-conn-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.connection-line.ultracode-connection:hover {
|
||||
stroke-width: 3.5;
|
||||
}
|
||||
@keyframes ultracode-conn-pulse {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
|
||||
default via the marker class below; the server strips it at render when the
|
||||
setting is on and the client toggles it live on save. */
|
||||
.header-plan-usage {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 11px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
/* Readable two-window layout: dim uppercase label + bold, color-coded value. */
|
||||
.header-plan-usage .pu-win {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.header-plan-usage .pu-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
opacity: 0.9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.header-plan-usage .pu-val {
|
||||
font-weight: 700;
|
||||
}
|
||||
.header-plan-usage .pu-sep {
|
||||
margin: 0 7px;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.45;
|
||||
}
|
||||
/* Green/yellow/red by how much of the window is used up. */
|
||||
.header-plan-usage .pu-green {
|
||||
color: #3fb950;
|
||||
}
|
||||
.header-plan-usage .pu-yellow {
|
||||
color: #d6a01a;
|
||||
}
|
||||
.header-plan-usage .pu-red {
|
||||
color: #f85149;
|
||||
}
|
||||
/* Marker class must out-specify the display:inline-flex !important above. */
|
||||
.header-plan-usage.header-plan-usage--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -8895,6 +9309,7 @@ kbd {
|
||||
display: block;
|
||||
min-height: 44px;
|
||||
max-height: 96px;
|
||||
padding: 12px 10px;
|
||||
border: 1px solid rgba(80, 120, 190, 0.55);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
@@ -9175,6 +9590,386 @@ body.touch-device.cjk-input-visible .main {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.attachment-card-stack {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: calc(var(--toolbar-height) + 18px);
|
||||
z-index: 1900;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: min(540px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.attachment-clear-all {
|
||||
align-self: flex-end;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 5px;
|
||||
background: rgba(19, 19, 22, 0.96);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.attachment-clear-all:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.attachment-clear-all[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.attachment-card {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr) 24px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(19, 19, 22, 0.96);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.attachment-thumbnail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96px;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: #f8f8fb;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.attachment-thumbnail-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment-thumbnail-fallback {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: var(--text);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
background: #20202a;
|
||||
}
|
||||
|
||||
.attachment-thumbnail-fallback.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.attachment-type-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 6px;
|
||||
background: #20202a;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-light);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.attachment-card-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-file-name {
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-file-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.attachment-actions button,
|
||||
.attachment-actions a {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-actions button:hover,
|
||||
.attachment-actions a:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.attachment-close-btn {
|
||||
align-self: start;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-close-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* PDF/Office previews render in an iframe inside the preview body. */
|
||||
.file-preview-body iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Positioning context so the absolutely-positioned badge anchors to the icon,
|
||||
not the header bar (mirrors .btn-notifications). */
|
||||
.btn-attachments-history {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.attachment-history-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 1px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Attachment History Drawer */
|
||||
.attachment-history-drawer {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
right: 0;
|
||||
width: 390px;
|
||||
max-width: calc(100vw - 24px);
|
||||
height: calc(100vh - var(--header-height) - var(--toolbar-height));
|
||||
height: calc(100dvh - var(--header-height) - var(--toolbar-height));
|
||||
background: rgba(19, 19, 22, 0.98);
|
||||
border-left: 1px solid var(--border);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.18s ease;
|
||||
box-shadow: -10px 0 28px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.attachment-history-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.attachment-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachment-history-title {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.attachment-history-subtitle {
|
||||
margin-top: 2px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.attachment-history-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.attachment-history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.attachment-history-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 160px;
|
||||
padding: 24px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attachment-history-empty-title {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attachment-history-empty code {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
padding: 5px 7px;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.attachment-history-item {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.attachment-history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.attachment-history-item.missing {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.attachment-history-thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 112px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
background: #f8f8fb;
|
||||
}
|
||||
|
||||
.attachment-history-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.attachment-history-thumb-fallback {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #20202a;
|
||||
color: var(--text);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.attachment-history-thumb-fallback.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.attachment-history-item-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-history-file-name {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-history-meta {
|
||||
margin-top: 2px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
|
||||
.attachment-history-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.attachment-history-actions button {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
font-size: 0.67rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-history-actions button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.attachment-history-actions button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
=== v1.0 Carbon Aurora · Daylight overrides ===
|
||||
Single appended block — trivially removable. Retones the hardcoded
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -114,8 +114,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.isComposing || ev.keyCode === 229) return false;
|
||||
|
||||
// Let Alt+digit pass through to browser (tab switching)
|
||||
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
|
||||
// Let the app's Alt/Option session-nav shortcuts reach the document keydown handler
|
||||
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
|
||||
// the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so
|
||||
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“") are suppressed here too and
|
||||
// don't leak an escape sequence into the focused terminal on every tab switch.
|
||||
if (ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code || '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
|
||||
// Route through our paste trap which handles both images and text.
|
||||
@@ -937,6 +943,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.loadTunnelStatus();
|
||||
this.loadHistorySessions();
|
||||
}
|
||||
// Home screen has no input target — hide the CJK textarea (activeSessionId
|
||||
// is null by the time we get here). Guarded: defined on the app object.
|
||||
this._updateCjkInputState?.();
|
||||
},
|
||||
|
||||
hideWelcome() {
|
||||
@@ -950,6 +959,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
clearTimeout(this._welcomeQrShrinkTimer);
|
||||
qrWrap.classList.remove('expanded');
|
||||
}
|
||||
// Entering a session — restore CJK textarea if the user has it enabled
|
||||
// (activeSessionId is already set by selectSession before this call).
|
||||
this._updateCjkInputState?.();
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* @fileoverview Ultracode / Workflow run visualization — master-detail dock panel.
|
||||
*
|
||||
* Mirrors Claude Code's "working agents" TUI: LEFT pane = runs and their phases
|
||||
* (selectable "tasks"), RIGHT pane = the selected run's agents with model, live
|
||||
* state, TOKENS burned, and TOOL CALLS. Opt-in via the `showUltracodeAgents`
|
||||
* setting; the launcher button + panel are hidden until enabled.
|
||||
*
|
||||
* Data: run SUMMARIES arrive via getLightState (`data.workflowRuns`) and the
|
||||
* `workflow:run_*` SSE events (LEFT list). The full run (with agents[]) is fetched
|
||||
* per-run from GET /api/workflows/:runId when a run is selected (RIGHT pane).
|
||||
*
|
||||
* Standalone: reads only the workflow-run endpoints; never touches subagent state.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @loadorder 11.5 (after panels-ui.js, before session-ui.js)
|
||||
*/
|
||||
/* global CodemanApp, SSE_EVENTS, escapeHtml */
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Ensure workflow state maps exist (lazy — constructor also seeds them). */
|
||||
_ensureWorkflowState() {
|
||||
if (!this.workflowRuns) this.workflowRuns = new Map(); // runId -> summary
|
||||
if (!this.workflowRunDetails) this.workflowRunDetails = new Map(); // runId -> full run (with agents)
|
||||
if (this.activeWorkflowRunId === undefined) this.activeWorkflowRunId = null;
|
||||
if (this.activeWorkflowPhaseIndex === undefined) this.activeWorkflowPhaseIndex = null;
|
||||
},
|
||||
|
||||
/** Seed the LEFT list from a getLightState snapshot (array of run summaries). */
|
||||
seedWorkflowRuns(summaries) {
|
||||
this._ensureWorkflowState();
|
||||
this.workflowRuns.clear();
|
||||
(summaries || []).forEach((s) => this.workflowRuns.set(s.runId, s));
|
||||
// Restore floating windows for runs that are still active & recent (additional layer).
|
||||
if (typeof this._syncUltracodeFloatingWindow === 'function') {
|
||||
(summaries || []).forEach((s) => this._syncUltracodeFloatingWindow(s, { fromSeed: true }));
|
||||
}
|
||||
this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
|
||||
// ----- SSE handlers (wired in app.js _SSE_HANDLER_MAP) -----
|
||||
_onWorkflowRunDiscovered(data) {
|
||||
this._upsertWorkflowRun(data);
|
||||
},
|
||||
_onWorkflowRunUpdated(data) {
|
||||
this._upsertWorkflowRun(data);
|
||||
},
|
||||
_onWorkflowRunRemoved(data) {
|
||||
this._ensureWorkflowState();
|
||||
if (!data || !data.runId) return;
|
||||
this.workflowRuns.delete(data.runId);
|
||||
this.workflowRunDetails.delete(data.runId);
|
||||
if (this.activeWorkflowRunId === data.runId) this.activeWorkflowRunId = null;
|
||||
// Retire the floating run window too (additional layer — ultracode-windows.js).
|
||||
if (typeof this.closeUltracodeWindow === 'function') this.closeUltracodeWindow(data.runId, false);
|
||||
this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
|
||||
_upsertWorkflowRun(summary) {
|
||||
this._ensureWorkflowState();
|
||||
if (!summary || !summary.runId) return;
|
||||
this.workflowRuns.set(summary.runId, summary);
|
||||
// If the live-updating run is the one open in the detail pane, refresh its agents.
|
||||
if (this.activeWorkflowRunId === summary.runId) {
|
||||
this._fetchWorkflowRunDetail(summary.runId);
|
||||
}
|
||||
// Auto-pop / refresh the floating run window for active runs (additional layer).
|
||||
if (typeof this._syncUltracodeFloatingWindow === 'function') this._syncUltracodeFloatingWindow(summary);
|
||||
this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
|
||||
// ----- Panel open/close -----
|
||||
toggleUltracodeAgentsPanel() {
|
||||
const panel = document.getElementById('ultracodeAgentsPanel');
|
||||
if (!panel) return;
|
||||
panel.classList.remove('hidden');
|
||||
panel.classList.toggle('open');
|
||||
if (panel.classList.contains('open')) this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
closeUltracodeAgentsPanel() {
|
||||
// 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');
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
// ----- Selection -----
|
||||
selectWorkflowRun(runId) {
|
||||
this._ensureWorkflowState();
|
||||
this.activeWorkflowRunId = runId;
|
||||
this.activeWorkflowPhaseIndex = null; // reset phase filter on run change
|
||||
this._fetchWorkflowRunDetail(runId);
|
||||
this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
selectWorkflowPhase(phaseIndex) {
|
||||
this._ensureWorkflowState();
|
||||
// phaseIndex null => show all phases
|
||||
this.activeWorkflowPhaseIndex = phaseIndex === null || phaseIndex === undefined ? null : Number(phaseIndex);
|
||||
this._renderUltracodeDetail();
|
||||
},
|
||||
|
||||
async _fetchWorkflowRunDetail(runId) {
|
||||
try {
|
||||
const res = await fetch(`/api/workflows/${encodeURIComponent(runId)}`);
|
||||
const env = await res.json();
|
||||
const run = env && env.success ? env.data : null;
|
||||
if (run) {
|
||||
this.workflowRunDetails.set(runId, run);
|
||||
if (this.activeWorkflowRunId === runId) this._renderUltracodeDetail();
|
||||
// Refresh the floating window (if one is open for this run) with the fetched agents[].
|
||||
if (this.ultracodeWindows && this.ultracodeWindows.has(runId)) this.renderUltracodeWindowContent(runId);
|
||||
}
|
||||
} catch {
|
||||
/* transient — next update retries */
|
||||
}
|
||||
},
|
||||
|
||||
// Phase 4: open an agent's live transcript by agentId. The workflow agent's
|
||||
// agentId is byte-identical to the agent-<id>.jsonl stem already tracked by
|
||||
// subagent-watcher, so we reuse the existing transcript route — no watcher edits.
|
||||
// Graceful when the agent isn't tracked yet / aged out / tracking disabled.
|
||||
async openWorkflowAgentTranscript(agentId) {
|
||||
if (!agentId) return;
|
||||
let data = null;
|
||||
try {
|
||||
const res = await fetch(`/api/subagents/${encodeURIComponent(agentId)}/transcript?format=formatted`);
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
const ok = data && data.success && data.data;
|
||||
const formatted = ok ? data.data.formatted : null;
|
||||
const entryCount = ok ? data.data.entryCount || 0 : 0;
|
||||
if (!formatted || !entryCount) {
|
||||
alert(
|
||||
'No transcript available for this agent yet — it may be queued, aged out of tracking, or subagent tracking is disabled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const win = window.open('', '_blank', 'width=860,height=640');
|
||||
if (!win) return; // popup blocked
|
||||
win.document.write(
|
||||
`<html><head><title>Workflow agent ${escapeHtml(agentId)} transcript</title>` +
|
||||
`<style>body{background:#1a1a2e;color:#eee;font-family:monospace;padding:20px}pre{white-space:pre-wrap;word-wrap:break-word}</style>` +
|
||||
`</head><body><h2>Workflow agent ${escapeHtml(agentId)} (${entryCount} entries)</h2>` +
|
||||
`<pre>${escapeHtml(formatted.join('\n'))}</pre></body></html>`
|
||||
);
|
||||
win.document.close();
|
||||
},
|
||||
|
||||
// ----- Render (debounced) -----
|
||||
renderUltracodeAgentsPanel() {
|
||||
clearTimeout(this._ultracodeRenderTimer);
|
||||
this._ultracodeRenderTimer = setTimeout(() => this._renderUltracodeAgentsPanelImmediate(), 150);
|
||||
},
|
||||
|
||||
_renderUltracodeAgentsPanelImmediate() {
|
||||
this._ensureWorkflowState();
|
||||
const panel = document.getElementById('ultracodeAgentsPanel');
|
||||
if (!panel) return;
|
||||
const badge = document.getElementById('ultracodeCountBadge');
|
||||
if (badge) badge.textContent = this.workflowRuns.size ? String(this.workflowRuns.size) : '';
|
||||
this._renderUltracodeRunList();
|
||||
this._renderUltracodeDetail();
|
||||
},
|
||||
|
||||
_renderUltracodeRunList() {
|
||||
const list = document.getElementById('ultracodeRunList');
|
||||
if (!list) return;
|
||||
const runs = Array.from(this.workflowRuns.values()).sort(
|
||||
(a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0)
|
||||
);
|
||||
if (!runs.length) {
|
||||
list.innerHTML = '<div class="subagent-empty">No ultracode runs detected</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = runs.map((r) => this._workflowRunRowHtml(r)).join('');
|
||||
},
|
||||
|
||||
_workflowRunRowHtml(r) {
|
||||
const active = r.runId === this.activeWorkflowRunId;
|
||||
const name = escapeHtml(r.workflowName || r.summary || r.runId);
|
||||
const status = String(r.status || '');
|
||||
const statusCls = this._workflowStatusClass(status);
|
||||
const stats = `${r.agentCount ?? 0} agents · ${this._fmtNum(r.totalTokens)} tok · ${r.totalToolCalls ?? 0} tools`;
|
||||
let phasesHtml = '';
|
||||
if (active && Array.isArray(r.phases) && r.phases.length) {
|
||||
const allActive = this.activeWorkflowPhaseIndex === null ? ' selected' : '';
|
||||
const chips = [
|
||||
`<div class="ultracode-phase-chip${allActive}" onclick="event.stopPropagation();app.selectWorkflowPhase(null)">All</div>`,
|
||||
];
|
||||
r.phases.forEach((p, i) => {
|
||||
const sel = this.activeWorkflowPhaseIndex === i + 1 ? ' selected' : '';
|
||||
chips.push(
|
||||
`<div class="ultracode-phase-chip${sel}" title="${escapeHtml(p.detail || '')}" onclick="event.stopPropagation();app.selectWorkflowPhase(${i + 1})">${escapeHtml(p.title || 'Phase ' + (i + 1))}</div>`
|
||||
);
|
||||
});
|
||||
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
|
||||
}
|
||||
return (
|
||||
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun('${escapeHtml(r.runId)}')">` +
|
||||
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
|
||||
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
|
||||
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
|
||||
phasesHtml +
|
||||
`</div>`
|
||||
);
|
||||
},
|
||||
|
||||
_renderUltracodeDetail() {
|
||||
const detail = document.getElementById('ultracodeAgentGrid');
|
||||
if (!detail) return;
|
||||
const runId = this.activeWorkflowRunId;
|
||||
if (!runId) {
|
||||
detail.innerHTML = '<div class="subagent-empty">Select a run to view its agents</div>';
|
||||
return;
|
||||
}
|
||||
const run = this.workflowRunDetails.get(runId);
|
||||
if (!run) {
|
||||
detail.innerHTML = '<div class="subagent-empty">Loading agents…</div>';
|
||||
return;
|
||||
}
|
||||
const phases = Array.isArray(run.phases) ? run.phases : [];
|
||||
let agents = Array.isArray(run.agents) ? run.agents : [];
|
||||
if (this.activeWorkflowPhaseIndex !== null) {
|
||||
agents = agents.filter((a) => a.phaseIndex === this.activeWorkflowPhaseIndex);
|
||||
}
|
||||
if (!agents.length) {
|
||||
detail.innerHTML = '<div class="subagent-empty">No agents in this view</div>';
|
||||
return;
|
||||
}
|
||||
// Group agents by phaseIndex, in phase order.
|
||||
const groups = new Map();
|
||||
agents.forEach((a) => {
|
||||
const key = a.phaseIndex || 0;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(a);
|
||||
});
|
||||
const orderedKeys = Array.from(groups.keys()).sort((a, b) => a - b);
|
||||
const html = orderedKeys
|
||||
.map((key) => {
|
||||
const group = groups.get(key);
|
||||
const title = (phases[key - 1] && phases[key - 1].title) || `Phase ${key}`;
|
||||
const tok = group.reduce((s, a) => s + (a.tokens || 0), 0);
|
||||
const tools = group.reduce((s, a) => s + (a.toolCalls || 0), 0);
|
||||
const header =
|
||||
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
||||
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
||||
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
|
||||
})
|
||||
.join('');
|
||||
detail.innerHTML = html;
|
||||
},
|
||||
|
||||
_workflowAgentCardHtml(a) {
|
||||
const state = String(a.state || 'start');
|
||||
const stateCls = this._workflowAgentStateClass(state);
|
||||
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state;
|
||||
const model = this._modelShort(a.model);
|
||||
const tokens = a.tokens === undefined ? '—' : this._fmtNum(a.tokens);
|
||||
const tools = a.toolCalls === undefined ? '—' : String(a.toolCalls);
|
||||
let secondary = '';
|
||||
if (state === 'done' && a.resultPreview) {
|
||||
secondary = escapeHtml(a.resultPreview);
|
||||
} else if (a.lastToolName) {
|
||||
secondary = escapeHtml(a.lastToolName + (a.lastToolSummary ? ' · ' + a.lastToolSummary : ''));
|
||||
}
|
||||
// Phase 4: cards with an agentId open the live transcript (the agentId is byte-identical
|
||||
// to the agent-<id>.jsonl stem already tracked by subagent-watcher). 'start' agents have
|
||||
// no agentId yet, so they stay non-clickable.
|
||||
const clickable = !!a.agentId;
|
||||
const cardAttrs = clickable
|
||||
? ` class="ultracode-agent-card ultracode-agent-card--clickable" role="button" tabindex="0"` +
|
||||
` title="View transcript" onclick="app.openWorkflowAgentTranscript('${escapeHtml(a.agentId)}')"`
|
||||
: ` class="ultracode-agent-card"`;
|
||||
return (
|
||||
`<div${cardAttrs}>` +
|
||||
`<div class="ultracode-agent-top">` +
|
||||
`<span class="ultracode-agent-label">${escapeHtml(a.label || 'agent')}</span>` +
|
||||
`<span class="ultracode-agent-state ${stateCls}">${escapeHtml(stateLabel)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="ultracode-agent-meta">` +
|
||||
`<span class="ultracode-chip" title="model">${escapeHtml(model)}</span>` +
|
||||
`<span class="ultracode-chip ultracode-chip-tok" title="tokens burned">${tokens} tok</span>` +
|
||||
`<span class="ultracode-chip ultracode-chip-tool" title="tool calls">${tools} tools</span>` +
|
||||
`</div>` +
|
||||
(secondary ? `<div class="ultracode-agent-sub">${secondary}</div>` : '') +
|
||||
`</div>`
|
||||
);
|
||||
},
|
||||
|
||||
// ----- helpers -----
|
||||
_workflowStatusClass(status) {
|
||||
if (status === 'completed') return 'completed';
|
||||
if (status === 'running') return 'active';
|
||||
if (status === 'killed' || status === 'failed') return 'failed';
|
||||
return '';
|
||||
},
|
||||
_workflowAgentStateClass(state) {
|
||||
if (state === 'done') return 'completed';
|
||||
if (state === 'progress') return 'active';
|
||||
return 'idle'; // start / queued
|
||||
},
|
||||
_modelShort(model) {
|
||||
if (!model) return '';
|
||||
return String(model)
|
||||
.replace(/^claude-/, '')
|
||||
.replace(/-\d{8}$/, '');
|
||||
},
|
||||
_fmtNum(n) {
|
||||
if (n === undefined || n === null) return '0';
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return String(n);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
},
|
||||
});
|
||||
+3
File diff suppressed because one or more lines are too long
+448
-26
@@ -11,15 +11,22 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
AttachmentRegistrationError,
|
||||
attachmentRecordToEvent,
|
||||
attachmentRegistry,
|
||||
buildFileThumbnailRoute,
|
||||
isSupportedAttachmentExtension,
|
||||
registerExternalAttachment,
|
||||
type AttachmentRecord,
|
||||
} from '../../attachment-registry.js';
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, SessionPort } from '../ports/index.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
@@ -159,7 +166,207 @@ async function resolveServableAttachmentPath(
|
||||
return resolved ? pathToCheck : record.filePath;
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||
/**
|
||||
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
|
||||
* stream it inline. PDF/PNG and text formats don't need conversion — callers
|
||||
* redirect those to the raw route instead.
|
||||
*/
|
||||
async function serveConvertedPreview(
|
||||
reply: FastifyReply,
|
||||
resolvedPath: string,
|
||||
fileName: string,
|
||||
extension: string
|
||||
): Promise<void> {
|
||||
if (extension !== 'docx' && extension !== 'pptx') {
|
||||
reply
|
||||
.code(400)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Preview is not supported for this file type'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const previewPath = await getOfficePreviewPdfPath(resolvedPath, extension);
|
||||
if (!previewPath) {
|
||||
reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Document preview conversion failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(previewPath);
|
||||
reply.header('Content-Type', 'application/pdf');
|
||||
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('Content-Length', content.length);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.send(content);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to generate preview: ${getErrorMessage(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate and stream a first-page thumbnail (PNG) for a supported attachment. */
|
||||
async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extension: string): Promise<void> {
|
||||
const thumbnail = await generateFirstPageThumbnail(resolvedPath, extension);
|
||||
if (!thumbnail) {
|
||||
reply.code(204).send();
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Content-Type', thumbnail.contentType);
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.send(thumbnail.content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session's working dir from the live session, falling back to the
|
||||
* persisted record so preview/thumbnail requests keep working for a session
|
||||
* that has since detached. Sends a 404 and returns undefined when unknown.
|
||||
*/
|
||||
function getKnownSessionWorkingDir(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string,
|
||||
reply: FastifyReply
|
||||
): string | undefined {
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) return liveSession.workingDir;
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
if (stored) return stored.workingDir;
|
||||
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Persisted sessions carry the private (externalPath-bearing) history under a
|
||||
// `__attachmentHistory` key so the list route can re-register external files.
|
||||
type StoredSessionWithPrivateAttachmentHistory = SessionState & {
|
||||
__attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
};
|
||||
|
||||
type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPath'> & {
|
||||
missing: boolean;
|
||||
rawUrl?: string;
|
||||
url?: string;
|
||||
previewUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
downloadUrl?: string;
|
||||
attachmentId?: string;
|
||||
};
|
||||
|
||||
function appendDownloadFlag(url: string): string {
|
||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
||||
}
|
||||
|
||||
function getSessionAttachmentHistory(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string
|
||||
): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined {
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) {
|
||||
return {
|
||||
workingDir: liveSession.workingDir,
|
||||
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined;
|
||||
if (!stored) return undefined;
|
||||
|
||||
return {
|
||||
workingDir: stored.workingDir,
|
||||
history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// History item for a file detected inside the workspace: re-stat for live
|
||||
// size/mtime and resolve preview/thumbnail/raw routes off the relative path.
|
||||
async function buildDetectedAttachmentRouteItem(
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
item: SessionAttachmentHistoryItem
|
||||
): Promise<AttachmentHistoryRouteItem> {
|
||||
const safe = sanitizeAttachmentHistoryItem(item);
|
||||
if (!item.relativePath) {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(workingDir, item.relativePath);
|
||||
if (!validated) {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
|
||||
let size = item.size;
|
||||
let mtimeMs = item.mtimeMs;
|
||||
try {
|
||||
const stat = await fs.stat(validated.resolvedPath);
|
||||
size = stat.size;
|
||||
mtimeMs = stat.mtimeMs ?? mtimeMs;
|
||||
} catch {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
|
||||
const encodedPath = encodeURIComponent(item.relativePath);
|
||||
const rawUrl = `/api/sessions/${sessionId}/file-raw?path=${encodedPath}`;
|
||||
const previewUrl =
|
||||
item.extension === 'docx' || item.extension === 'pptx'
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodedPath}`
|
||||
: rawUrl;
|
||||
const thumbnailUrl = isSupportedAttachmentExtension(item.extension)
|
||||
? buildFileThumbnailRoute(sessionId, item.relativePath)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...safe,
|
||||
size,
|
||||
mtimeMs,
|
||||
missing: false,
|
||||
rawUrl,
|
||||
url: rawUrl,
|
||||
previewUrl,
|
||||
thumbnailUrl,
|
||||
downloadUrl: appendDownloadFlag(rawUrl),
|
||||
};
|
||||
}
|
||||
|
||||
// History item for an explicitly published external file: re-register it to mint
|
||||
// a fresh id + by-id routes (the guard runs again), or mark it missing.
|
||||
async function buildExternalAttachmentRouteItem(
|
||||
sessionId: string,
|
||||
item: SessionAttachmentHistoryItem,
|
||||
sessionWorkingDir?: string
|
||||
): Promise<AttachmentHistoryRouteItem> {
|
||||
const safe = sanitizeAttachmentHistoryItem(item);
|
||||
if (!item.externalPath) {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await registerExternalAttachment(sessionId, item.externalPath, { sessionWorkingDir });
|
||||
return {
|
||||
...safe,
|
||||
fileName: event.fileName,
|
||||
extension: event.extension,
|
||||
attachmentType: event.attachmentType,
|
||||
size: event.size,
|
||||
missing: false,
|
||||
attachmentId: event.attachmentId,
|
||||
rawUrl: event.rawUrl,
|
||||
url: event.rawUrl,
|
||||
previewUrl: event.previewUrl,
|
||||
thumbnailUrl: event.thumbnailUrl,
|
||||
downloadUrl: appendDownloadFlag(event.rawUrl),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AttachmentRegistrationError) {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -304,49 +511,73 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
|
||||
// Check if it's a binary/media file
|
||||
// Classify by extension. Known media types render with a dedicated player;
|
||||
// other known-binary types are flagged so the client offers a download
|
||||
// affordance instead of trying to decode the bytes as text. Matches the
|
||||
// breadth of formats the attachments viewer renders (image/audio/video/pdf)
|
||||
// so the file viewer can open the same files.
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const binaryExts = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'ico',
|
||||
'svg',
|
||||
'bmp',
|
||||
'mp4',
|
||||
'webm',
|
||||
'mov',
|
||||
'avi',
|
||||
'mp3',
|
||||
'wav',
|
||||
'ogg',
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
||||
const videoExts = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']);
|
||||
const audioExts = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
|
||||
const otherBinaryExts = new Set([
|
||||
'pdf',
|
||||
'zip',
|
||||
'tar',
|
||||
'gz',
|
||||
'bz2',
|
||||
'xz',
|
||||
'7z',
|
||||
'rar',
|
||||
'exe',
|
||||
'dll',
|
||||
'so',
|
||||
'dylib',
|
||||
'bin',
|
||||
'wasm',
|
||||
'class',
|
||||
'o',
|
||||
'a',
|
||||
'woff',
|
||||
'woff2',
|
||||
'ttf',
|
||||
'eot',
|
||||
'otf',
|
||||
'xlsx',
|
||||
'xls',
|
||||
'doc',
|
||||
'docx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'odt',
|
||||
'ods',
|
||||
'odp',
|
||||
'avi',
|
||||
'mkv',
|
||||
'wmv',
|
||||
'flv',
|
||||
]);
|
||||
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
|
||||
const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']);
|
||||
|
||||
if (raw === 'true' || binaryExts.has(ext)) {
|
||||
// Return metadata for binary files
|
||||
const mediaType = imageExts.has(ext)
|
||||
? 'image'
|
||||
: videoExts.has(ext)
|
||||
? 'video'
|
||||
: audioExts.has(ext)
|
||||
? 'audio'
|
||||
: null;
|
||||
|
||||
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
|
||||
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
|
||||
// Return metadata for media/binary files (no text body)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: filePath,
|
||||
size: stat.size,
|
||||
type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary',
|
||||
type: mediaType ?? 'binary',
|
||||
extension: ext,
|
||||
url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`,
|
||||
url: fileRawUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -360,10 +591,39 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
);
|
||||
}
|
||||
|
||||
// Read as raw bytes so we can sniff for binary content before decoding. An
|
||||
// unrecognized extension (none at all, or a format not listed above) that
|
||||
// is actually binary would otherwise be dumped to the viewer as UTF-8
|
||||
// mojibake; a NUL byte in the first 8KB is a reliable binary signal that
|
||||
// (unlike a static extension list) catches arbitrary binary formats.
|
||||
const fileBuffer = await fs.readFile(resolvedPath);
|
||||
const buf = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(String(fileBuffer));
|
||||
const sniffLength = Math.min(buf.length, 8192);
|
||||
let looksBinary = false;
|
||||
for (let i = 0; i < sniffLength; i++) {
|
||||
if (buf[i] === 0) {
|
||||
looksBinary = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (looksBinary) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: filePath,
|
||||
size: stat.size,
|
||||
type: 'binary',
|
||||
extension: ext,
|
||||
url: fileRawUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Read text file with line limit (bounded to prevent DoS)
|
||||
const MAX_LINES_LIMIT = 10000;
|
||||
const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
|
||||
const content = await fs.readFile(resolvedPath, 'utf-8');
|
||||
const content = buf.toString('utf-8');
|
||||
const allLines = content.split('\n');
|
||||
const truncatedContent = allLines.length > maxLines;
|
||||
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
|
||||
@@ -431,9 +691,16 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
m4v: 'video/mp4',
|
||||
ogv: 'video/ogg',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
oga: 'audio/ogg',
|
||||
opus: 'audio/ogg',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
pdf: 'application/pdf',
|
||||
json: 'application/json',
|
||||
};
|
||||
@@ -495,6 +762,70 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
});
|
||||
|
||||
// List a session's attachment history (live session or persisted), resolving
|
||||
// each entry to current metadata + routes. External entries are re-registered.
|
||||
app.get('/api/sessions/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const sessionHistory = getSessionAttachmentHistory(ctx, id);
|
||||
if (!sessionHistory) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await Promise.all(
|
||||
sessionHistory.history.map((item) =>
|
||||
(item.source === 'external'
|
||||
? buildExternalAttachmentRouteItem(id, item, sessionHistory.workingDir)
|
||||
: buildDetectedAttachmentRouteItem(id, sessionHistory.workingDir, item)
|
||||
).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Metadata poll for a single registered attachment (re-stats for live
|
||||
// size/mtime as the underlying file is rewritten).
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
if (!(await resolveServableAttachmentPath(reply, record, workingDir))) return;
|
||||
const event = attachmentRecordToEvent(record);
|
||||
let size = record.size;
|
||||
let mtimeMs = record.mtimeMs;
|
||||
try {
|
||||
const stat = await fs.stat(record.filePath);
|
||||
size = stat.size;
|
||||
mtimeMs = stat.mtimeMs ?? mtimeMs;
|
||||
} catch {
|
||||
// File temporarily unavailable mid-write — keep cached values.
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: record.fileName,
|
||||
size,
|
||||
mtimeMs,
|
||||
type: record.attachmentType,
|
||||
extension: record.extension,
|
||||
url: event.rawUrl,
|
||||
previewUrl: event.previewUrl,
|
||||
thumbnailUrl: event.thumbnailUrl,
|
||||
attachmentId: record.attachmentId,
|
||||
fileName: record.fileName,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Serve the raw bytes of a registered attachment by id. Re-checks the
|
||||
// attachment-guard policy on every request (defense-in-depth) before streaming.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
||||
@@ -515,6 +846,97 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
});
|
||||
|
||||
// Serve a converted PDF preview of a registered attachment by id. Office docs
|
||||
// convert server-side; PDF/PNG/text redirect to the raw route.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
|
||||
if (!servePath) return;
|
||||
|
||||
// Only Office formats need server-side conversion; PDF/PNG and text formats
|
||||
// (md/txt) preview directly from their raw bytes.
|
||||
if (record.extension !== 'docx' && record.extension !== 'pptx') {
|
||||
reply.redirect(`/api/sessions/${id}/attachments/${encodeURIComponent(attachmentId)}/raw`);
|
||||
return;
|
||||
}
|
||||
|
||||
await serveConvertedPreview(reply, servePath, record.fileName, record.extension);
|
||||
});
|
||||
|
||||
// Serve a first-page thumbnail of a registered attachment by id.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
|
||||
if (!servePath) return;
|
||||
await serveThumbnail(reply, servePath, record.extension);
|
||||
});
|
||||
|
||||
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
|
||||
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
const { resolvedPath } = validated;
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
if (ext !== 'docx' && ext !== 'pptx') {
|
||||
reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await serveConvertedPreview(reply, resolvedPath, filePath, ext);
|
||||
});
|
||||
|
||||
// Serve a first-page thumbnail for a workspace-relative path.
|
||||
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
if (!isSupportedAttachmentExtension(ext)) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnail is not supported for this file type'));
|
||||
return;
|
||||
}
|
||||
|
||||
await serveThumbnail(reply, validated.resolvedPath, ext);
|
||||
});
|
||||
|
||||
// Stream file content via tail -f (SSE endpoint)
|
||||
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -9,6 +9,7 @@ export { registerFileRoutes } from './file-routes.js';
|
||||
export { registerScheduledRoutes } from './scheduled-routes.js';
|
||||
export { registerSystemRoutes } from './system-routes.js';
|
||||
export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerSessionRoutes } from './session-routes.js';
|
||||
export { registerRespawnRoutes } from './respawn-routes.js';
|
||||
|
||||
@@ -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,
|
||||
@@ -45,7 +45,13 @@ import {
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import {
|
||||
writeHooksConfig,
|
||||
updateCaseModel,
|
||||
stripCaseEnvKeys,
|
||||
applyStatusLineConfig,
|
||||
refreshStaleHookSecret,
|
||||
} from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
@@ -73,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;
|
||||
@@ -297,6 +304,28 @@ export function registerSessionRoutes(
|
||||
await updateCaseModel(workingDir, body.modelOverride || null);
|
||||
}
|
||||
|
||||
// Plan-usage statusLine exporter (App Settings → Display → "Plan Usage
|
||||
// Limits"). Claude-only; runs for ANY working dir (linked cases / real repos,
|
||||
// where most sessions live), mirroring updateCaseModel above.
|
||||
//
|
||||
// ADD-ONLY: we never remove on create. Sessions in a repo share one
|
||||
// settings.local.json, so a single create-with-false (e.g. a client whose
|
||||
// synced setting hadn't loaded yet) must NOT yank the statusLine out from
|
||||
// under other live sessions in that repo — that breaks their footer + the
|
||||
// chip's data feed for everyone. The exporter is benign when the chip is off
|
||||
// (the footer just shows session status). isOurs-guarded so a user's own
|
||||
// statusLine is never touched.
|
||||
if ((body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) {
|
||||
await applyStatusLineConfig(workingDir, true);
|
||||
}
|
||||
|
||||
// COD-91 self-heal: refresh a pre-secret hooks block in an existing case so the now
|
||||
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
|
||||
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
|
||||
if ((body.mode ?? 'claude') === 'claude') {
|
||||
await refreshStaleHookSecret(workingDir).catch(() => {});
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (body.mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
@@ -961,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, '')
|
||||
@@ -1264,6 +1294,11 @@ export function registerSessionRoutes(
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} else if (mode !== 'opencode') {
|
||||
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
|
||||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
||||
// the hooks aren't ours or already carry the secret.
|
||||
await refreshStaleHookSecret(casePath).catch(() => {});
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @fileoverview Status-telemetry route.
|
||||
*
|
||||
* Receives Claude Code statusline payloads POSTed by the Codeman-managed
|
||||
* statusLine exporter (see `hooks-config.generateStatusLineCommand`) and
|
||||
* broadcasts the parsed plan-usage limits (5-hour + weekly) to SSE clients for
|
||||
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
|
||||
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
||||
*
|
||||
* Returns a compact plain-text status string for the exporter to print as the
|
||||
* in-terminal footer (print-through), so injecting our statusLine doesn't leave
|
||||
* the terminal footer blank.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { StatusTelemetrySchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
parseStatusTelemetry,
|
||||
parseSessionStatus,
|
||||
formatSessionStatusText,
|
||||
telemetrySignature,
|
||||
type RawStatuslinePayload,
|
||||
} from '../../usage-telemetry.js';
|
||||
import { SessionStatusTelemetry } from '../sse-events.js';
|
||||
import { setLatestPlanUsage } from '../plan-usage-latest.js';
|
||||
import type { SessionPort, EventPort } from '../ports/index.js';
|
||||
|
||||
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||
// Last broadcast telemetry signature per session — the statusline fires on
|
||||
// every assistant message, so we only rebroadcast when the value changes.
|
||||
const lastSig = new Map<string, string>();
|
||||
|
||||
app.post('/api/status-telemetry', async (req, reply) => {
|
||||
const { sessionId, data } = parseBody(StatusTelemetrySchema, req.body);
|
||||
|
||||
reply.type('text/plain; charset=utf-8');
|
||||
|
||||
// Unknown session — minimal footer, no broadcast.
|
||||
if (!ctx.sessions.has(sessionId)) {
|
||||
lastSig.delete(sessionId);
|
||||
return 'codeman';
|
||||
}
|
||||
|
||||
const payload = data as RawStatuslinePayload | undefined;
|
||||
|
||||
// Plan-usage limits (account-wide) → broadcast to the header chip, when
|
||||
// present and changed (the statusline fires on every assistant message).
|
||||
const telemetry = parseStatusTelemetry(payload);
|
||||
if (telemetry) {
|
||||
const sig = telemetrySignature(telemetry);
|
||||
if (lastSig.get(sessionId) !== sig) {
|
||||
lastSig.set(sessionId, sig);
|
||||
// Bound the map across long multi-session runs: prune dead sessions.
|
||||
if (lastSig.size > 256) {
|
||||
for (const id of [...lastSig.keys()]) {
|
||||
if (!ctx.sessions.has(id)) lastSig.delete(id);
|
||||
}
|
||||
}
|
||||
const payload = { sessionId, ...telemetry };
|
||||
setLatestPlanUsage(payload); // replayed in the SSE init snapshot for fresh loads
|
||||
ctx.broadcast(SessionStatusTelemetry, payload);
|
||||
}
|
||||
}
|
||||
|
||||
// In-terminal statusline footer → CURRENT SESSION status (model / tokens /
|
||||
// context %), NOT the plan limits. Available from the first render, even
|
||||
// before rate_limits appears.
|
||||
return formatSessionStatusText(parseSessionStatus(payload));
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
} from '../schemas.js';
|
||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
|
||||
import { applyStatusLineConfig } from '../../hooks-config.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
findSessionOrFail,
|
||||
@@ -530,12 +532,24 @@ export function registerSystemRoutes(
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const merged = { ...existing, ...settings };
|
||||
// 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;
|
||||
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).
|
||||
// Either the docked panel OR the floating windows keep the watcher running.
|
||||
toggleService(
|
||||
((settings.showUltracodeAgents as boolean) ?? false) ||
|
||||
((settings.ultracodeFloatingWindows as boolean) ?? false),
|
||||
workflowRunWatcher,
|
||||
'Workflow run watcher'
|
||||
);
|
||||
|
||||
// Handle image watcher toggle dynamically
|
||||
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled
|
||||
@@ -546,6 +560,22 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
|
||||
// Telemetry COLLECTION is server-side and enable-sticky — when a client turns
|
||||
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
|
||||
// into every ACTIVE Claude session's working dir so the live % starts flowing
|
||||
// immediately (no new session needed). We deliberately never auto-REMOVE here:
|
||||
// the exporter is benign/print-through and a per-repo settings.local.json is
|
||||
// shared by sibling sessions, so one device's "off" must not yank the exporter
|
||||
// another device's chip depends on. Each dir handled once.
|
||||
if (statusLineTelemetry === true) {
|
||||
const dirs = new Set<string>();
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.mode === 'claude' && session.workingDir) dirs.add(session.workingDir);
|
||||
}
|
||||
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
|
||||
}
|
||||
|
||||
// Handle tunnel toggle dynamically
|
||||
if ('tunnelEnabled' in settings) {
|
||||
const tunnelEnabled = settings.tunnelEnabled as boolean;
|
||||
@@ -671,6 +701,27 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Workflow Run Monitoring (ultracode) ==========
|
||||
|
||||
// LEFT-pane list: lightweight run summaries (no agents[]).
|
||||
app.get('/api/workflows', async (req) => {
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const runs = minutes
|
||||
? workflowRunWatcher.getRecentRunSummaries(parseInt(minutes, 10))
|
||||
: workflowRunWatcher.getAllRunSummaries();
|
||||
return { success: true, data: runs };
|
||||
});
|
||||
|
||||
// RIGHT-pane detail: full run incl. agents[] (tokens/toolCalls/state per agent).
|
||||
app.get('/api/workflows/:runId', async (req) => {
|
||||
const { runId } = req.params as { runId: string };
|
||||
const run = workflowRunWatcher.getRun(runId);
|
||||
if (!run) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Workflow run ${runId} not found`);
|
||||
}
|
||||
return { success: true, data: run };
|
||||
});
|
||||
|
||||
// ========== Subagent Monitoring ==========
|
||||
|
||||
app.get('/api/subagents', async (req) => {
|
||||
|
||||
@@ -158,6 +158,8 @@ export const CreateSessionSchema = z.object({
|
||||
effort: effortLevelSchema,
|
||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
/** Inject the plan-usage statusLine exporter into the case (App Settings → Display → "Plan Usage Limits"). Claude-only. */
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
@@ -186,6 +188,51 @@ export const ResizeSchema = z.object({
|
||||
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for POST /api/status-telemetry
|
||||
* Claude Code statusline payload forwarded by the Codeman-managed statusLine
|
||||
* exporter (see hooks-config.generateStatusLineCommand). Validates only the
|
||||
* subset Codeman displays; unknown keys (session_id, transcript_path, cwd, …)
|
||||
* are stripped by z.object. Auth-exempt like /api/hook-event.
|
||||
*/
|
||||
// NOTE: every modeled field is `.nullish()` (not `.optional()`) on purpose.
|
||||
// Claude's statusline blob is officially shipped but undocumented in exact
|
||||
// shape, and `z.optional()` REJECTS an explicit `null` (accepts only
|
||||
// `undefined`) — a single stray `null` (e.g. `cost:{total_cost_usd:null}`)
|
||||
// would 400 the ENTIRE POST before the deliberately-tolerant parser
|
||||
// (usage-telemetry.ts, which only acts on `typeof === 'number'/'string'`) ever
|
||||
// runs, silently killing the chip's data feed. `.nullish()` keeps the schema
|
||||
// gate as forgiving as the parser it guards.
|
||||
const RateLimitWindowSchema = z
|
||||
.object({
|
||||
used_percentage: z.number().nullish(),
|
||||
resets_at: z.number().nullish(),
|
||||
})
|
||||
.nullish();
|
||||
|
||||
export const StatusTelemetrySchema = z.object({
|
||||
sessionId: z.string().min(1).max(100),
|
||||
data: z
|
||||
.object({
|
||||
rate_limits: z
|
||||
.object({
|
||||
five_hour: RateLimitWindowSchema,
|
||||
seven_day: RateLimitWindowSchema,
|
||||
})
|
||||
.nullish(),
|
||||
context_window: z
|
||||
.object({
|
||||
used_percentage: z.number().nullish(),
|
||||
total_input_tokens: z.number().nullish(),
|
||||
total_output_tokens: z.number().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
cost: z.object({ total_cost_usd: z.number().nullish() }).nullish(),
|
||||
model: z.object({ display_name: z.string().max(100).nullish() }).nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
// ========== Case Routes ==========
|
||||
|
||||
/**
|
||||
@@ -301,6 +348,10 @@ export const SettingsUpdateSchema = z
|
||||
ralphTrackerEnabled: z.boolean().optional(),
|
||||
subagentTrackingEnabled: z.boolean().optional(),
|
||||
subagentActiveTabOnly: z.boolean().optional(),
|
||||
/** Ultracode/Workflow run visualization (default OFF). Gates workflowRunWatcher + the master-detail tab. SYNCED. */
|
||||
showUltracodeAgents: z.boolean().optional(),
|
||||
/** Floating ultracode run windows w/ tab connector lines (default OFF). Also starts workflowRunWatcher. SYNCED. */
|
||||
ultracodeFloatingWindows: z.boolean().optional(),
|
||||
imageWatcherEnabled: z.boolean().optional(),
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
@@ -321,6 +372,13 @@ export const SettingsUpdateSchema = z
|
||||
showFileBrowser: z.boolean().optional(),
|
||||
showSubagents: z.boolean().optional(),
|
||||
showMultiMonitorButton: z.boolean().optional(),
|
||||
showPlanUsageLimits: z.boolean().optional(),
|
||||
// Action field (NOT persisted as a setting): when true, (re)injects the
|
||||
// plan-usage statusLine exporter into active Claude sessions so live usage %
|
||||
// starts flowing. Sent on ENABLE only — the chip's DISPLAY is per-device
|
||||
// (client-side), but telemetry COLLECTION is server-side, so the per-device
|
||||
// toggle signals it out-of-band here rather than via showPlanUsageLimits.
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
// Input
|
||||
gestureControlEnabled: z.boolean().optional(),
|
||||
// Claude CLI settings
|
||||
|
||||
+123
-6
@@ -44,7 +44,7 @@ import { dataPath } from '../config/instance.js';
|
||||
import { getHookSecret } from '../config/hook-secret.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||
import type { ClaudeMode, SessionState } from '../types.js';
|
||||
import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState, WorkflowRunInfo } from '../types.js';
|
||||
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
|
||||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||
import { createMultiplexer } from '../mux-factory.js';
|
||||
@@ -60,7 +60,12 @@ import {
|
||||
type SubagentToolResult,
|
||||
} from '../subagent-watcher.js';
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import {
|
||||
buildDetectedAttachmentHistoryItem,
|
||||
buildExternalAttachmentHistoryItem,
|
||||
} from '../session-attachment-history.js';
|
||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||
import { TeamWatcher } from '../team-watcher.js';
|
||||
import { TunnelManager } from '../tunnel-manager.js';
|
||||
@@ -124,6 +129,7 @@ import {
|
||||
import type { EventLoopMonitorHandle } from '../utils/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
||||
import { installRouteErrorHandler } from './route-error-handler.js';
|
||||
@@ -135,6 +141,7 @@ import {
|
||||
registerFileRoutes,
|
||||
registerScheduledRoutes,
|
||||
registerHookEventRoutes,
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
registerCaseRoutes,
|
||||
registerSessionRoutes,
|
||||
@@ -253,6 +260,11 @@ export class WebServer extends EventEmitter {
|
||||
attachmentDetected: (event: AttachmentDetectedEvent) => void;
|
||||
error: (error: Error, sessionId?: string) => void;
|
||||
} | null = null;
|
||||
private workflowRunWatcherHandlers: {
|
||||
discovered: (info: WorkflowRunInfo) => void;
|
||||
updated: (info: WorkflowRunInfo) => void;
|
||||
removed: (data: { runId: string }) => void;
|
||||
} | null = null;
|
||||
private tunnelManager: TunnelManager = new TunnelManager();
|
||||
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
|
||||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||||
@@ -332,6 +344,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Set up subagent watcher listeners
|
||||
this.setupSubagentWatcherListeners();
|
||||
this.setupWorkflowRunWatcherListeners();
|
||||
|
||||
// Set up image watcher listeners
|
||||
this.setupImageWatcherListeners();
|
||||
@@ -431,6 +444,31 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge WorkflowRunWatcher events → SSE. Broadcasts run SUMMARIES (no agents[])
|
||||
* to keep payloads small; the full agents[] is fetched per-run via
|
||||
* GET /api/workflows/:runId when the user selects a run.
|
||||
*/
|
||||
private setupWorkflowRunWatcherListeners(): void {
|
||||
this.workflowRunWatcherHandlers = {
|
||||
discovered: (info: WorkflowRunInfo) => this.broadcast(SseEvent.WorkflowRunDiscovered, summarizeRun(info)),
|
||||
updated: (info: WorkflowRunInfo) => this.broadcast(SseEvent.WorkflowRunUpdated, summarizeRun(info)),
|
||||
removed: (data: { runId: string }) => this.broadcast(SseEvent.WorkflowRunRemoved, data),
|
||||
};
|
||||
workflowRunWatcher.on('run_discovered', this.workflowRunWatcherHandlers.discovered);
|
||||
workflowRunWatcher.on('run_updated', this.workflowRunWatcherHandlers.updated);
|
||||
workflowRunWatcher.on('run_removed', this.workflowRunWatcherHandlers.removed);
|
||||
}
|
||||
|
||||
private cleanupWorkflowRunWatcherListeners(): void {
|
||||
if (this.workflowRunWatcherHandlers) {
|
||||
workflowRunWatcher.off('run_discovered', this.workflowRunWatcherHandlers.discovered);
|
||||
workflowRunWatcher.off('run_updated', this.workflowRunWatcherHandlers.updated);
|
||||
workflowRunWatcher.off('run_removed', this.workflowRunWatcherHandlers.removed);
|
||||
this.workflowRunWatcherHandlers = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for image watcher.
|
||||
* Broadcasts image detection events to SSE clients for auto-popup.
|
||||
@@ -439,8 +477,20 @@ export class WebServer extends EventEmitter {
|
||||
// Store handlers for cleanup on shutdown
|
||||
this.imageWatcherHandlers = {
|
||||
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
||||
attachmentDetected: (event: AttachmentDetectedEvent) =>
|
||||
this.broadcast(SseEvent.AttachmentDetected, { ...event, source: event.source || 'detected' }),
|
||||
attachmentDetected: (event: AttachmentDetectedEvent) => {
|
||||
const attachmentEvent = {
|
||||
...event,
|
||||
source: event.source || 'detected',
|
||||
thumbnailUrl:
|
||||
event.thumbnailUrl || buildFileThumbnailRoute(event.sessionId, event.relativePath || event.fileName),
|
||||
};
|
||||
const session = this.sessions.get(event.sessionId);
|
||||
if (session) {
|
||||
session.upsertAttachmentHistory(buildDetectedAttachmentHistoryItem(attachmentEvent));
|
||||
this.persistSessionState(session);
|
||||
}
|
||||
this.broadcast(SseEvent.AttachmentDetected, attachmentEvent);
|
||||
},
|
||||
error: (error: Error, sessionId?: string) => {
|
||||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||||
},
|
||||
@@ -612,7 +662,7 @@ export class WebServer extends EventEmitter {
|
||||
registerHostGuard(this.app, () => this.getHostPolicy());
|
||||
|
||||
// Auth middleware (Basic Auth + session cookies + rate limiting)
|
||||
const authState = registerAuthMiddleware(this.app, this.https, () => this.tunnelManager.isRunning());
|
||||
const authState = registerAuthMiddleware(this.app, this.https);
|
||||
if (authState) {
|
||||
this.authSessions = authState.authSessions;
|
||||
this.authFailures = authState.authFailures;
|
||||
@@ -811,6 +861,7 @@ export class WebServer extends EventEmitter {
|
||||
registerFileRoutes(this.app, ctx);
|
||||
registerScheduledRoutes(this.app, ctx);
|
||||
registerHookEventRoutes(this.app, ctx);
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
registerCaseRoutes(this.app, ctx);
|
||||
registerSessionRoutes(this.app, ctx);
|
||||
@@ -901,7 +952,14 @@ export class WebServer extends EventEmitter {
|
||||
// field kept off SessionState to avoid leaking via API broadcasts.
|
||||
const base = session.toState();
|
||||
const envOverrides = session.getEnvOverridesForPersist();
|
||||
const state = (envOverrides ? { ...base, __envOverrides: envOverrides } : base) as SessionState;
|
||||
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
|
||||
// separate from the sanitized public attachmentHistory in toState().
|
||||
const attachmentHistory = session.getAttachmentHistoryForPersist();
|
||||
const state = {
|
||||
...base,
|
||||
...(envOverrides ? { __envOverrides: envOverrides } : {}),
|
||||
...(attachmentHistory ? { __attachmentHistory: attachmentHistory } : {}),
|
||||
} as SessionState;
|
||||
const controller = this.respawnControllers.get(session.id);
|
||||
if (controller) {
|
||||
const config = controller.getConfig();
|
||||
@@ -1121,6 +1179,11 @@ export class WebServer extends EventEmitter {
|
||||
if (settings.showMultiMonitorButton === true) {
|
||||
html = html.replace(' btn-multimonitor--hidden', '');
|
||||
}
|
||||
// Plan-usage chip: ships hidden (`header-plan-usage--hidden`) and is revealed
|
||||
// PER-DEVICE by the client (settings-ui.js applyHeaderVisibilitySettings). It
|
||||
// used to be server-revealed from a synced setting, but that leaked the desktop
|
||||
// choice onto mobile — display is now per-device only (like the response viewer).
|
||||
// Telemetry collection stays server-side via the statusLineTelemetry action.
|
||||
// Detached single-session ("solo") window: inject the target session id so
|
||||
// the client can enter solo mode even if a (network-first) service worker
|
||||
// later serves a cached shell. The client primarily detects solo mode from
|
||||
@@ -1284,6 +1347,21 @@ export class WebServer extends EventEmitter {
|
||||
sessionWorkingDir: session.workingDir,
|
||||
forceWorkspaceConfinement: true,
|
||||
});
|
||||
const record = attachmentRegistry.get(sessionId, event.attachmentId);
|
||||
if (record) {
|
||||
session.upsertAttachmentHistory(
|
||||
buildExternalAttachmentHistoryItem({
|
||||
sessionId,
|
||||
externalPath: record.filePath,
|
||||
fileName: record.fileName,
|
||||
extension: record.extension,
|
||||
size: record.size,
|
||||
mtimeMs: record.mtimeMs,
|
||||
timestamp: event.timestamp,
|
||||
})
|
||||
);
|
||||
this.persistSessionState(session);
|
||||
}
|
||||
this.broadcast(SseEvent.AttachmentDetected, event);
|
||||
}
|
||||
|
||||
@@ -1623,8 +1701,10 @@ export class WebServer extends EventEmitter {
|
||||
respawnStatus,
|
||||
globalStats: this.store.getAggregateStats(activeSessionTokens),
|
||||
subagents: subagentWatcher.getRecentSubagents(15), // 15 min to avoid stale agents
|
||||
workflowRuns: workflowRunWatcher.getAllRunSummaries(), // ultracode run summaries (no agents[]) for the LEFT list
|
||||
timestamp: now,
|
||||
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
|
||||
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
|
||||
};
|
||||
|
||||
this.cachedLightState = { data: result, timestamp: now };
|
||||
@@ -1890,6 +1970,14 @@ export class WebServer extends EventEmitter {
|
||||
console.log('Subagent watcher disabled by user settings');
|
||||
}
|
||||
|
||||
// Start workflow run watcher for ultracode / Workflow run visualization (if enabled)
|
||||
if (await this.isWorkflowAgentTrackingEnabled()) {
|
||||
workflowRunWatcher.start();
|
||||
console.log('Workflow run watcher started - monitoring ~/.claude/projects for ultracode run activity');
|
||||
} else {
|
||||
console.log('Workflow run watcher disabled by user settings (showUltracodeAgents off)');
|
||||
}
|
||||
|
||||
// Start image watcher for auto-popup of screenshots (if enabled)
|
||||
if (await this.isImageWatcherEnabled()) {
|
||||
imageWatcher.start();
|
||||
@@ -1936,6 +2024,25 @@ export class WebServer extends EventEmitter {
|
||||
return true; // Default enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ultracode/workflow run tracking is enabled in settings (default: FALSE — opt-in).
|
||||
* The watcher feeds BOTH the docked Ultracode Agents panel (`showUltracodeAgents`) and the
|
||||
* floating run windows (`ultracodeFloatingWindows`), so either toggle starts it.
|
||||
*/
|
||||
private async isWorkflowAgentTrackingEnabled(): Promise<boolean> {
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
return (settings.showUltracodeAgents ?? false) || (settings.ultracodeFloatingWindows ?? false);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('Failed to read showUltracodeAgents setting:', err);
|
||||
}
|
||||
}
|
||||
return false; // Default disabled (opt-in)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image watcher is enabled in settings (default: false)
|
||||
*/
|
||||
@@ -2001,6 +2108,11 @@ export class WebServer extends EventEmitter {
|
||||
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
|
||||
// by the Session constructor (env var would hard-lock /effort switching).
|
||||
const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
|
||||
// Prefer the private (externalPath-bearing) history; fall back to the
|
||||
// sanitized public copy for sessions persisted before that split.
|
||||
const savedAttachmentHistory =
|
||||
(savedState as { __attachmentHistory?: SessionAttachmentHistoryItem[] })?.__attachmentHistory ??
|
||||
savedState?.attachmentHistory;
|
||||
const session = new Session({
|
||||
id: muxSession.sessionId, // Preserve the original session ID
|
||||
workingDir: muxSession.workingDir,
|
||||
@@ -2013,6 +2125,7 @@ export class WebServer extends EventEmitter {
|
||||
allowedTools: recoveryClaudeMode.allowedTools,
|
||||
envOverrides: savedEnvOverrides,
|
||||
effort: savedState?.effort,
|
||||
attachmentHistory: savedAttachmentHistory,
|
||||
});
|
||||
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
@@ -2284,12 +2397,16 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Clean up watcher listeners to prevent memory leaks
|
||||
this.cleanupSubagentWatcherListeners();
|
||||
this.cleanupWorkflowRunWatcherListeners();
|
||||
this.cleanupImageWatcherListeners();
|
||||
this.cleanupTeamWatcherListeners();
|
||||
|
||||
// Stop subagent watcher
|
||||
subagentWatcher.stop();
|
||||
|
||||
// Stop workflow run watcher
|
||||
workflowRunWatcher.stop();
|
||||
|
||||
// Stop image watcher
|
||||
imageWatcher.stop();
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* - **Mux** (4): created, killed, died, statsUpdated
|
||||
* - **Respawn** (24): stateChanged, cycleStarted/Completed, step*, aiCheck*, planCheck*, timer*, log, ...
|
||||
* - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed
|
||||
* - **Workflow runs** (3): run_discovered, run_updated, run_removed (ultracode / Workflow tool)
|
||||
* - **Scheduled** (6): created, updated, completed, stopped, log, deleted
|
||||
* - **Teams** (4): created, updated, removed, taskUpdated
|
||||
* - **Transcript** (4): complete, plan_mode, tool_start, tool_end
|
||||
@@ -87,6 +88,8 @@ export const SessionMessage = 'session:message' as const;
|
||||
export const SessionInteractive = 'session:interactive' as const;
|
||||
/** Prompt sent to session for execution. */
|
||||
export const SessionRunning = 'session:running' as const;
|
||||
/** Claude plan-usage telemetry (5-hour + weekly limits) parsed from the statusline. */
|
||||
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
|
||||
|
||||
// ─── Session: Ralph ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -213,6 +216,15 @@ export const SubagentMessage = 'subagent:message' as const;
|
||||
/** Subagent finished. */
|
||||
export const SubagentCompleted = 'subagent:completed' as const;
|
||||
|
||||
// ─── Workflow Runs (ultracode / Workflow tool) ───────────────────────────────
|
||||
|
||||
/** A workflow run was discovered (first time seen). Payload: WorkflowRunInfo. */
|
||||
export const WorkflowRunDiscovered = 'workflow:run_discovered' as const;
|
||||
/** A workflow run changed (agent state/token tick). Payload: WorkflowRunInfo. */
|
||||
export const WorkflowRunUpdated = 'workflow:run_updated' as const;
|
||||
/** A workflow run's file disappeared. Payload: { runId: string }. */
|
||||
export const WorkflowRunRemoved = 'workflow:run_removed' as const;
|
||||
|
||||
// ─── Scheduled Runs ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Scheduled run created. */
|
||||
@@ -376,6 +388,7 @@ export const SseEvent = {
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
SessionStatusTelemetry,
|
||||
|
||||
// Session: Ralph
|
||||
SessionRalphLoopUpdate,
|
||||
@@ -443,6 +456,11 @@ export const SseEvent = {
|
||||
SubagentMessage,
|
||||
SubagentCompleted,
|
||||
|
||||
// Workflow runs (ultracode)
|
||||
WorkflowRunDiscovered,
|
||||
WorkflowRunUpdated,
|
||||
WorkflowRunRemoved,
|
||||
|
||||
// Scheduled runs
|
||||
ScheduledCreated,
|
||||
ScheduledUpdated,
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* @fileoverview Workflow (ultracode) Run Watcher
|
||||
*
|
||||
* Emits events powering the master-detail "working agents" view (tasks/phases on
|
||||
* the LEFT, per-agent tokens/tool-calls on the RIGHT) AND the floating run
|
||||
* windows, from TWO disk sources per run:
|
||||
*
|
||||
* 1. COMPLETION artifact — `…/workflows/wf_<id>.json`. The Workflow runtime
|
||||
* writes this with the FULL run state (phases, per-agent tokens/tool-calls,
|
||||
* result), but — as of the mid-2026 runtime — only when the run FINISHES
|
||||
* (always a terminal status). It is the authoritative, detailed record.
|
||||
* 2. LIVE transcript dir — `…/subagents/workflows/wf_<id>/` (agent-*.jsonl +
|
||||
* journal.jsonl). This appears WHILE a run is in flight, before any
|
||||
* `wf_<id>.json` exists. From it we synthesize a minimal ACTIVE run
|
||||
* (status 'running', agent slots keyed by agentId, lastActivityAt from file
|
||||
* mtimes) so the floating window pops DURING the run instead of only after.
|
||||
*
|
||||
* Precedence: when a completion `wf_<id>.json` exists it ALWAYS supersedes the
|
||||
* synthesized live record (same runId), so a finished run shows full detail and
|
||||
* the normal finish→auto-close flow runs. Without source 2 the floating-window
|
||||
* feature is dead for live runs (the completion file only lands at the end, so
|
||||
* the watcher would never see a run while it is active).
|
||||
*
|
||||
* Still STANDALONE: it never imports from or touches subagent-watcher.ts. It
|
||||
* independently reads the same `subagents/workflows/` tree subagent-watcher uses,
|
||||
* but as a separate singleton with no shared mutable state.
|
||||
*
|
||||
* Discovery is dual: a periodic poll (catches new run dirs + removals) plus a
|
||||
* per-dir chokidar watcher (live updates). A per-source mtime skip keeps the hot
|
||||
* path cheap — the completion JSON / live dir is re-read only when its mtime moves.
|
||||
*
|
||||
* @module workflow-run-watcher
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { watch as chokidarWatch, type FSWatcher as ChokidarWatcher } from 'chokidar';
|
||||
|
||||
import type { WorkflowRunInfo, WorkflowRunSummary, WorkflowAgentInfo, WorkflowRunPhase } from './types/workflow-run.js';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
import {
|
||||
WORKFLOW_RUN_POLL_INTERVAL_MS,
|
||||
MAX_CACHED_WORKFLOW_RUNS,
|
||||
WORKFLOW_RUN_RECENT_WINDOW_MIN,
|
||||
} from './config/workflow-config.js';
|
||||
|
||||
const WORKFLOWS_SUBDIR = 'workflows';
|
||||
const SUBAGENTS_SUBDIR = 'subagents';
|
||||
const RUN_FILE_PREFIX = 'wf_';
|
||||
const RUN_FILE_SUFFIX = '.json';
|
||||
const LIVE_JOURNAL_FILE = 'journal.jsonl';
|
||||
const LIVE_AGENT_PREFIX = 'agent-';
|
||||
|
||||
/** Hard caps on the largest per-agent strings so a 28-agent run stays compact. */
|
||||
const PROMPT_PREVIEW_MAX = 200;
|
||||
const RESULT_PREVIEW_MAX = 240;
|
||||
|
||||
function truncate(value: string | undefined, max: number): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
/** Drop the heavy `agents[]` for list/snapshot use. */
|
||||
export function summarizeRun(info: WorkflowRunInfo): WorkflowRunSummary {
|
||||
const { agents: _agents, ...summary } = info;
|
||||
void _agents;
|
||||
return summary;
|
||||
}
|
||||
|
||||
interface DiscoveredRun {
|
||||
filePath: string;
|
||||
projectHash: string;
|
||||
sessionUuid: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
/** An in-flight run discovered from its `subagents/workflows/wf_<id>/` transcript dir. */
|
||||
interface DiscoveredLiveRun {
|
||||
dirPath: string;
|
||||
projectHash: string;
|
||||
sessionUuid: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
/** A `workflowProgress[]` entry as it appears on disk (loosely typed for defensive parsing). */
|
||||
interface RawProgressEntry {
|
||||
type?: string;
|
||||
index?: number;
|
||||
label?: string;
|
||||
phaseIndex?: number;
|
||||
phaseTitle?: string;
|
||||
model?: string;
|
||||
state?: string;
|
||||
queuedAt?: number;
|
||||
lastProgressAt?: number;
|
||||
promptPreview?: string;
|
||||
agentId?: string;
|
||||
startedAt?: number;
|
||||
attempt?: number;
|
||||
tokens?: number;
|
||||
toolCalls?: number;
|
||||
lastToolName?: string;
|
||||
lastToolSummary?: string;
|
||||
durationMs?: number;
|
||||
resultPreview?: string;
|
||||
}
|
||||
|
||||
export class WorkflowRunWatcher extends EventEmitter {
|
||||
private projectsDir: string;
|
||||
private pollTimer: NodeJS.Timeout | null = null;
|
||||
private _isRunning = false;
|
||||
|
||||
/** runId -> latest parsed run info (LRU-bounded). */
|
||||
private runs = new LRUMap<string, WorkflowRunInfo>({ maxSize: MAX_CACHED_WORKFLOW_RUNS });
|
||||
/** absolute run-file path -> last seen mtimeMs (skip unchanged files). */
|
||||
private fileMtimes = new Map<string, number>();
|
||||
/** runId -> absolute run-file path (for mtime cleanup on removal). */
|
||||
private runIdToPath = new Map<string, string>();
|
||||
/** absolute live transcript-dir path -> newest member mtimeMs (skip unchanged live runs). */
|
||||
private liveDirMtimes = new Map<string, number>();
|
||||
/** runId -> absolute live transcript-dir path (for mtime cleanup on removal). */
|
||||
private runIdToLiveDir = new Map<string, string>();
|
||||
/** watched-dir absolute path -> chokidar watcher (workflows/ + subagents/workflows/). */
|
||||
private dirWatchers = new Map<string, ChokidarWatcher>();
|
||||
|
||||
constructor(projectsDir?: string) {
|
||||
super();
|
||||
this.projectsDir = projectsDir || join(homedir(), '.claude', 'projects');
|
||||
this.setMaxListeners(50);
|
||||
}
|
||||
|
||||
// ========== Public API ==========
|
||||
|
||||
isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this._isRunning) return;
|
||||
this._isRunning = true;
|
||||
this.poll();
|
||||
this.pollTimer = setInterval(() => this.poll(), WORKFLOW_RUN_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this._isRunning = false;
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
for (const watcher of this.dirWatchers.values()) {
|
||||
watcher.close().catch(() => {}); // best-effort teardown
|
||||
}
|
||||
this.dirWatchers.clear();
|
||||
this.runs.clear();
|
||||
this.fileMtimes.clear();
|
||||
this.runIdToPath.clear();
|
||||
this.liveDirMtimes.clear();
|
||||
this.runIdToLiveDir.clear();
|
||||
}
|
||||
|
||||
/** All cached runs (no recency filter), most-recently-active first. */
|
||||
getAllRuns(): WorkflowRunInfo[] {
|
||||
return Array.from(this.runs.values()).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
||||
}
|
||||
|
||||
/** Runs active within the last `minutes`, most-recently-active first. */
|
||||
getRecentRuns(minutes: number = WORKFLOW_RUN_RECENT_WINDOW_MIN): WorkflowRunInfo[] {
|
||||
const cutoff = Date.now() - minutes * 60_000;
|
||||
return this.getAllRuns().filter((r) => r.lastActivityAt >= cutoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight summaries (no agents[]) of ALL cached runs, most-recently-active
|
||||
* first. This is the LEFT-pane list + getLightState snapshot source: the cache
|
||||
* is LRU-bounded (MAX_CACHED_WORKFLOW_RUNS), so it's already size-capped, and a
|
||||
* run-browser should show past runs — NOT hide everything older than a window.
|
||||
*/
|
||||
getAllRunSummaries(): WorkflowRunSummary[] {
|
||||
return this.getAllRuns().map(summarizeRun);
|
||||
}
|
||||
|
||||
/** Summaries filtered to the last `minutes` of activity (opt-in via ?minutes). */
|
||||
getRecentRunSummaries(minutes: number = WORKFLOW_RUN_RECENT_WINDOW_MIN): WorkflowRunSummary[] {
|
||||
return this.getRecentRuns(minutes).map(summarizeRun);
|
||||
}
|
||||
|
||||
getRun(runId: string): WorkflowRunInfo | undefined {
|
||||
return this.runs.get(runId);
|
||||
}
|
||||
|
||||
getStats(): { runCount: number; running: number; agentCount: number } {
|
||||
let running = 0;
|
||||
let agentCount = 0;
|
||||
for (const run of this.runs.values()) {
|
||||
if (run.status === 'running') running++;
|
||||
agentCount += run.agents.length;
|
||||
}
|
||||
return { runCount: this.runs.size, running, agentCount };
|
||||
}
|
||||
|
||||
// ========== Private ==========
|
||||
|
||||
private poll(): void {
|
||||
this.pollAsync().catch(() => {
|
||||
// Filesystem may be transiently unavailable; the next poll retries.
|
||||
});
|
||||
}
|
||||
|
||||
private async pollAsync(): Promise<void> {
|
||||
const { files, liveDirs, watchDirs } = await this.discover();
|
||||
|
||||
// Install a live watcher for each watched dir; tear down watchers for dirs that vanished.
|
||||
for (const dir of watchDirs) this.ensureDirWatcher(dir);
|
||||
for (const dir of Array.from(this.dirWatchers.keys())) {
|
||||
if (!watchDirs.has(dir)) this.removeDirWatcher(dir);
|
||||
}
|
||||
|
||||
const seenRunIds = new Set<string>();
|
||||
const realRunIds = new Set<string>();
|
||||
for (const file of files) {
|
||||
seenRunIds.add(file.runId);
|
||||
realRunIds.add(file.runId);
|
||||
await this.maybeParse(file);
|
||||
}
|
||||
|
||||
// In-flight runs: synthesize from the transcript tree ONLY while no completion
|
||||
// wf_*.json exists yet — the real file (full detail + terminal status) supersedes.
|
||||
for (const live of liveDirs) {
|
||||
if (realRunIds.has(live.runId)) continue;
|
||||
seenRunIds.add(live.runId);
|
||||
await this.maybeParseLive(live);
|
||||
}
|
||||
|
||||
// Removal by set-diff: a cached run discoverable from neither source.
|
||||
for (const runId of Array.from(this.runs.keys())) {
|
||||
if (!seenRunIds.has(runId)) {
|
||||
this.runs.delete(runId);
|
||||
const path = this.runIdToPath.get(runId);
|
||||
if (path) this.fileMtimes.delete(path);
|
||||
this.runIdToPath.delete(runId);
|
||||
const liveDir = this.runIdToLiveDir.get(runId);
|
||||
if (liveDir) this.liveDirMtimes.delete(liveDir);
|
||||
this.runIdToLiveDir.delete(runId);
|
||||
this.emit('run_removed', { runId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk projects/<projHash>/<sessionUuid>/ for both run sources:
|
||||
* - completion files: `workflows/wf_*.json`
|
||||
* - in-flight runs: `subagents/workflows/wf_<id>/` (transcript dirs)
|
||||
* Returns the dirs to chokidar-watch (so a new run/file is caught sub-poll).
|
||||
*/
|
||||
private async discover(): Promise<{
|
||||
files: DiscoveredRun[];
|
||||
liveDirs: DiscoveredLiveRun[];
|
||||
watchDirs: Set<string>;
|
||||
}> {
|
||||
const files: DiscoveredRun[] = [];
|
||||
const liveDirs: DiscoveredLiveRun[] = [];
|
||||
const watchDirs = new Set<string>();
|
||||
|
||||
let projectHashes: string[];
|
||||
try {
|
||||
projectHashes = await readdir(this.projectsDir);
|
||||
} catch {
|
||||
return { files, liveDirs, watchDirs };
|
||||
}
|
||||
|
||||
for (const projectHash of projectHashes) {
|
||||
let sessions: string[];
|
||||
try {
|
||||
sessions = await readdir(join(this.projectsDir, projectHash));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const sessionUuid of sessions) {
|
||||
const sessionDir = join(this.projectsDir, projectHash, sessionUuid);
|
||||
|
||||
// (1) Completion artifacts: workflows/wf_*.json
|
||||
const workflowsDir = join(sessionDir, WORKFLOWS_SUBDIR);
|
||||
try {
|
||||
const names = await readdir(workflowsDir);
|
||||
let hasRun = false;
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(RUN_FILE_PREFIX) || !name.endsWith(RUN_FILE_SUFFIX)) continue;
|
||||
hasRun = true;
|
||||
files.push({
|
||||
filePath: join(workflowsDir, name),
|
||||
projectHash,
|
||||
sessionUuid,
|
||||
runId: name.slice(0, -RUN_FILE_SUFFIX.length),
|
||||
});
|
||||
}
|
||||
if (hasRun) watchDirs.add(workflowsDir);
|
||||
} catch {
|
||||
// no workflows dir for this session — normal
|
||||
}
|
||||
|
||||
// (2) In-flight runs: subagents/workflows/wf_*/
|
||||
const liveParent = join(sessionDir, SUBAGENTS_SUBDIR, WORKFLOWS_SUBDIR);
|
||||
try {
|
||||
const names = await readdir(liveParent);
|
||||
let hasLive = false;
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(RUN_FILE_PREFIX) || name.endsWith(RUN_FILE_SUFFIX)) continue; // wf_<id> dir, not a file
|
||||
hasLive = true;
|
||||
liveDirs.push({
|
||||
dirPath: join(liveParent, name),
|
||||
projectHash,
|
||||
sessionUuid,
|
||||
runId: name,
|
||||
});
|
||||
}
|
||||
if (hasLive) watchDirs.add(liveParent);
|
||||
} catch {
|
||||
// no subagents/workflows dir for this session — normal
|
||||
}
|
||||
}
|
||||
}
|
||||
return { files, liveDirs, watchDirs };
|
||||
}
|
||||
|
||||
private async maybeParse(file: DiscoveredRun): Promise<void> {
|
||||
let mtime: number;
|
||||
try {
|
||||
mtime = (await stat(file.filePath)).mtimeMs;
|
||||
} catch {
|
||||
return; // vanished between discover and stat
|
||||
}
|
||||
if (this.fileMtimes.get(file.filePath) === mtime) return;
|
||||
this.fileMtimes.set(file.filePath, mtime);
|
||||
|
||||
const info = await this.parseFile(file);
|
||||
if (!info) return;
|
||||
|
||||
const existed = this.runs.has(info.runId);
|
||||
this.runs.set(info.runId, info);
|
||||
this.runIdToPath.set(info.runId, file.filePath);
|
||||
this.emit(existed ? 'run_updated' : 'run_discovered', info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-synthesize an in-flight run from its transcript dir when its newest member
|
||||
* mtime moved (skip otherwise so we don't re-emit run_updated on idle polls).
|
||||
*/
|
||||
private async maybeParseLive(live: DiscoveredLiveRun): Promise<void> {
|
||||
const info = await this.parseLiveDir(live);
|
||||
if (!info) return;
|
||||
if (this.liveDirMtimes.get(live.dirPath) === info.lastActivityAt) return;
|
||||
this.liveDirMtimes.set(live.dirPath, info.lastActivityAt);
|
||||
|
||||
const existed = this.runs.has(info.runId);
|
||||
this.runs.set(info.runId, info);
|
||||
this.runIdToLiveDir.set(info.runId, live.dirPath);
|
||||
this.emit(existed ? 'run_updated' : 'run_discovered', info);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal ACTIVE WorkflowRunInfo from `subagents/workflows/wf_<id>/`.
|
||||
* The transcript tree carries no phases/tokens — those arrive with the
|
||||
* completion wf_*.json — so we expose: the agent slots (keyed by agentId, so the
|
||||
* card→transcript click still works), each marked done/running from journal
|
||||
* `result` lines, and lastActivityAt from the newest agent/journal mtime.
|
||||
*/
|
||||
private async parseLiveDir(live: DiscoveredLiveRun): Promise<WorkflowRunInfo | null> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(live.dirPath);
|
||||
} catch {
|
||||
return null; // vanished between discover and read
|
||||
}
|
||||
|
||||
const agentIds = new Set<string>();
|
||||
let newestMtime = 0;
|
||||
for (const name of entries) {
|
||||
if (name.startsWith(LIVE_AGENT_PREFIX)) {
|
||||
const stem = name.slice(LIVE_AGENT_PREFIX.length).replace(/\.(meta\.json|jsonl)$/, '');
|
||||
if (stem) agentIds.add(stem);
|
||||
}
|
||||
if (name === LIVE_JOURNAL_FILE || name.startsWith(LIVE_AGENT_PREFIX)) {
|
||||
try {
|
||||
const m = (await stat(join(live.dirPath, name))).mtimeMs;
|
||||
if (m > newestMtime) newestMtime = m;
|
||||
} catch {
|
||||
// entry vanished — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
if (agentIds.size === 0) return null; // nothing to show yet
|
||||
|
||||
const doneIds = await this.readJournalDoneAgents(join(live.dirPath, LIVE_JOURNAL_FILE));
|
||||
const agents: WorkflowAgentInfo[] = Array.from(agentIds)
|
||||
.sort()
|
||||
.map((id, i) => ({
|
||||
index: i + 1,
|
||||
label: `agent ${i + 1}`,
|
||||
phaseIndex: 1,
|
||||
phaseTitle: '',
|
||||
model: '',
|
||||
state: doneIds.has(id) ? 'done' : 'progress',
|
||||
agentId: id,
|
||||
}));
|
||||
|
||||
return {
|
||||
runId: live.runId,
|
||||
status: 'running',
|
||||
agentCount: agents.length,
|
||||
phases: [],
|
||||
agents,
|
||||
sessionUuid: live.sessionUuid,
|
||||
projectHash: live.projectHash,
|
||||
lastActivityAt: newestMtime || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Agent ids that already emitted a `result` event in the run journal. */
|
||||
private async readJournalDoneAgents(journalPath: string): Promise<Set<string>> {
|
||||
const done = new Set<string>();
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(journalPath, 'utf-8');
|
||||
} catch {
|
||||
return done; // journal not written yet — all agents still in progress
|
||||
}
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line) as { type?: string; agentId?: string };
|
||||
if (ev && ev.type === 'result' && typeof ev.agentId === 'string') done.add(ev.agentId);
|
||||
} catch {
|
||||
// tolerate a partially-written trailing line
|
||||
}
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a wf_<runId>.json into WorkflowRunInfo, STRIPPING the heavyweight
|
||||
* `script`/`scriptPath`/`result`/`logs` fields (the embedded script alone is
|
||||
* 15–660KB) so they never reach the cache, SSE, or routes.
|
||||
*/
|
||||
private async parseFile(file: DiscoveredRun): Promise<WorkflowRunInfo | null> {
|
||||
let raw: Record<string, unknown>;
|
||||
try {
|
||||
raw = JSON.parse(await readFile(file.filePath, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null; // mid-write or malformed — next mtime change re-parses
|
||||
}
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
|
||||
const progress = Array.isArray(raw.workflowProgress) ? (raw.workflowProgress as RawProgressEntry[]) : [];
|
||||
const agents: WorkflowAgentInfo[] = progress
|
||||
.filter((e) => e && e.type === 'workflow_agent')
|
||||
.map((e) => this.toAgent(e));
|
||||
|
||||
const phases: WorkflowRunPhase[] = Array.isArray(raw.phases)
|
||||
? (raw.phases as Array<Record<string, unknown>>).map((p) => ({
|
||||
title: typeof p.title === 'string' ? p.title : '',
|
||||
detail: typeof p.detail === 'string' ? p.detail : '',
|
||||
}))
|
||||
: [];
|
||||
|
||||
let lastProgress = 0;
|
||||
for (const a of agents) {
|
||||
if (typeof a.lastProgressAt === 'number' && a.lastProgressAt > lastProgress) lastProgress = a.lastProgressAt;
|
||||
}
|
||||
const startTime = typeof raw.startTime === 'number' ? raw.startTime : undefined;
|
||||
const lastActivityAt = lastProgress || startTime || 0;
|
||||
|
||||
return {
|
||||
runId: typeof raw.runId === 'string' ? raw.runId : file.runId,
|
||||
workflowName: typeof raw.workflowName === 'string' ? raw.workflowName : undefined,
|
||||
status: typeof raw.status === 'string' ? raw.status : undefined,
|
||||
summary: typeof raw.summary === 'string' ? raw.summary : undefined,
|
||||
agentCount: typeof raw.agentCount === 'number' ? raw.agentCount : undefined,
|
||||
totalTokens: typeof raw.totalTokens === 'number' ? raw.totalTokens : undefined,
|
||||
totalToolCalls: typeof raw.totalToolCalls === 'number' ? raw.totalToolCalls : undefined,
|
||||
durationMs: typeof raw.durationMs === 'number' ? raw.durationMs : undefined,
|
||||
startTime,
|
||||
timestamp: typeof raw.timestamp === 'string' ? raw.timestamp : undefined,
|
||||
defaultModel: typeof raw.defaultModel === 'string' ? raw.defaultModel : undefined,
|
||||
taskId: typeof raw.taskId === 'string' ? raw.taskId : undefined,
|
||||
error: typeof raw.error === 'string' ? raw.error : undefined,
|
||||
phases,
|
||||
agents,
|
||||
sessionUuid: file.sessionUuid,
|
||||
projectHash: file.projectHash,
|
||||
lastActivityAt,
|
||||
};
|
||||
}
|
||||
|
||||
private toAgent(e: RawProgressEntry): WorkflowAgentInfo {
|
||||
return {
|
||||
index: typeof e.index === 'number' ? e.index : 0,
|
||||
label: typeof e.label === 'string' ? e.label : '',
|
||||
phaseIndex: typeof e.phaseIndex === 'number' ? e.phaseIndex : 0,
|
||||
phaseTitle: typeof e.phaseTitle === 'string' ? e.phaseTitle : '',
|
||||
model: typeof e.model === 'string' ? e.model : '',
|
||||
state: typeof e.state === 'string' ? e.state : 'start',
|
||||
queuedAt: e.queuedAt,
|
||||
lastProgressAt: e.lastProgressAt,
|
||||
promptPreview: truncate(e.promptPreview, PROMPT_PREVIEW_MAX),
|
||||
agentId: e.agentId,
|
||||
startedAt: e.startedAt,
|
||||
attempt: e.attempt,
|
||||
tokens: e.tokens,
|
||||
toolCalls: e.toolCalls,
|
||||
lastToolName: e.lastToolName,
|
||||
lastToolSummary: truncate(e.lastToolSummary, RESULT_PREVIEW_MAX),
|
||||
durationMs: e.durationMs,
|
||||
resultPreview: truncate(e.resultPreview, RESULT_PREVIEW_MAX),
|
||||
};
|
||||
}
|
||||
|
||||
private ensureDirWatcher(workflowsDir: string): void {
|
||||
if (this.dirWatchers.has(workflowsDir)) return;
|
||||
try {
|
||||
const watcher = chokidarWatch(workflowsDir, {
|
||||
depth: 0,
|
||||
awaitWriteFinish: { stabilityThreshold: 200 },
|
||||
ignoreInitial: true,
|
||||
persistent: false,
|
||||
});
|
||||
const handler = () => this.poll();
|
||||
watcher.on('add', handler);
|
||||
watcher.on('change', handler);
|
||||
watcher.on('unlink', handler);
|
||||
// subagents/workflows/ children are wf_<id>/ DIRS — catch their add/remove too.
|
||||
watcher.on('addDir', handler);
|
||||
watcher.on('unlinkDir', handler);
|
||||
watcher.on('error', () => {
|
||||
// chokidar surfaced an error for this dir — drop the watcher; poll still covers it.
|
||||
this.removeDirWatcher(workflowsDir);
|
||||
});
|
||||
this.dirWatchers.set(workflowsDir, watcher);
|
||||
} catch {
|
||||
// Watch setup failed — periodic poll still discovers changes.
|
||||
}
|
||||
}
|
||||
|
||||
private removeDirWatcher(workflowsDir: string): void {
|
||||
const watcher = this.dirWatchers.get(workflowsDir);
|
||||
if (watcher) {
|
||||
watcher.close().catch(() => {});
|
||||
this.dirWatchers.delete(workflowsDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide singleton (mirrors subagentWatcher / imageWatcher). */
|
||||
export const workflowRunWatcher = new WorkflowRunWatcher();
|
||||
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi }
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
|
||||
|
||||
const AUTH_PORT = 3160;
|
||||
const NOAUTH_PORT = 3161;
|
||||
@@ -250,28 +251,28 @@ describe('Auth Security', () => {
|
||||
});
|
||||
|
||||
describe('Hook Event Endpoint', () => {
|
||||
it('should allow hook events from localhost without auth', async () => {
|
||||
it('should allow hook events from localhost with the hook secret (no Basic auth)', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/hook-event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
|
||||
body: JSON.stringify({
|
||||
event: 'stop',
|
||||
sessionId: 'nonexistent-session',
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
// Should pass auth (localhost bypass) but may 404 on session — that's fine
|
||||
// The key assertion is it does NOT return 401
|
||||
// Should pass auth (localhost bypass + hook secret) but may 404 on session — that's fine.
|
||||
// The key assertion is it does NOT return 401 (COD-91: secret required even with no tunnel).
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it('should reject hook events with invalid schema', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/hook-event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', [HOOK_SECRET_HEADER]: getHookSecret() },
|
||||
body: JSON.stringify({ invalid: 'data' }),
|
||||
});
|
||||
// Schema validation should catch this
|
||||
// Past the auth gate (valid secret) → schema validation should catch this (not a 401).
|
||||
expect(res.status).not.toBe(401); // Not an auth error
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,19 @@
|
||||
* The `/api/hook-event` localhost bypass let tunnel traffic (cloudflared
|
||||
* --url http://127.0.0.1:port) reach the loopback origin with req.ip ===
|
||||
* 127.0.0.1 and drive respawn/Ralph signals unauthenticated. The fix gates
|
||||
* the bypass behind a shared hook secret WHEN A TUNNEL IS RUNNING, while
|
||||
* keeping the plain localhost bypass for the normal loopback-only case so
|
||||
* already-deployed (pre-secret) hooks and the loop's own channel keep working.
|
||||
* the bypass behind a shared hook secret. COD-91 makes that requirement
|
||||
* UNCONDITIONAL — the loopback bypass requires the secret whether or not a
|
||||
* managed tunnel is running, because Codeman can't detect a user's own loopback
|
||||
* reverse proxy (own cloudflared / `tailscale serve` / nginx → 127.0.0.1).
|
||||
* Managed-session hooks always present the secret, so the legitimate channel
|
||||
* keeps working.
|
||||
*
|
||||
* Tests:
|
||||
* - tunnel running + no secret → 401 (closes the hole)
|
||||
* - tunnel running + bad secret → 401
|
||||
* - tunnel running + good secret → not 401 (allowed)
|
||||
* - tunnel NOT running + no secret → not 401 (back-compat regression guard)
|
||||
* - tunnel NOT running + no secret → 401 (COD-91: secret required unconditionally)
|
||||
* - tunnel NOT running + good secret → not 401 (allowed)
|
||||
* - rate limiting: rapid unauthorized hook POSTs eventually 429
|
||||
*
|
||||
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
|
||||
@@ -83,7 +87,7 @@ describe('COD-54 hook-event auth — tunnel running requires secret', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-compat)', () => {
|
||||
describe('COD-91 hook-event auth — tunnel down ALSO requires the secret', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
let isRunningSpy: ReturnType<typeof vi.spyOn>;
|
||||
@@ -105,8 +109,13 @@ describe('COD-54 hook-event auth — tunnel down keeps localhost bypass (back-co
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
});
|
||||
|
||||
it('still allows a localhost hook POST WITHOUT a secret (existing hooks + loop channel keep working)', async () => {
|
||||
it('rejects a localhost hook POST WITHOUT a secret even with no tunnel (COD-91)', async () => {
|
||||
const res = await postHook(baseUrl);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('allows a localhost hook POST WITH the correct secret when no tunnel is running', async () => {
|
||||
const res = await postHook(baseUrl, { [HOOK_SECRET_HEADER]: getHookSecret() });
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DEPENDENCY_REGISTRY } from '../src/config/dependency-registry.js';
|
||||
import {
|
||||
detectEnvironment,
|
||||
extractVersion,
|
||||
compareVersions,
|
||||
checkTool,
|
||||
checkAll,
|
||||
createRealHost,
|
||||
} from '../src/utils/dependency-checker.js';
|
||||
import type { ProbeHost } from '../src/utils/dependency-checker.js';
|
||||
import type { ProbeEnvironment, ToolDependency } from '../src/config/dependency-registry.js';
|
||||
|
||||
describe('DEPENDENCY_REGISTRY', () => {
|
||||
it('has unique ids', () => {
|
||||
const ids = DEPENDENCY_REGISTRY.map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('hard-requires only node and tmux; agent CLIs and office are optional', () => {
|
||||
const required = DEPENDENCY_REGISTRY.filter((t) => t.required)
|
||||
.map((t) => t.id)
|
||||
.sort();
|
||||
expect(required).toEqual(['node', 'tmux']);
|
||||
// all agent CLIs are optional (Codeman runs any of them)
|
||||
const agentClis = ['claude', 'opencode', 'codex'];
|
||||
expect(DEPENDENCY_REGISTRY.filter((t) => agentClis.includes(t.id)).every((t) => t.required === false)).toBe(true);
|
||||
const office = DEPENDENCY_REGISTRY.filter((t) => t.category === 'office');
|
||||
expect(office.every((t) => t.required === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => {
|
||||
const ms = DEPENDENCY_REGISTRY.find((t) => t.id === 'msoffice');
|
||||
expect(ms).toBeDefined();
|
||||
const spec = ms!.resolvers.find((r) => r.resolver.kind === 'windows-side');
|
||||
expect(spec).toBeDefined();
|
||||
expect([...spec!.match].sort()).toEqual(['win32', 'wsl']);
|
||||
expect(ms!.resolvers.some((r) => r.match.includes('linux'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectEnvironment', () => {
|
||||
it('returns win32/darwin straight from platform', () => {
|
||||
expect(detectEnvironment({ platform: 'win32', procVersion: '', hasWindowsInterop: false })).toBe('win32');
|
||||
expect(detectEnvironment({ platform: 'darwin', procVersion: '', hasWindowsInterop: false })).toBe('darwin');
|
||||
});
|
||||
|
||||
it('detects wsl from /proc/version + interop, else linux', () => {
|
||||
const wsl = detectEnvironment({
|
||||
platform: 'linux',
|
||||
procVersion: 'Linux version 6.6 (Microsoft@WSL2)',
|
||||
hasWindowsInterop: true,
|
||||
});
|
||||
expect(wsl).toBe('wsl');
|
||||
expect(detectEnvironment({ platform: 'linux', procVersion: 'Microsoft', hasWindowsInterop: false })).toBe('linux');
|
||||
expect(detectEnvironment({ platform: 'linux', procVersion: 'generic', hasWindowsInterop: true })).toBe('linux');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractVersion', () => {
|
||||
it('pulls a dotted version from typical --version output', () => {
|
||||
expect(extractVersion('v22.22.1')).toBe('22.22.1');
|
||||
expect(extractVersion('tmux 3.4')).toBe('3.4');
|
||||
expect(extractVersion('no digits here')).toBeUndefined();
|
||||
});
|
||||
it('honors a custom regex', () => {
|
||||
expect(extractVersion('ProductVersion 16.0.19929.20172', /(\d+\.\d+\.\d+)/)).toBe('16.0.19929');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareVersions', () => {
|
||||
it('orders by numeric components', () => {
|
||||
expect(compareVersions('18.0.0', '18.0.0')).toBe(0);
|
||||
expect(compareVersions('16.5.0', '18.0.0')).toBe(-1);
|
||||
expect(compareVersions('22.22.1', '18.0.0')).toBe(1);
|
||||
expect(compareVersions('3.4', '3.4.0')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function fakeHost(env: ProbeEnvironment, over: Partial<ProbeHost> = {}): ProbeHost {
|
||||
return {
|
||||
environment: env,
|
||||
which: () => null,
|
||||
fileExists: () => false,
|
||||
runVersion: () => null,
|
||||
windowsProgramRoots: () => [],
|
||||
windowsFileVersion: () => null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const tmuxTool: ToolDependency = {
|
||||
id: 'tmux',
|
||||
label: 'tmux',
|
||||
category: 'core',
|
||||
required: true,
|
||||
resolvers: [{ match: ['linux', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
|
||||
};
|
||||
const nodeTool: ToolDependency = {
|
||||
id: 'node',
|
||||
label: 'Node.js',
|
||||
category: 'core',
|
||||
required: true,
|
||||
minVersion: '18.0.0',
|
||||
resolvers: [{ match: ['linux'], resolver: { kind: 'path', bins: ['node'] } }],
|
||||
};
|
||||
const msTool: ToolDependency = {
|
||||
id: 'msoffice',
|
||||
label: 'MS Office',
|
||||
category: 'office',
|
||||
required: false,
|
||||
resolvers: [
|
||||
{
|
||||
match: ['wsl', 'win32'],
|
||||
resolver: { kind: 'windows-side', appDirs: ['Microsoft Office/root/Office16'], exes: ['WINWORD.EXE'] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('checkTool', () => {
|
||||
it('reports ok with path + version when found on PATH', () => {
|
||||
const host = fakeHost('linux', {
|
||||
which: (b) => (b === 'tmux' ? '/usr/bin/tmux' : null),
|
||||
runVersion: () => 'tmux 3.4',
|
||||
});
|
||||
expect(checkTool(tmuxTool, host)).toMatchObject({
|
||||
id: 'tmux',
|
||||
status: 'ok',
|
||||
version: '3.4',
|
||||
path: '/usr/bin/tmux',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports missing when no bin resolves', () => {
|
||||
expect(checkTool(tmuxTool, fakeHost('linux'))).toMatchObject({ id: 'tmux', status: 'missing' });
|
||||
});
|
||||
|
||||
it('reports outdated when below minVersion', () => {
|
||||
const host = fakeHost('linux', { which: () => '/n', runVersion: () => 'v16.5.0' });
|
||||
expect(checkTool(nodeTool, host)).toMatchObject({ id: 'node', status: 'outdated', version: '16.5.0' });
|
||||
});
|
||||
|
||||
it('reports error when minVersion set but version unparseable', () => {
|
||||
const host = fakeHost('linux', { which: () => '/n', runVersion: () => 'unknown' });
|
||||
expect(checkTool(nodeTool, host)).toMatchObject({ id: 'node', status: 'error' });
|
||||
});
|
||||
|
||||
it('reports skipped when no resolver matches the environment', () => {
|
||||
expect(checkTool(msTool, fakeHost('linux'))).toMatchObject({ id: 'msoffice', status: 'skipped' });
|
||||
});
|
||||
|
||||
it('finds windows-side apps under WSL', () => {
|
||||
const host = fakeHost('wsl', {
|
||||
windowsProgramRoots: () => ['/mnt/c/Program Files'],
|
||||
fileExists: (p) => p === '/mnt/c/Program Files/Microsoft Office/root/Office16/WINWORD.EXE',
|
||||
windowsFileVersion: () => '16.0.19929.20172',
|
||||
});
|
||||
expect(checkTool(msTool, host)).toMatchObject({
|
||||
id: 'msoffice',
|
||||
status: 'ok',
|
||||
version: '16.0.19929',
|
||||
path: '/mnt/c/Program Files/Microsoft Office/root/Office16/WINWORD.EXE',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAll', () => {
|
||||
it('maps every tool to a result', () => {
|
||||
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));
|
||||
expect(results.map((r) => r.id)).toEqual(['tmux', 'msoffice']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRealHost', () => {
|
||||
it('returns a host with a valid detected environment and callable methods', () => {
|
||||
const host = createRealHost();
|
||||
expect(['linux', 'darwin', 'win32', 'wsl']).toContain(host.environment);
|
||||
expect(typeof host.which).toBe('function');
|
||||
expect(Array.isArray(host.windowsProgramRoots())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderTable, renderJson, computeExitCode } from '../src/utils/dependency-report.js';
|
||||
import type { ToolResult } from '../src/utils/dependency-checker.js';
|
||||
|
||||
const results: ToolResult[] = [
|
||||
{
|
||||
id: 'node',
|
||||
label: 'Node.js',
|
||||
category: 'core',
|
||||
required: true,
|
||||
usedBy: [],
|
||||
status: 'ok',
|
||||
version: '22.22.1',
|
||||
path: '/n',
|
||||
},
|
||||
{
|
||||
id: 'tmux',
|
||||
label: 'tmux',
|
||||
category: 'core',
|
||||
required: true,
|
||||
usedBy: [],
|
||||
status: 'missing',
|
||||
installHint: 'sudo apt install tmux',
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'thumbnails'],
|
||||
status: 'missing',
|
||||
},
|
||||
{
|
||||
id: 'msoffice',
|
||||
label: 'MS Office',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'thumbnails'],
|
||||
status: 'skipped',
|
||||
reason: 'not applicable on linux',
|
||||
},
|
||||
];
|
||||
|
||||
describe('computeExitCode', () => {
|
||||
it('non-zero when a required tool is missing/outdated/error', () => {
|
||||
expect(computeExitCode(results)).toBe(1);
|
||||
});
|
||||
it('zero when only optional tools are missing', () => {
|
||||
const ok = results.filter((r) => r.id !== 'tmux');
|
||||
expect(computeExitCode(ok)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTable', () => {
|
||||
it('groups by category and shows status, version, and install hints', () => {
|
||||
const out = renderTable(results, 'linux');
|
||||
expect(out).toContain('CORE');
|
||||
expect(out).toContain('Node.js');
|
||||
expect(out).toContain('22.22.1');
|
||||
expect(out).toContain('OFFICE');
|
||||
expect(out).toContain('document preview');
|
||||
expect(out).toContain('sudo apt install tmux');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderJson', () => {
|
||||
it('includes environment, summary, and per-tool data', () => {
|
||||
const json = renderJson(results, 'linux');
|
||||
expect(json.platform.environment).toBe('linux');
|
||||
expect(json.summary.exitCode).toBe(1);
|
||||
expect(json.summary.ok).toBe(1);
|
||||
expect(json.tools).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runWithConversionLimit, getActiveConversionCount } from '../src/document-conversion-limiter.js';
|
||||
|
||||
describe('document-conversion-limiter', () => {
|
||||
it('never runs more converters than the cap (default 3) concurrently', async () => {
|
||||
let running = 0;
|
||||
let maxObserved = 0;
|
||||
|
||||
const task = () => async () => {
|
||||
running++;
|
||||
maxObserved = Math.max(maxObserved, running);
|
||||
// The module's own accounting must also stay within the cap.
|
||||
expect(getActiveConversionCount()).toBeLessThanOrEqual(3);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
running--;
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: 12 }, () => runWithConversionLimit(task())));
|
||||
|
||||
expect(maxObserved).toBeLessThanOrEqual(3);
|
||||
expect(maxObserved).toBeGreaterThan(1); // proves it genuinely parallelizes, not serializes
|
||||
expect(getActiveConversionCount()).toBe(0); // every slot released
|
||||
});
|
||||
|
||||
it('processes every queued task even when far more are submitted than the cap', async () => {
|
||||
let completed = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: 25 }, () =>
|
||||
runWithConversionLimit(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
completed++;
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(completed).toBe(25);
|
||||
expect(getActiveConversionCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('releases the slot when a task throws', async () => {
|
||||
await expect(
|
||||
runWithConversionLimit(async () => {
|
||||
throw new Error('boom');
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
expect(getActiveConversionCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { pruneDocumentPreviewCache } from '../src/document-preview-cache.js';
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedRm = vi.mocked(fs.rm);
|
||||
|
||||
describe('pruneDocumentPreviewCache', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// mtime encoded in the filename (f0 oldest ... f101 newest)
|
||||
mockedStat.mockImplementation(async (p) => {
|
||||
const m = /f(\d+)\.pdf$/.exec(String(p));
|
||||
return { mtimeMs: m ? Number(m[1]) : 0, isFile: () => true } as never;
|
||||
});
|
||||
});
|
||||
|
||||
it('evicts the oldest *.pdf files once the cache exceeds the cap (default 100)', async () => {
|
||||
const names = Array.from({ length: 102 }, (_, i) => `f${i}.pdf`);
|
||||
mockedReaddir.mockResolvedValue(names as never);
|
||||
|
||||
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
|
||||
|
||||
// 102 - 100 = 2 oldest removed
|
||||
expect(mockedRm).toHaveBeenCalledTimes(2);
|
||||
const removed = mockedRm.mock.calls.map((c) => String(c[0]));
|
||||
expect(removed.some((p) => p.endsWith('f0.pdf'))).toBe(true);
|
||||
expect(removed.some((p) => p.endsWith('f1.pdf'))).toBe(true);
|
||||
expect(removed.some((p) => p.endsWith('f101.pdf'))).toBe(false); // newest kept
|
||||
});
|
||||
|
||||
it('ignores non-pdf entries (e.g. transient work-* dirs) when counting', async () => {
|
||||
const names = [...Array.from({ length: 50 }, (_, i) => `f${i}.pdf`), 'work-abc', 'work-def'];
|
||||
mockedReaddir.mockResolvedValue(names as never);
|
||||
|
||||
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
|
||||
|
||||
expect(mockedRm).not.toHaveBeenCalled(); // 50 pdfs <= cap
|
||||
});
|
||||
|
||||
it('never throws when the cache dir cannot be read', async () => {
|
||||
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
|
||||
await expect(pruneDocumentPreviewCache('/tmp/missing')).resolves.toBeUndefined();
|
||||
expect(mockedRm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { clearDocumentPreviewCache, getOfficePreviewPdfPath } from '../src/document-preview-cache.js';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn((_cmd, _args, _options, callback) => {
|
||||
setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1);
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
stat: vi.fn(),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
copyFile: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => '/tmp/codeman-document-preview-cache/work-test'),
|
||||
readdir: vi.fn(async () => ['deck.pdf']),
|
||||
rename: vi.fn(async () => undefined),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedExecFile = vi.mocked(execFile);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedRename = vi.mocked(fs.rename);
|
||||
|
||||
describe('document-preview-cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearDocumentPreviewCache();
|
||||
|
||||
let cachedPdfExists = false;
|
||||
mockedStat.mockImplementation(async (path) => {
|
||||
const pathText = String(path);
|
||||
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
|
||||
if (!cachedPdfExists) throw new Error('ENOENT');
|
||||
return { size: 4096, isFile: () => true } as never;
|
||||
}
|
||||
|
||||
return { size: 1845494, mtimeMs: 12345, isFile: () => true } as never;
|
||||
});
|
||||
mockedRename.mockImplementation(async () => {
|
||||
cachedPdfExists = true;
|
||||
});
|
||||
mockedExecFile.mockImplementation((cmd, _args, _options, callback) => {
|
||||
if (cmd === 'powershell.exe') {
|
||||
cachedPdfExists = true;
|
||||
}
|
||||
|
||||
setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1);
|
||||
return {} as never;
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates concurrent Office preview conversions and reuses the cached PDF', async () => {
|
||||
const [firstPath, secondPath] = await Promise.all([
|
||||
getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'),
|
||||
getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'),
|
||||
]);
|
||||
const thirdPath = await getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx');
|
||||
|
||||
expect(firstPath).toBeTruthy();
|
||||
expect(secondPath).toBe(firstPath);
|
||||
expect(thirdPath).toBe(firstPath);
|
||||
expect(mockedExecFile).toHaveBeenCalledTimes(1);
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'soffice',
|
||||
expect.arrayContaining([
|
||||
'--headless',
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
expect.stringMatching(/^-env:UserInstallation=file:/),
|
||||
]),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers Microsoft Word for DOCX files on Windows-mounted paths', async () => {
|
||||
const result = await getOfficePreviewPdfPath(
|
||||
'/mnt/c/Users/aakhter/Documents/codeman-inline-viewer-test.docx',
|
||||
'docx'
|
||||
);
|
||||
|
||||
expect(result).toContain('/mnt/c/Users/aakhter/AppData/Local/Temp/codeman-document-preview-cache/');
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
expect.arrayContaining(['-NoProfile', '-NonInteractive', '-EncodedCommand']),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
const powerShellCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'powershell.exe');
|
||||
const encodedCommand = powerShellCall?.[1]?.at(-1);
|
||||
const decodedCommand = Buffer.from(String(encodedCommand), 'base64').toString('utf16le');
|
||||
expect(decodedCommand).toContain('$source = ');
|
||||
expect(decodedCommand).toContain('C:\\Users\\aakhter\\AppData\\Local\\Temp\\codeman-document-preview-cache\\');
|
||||
expect(decodedCommand).toContain('.docx');
|
||||
expect(decodedCommand).toContain('$output = ');
|
||||
expect(decodedCommand).toContain('.pdf');
|
||||
expect(mockedExecFile).not.toHaveBeenCalledWith(
|
||||
'soffice',
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { generateFirstPageThumbnail } from '../src/document-thumbnailer.js';
|
||||
import { clearDocumentPreviewCache } from '../src/document-preview-cache.js';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn((_cmd, _args, _options, callback) => {
|
||||
callback(null, { stdout: '', stderr: '' });
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
|
||||
readFile: vi.fn(async () => Buffer.from('png')),
|
||||
readdir: vi.fn(async () => ['converted.pdf']),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => '/tmp/codeman-thumb-test'),
|
||||
rename: vi.fn(async () => undefined),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedExecFile = vi.mocked(execFile);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedMkdir = vi.mocked(fs.mkdir);
|
||||
const mockedMkdtemp = vi.mocked(fs.mkdtemp);
|
||||
const mockedRename = vi.mocked(fs.rename);
|
||||
const mockedRm = vi.mocked(fs.rm);
|
||||
|
||||
describe('document-thumbnailer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearDocumentPreviewCache();
|
||||
mockedStat.mockImplementation(async (path) => {
|
||||
const pathText = String(path);
|
||||
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
|
||||
return { size: 500 * 1024 * 1024, mtimeMs: 12345, isFile: () => true } as never;
|
||||
});
|
||||
mockedReadFile.mockResolvedValue(Buffer.from('large thumbnail') as never);
|
||||
mockedReaddir.mockResolvedValue(['converted.pdf'] as never);
|
||||
mockedMkdir.mockResolvedValue(undefined as never);
|
||||
mockedMkdtemp.mockResolvedValue('/tmp/codeman-thumb-test' as never);
|
||||
mockedRename.mockResolvedValue(undefined as never);
|
||||
mockedRm.mockResolvedValue(undefined as never);
|
||||
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, { stdout: '', stderr: '' });
|
||||
return {} as never;
|
||||
});
|
||||
});
|
||||
|
||||
it('renders first-page thumbnails for large documents without an app-level size cap', async () => {
|
||||
const result = await generateFirstPageThumbnail('/tmp/large-deck.pdf', 'pdf');
|
||||
|
||||
expect(result).toEqual({
|
||||
content: Buffer.from('large thumbnail'),
|
||||
contentType: 'image/png',
|
||||
});
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'pdftoppm',
|
||||
expect.arrayContaining(['-png', '-singlefile', '-f', '1', '-l', '1']),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('renders Office thumbnails from the cached converted PDF after conversion cleanup', async () => {
|
||||
mockedMkdtemp.mockImplementation(async (prefix) =>
|
||||
String(prefix).includes('codeman-document-preview-cache')
|
||||
? '/tmp/codeman-document-preview-cache/work-test'
|
||||
: '/tmp/codeman-thumb-pdf-test'
|
||||
);
|
||||
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, { stdout: '', stderr: '' });
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const result = await generateFirstPageThumbnail('/tmp/deck.pptx', 'pptx');
|
||||
|
||||
expect(result).toEqual({
|
||||
content: Buffer.from('large thumbnail'),
|
||||
contentType: 'image/png',
|
||||
});
|
||||
const pdftoppmCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'pdftoppm');
|
||||
expect(pdftoppmCall?.[1]).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('codeman-document-preview-cache')])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* COD-91 — `refreshStaleHookSecret` self-heal.
|
||||
*
|
||||
* Making the hook-event secret unconditionally required (PR #127) would silently 401 the
|
||||
* hook curls baked into cases created before the secret header existed (COD-54). Those
|
||||
* curls live in `.claude/settings.local.json` and `writeHooksConfig` only runs at case
|
||||
* CREATION, so existing cases never refresh. `refreshStaleHookSecret` regenerates the
|
||||
* hooks block on session spawn — but ONLY when the case already holds Codeman's own
|
||||
* pre-secret hook curls, never clobbering a user's customizations.
|
||||
*
|
||||
* Pure filesystem logic against a temp dir — no port / server / tmux.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { refreshStaleHookSecret } from '../src/hooks-config.js';
|
||||
|
||||
const SECRET_HEADER = 'X-Codeman-Hook-Secret';
|
||||
|
||||
// A faithful pre-secret Codeman hook curl (what cases created before COD-54 contain):
|
||||
// targets /api/hook-event, but with NO X-Codeman-Hook-Secret header.
|
||||
function staleCodemanHooks() {
|
||||
return {
|
||||
Stop: [
|
||||
{
|
||||
matcher: '',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command:
|
||||
"HOOK_DATA=$(cat 2>/dev/null || echo '{}'); " +
|
||||
'printf \'{"event":"stop","sessionId":"%s","data":%s}\' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ' +
|
||||
'curl -s -X POST "$CODEMAN_API_URL/api/hook-event" -H \'Content-Type: application/json\' --data @- 2>/dev/null || true',
|
||||
timeout: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('refreshStaleHookSecret', () => {
|
||||
let dir: string;
|
||||
let settingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-selfheal-'));
|
||||
mkdirSync(join(dir, '.claude'), { recursive: true });
|
||||
settingsPath = join(dir, '.claude', 'settings.local.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('adds the secret header to a stale Codeman hooks block and preserves other keys', async () => {
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({ env: { CLAUDE_CODE_FOO: '1' }, model: 'opus', hooks: staleCodemanHooks() }, null, 2)
|
||||
);
|
||||
await refreshStaleHookSecret(dir);
|
||||
|
||||
const after = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER);
|
||||
expect(JSON.stringify(after.hooks)).toContain('CODEMAN_HOOK_SECRET_FILE');
|
||||
// sibling keys untouched
|
||||
expect(after.env).toEqual({ CLAUDE_CODE_FOO: '1' });
|
||||
expect(after.model).toBe('opus');
|
||||
});
|
||||
|
||||
it('leaves a hooks block that already carries the secret unchanged', async () => {
|
||||
// Seed with a current block by healing a stale one first, then re-heal: second pass must no-op.
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2));
|
||||
await refreshStaleHookSecret(dir);
|
||||
const healed = readFileSync(settingsPath, 'utf-8');
|
||||
expect(healed).toContain(SECRET_HEADER);
|
||||
|
||||
await refreshStaleHookSecret(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite
|
||||
});
|
||||
|
||||
it('does not touch hooks that are not Codeman’s (no /api/hook-event)', async () => {
|
||||
const foreign = JSON.stringify(
|
||||
{ hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'echo hi', timeout: 5 }] }] } },
|
||||
null,
|
||||
2
|
||||
);
|
||||
writeFileSync(settingsPath, foreign);
|
||||
await refreshStaleHookSecret(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(foreign);
|
||||
});
|
||||
|
||||
it('is a no-op when settings.local.json is absent (does not create one)', async () => {
|
||||
await refreshStaleHookSecret(dir);
|
||||
expect(existsSync(settingsPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a malformed settings file untouched', async () => {
|
||||
const garbage = '{ not valid json';
|
||||
writeFileSync(settingsPath, garbage);
|
||||
await refreshStaleHookSecret(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(garbage);
|
||||
});
|
||||
});
|
||||
@@ -145,9 +145,9 @@ describe('ImageWatcher', () => {
|
||||
// ========== Image Detection ==========
|
||||
|
||||
describe('image detection', () => {
|
||||
it('should emit image:detected (popup) for .png files', () => {
|
||||
it('should emit attachment:detected for .png files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
watcher.on('attachment:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
@@ -161,11 +161,14 @@ describe('ImageWatcher', () => {
|
||||
expect(event.fileName).toBe('screenshot.png');
|
||||
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
||||
expect(event.relativePath).toBe('screenshot.png');
|
||||
expect(event.extension).toBe('png');
|
||||
expect(event.attachmentType).toBe('image');
|
||||
expect(event.size).toBe(2048);
|
||||
});
|
||||
|
||||
it('should not emit attachment:detected for .png (stays on the popup path)', () => {
|
||||
it('should not emit legacy image:detected for .png attachment cards', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('attachment:detected', handler);
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const appSource = readFileSync('src/web/public/app.js', 'utf8');
|
||||
const terminalUiSource = readFileSync('src/web/public/terminal-ui.js', 'utf8');
|
||||
const helpHtml = readFileSync('src/web/public/index.html', 'utf8');
|
||||
const readme = readFileSync('README.md', 'utf8');
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
it('uses physical Option+number keys so macOS special characters do not break tab switching', () => {
|
||||
expect(appSource).toContain('e.code ||');
|
||||
expect(appSource).toContain('Digit([1-9])');
|
||||
expect(appSource).toContain('parseInt(digitMatch[1], 10) - 1');
|
||||
});
|
||||
|
||||
it('provides Option+bracket shortcuts for previous and next session', () => {
|
||||
expect(appSource).toContain("e.code === 'BracketLeft'");
|
||||
expect(appSource).toContain("e.code === 'BracketRight'");
|
||||
expect(appSource).toContain('this.prevSession()');
|
||||
expect(appSource).toContain('this.nextSession()');
|
||||
});
|
||||
|
||||
it('suppresses xterm PTY injection for the same physical Alt nav codes (no ESC leak)', () => {
|
||||
// terminal-ui.js must gate its xterm pass-through on the SAME physical e.code set the
|
||||
// app.js handler consumes; otherwise Alt+[ / Alt+] (and Option+digit on remapped macOS
|
||||
// layouts) switch tabs AND inject ESC<char> into the focused terminal. Keep in sync.
|
||||
expect(terminalUiSource).toContain('/^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code');
|
||||
});
|
||||
|
||||
it('documents the Alt/Option shortcuts in help and README', () => {
|
||||
expect(helpHtml).toContain('<kbd>Alt/Option</kbd>+<kbd>[</kbd>');
|
||||
expect(helpHtml).toContain('<kbd>Alt/Option</kbd>+<kbd>]</kbd>');
|
||||
expect(helpHtml).toContain('<kbd>Alt/Option</kbd>+<kbd>1-9</kbd>');
|
||||
expect(readme).toContain('`Alt/Option+[` / `Alt/Option+]`');
|
||||
expect(readme).toContain('`Alt/Option+1`-`Alt/Option+9`');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* COD-56 — markdown HTML sanitizer (mXSS hardening).
|
||||
*
|
||||
* The response viewer / attachment preview render agent- and transcript-derived markdown to
|
||||
* HTML via `marked` (raw-HTML passthrough) and assign the result with innerHTML. The HTML must
|
||||
* be sanitized first. The original sanitizer (`_sanitizeHtml` in app.js) was a hand-rolled
|
||||
* DENYLIST and is mXSS-prone — it never stripped `svg`/`math`/`style`, so foreign-namespace and
|
||||
* CSS vectors survived.
|
||||
*
|
||||
* This suite drives the EXACT shipping artifacts:
|
||||
* - src/web/public/vendor/dompurify.min.js (the vendored sanitizer)
|
||||
* - src/web/public/sanitize-html.js (our allowlist config wired to DOMPurify)
|
||||
*
|
||||
* It runs in the DEFAULT node environment (it deliberately does NOT declare a per-file jsdom
|
||||
* environment) and constructs a jsdom window here, then binds the vendored DOMPurify to it. A
|
||||
* per-file jsdom environment externalizes node:fs/node:path under vite, which made this suite fail
|
||||
* to load when
|
||||
* run in isolation (it only survived the full CI run because an earlier node-env test happened to
|
||||
* pre-cache node:fs). Building the window in-test keeps fs/path native and the suite order-robust.
|
||||
*
|
||||
* It feeds a corpus of mXSS payloads (svg/math/style/namespace-confusion/event-handler) and
|
||||
* asserts the output carries NO script-executing constructs, that the curated allowlist is
|
||||
* actually enforced (non-markdown tags dropped), and that legitimate markdown-rendered HTML
|
||||
* survives unchanged. A faithful re-implementation of the OLD denylist is included and asserted to
|
||||
* LET payloads through — the gap this fix closes.
|
||||
*
|
||||
* No port / server needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const publicDir = join(process.cwd(), 'src/web/public');
|
||||
|
||||
// One jsdom window shared by the shipping sanitizer (bound to its DOMPurify) and the old-denylist
|
||||
// reference impl (which needs a DOM `document`).
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
const jsdomDocument = jsdomWindow.document;
|
||||
|
||||
/** Build the SHIPPING sanitizer the way the browser does: vendored DOMPurify (bound to our jsdom
|
||||
* window) + the EXACT CONFIG from sanitize-html.js — so the same allow/forbid lists are exercised
|
||||
* under vitest without a real browser. */
|
||||
function loadShippingSanitizer(): (html: string) => string {
|
||||
const dompurifySrc = readFileSync(join(publicDir, 'vendor/dompurify.min.js'), 'utf8');
|
||||
const sanitizeSrc = readFileSync(join(publicDir, 'sanitize-html.js'), 'utf8');
|
||||
|
||||
// dompurify.min.js is a UMD — evaluate it as CommonJS to obtain the factory (createDOMPurify),
|
||||
// then bind it to our jsdom window so DOMPurify sanitizes against a real DOM.
|
||||
const dpModule: { exports: unknown } = { exports: {} };
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', dompurifySrc)(dpModule, dpModule.exports);
|
||||
const factory = dpModule.exports as (win: unknown) => { sanitize: (h: string, c?: unknown) => string };
|
||||
const DOMPurify = factory(jsdomWindow);
|
||||
|
||||
// sanitize-html.js exposes createMarkdownSanitizer via its CommonJS export.
|
||||
const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = {
|
||||
exports: {},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', sanitizeSrc)(sanModule, sanModule.exports);
|
||||
const create = sanModule.exports.createMarkdownSanitizer;
|
||||
if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported');
|
||||
const fn = create(DOMPurify);
|
||||
if (typeof fn !== 'function') throw new Error('sanitizeMarkdownHtml not wired');
|
||||
return fn;
|
||||
}
|
||||
|
||||
/** Faithful copy of the OLD denylist _sanitizeHtml (app.js pre-COD-56) — used only to prove RED. */
|
||||
function oldDenylistSanitize(html: string): string {
|
||||
const tpl = jsdomDocument.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
const frag = tpl.content;
|
||||
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
|
||||
el.remove();
|
||||
}
|
||||
for (const el of frag.querySelectorAll('*')) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on')) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
|
||||
const val = attr.value.replace(/\s/g, '').toLowerCase();
|
||||
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const div = jsdomDocument.createElement('div');
|
||||
div.appendChild(frag);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// mXSS / XSS payloads. Each must be neutralized by the shipping sanitizer.
|
||||
const PAYLOADS: { name: string; html: string }[] = [
|
||||
{ name: 'img onerror', html: '<img src=x onerror=alert(1)>' },
|
||||
{ name: 'svg onload', html: '<svg onload=alert(1)></svg>' },
|
||||
{ name: 'svg/script', html: '<svg><script>alert(1)</script></svg>' },
|
||||
{ name: 'svg/style mXSS', html: '<svg><style><img src=x onerror=alert(1)></style></svg>' },
|
||||
{
|
||||
name: 'math/mtext/table namespace confusion',
|
||||
html: '<math><mtext><table><mglyph><style><img src=x onerror=alert(1)></style></table></mtext></math>',
|
||||
},
|
||||
{ name: 'style attr expression', html: '<div style="width:expression(alert(1))">x</div>' },
|
||||
{ name: 'style attr url(javascript:)', html: '<div style="background:url(javascript:alert(1))">x</div>' },
|
||||
{ name: 'style element', html: '<style>body{background:url("javascript:alert(1)")}</style>' },
|
||||
{ name: 'noscript wrap', html: '<noscript><p title="</noscript><img src=x onerror=alert(1)>">' },
|
||||
{ name: 'a javascript: href', html: '<a href="javascript:alert(1)">x</a>' },
|
||||
{ name: 'iframe srcdoc', html: '<iframe srcdoc="<img src=x onerror=alert(1)>"></iframe>' },
|
||||
{
|
||||
name: 'foreignObject mXSS',
|
||||
html: '<svg><foreignObject><iframe src="javascript:alert(1)"></iframe></foreignObject></svg>',
|
||||
},
|
||||
{ name: 'details ontoggle', html: '<details open ontoggle=alert(1)>x</details>' },
|
||||
{ name: 'object data', html: '<object data="javascript:alert(1)"></object>' },
|
||||
];
|
||||
|
||||
function assertNeutralized(out: string, label: string) {
|
||||
const lower = out.toLowerCase();
|
||||
expect(lower, `${label}: no <script>`).not.toContain('<script');
|
||||
expect(lower, `${label}: no <svg>`).not.toContain('<svg');
|
||||
expect(lower, `${label}: no <math>`).not.toContain('<math');
|
||||
expect(lower, `${label}: no <iframe>`).not.toContain('<iframe');
|
||||
expect(lower, `${label}: no <object>`).not.toContain('<object');
|
||||
expect(lower, `${label}: no <style>`).not.toContain('<style');
|
||||
expect(lower, `${label}: no onerror`).not.toContain('onerror');
|
||||
expect(lower, `${label}: no onload`).not.toContain('onload');
|
||||
expect(lower, `${label}: no ontoggle`).not.toContain('ontoggle');
|
||||
expect(lower, `${label}: no style= attr`).not.toMatch(/\sstyle\s*=/);
|
||||
expect(lower, `${label}: no javascript: scheme`).not.toContain('javascript:');
|
||||
expect(lower, `${label}: no expression(`).not.toContain('expression(');
|
||||
}
|
||||
|
||||
describe('COD-56 markdown sanitizer (DOMPurify allowlist)', () => {
|
||||
let sanitize: (html: string) => string;
|
||||
|
||||
beforeAll(() => {
|
||||
sanitize = loadShippingSanitizer();
|
||||
});
|
||||
|
||||
describe('mXSS / XSS payloads are neutralized', () => {
|
||||
for (const { name, html } of PAYLOADS) {
|
||||
it(`blocks: ${name}`, () => {
|
||||
assertNeutralized(sanitize(html), name);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('curated allowlist is actually enforced (USE_PROFILES must not override it)', () => {
|
||||
// These tags are in DOMPurify's default html profile but NOT in the curated ALLOWED_TAGS.
|
||||
// If USE_PROFILES were set, the profile would override the allowlist and these would survive.
|
||||
const NON_MARKDOWN_TAGS: { name: string; html: string; tag: string }[] = [
|
||||
{ name: 'button', html: '<button>click</button>', tag: '<button' },
|
||||
{ name: 'input', html: '<input value="x">', tag: '<input' },
|
||||
{ name: 'details', html: '<details open>d</details>', tag: '<details' },
|
||||
{ name: 'audio', html: '<audio controls></audio>', tag: '<audio' },
|
||||
{ name: 'select/option', html: '<select><option>o</option></select>', tag: '<select' },
|
||||
{ name: 'label', html: '<label>l</label>', tag: '<label' },
|
||||
];
|
||||
for (const { name, html, tag } of NON_MARKDOWN_TAGS) {
|
||||
it(`drops non-markdown tag: ${name}`, () => {
|
||||
expect(sanitize(html).toLowerCase()).not.toContain(tag);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('legitimate markdown-rendered HTML survives', () => {
|
||||
it('keeps bold, links, lists, code, headings, tables, safe images', () => {
|
||||
const md =
|
||||
'<h2>Title</h2>' +
|
||||
'<p><strong>bold</strong> and <em>em</em> and <a href="https://example.com">link</a></p>' +
|
||||
'<ul><li>one</li><li>two</li></ul>' +
|
||||
'<pre><code class="language-js">const x = 1;</code></pre>' +
|
||||
'<blockquote><p>quote</p></blockquote>' +
|
||||
'<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>c</td></tr></tbody></table>' +
|
||||
'<img src="https://example.com/a.png" alt="pic">';
|
||||
const out = sanitize(md);
|
||||
expect(out).toContain('<strong>bold</strong>');
|
||||
expect(out).toContain('<em>em</em>');
|
||||
expect(out).toContain('href="https://example.com"');
|
||||
expect(out).toContain('<li>one</li>');
|
||||
expect(out).toContain('<code class="language-js">const x = 1;</code>');
|
||||
expect(out).toContain('<blockquote>');
|
||||
expect(out).toContain('<th>h</th>');
|
||||
expect(out).toContain('<td>c</td>');
|
||||
expect(out).toContain('src="https://example.com/a.png"');
|
||||
expect(out).toContain('alt="pic"');
|
||||
});
|
||||
|
||||
it('preserves a relative/inline image src and code fences', () => {
|
||||
const out = sanitize('<p>see <code>code</code></p><img src="/local/path.png" alt="x">');
|
||||
expect(out).toContain('<code>code</code>');
|
||||
expect(out).toContain('src="/local/path.png"');
|
||||
});
|
||||
});
|
||||
|
||||
// RED EVIDENCE: the OLD denylist let mXSS through. This documents the gap the fix closes;
|
||||
// it asserts the OLD logic FAILS to neutralize at least the svg/math/style vectors.
|
||||
describe('RED: the old denylist sanitizer was bypassable', () => {
|
||||
it('old code leaves <svg> / <math> roots in the output', () => {
|
||||
// svg/math were never in the denylist tag set -> they survive (mXSS foreign namespace).
|
||||
const svgOut = oldDenylistSanitize('<svg><circle></circle></svg>').toLowerCase();
|
||||
const mathOut = oldDenylistSanitize('<math><mtext>x</mtext></math>').toLowerCase();
|
||||
expect(svgOut).toContain('<svg');
|
||||
expect(mathOut).toContain('<math');
|
||||
});
|
||||
|
||||
it('old code leaves a CSS-vector style attribute in the output', () => {
|
||||
const out = oldDenylistSanitize('<div style="background:url(javascript:alert(1))">x</div>').toLowerCase();
|
||||
// style attributes were never stripped by the denylist.
|
||||
expect(out).toMatch(/\sstyle\s*=/);
|
||||
expect(out).toContain('javascript:');
|
||||
});
|
||||
|
||||
it('NEW code closes those same gaps', () => {
|
||||
expect(sanitize('<svg><circle></circle></svg>').toLowerCase()).not.toContain('<svg');
|
||||
expect(sanitize('<math><mtext>x</mtext></math>').toLowerCase()).not.toContain('<math');
|
||||
const out = sanitize('<div style="background:url(javascript:alert(1))">x</div>').toLowerCase();
|
||||
expect(out).not.toMatch(/\sstyle\s*=/);
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// Port: none (pure static analysis — runs in CI, no browser/server).
|
||||
//
|
||||
// Regression guard for "a header button leaks onto the cramped mobile header".
|
||||
// This exact class of bug shipped twice (the plan-usage chip, then the COD-39
|
||||
// attachments-history button): a new control was added to the header, looked
|
||||
// fine on desktop, and nobody noticed it cluttering the phone header. The mobile
|
||||
// Playwright suite that *would* catch it (test/mobile/**) is EXCLUDED from CI, so
|
||||
// it never gated. This test is intentionally a pure parser of index.html +
|
||||
// mobile.css so it runs in the normal CI sweep with zero browser dependencies.
|
||||
//
|
||||
// Policy: every header button that is VISIBLE BY DEFAULT on desktop must have an
|
||||
// explicit decision for phones — either it's hidden via an @media (max-width:
|
||||
// 430px) display:none rule in mobile.css, or it's added to MOBILE_VISIBLE_ALLOWLIST
|
||||
// below with a reason. A new default-visible header button with neither fails this
|
||||
// test, forcing the author to decide its mobile behavior.
|
||||
//
|
||||
// The real-browser counterpart (actual computed visibility on an emulated phone)
|
||||
// lives in test/mobile/header-buttons.test.ts.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC = join(HERE, '../src/web/public');
|
||||
|
||||
// Canonical phone width used to decide whether a media query applies on a phone.
|
||||
// Matches the device the browser-based test emulates (iPhone 14 Pro = 393px CSS).
|
||||
const PHONE_WIDTH = 393;
|
||||
|
||||
// Header buttons intentionally kept VISIBLE in the phone header. Empty today: the
|
||||
// mobile header is deliberately minimal and essential controls (settings, case)
|
||||
// live in the toolbar. Add a class here ONLY with a justifying comment.
|
||||
const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
|
||||
|
||||
// Buttons we expect to STAY hidden on phones — an explicit lock so a future edit
|
||||
// that removes a hide rule fails loudly (not silently). The attachments button is
|
||||
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
|
||||
// it's excluded from the default-visible enumeration rather than mobile-hidden.
|
||||
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log'];
|
||||
|
||||
function attrOf(openTag: string, name: string): string {
|
||||
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
/** Does a media query's width range include a phone-width viewport? */
|
||||
function appliesToPhone(params: string): boolean {
|
||||
const max = params.match(/max-width:\s*(\d+)px/);
|
||||
const min = params.match(/min-width:\s*(\d+)px/);
|
||||
if (max && PHONE_WIDTH > Number(max[1])) return false; // phone is wider than the cap
|
||||
if (min && PHONE_WIDTH < Number(min[1])) return false; // phone is narrower than the floor
|
||||
return Boolean(max || min); // must actually be a width-bounded query
|
||||
}
|
||||
|
||||
function loadHeaderButtons(): { classes: string[]; id: string; distinguishing: string[] }[] {
|
||||
const html = readFileSync(join(PUBLIC, 'index.html'), 'utf-8');
|
||||
// The header controls live in <div class="header-right" id="headerRight"> … </header>.
|
||||
const region = html.match(/<div class="header-right"[^>]*>([\s\S]*?)<\/header>/);
|
||||
expect(region, '#headerRight region not found in index.html — update the selector in this test').toBeTruthy();
|
||||
const headerHtml = region![1];
|
||||
|
||||
return [...headerHtml.matchAll(/<button\b([^>]*)>/g)]
|
||||
.map((m) => {
|
||||
const open = m[1];
|
||||
const classes = attrOf(open, 'class').split(/\s+/).filter(Boolean);
|
||||
return {
|
||||
classes,
|
||||
id: attrOf(open, 'id'),
|
||||
style: attrOf(open, 'style'),
|
||||
distinguishing: classes.filter(
|
||||
(c) => c.startsWith('btn-') && c !== 'btn-icon-header' && c !== 'btn-sm' && !c.endsWith('--hidden')
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((b) => b.classes.includes('btn-icon-header'))
|
||||
.filter((b) => !b.classes.includes('btn-sm')) // font A-/A+ controls — separate sub-group
|
||||
.filter((b) => !/display:\s*none/i.test(b.style)) // JS-gated (solo-redock, retired bell)
|
||||
.filter((b) => !b.classes.some((c) => c.endsWith('--hidden'))); // opt-in, hidden by default
|
||||
}
|
||||
|
||||
function loadPhoneHiddenClasses(): Set<string> {
|
||||
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
|
||||
const hidden = new Set<string>();
|
||||
postcss.parse(css).walkAtRules('media', (atRule) => {
|
||||
if (!appliesToPhone(atRule.params)) return;
|
||||
atRule.walkRules((rule) => {
|
||||
let hides = false;
|
||||
rule.walkDecls('display', (decl) => {
|
||||
if (decl.value.replace(/!important/i, '').trim() === 'none') hides = true;
|
||||
});
|
||||
if (!hides) return;
|
||||
for (const token of rule.selector.match(/\.btn-[a-z0-9-]+/gi) || []) {
|
||||
hidden.add(token.slice(1).toLowerCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
return hidden;
|
||||
}
|
||||
|
||||
describe('Mobile header button policy (static guard)', () => {
|
||||
const buttons = loadHeaderButtons();
|
||||
const phoneHidden = loadPhoneHiddenClasses();
|
||||
|
||||
it('finds the default-visible header buttons (sanity)', () => {
|
||||
// If this drops to 0 the parser/markup drifted — fix the parser, don't delete the test.
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every default-visible header button has a mobile-visibility decision', () => {
|
||||
for (const btn of buttons) {
|
||||
expect(
|
||||
btn.distinguishing.length,
|
||||
`Header button (id=${btn.id || '?'}, class="${btn.classes.join(' ')}") has no distinguishing ` +
|
||||
`btn-* class to target on mobile. Give it one so its phone visibility can be controlled.`
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const hidden = btn.distinguishing.some((c) => phoneHidden.has(c.toLowerCase()));
|
||||
const allowed = btn.distinguishing.some((c) => MOBILE_VISIBLE_ALLOWLIST.has(c));
|
||||
|
||||
expect(
|
||||
hidden || allowed,
|
||||
`Header button .${btn.distinguishing.join('.')} (id=${btn.id || '?'}) is VISIBLE BY DEFAULT but has ` +
|
||||
`no mobile-visibility decision.\n` +
|
||||
` → To hide it on phones: add it to the @media (max-width: 430px) "display: none" block in ` +
|
||||
`src/web/public/mobile.css (next to .btn-settings / .btn-lifecycle-log).\n` +
|
||||
` → To keep it visible on phones: add '${btn.distinguishing[0]}' to MOBILE_VISIBLE_ALLOWLIST in ` +
|
||||
`this test, with a reason.\n` +
|
||||
`This guard exists because the plan-usage chip and the attachments button both leaked onto the ` +
|
||||
`mobile header unnoticed.`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('locks the known phone-hidden header buttons', () => {
|
||||
for (const cls of KNOWN_PHONE_HIDDEN) {
|
||||
expect(
|
||||
phoneHidden.has(cls),
|
||||
`${cls} must stay hidden on phones — restore its rule in the @media (max-width: 430px) ` +
|
||||
`display:none block in src/web/public/mobile.css.`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Port 3208 - Header button visibility (real-browser E2E).
|
||||
//
|
||||
// Companion to the CI static guard (test/mobile-header-buttons-policy.test.ts).
|
||||
// Renders the real app and asserts ACTUAL computed visibility, catching CSS/layout
|
||||
// regressions the static parser can't see. Two behaviours are covered:
|
||||
// 1. The minimal phone header hides the settings gear + lifecycle log.
|
||||
// 2. The attachments button (COD-39) is OPT-IN — default-hidden everywhere, shown
|
||||
// only when App Settings → Display → "Attachments Button" is enabled. It first
|
||||
// shipped always-visible (mobile, then desktop), which is the regression here.
|
||||
import { describe, it, beforeAll, afterAll } from 'vitest';
|
||||
import type { WebServer } from '../../src/web/server.js';
|
||||
import { createTestServer, stopTestServer } from './helpers/server.js';
|
||||
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
|
||||
import { assertHidden, assertVisible } from './helpers/assertions.js';
|
||||
import { REPRESENTATIVE_DEVICES } from './devices.js';
|
||||
import { PORTS, WAIT } from './helpers/constants.js';
|
||||
|
||||
const PORT = PORTS.HEADER_BUTTONS;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('Header button visibility (E2E)', () => {
|
||||
let server: WebServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createTestServer(PORT);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeAllBrowsers();
|
||||
await stopTestServer(server);
|
||||
});
|
||||
|
||||
it('hides the settings gear + lifecycle log on a standard phone (iPhone 14 Pro)', async () => {
|
||||
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL);
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertHidden(page, '.btn-icon-header.btn-settings');
|
||||
await assertHidden(page, '.btn-icon-header.btn-lifecycle-log');
|
||||
});
|
||||
|
||||
it('keeps the opt-in attachments button HIDDEN by default on a desktop-class viewport', async () => {
|
||||
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertHidden(page, '#attachmentsHistoryBtn');
|
||||
});
|
||||
|
||||
it('shows the attachments button once the setting is enabled', async () => {
|
||||
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
|
||||
// Desktop-class devices use the non-mobile settings blob.
|
||||
await page.evaluate(() => {
|
||||
const cur = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
|
||||
cur.showAttachmentsButton = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(cur));
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertVisible(page, '#attachmentsHistoryBtn');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export const PORTS = {
|
||||
DEVICE_MATRIX: 3205,
|
||||
VISUAL_REGRESSION: 3206,
|
||||
ACCESSIBILITY: 3207,
|
||||
HEADER_BUTTONS: 3208,
|
||||
} as const;
|
||||
|
||||
// CSS Selectors
|
||||
@@ -64,8 +65,8 @@ export const BREAKPOINTS = {
|
||||
|
||||
// Keyboard constants (match app.js KeyboardHandler)
|
||||
export const KEYBOARD = {
|
||||
SHOW_THRESHOLD: 150, // heightDiff > 150px triggers show
|
||||
HIDE_THRESHOLD: 100, // heightDiff < 100px triggers hide
|
||||
SHOW_THRESHOLD: 150, // heightDiff > 150px triggers show
|
||||
HIDE_THRESHOLD: 100, // heightDiff < 100px triggers hide
|
||||
TYPICAL_IOS_HEIGHT: 336,
|
||||
FOCUSIN_DELAY: 400,
|
||||
ANIMATION_DELAY: 150,
|
||||
@@ -87,7 +88,7 @@ export const SWIPE = {
|
||||
export const SUBAGENT = {
|
||||
MOBILE_CARD_HEIGHT: 110,
|
||||
MOBILE_CARD_GAP: 4,
|
||||
MOBILE_CARD_STRIDE: 114, // height + gap
|
||||
MOBILE_CARD_STRIDE: 114, // height + gap
|
||||
TOOLBAR_OFFSET: 40,
|
||||
DEFAULT_HEADER_HEIGHT: 36,
|
||||
} as const;
|
||||
|
||||
@@ -528,15 +528,24 @@ describe('Virtual Keyboard', () => {
|
||||
expect(afterEnter.sentInputs).toEqual(['hello', '\r']);
|
||||
});
|
||||
|
||||
it('shows the CJK textarea on mobile only for server override', async () => {
|
||||
it('shows the CJK textarea on mobile for server override only inside an active session', async () => {
|
||||
const state = await page.evaluate(() => {
|
||||
app._serverCjkOverride = true;
|
||||
app._updateCjkInputState();
|
||||
|
||||
const input = document.getElementById('cjkInput');
|
||||
if (!(input instanceof HTMLElement)) return null;
|
||||
|
||||
// Welcome screen (no active session): even with the server override on, the
|
||||
// fixed-position textarea must stay hidden so it doesn't float over the overlay.
|
||||
app.activeSessionId = null;
|
||||
app._serverCjkOverride = true;
|
||||
app._updateCjkInputState();
|
||||
const onWelcomeDisplay = getComputedStyle(input).display;
|
||||
|
||||
// Entering a session reveals it.
|
||||
app.activeSessionId = 'cjk-server-override-test';
|
||||
app._updateCjkInputState();
|
||||
const cs = getComputedStyle(input);
|
||||
return {
|
||||
onWelcomeDisplay,
|
||||
display: cs.display,
|
||||
position: cs.position,
|
||||
bottom: cs.bottom,
|
||||
@@ -546,6 +555,7 @@ describe('Virtual Keyboard', () => {
|
||||
});
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.onWelcomeDisplay).toBe('none');
|
||||
expect(state?.display).not.toBe('none');
|
||||
expect(state?.position).toBe('fixed');
|
||||
expect(Number(state?.zIndex)).toBeGreaterThan(50);
|
||||
|
||||
@@ -132,13 +132,16 @@ describe('Tab Navigation', () => {
|
||||
expect(modalClass).toMatch(/active/);
|
||||
});
|
||||
|
||||
it('header has no utility toggle and the tray stays collapsed on mobile', async () => {
|
||||
it('header has no utility toggle and the tray is reachable inline on mobile', async () => {
|
||||
// The three-dot header utility toggle was removed (owner decision,
|
||||
// 2026-06-10): nothing interactive may occupy the top-left corner, and
|
||||
// the headerRight tray stays collapsed (hidden) on small viewports.
|
||||
// 2026-06-10) and the collapsible position:fixed headerRight tray it
|
||||
// controlled was dropped (PR #122): with the toggle gone, leaving the
|
||||
// tray collapsed made every header-right utility — including the opt-in
|
||||
// response-viewer eye button — permanently unreachable on phones. The
|
||||
// utilities now flow INLINE and must stay reachable on small viewports;
|
||||
// nothing interactive may occupy the top-left corner.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.modal.active').forEach((modal) => modal.classList.remove('active'));
|
||||
document.getElementById('headerRight')?.classList.add('mobile-collapsed');
|
||||
});
|
||||
|
||||
const toggleCount = await page.locator('#mobileHeaderUtilityToggle').count();
|
||||
@@ -148,7 +151,7 @@ describe('Tab Navigation', () => {
|
||||
const tray = document.getElementById('headerRight');
|
||||
return tray ? getComputedStyle(tray).display !== 'none' : false;
|
||||
});
|
||||
expect(trayVisible).toBe(false);
|
||||
expect(trayVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('tabs remain visible on large phone and tablet headers', async () => {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @fileoverview Route coverage for the document preview/thumbnail endpoints
|
||||
* added in COD-38 (PR #120): the by-attachmentId routes
|
||||
* (/attachments/:id/preview|thumbnail) and the workspace-path routes
|
||||
* (/file-preview|/file-thumbnail). Converters are mocked, so no real
|
||||
* pdftoppm/LibreOffice is needed. Uses app.inject() — no real ports.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => Buffer.from('%PDF-1.4 fake pdf bytes')),
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true, mtimeMs: 1 })),
|
||||
readdir: vi.fn(async () => []),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => '/tmp/codeman-preview-test'),
|
||||
rename: vi.fn(async () => undefined),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, realpathSync: vi.fn((p: string) => p) };
|
||||
});
|
||||
|
||||
// Mock the converters so the routes don't shell out to real binaries.
|
||||
vi.mock('../../src/document-thumbnailer.js', () => ({
|
||||
generateFirstPageThumbnail: vi.fn(async () => ({ content: Buffer.from('\x89PNG fake'), contentType: 'image/png' })),
|
||||
}));
|
||||
vi.mock('../../src/document-preview-cache.js', () => ({
|
||||
getOfficePreviewPdfPath: vi.fn(async () => '/tmp/codeman-document-preview-cache/out.pdf'),
|
||||
getPreviewPdfDownloadName: vi.fn((name: string) => `${name.replace(/\.[^.]+$/, '')}.pdf`),
|
||||
}));
|
||||
|
||||
import { generateFirstPageThumbnail } from '../../src/document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath } from '../../src/document-preview-cache.js';
|
||||
import { attachmentRegistry, type AttachmentRecord } from '../../src/attachment-registry.js';
|
||||
|
||||
const SID = 'test-session-1';
|
||||
const WORKDIR = '/tmp/test-workdir';
|
||||
|
||||
function makeRecord(over: Partial<AttachmentRecord>): AttachmentRecord {
|
||||
return {
|
||||
attachmentId: 'att_x',
|
||||
sessionId: SID,
|
||||
filePath: `${WORKDIR}/file`,
|
||||
fileName: 'file',
|
||||
extension: 'pdf',
|
||||
attachmentType: 'document',
|
||||
size: 100,
|
||||
mtimeMs: 1,
|
||||
timestamp: 1,
|
||||
source: 'detected',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('file-routes preview/thumbnail (COD-38)', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes, { sessionId: SID });
|
||||
vi.clearAllMocks();
|
||||
attachmentRegistry.clearSession(SID);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
attachmentRegistry.clearSession(SID);
|
||||
});
|
||||
|
||||
// ---- workspace-path routes ----
|
||||
|
||||
it('file-preview converts a workspace DOCX to an inline PDF', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview?path=deck.docx` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/pdf');
|
||||
expect(res.headers['content-disposition']).toContain('inline');
|
||||
expect(getOfficePreviewPdfPath).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('file-preview redirects a non-Office workspace file (PDF) to the raw route', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview?path=report.pdf` });
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(300);
|
||||
expect(res.statusCode).toBeLessThan(400);
|
||||
expect(res.headers.location).toContain('/file-raw?path=report.pdf');
|
||||
expect(getOfficePreviewPdfPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('file-preview 400s for a missing path parameter', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('file-thumbnail returns a PNG for a supported workspace file', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-thumbnail?path=deck.pdf` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(generateFirstPageThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('file-thumbnail 400s for an unsupported extension', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-thumbnail?path=notes.exe` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(generateFirstPageThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- by-attachmentId routes ----
|
||||
|
||||
it('by-id preview converts a registered DOCX attachment', async () => {
|
||||
attachmentRegistry.register(
|
||||
makeRecord({
|
||||
attachmentId: 'att_docx',
|
||||
filePath: `${WORKDIR}/deck.docx`,
|
||||
fileName: 'deck.docx',
|
||||
extension: 'docx',
|
||||
})
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SID}/attachments/att_docx/preview`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/pdf');
|
||||
});
|
||||
|
||||
it('by-id preview redirects a non-Office attachment (PNG) to its raw route', async () => {
|
||||
attachmentRegistry.register(
|
||||
makeRecord({ attachmentId: 'att_png', filePath: `${WORKDIR}/shot.png`, fileName: 'shot.png', extension: 'png' })
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SID}/attachments/att_png/preview`,
|
||||
});
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(300);
|
||||
expect(res.statusCode).toBeLessThan(400);
|
||||
expect(res.headers.location).toContain('/attachments/att_png/raw');
|
||||
});
|
||||
|
||||
it('by-id thumbnail returns a PNG for a registered attachment', async () => {
|
||||
attachmentRegistry.register(
|
||||
makeRecord({ attachmentId: 'att_pdf', filePath: `${WORKDIR}/deck.pdf`, fileName: 'deck.pdf', extension: 'pdf' })
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SID}/attachments/att_pdf/thumbnail`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
});
|
||||
});
|
||||
@@ -215,6 +215,50 @@ describe('file-routes', () => {
|
||||
expect(body.data.url).toContain('file-raw');
|
||||
});
|
||||
|
||||
it('returns audio metadata for audio files', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 2048 } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=clip.mp3`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.type).toBe('audio');
|
||||
expect(body.data.url).toContain('file-raw');
|
||||
});
|
||||
|
||||
it('flags known-binary extensions (e.g. xlsx) instead of dumping mojibake', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 4096 } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.type).toBe('binary');
|
||||
expect(body.data.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sniffs NUL bytes and flags binary content for unknown extensions', async () => {
|
||||
const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]);
|
||||
mockedReadFile.mockResolvedValue(binary as never);
|
||||
mockedStat.mockResolvedValue({ size: binary.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=mystery.dat`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.type).toBe('binary');
|
||||
expect(body.data.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects path traversal attempts', async () => {
|
||||
// realpathSync resolves the symlink to a path outside workingDir
|
||||
mockedRealpathSync.mockReturnValue('/etc/passwd' as never);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Route tests for POST /api/status-telemetry — the Claude statusline exporter
|
||||
* endpoint that feeds the header "Plan Usage Limits" chip.
|
||||
*
|
||||
* Covers: broadcast on real telemetry + footer print-through, unknown-session
|
||||
* skip, per-session change-detection (dedup), rebroadcast on a displayed change,
|
||||
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
|
||||
* fields (the .nullish() schema — the project's recurring .optional()/null trap),
|
||||
* and 400 on a malformed body.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js';
|
||||
import { SessionStatusTelemetry } from '../../src/web/sse-events.js';
|
||||
|
||||
const SID = 'test-session-1'; // default id created by createMockRouteContext
|
||||
|
||||
const REAL = {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 15, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: 34, resets_at: 1781827200 },
|
||||
},
|
||||
context_window: { used_percentage: 56, total_input_tokens: 562411, total_output_tokens: 1188 },
|
||||
cost: { total_cost_usd: 0.0415 },
|
||||
model: { display_name: 'Opus 4.8 (1M context)' },
|
||||
};
|
||||
|
||||
describe('POST /api/status-telemetry', () => {
|
||||
let h: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID });
|
||||
});
|
||||
|
||||
const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body });
|
||||
|
||||
it('broadcasts plan-usage telemetry and returns the session-status footer', async () => {
|
||||
const res = await post({ sessionId: SID, data: REAL });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/plain');
|
||||
expect(res.body).toBe('Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%');
|
||||
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
||||
const [event, payload] = h.ctx.broadcast.mock.calls[0];
|
||||
expect(event).toBe(SessionStatusTelemetry);
|
||||
expect(payload).toMatchObject({
|
||||
sessionId: SID,
|
||||
fiveHour: { usedPercentage: 15 },
|
||||
sevenDay: { usedPercentage: 34 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not broadcast for an unknown session; returns the brand footer', async () => {
|
||||
const res = await post({ sessionId: 'does-not-exist', data: REAL });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('codeman');
|
||||
expect(h.ctx.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dedups identical telemetry — rebroadcasts only once', async () => {
|
||||
await post({ sessionId: SID, data: REAL });
|
||||
await post({ sessionId: SID, data: REAL });
|
||||
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rebroadcasts when a displayed window percentage changes', async () => {
|
||||
await post({ sessionId: SID, data: REAL });
|
||||
const moved = {
|
||||
...REAL,
|
||||
rate_limits: { ...REAL.rate_limits, five_hour: { used_percentage: 16, resets_at: 1781409000 } },
|
||||
};
|
||||
await post({ sessionId: SID, data: moved });
|
||||
expect(h.ctx.broadcast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does NOT rebroadcast on context-only drift (the chip never shows context %)', async () => {
|
||||
await post({ sessionId: SID, data: REAL });
|
||||
await post({ sessionId: SID, data: { ...REAL, context_window: { ...REAL.context_window, used_percentage: 91 } } });
|
||||
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tolerates null in Claude's undocumented fields (no 400) and ignores them", async () => {
|
||||
const res = await post({
|
||||
sessionId: SID,
|
||||
data: {
|
||||
rate_limits: { five_hour: { used_percentage: 20, resets_at: 1781409000 }, seven_day: null },
|
||||
cost: { total_cost_usd: null },
|
||||
model: { display_name: null },
|
||||
context_window: { used_percentage: null, total_input_tokens: null, total_output_tokens: null },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
||||
const [, payload] = h.ctx.broadcast.mock.calls[0];
|
||||
expect(payload).toMatchObject({ fiveHour: { usedPercentage: 20 } });
|
||||
expect(payload.sevenDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a malformed body (missing sessionId) with 400', async () => {
|
||||
const res = await post({ data: REAL });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Route tests for the ultracode workflow endpoints:
|
||||
* GET /api/workflows → run summaries (no agents[])
|
||||
* GET /api/workflows/:runId → full run (with agents[]) or 404
|
||||
*
|
||||
* Uses app.inject() — no real ports. The workflow-run-watcher singleton is mocked
|
||||
* so we control the returned data (positive + not-found) without touching ~/.claude.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// ── Mocks required by registerSystemRoutes ──────────────────────────
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: { readFile: vi.fn(async () => '{}'), writeFile: vi.fn(async () => undefined) },
|
||||
}));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
|
||||
});
|
||||
vi.mock('../../src/subagent-watcher.js', () => ({
|
||||
subagentWatcher: {
|
||||
getSubagents: vi.fn(() => []),
|
||||
getRecentSubagents: vi.fn(() => []),
|
||||
isRunning: vi.fn(() => true),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/image-watcher.js', () => ({
|
||||
imageWatcher: { isRunning: vi.fn(() => false), start: vi.fn(), stop: vi.fn(), watchSession: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../src/session-lifecycle-log.js', () => ({
|
||||
getLifecycleLog: vi.fn(() => ({ log: vi.fn(), query: vi.fn(async () => []) })),
|
||||
}));
|
||||
vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({
|
||||
isOpenCodeAvailable: vi.fn(() => false),
|
||||
resolveOpenCodeDir: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const SUMMARY = {
|
||||
runId: 'wf_abc123',
|
||||
workflowName: 'review-open-prs',
|
||||
status: 'completed',
|
||||
summary: 'Deep review',
|
||||
agentCount: 2,
|
||||
totalTokens: 1000,
|
||||
totalToolCalls: 10,
|
||||
phases: [{ title: 'Review', detail: 'one per PR' }],
|
||||
sessionUuid: 'sess-1',
|
||||
projectHash: 'proj-1',
|
||||
lastActivityAt: 123,
|
||||
};
|
||||
const FULL_RUN = {
|
||||
...SUMMARY,
|
||||
agents: [
|
||||
{
|
||||
index: 1,
|
||||
label: 'review:pr-1',
|
||||
phaseIndex: 1,
|
||||
phaseTitle: 'Review',
|
||||
model: 'opus',
|
||||
state: 'done',
|
||||
tokens: 500,
|
||||
toolCalls: 5,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
label: 'review:pr-2',
|
||||
phaseIndex: 1,
|
||||
phaseTitle: 'Review',
|
||||
model: 'opus',
|
||||
state: 'done',
|
||||
tokens: 500,
|
||||
toolCalls: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mock('../../src/workflow-run-watcher.js', () => ({
|
||||
workflowRunWatcher: {
|
||||
getAllRunSummaries: vi.fn(() => [SUMMARY]),
|
||||
getRecentRunSummaries: vi.fn(() => [SUMMARY]),
|
||||
getRun: vi.fn((runId: string) => (runId === 'wf_abc123' ? FULL_RUN : undefined)),
|
||||
isRunning: vi.fn(() => false),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('workflow routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('GET /api/workflows returns the envelope with summaries (no agents[])', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/workflows' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].runId).toBe('wf_abc123');
|
||||
expect('agents' in body.data[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /api/workflows/:runId returns the full run with agents[]', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/workflows/wf_abc123' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.agents).toHaveLength(2);
|
||||
expect(body.data.agents[0].tokens).toBe(500);
|
||||
});
|
||||
|
||||
it('GET /api/workflows/:runId 404s for an unknown run', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/workflows/wf_nope' });
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
});
|
||||
@@ -21,11 +21,11 @@
|
||||
* Port: N/A (no server start)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { readFileSync, mkdtempSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { hostname as osHostname } from 'node:os';
|
||||
import { hostname as osHostname, tmpdir } from 'node:os';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -40,6 +40,21 @@ async function render(host?: string): Promise<string> {
|
||||
}
|
||||
|
||||
describe('WebServer index.html <title> templating (#82)', () => {
|
||||
// renderIndexHtml reads the ambient settings.json (for the gesture bundle and
|
||||
// the header-toggle marker-class strips, e.g. showPlanUsageLimits /
|
||||
// showMultiMonitorButton). Point it at an empty data dir so this test is
|
||||
// deterministic regardless of the developer's real settings — otherwise an
|
||||
// enabled toggle would strip a marker class and break the byte-identical
|
||||
// assertion below. getDataDir() reads CODEMAN_DATA_DIR fresh per call.
|
||||
const _prevDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
beforeAll(() => {
|
||||
process.env.CODEMAN_DATA_DIR = mkdtempSync(join(tmpdir(), 'codeman-title-test-'));
|
||||
});
|
||||
afterAll(() => {
|
||||
if (_prevDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
||||
else process.env.CODEMAN_DATA_DIR = _prevDataDir;
|
||||
});
|
||||
|
||||
it('substitutes the bare <title>Codeman</title> with codeman:<host>', async () => {
|
||||
const html = await render('laptop');
|
||||
expect(html).toContain('<title>codeman:laptop</title>');
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import type { SessionAttachmentHistoryItem } from '../src/types/session.js';
|
||||
import {
|
||||
ATTACHMENT_HISTORY_LIMIT,
|
||||
buildDetectedAttachmentHistoryItem,
|
||||
buildExternalAttachmentHistoryItem,
|
||||
upsertAttachmentHistory,
|
||||
} from '../src/session-attachment-history.js';
|
||||
|
||||
describe('session attachment history', () => {
|
||||
it('dedupes explicit external attachments by source path and moves latest to top', () => {
|
||||
const first = buildExternalAttachmentHistoryItem({
|
||||
sessionId: 's1',
|
||||
externalPath: '/mnt/c/docs/brief.docx',
|
||||
fileName: 'brief.docx',
|
||||
extension: 'docx',
|
||||
size: 100,
|
||||
mtimeMs: 1,
|
||||
timestamp: 10,
|
||||
});
|
||||
const second = { ...first, size: 200, mtimeMs: 2, timestamp: 20 };
|
||||
|
||||
const result = upsertAttachmentHistory(upsertAttachmentHistory([], first), second);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
size: 200,
|
||||
mtimeMs: 2,
|
||||
timestamp: 20,
|
||||
externalPath: '/mnt/c/docs/brief.docx',
|
||||
});
|
||||
});
|
||||
|
||||
it('dedupes detected workspace attachments by relative path', () => {
|
||||
const first = buildDetectedAttachmentHistoryItem({
|
||||
sessionId: 's1',
|
||||
filePath: 'report.pdf',
|
||||
relativePath: 'out/report.pdf',
|
||||
fileName: 'report.pdf',
|
||||
extension: 'pdf',
|
||||
attachmentType: 'pdf',
|
||||
size: 100,
|
||||
timestamp: 10,
|
||||
});
|
||||
const second = { ...first, size: 150, timestamp: 20 };
|
||||
|
||||
const result = upsertAttachmentHistory(upsertAttachmentHistory([], first), second);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ relativePath: 'out/report.pdf', size: 150, timestamp: 20 });
|
||||
});
|
||||
|
||||
it('caps history to newest 100 items', () => {
|
||||
let history: SessionAttachmentHistoryItem[] = [];
|
||||
for (let i = 0; i < ATTACHMENT_HISTORY_LIMIT + 5; i++) {
|
||||
history = upsertAttachmentHistory(
|
||||
history,
|
||||
buildDetectedAttachmentHistoryItem({
|
||||
sessionId: 's1',
|
||||
filePath: `${i}.png`,
|
||||
relativePath: `out/${i}.png`,
|
||||
fileName: `${i}.png`,
|
||||
extension: 'png',
|
||||
attachmentType: 'image',
|
||||
size: i,
|
||||
timestamp: i,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
expect(history).toHaveLength(ATTACHMENT_HISTORY_LIMIT);
|
||||
expect(history[0].fileName).toBe('104.png');
|
||||
expect(history.at(-1)?.fileName).toBe('5.png');
|
||||
});
|
||||
|
||||
it('includes attachment history in session state', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
session.upsertAttachmentHistory({
|
||||
id: 'detected:file.png',
|
||||
sessionId: session.id,
|
||||
fileName: 'file.png',
|
||||
extension: 'png',
|
||||
attachmentType: 'image',
|
||||
size: 12,
|
||||
mtimeMs: 0,
|
||||
timestamp: 100,
|
||||
source: 'detected',
|
||||
relativePath: 'file.png',
|
||||
});
|
||||
|
||||
expect(session.toState().attachmentHistory).toHaveLength(1);
|
||||
expect(session.toState().attachmentHistory?.[0].fileName).toBe('file.png');
|
||||
});
|
||||
|
||||
it('sanitizes external attachment paths from public session state', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
session.upsertAttachmentHistory(
|
||||
buildExternalAttachmentHistoryItem({
|
||||
sessionId: session.id,
|
||||
externalPath: '/mnt/c/private/board-update.pdf',
|
||||
fileName: 'board-update.pdf',
|
||||
extension: 'pdf',
|
||||
size: 100,
|
||||
timestamp: 100,
|
||||
})
|
||||
);
|
||||
|
||||
const publicState = session.toState();
|
||||
const persistedHistory = session.getAttachmentHistoryForPersist();
|
||||
|
||||
expect(JSON.stringify(publicState.attachmentHistory)).not.toContain('/mnt/c/private/board-update.pdf');
|
||||
expect(publicState.attachmentHistory?.[0].id).not.toContain('/mnt/c/private/board-update.pdf');
|
||||
expect(persistedHistory?.[0].externalPath).toBe('/mnt/c/private/board-update.pdf');
|
||||
});
|
||||
|
||||
it('round-trips persisted history (incl. externalPath) through constructor restore', () => {
|
||||
const a = new Session({ workingDir: '/tmp' });
|
||||
a.upsertAttachmentHistory(
|
||||
buildExternalAttachmentHistoryItem({
|
||||
sessionId: a.id,
|
||||
externalPath: '/mnt/c/docs/deck.docx',
|
||||
fileName: 'deck.docx',
|
||||
extension: 'docx',
|
||||
size: 10,
|
||||
timestamp: 5,
|
||||
})
|
||||
);
|
||||
const persisted = a.getAttachmentHistoryForPersist();
|
||||
|
||||
const b = new Session({ workingDir: '/tmp', attachmentHistory: persisted });
|
||||
// Private copy keeps externalPath (needed to re-register/serve the file)...
|
||||
expect(b.getAttachmentHistoryForPersist()?.[0].externalPath).toBe('/mnt/c/docs/deck.docx');
|
||||
// ...but the public copy stays sanitized after restore.
|
||||
expect(JSON.stringify(b.toState().attachmentHistory)).not.toContain('/mnt/c/docs/deck.docx');
|
||||
});
|
||||
|
||||
it('ignores malformed saved history items during restore instead of throwing', () => {
|
||||
const valid: SessionAttachmentHistoryItem = {
|
||||
id: 'detected:ok.png',
|
||||
sessionId: 's',
|
||||
fileName: 'ok.png',
|
||||
extension: 'png',
|
||||
attachmentType: 'image',
|
||||
size: 1,
|
||||
mtimeMs: 0,
|
||||
timestamp: 1,
|
||||
source: 'detected',
|
||||
relativePath: 'ok.png',
|
||||
};
|
||||
// null, a non-object, and an item missing required fields must be skipped —
|
||||
// historyKey() would otherwise throw inside the constructor and abort recovery.
|
||||
const malformed = [null, 'nope', { partial: true }, valid] as unknown as SessionAttachmentHistoryItem[];
|
||||
|
||||
let session!: Session;
|
||||
expect(() => {
|
||||
session = new Session({ workingDir: '/tmp', attachmentHistory: malformed });
|
||||
}).not.toThrow();
|
||||
|
||||
const persisted = session.getAttachmentHistoryForPersist();
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect(persisted?.[0].fileName).toBe('ok.png');
|
||||
});
|
||||
});
|
||||
@@ -404,6 +404,136 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meta-sidecar discovery (2026-06 Claude Code format)', () => {
|
||||
it('should discover a subagent from agent-{id}.meta.json when no .jsonl exists', async () => {
|
||||
// New format: TUI Task subagents write only a meta sidecar (no per-agent .jsonl).
|
||||
mockExistsSync.mockImplementation((p: string) => !String(p).endsWith('.jsonl'));
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
if (path.includes('subagents')) return ['agent-meta1.meta.json'];
|
||||
if (path.includes('session1')) return ['subagents'];
|
||||
if (path.includes('project1')) return ['session1'];
|
||||
return ['project1'];
|
||||
});
|
||||
mockStatSync.mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 120,
|
||||
});
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({ agentType: 'general-purpose', description: 'Audit server.js', toolUseId: 'toolu_x' })
|
||||
);
|
||||
|
||||
const discoveredHandler = vi.fn();
|
||||
watcher.on('subagent:discovered', discoveredHandler);
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
|
||||
expect(discoveredHandler).toHaveBeenCalled();
|
||||
const info = discoveredHandler.mock.calls[0][0] as SubagentInfo;
|
||||
expect(info.agentId).toBe('meta1');
|
||||
expect(info.description).toBe('Audit server.js');
|
||||
expect(info.status).toBe('active');
|
||||
expect(watcher.getSubagents()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should prefer the real .jsonl transcript when one exists alongside the meta', async () => {
|
||||
// Both sidecar and transcript present → defer to the richer .jsonl path.
|
||||
mockExistsSync.mockReturnValue(true); // sibling agent-both.jsonl exists
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
if (path.includes('subagents')) return ['agent-both.meta.json'];
|
||||
if (path.includes('session1')) return ['subagents'];
|
||||
if (path.includes('project1')) return ['session1'];
|
||||
return ['project1'];
|
||||
});
|
||||
mockStatSync.mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 100,
|
||||
});
|
||||
// Mirror the passing discovery tests: leave createReadStream unmocked so
|
||||
// _resolveDescription fails gracefully (undefined) and discovery still fires.
|
||||
mockReadFileSync.mockReturnValue('');
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
|
||||
const discoveredHandler = vi.fn();
|
||||
watcher.on('subagent:discovered', discoveredHandler);
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(discoveredHandler).toHaveBeenCalled();
|
||||
const info = discoveredHandler.mock.calls[0][0] as SubagentInfo;
|
||||
expect(info.agentId).toBe('both');
|
||||
// filePath points at the transcript, not the sidecar.
|
||||
expect(info.filePath.endsWith('.jsonl')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow-nested subagents (subagents/workflows/{wf}/)', () => {
|
||||
// The Workflow tool nests its agents one level deeper than TUI Task subagents:
|
||||
// subagents/workflows/{workflowId}/agent-{id}.jsonl — plus a sibling journal.jsonl.
|
||||
// The flat subagents/ scan misses them; watchWorkflowDirs() descends one level.
|
||||
function mockWorkflowLayout(wfFiles: string[]) {
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
const p = String(path);
|
||||
if (p.endsWith('wf_test')) return wfFiles;
|
||||
if (p.endsWith('workflows')) return ['wf_test'];
|
||||
if (p.endsWith('subagents')) return ['workflows']; // no flat agents, just the workflows dir
|
||||
if (p.includes('session1')) return ['subagents'];
|
||||
if (p.includes('project1')) return ['session1'];
|
||||
return ['project1'];
|
||||
});
|
||||
mockStatSync.mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 100,
|
||||
});
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
return mockRl;
|
||||
}
|
||||
|
||||
it('should discover an agent nested under subagents/workflows/{wf}/', async () => {
|
||||
mockExistsSync.mockReturnValue(true); // sibling .jsonl exists for the meta
|
||||
const mockRl = mockWorkflowLayout(['agent-wf1.jsonl', 'agent-wf1.meta.json', 'journal.jsonl']);
|
||||
|
||||
const discoveredHandler = vi.fn();
|
||||
watcher.on('subagent:discovered', discoveredHandler);
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(discoveredHandler).toHaveBeenCalled();
|
||||
const info = discoveredHandler.mock.calls[0][0] as SubagentInfo;
|
||||
expect(info.agentId).toBe('wf1');
|
||||
expect(info.filePath.endsWith('.jsonl')).toBe(true);
|
||||
expect(info.filePath).toContain('workflows');
|
||||
});
|
||||
|
||||
it('should NOT register a workflow dir journal.jsonl as a bogus agent', async () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
const mockRl = mockWorkflowLayout(['agent-wf1.jsonl', 'journal.jsonl']);
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const agents = watcher.getSubagents();
|
||||
expect(agents).toHaveLength(1); // only agent-wf1, never "journal"
|
||||
expect(agents.every((a) => a.agentId !== 'journal')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Lifecycle', () => {
|
||||
it('should start agents as active', async () => {
|
||||
const mockRl = createMockRl();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function loadTabOverflowHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (context.window as { CodemanTabOverflow: { shouldAutoWrapTabs: (input: unknown) => boolean } })
|
||||
.CodemanTabOverflow;
|
||||
}
|
||||
|
||||
describe('tab overflow layout policy', () => {
|
||||
it('auto-wraps desktop tabs when their rendered width exceeds available tab space', () => {
|
||||
const helper = loadTabOverflowHelper();
|
||||
|
||||
expect(
|
||||
helper.shouldAutoWrapTabs({
|
||||
deviceType: 'desktop',
|
||||
manualTwoRows: false,
|
||||
tabCount: 18,
|
||||
scrollWidth: 1400,
|
||||
clientWidth: 760,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not auto-wrap when manual tall tabs are enabled or on mobile/tablet', () => {
|
||||
const helper = loadTabOverflowHelper();
|
||||
|
||||
expect(
|
||||
helper.shouldAutoWrapTabs({
|
||||
deviceType: 'desktop',
|
||||
manualTwoRows: true,
|
||||
tabCount: 18,
|
||||
scrollWidth: 1400,
|
||||
clientWidth: 760,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
helper.shouldAutoWrapTabs({
|
||||
deviceType: 'mobile',
|
||||
manualTwoRows: false,
|
||||
tabCount: 18,
|
||||
scrollWidth: 1400,
|
||||
clientWidth: 320,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('respects the boundary conditions (exact fit, +1 tolerance, and tabCount < 2)', () => {
|
||||
const helper = loadTabOverflowHelper();
|
||||
const base = { deviceType: 'desktop' as const, manualTwoRows: false, tabCount: 6 };
|
||||
|
||||
// Exact fit: no overflow, no wrap.
|
||||
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 800, clientWidth: 800 })).toBe(false);
|
||||
// Within the +1 sub-pixel tolerance: still no wrap.
|
||||
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 801, clientWidth: 800 })).toBe(false);
|
||||
// 2px over: wrap.
|
||||
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 802, clientWidth: 800 })).toBe(true);
|
||||
// A single overflowing tab must not wrap (need at least 2 to form a second row).
|
||||
expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseStatusTelemetry,
|
||||
parseSessionStatus,
|
||||
formatSessionStatusText,
|
||||
telemetrySignature,
|
||||
type RawStatuslinePayload,
|
||||
} from '../src/usage-telemetry.js';
|
||||
|
||||
// Mirrors the real captured statusline payload (CC 2.1.177, Claude Max) — see
|
||||
// docs/usage-limits-display-plan.md. resets_at is epoch SECONDS.
|
||||
const REAL: RawStatuslinePayload = {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 15, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: 34, resets_at: 1781827200 },
|
||||
},
|
||||
context_window: { used_percentage: 56, total_input_tokens: 562411, total_output_tokens: 1188 },
|
||||
cost: { total_cost_usd: 0.0415495 },
|
||||
model: { display_name: 'Opus 4.8 (1M context)' },
|
||||
};
|
||||
|
||||
describe('parseStatusTelemetry', () => {
|
||||
it('normalizes the real payload, converting resets_at seconds → ms', () => {
|
||||
const t = parseStatusTelemetry(REAL);
|
||||
expect(t).not.toBeNull();
|
||||
expect(t!.fiveHour).toEqual({ usedPercentage: 15, resetAt: 1781409000 * 1000 });
|
||||
expect(t!.sevenDay).toEqual({ usedPercentage: 34, resetAt: 1781827200 * 1000 });
|
||||
expect(t!.contextUsedPercentage).toBe(56);
|
||||
expect(t!.costUsd).toBeCloseTo(0.0415495);
|
||||
expect(t!.modelDisplayName).toBe('Opus 4.8 (1M context)');
|
||||
});
|
||||
|
||||
it('returns null when there is no rate_limits (pre-first-response / non-subscriber)', () => {
|
||||
expect(parseStatusTelemetry({})).toBeNull();
|
||||
expect(parseStatusTelemetry(undefined)).toBeNull();
|
||||
expect(parseStatusTelemetry({ context_window: { used_percentage: 5 } })).toBeNull();
|
||||
expect(parseStatusTelemetry({ rate_limits: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a single window when only one is present', () => {
|
||||
const t = parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: 50, resets_at: 1781409000 } } });
|
||||
expect(t!.fiveHour?.usedPercentage).toBe(50);
|
||||
expect(t!.sevenDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops a window with a missing or non-numeric field', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 20 }, // no resets_at → dropped
|
||||
seven_day: { used_percentage: 40, resets_at: 1781827200 },
|
||||
},
|
||||
});
|
||||
expect(t!.fiveHour).toBeUndefined();
|
||||
expect(t!.sevenDay?.usedPercentage).toBe(40);
|
||||
});
|
||||
|
||||
it('clamps percentages to 0–100', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 150, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: -5, resets_at: 1781827200 },
|
||||
},
|
||||
});
|
||||
expect(t!.fiveHour?.usedPercentage).toBe(100);
|
||||
expect(t!.sevenDay?.usedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a zero/negative reset timestamp', () => {
|
||||
expect(parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: 10, resets_at: 0 } } })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a NaN percentage as 0 and drops a window with a non-finite reset', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: NaN, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: 40, resets_at: Infinity },
|
||||
},
|
||||
});
|
||||
expect(t!.fiveHour).toEqual({ usedPercentage: 0, resetAt: 1781409000 * 1000 });
|
||||
expect(t!.sevenDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rounds a fractional resets_at to whole milliseconds', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: { five_hour: { used_percentage: 10, resets_at: 1781409000.7 } },
|
||||
});
|
||||
expect(t!.fiveHour?.resetAt).toBe(Math.round(1781409000.7 * 1000));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSessionStatus', () => {
|
||||
it('extracts model, token totals, and context % for the footer', () => {
|
||||
const s = parseSessionStatus(REAL);
|
||||
expect(s).toEqual({
|
||||
modelDisplayName: 'Opus 4.8 (1M context)',
|
||||
inputTokens: 562411,
|
||||
outputTokens: 1188,
|
||||
contextUsedPercentage: 56,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when none of the footer fields are present', () => {
|
||||
expect(parseSessionStatus({})).toBeNull();
|
||||
expect(parseSessionStatus(undefined)).toBeNull();
|
||||
// rate_limits alone is not session status
|
||||
expect(parseSessionStatus({ rate_limits: { five_hour: { used_percentage: 5, resets_at: 1 } } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSessionStatusText', () => {
|
||||
it('formats the footer with comma-grouped tokens', () => {
|
||||
expect(formatSessionStatusText(parseSessionStatus(REAL))).toBe(
|
||||
'Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%'
|
||||
);
|
||||
});
|
||||
|
||||
it('omits groups that are missing', () => {
|
||||
expect(formatSessionStatusText({ contextUsedPercentage: 12 })).toBe('ctx:12%');
|
||||
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)');
|
||||
});
|
||||
|
||||
it('falls back to a brand string when there is no data', () => {
|
||||
expect(formatSessionStatusText(null)).toBe('codeman');
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetrySignature', () => {
|
||||
it('is stable for equal telemetry and changes when a percentage moves', () => {
|
||||
const a = parseStatusTelemetry(REAL)!;
|
||||
const b = parseStatusTelemetry(REAL)!;
|
||||
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
|
||||
|
||||
const moved = parseStatusTelemetry({
|
||||
...REAL,
|
||||
rate_limits: { ...REAL.rate_limits, five_hour: { used_percentage: 16, resets_at: 1781409000 } },
|
||||
})!;
|
||||
expect(telemetrySignature(moved)).not.toBe(telemetrySignature(a));
|
||||
});
|
||||
|
||||
it('ignores contextUsedPercentage (not displayed) so it does not churn each message', () => {
|
||||
const base = { rate_limits: { five_hour: { used_percentage: 15, resets_at: 1781409000 } } };
|
||||
const a = parseStatusTelemetry({ ...base, context_window: { used_percentage: 56 } })!;
|
||||
const b = parseStatusTelemetry({ ...base, context_window: { used_percentage: 91 } })!;
|
||||
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
|
||||
});
|
||||
|
||||
it('keys on the ROUNDED window percentage (matches the chip) — sub-integer drift is ignored', () => {
|
||||
const sig = (p: number) =>
|
||||
telemetrySignature(
|
||||
parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: p, resets_at: 1781409000 } } })!
|
||||
);
|
||||
expect(sig(15.1)).toBe(sig(15.4)); // both render as 15%
|
||||
expect(sig(15.1)).not.toBe(sig(15.6)); // 15% vs 16%
|
||||
});
|
||||
|
||||
it('excludes cost/model (not shown in the chip) from the signature', () => {
|
||||
const base = { rate_limits: { five_hour: { used_percentage: 15, resets_at: 1781409000 } } };
|
||||
const a = parseStatusTelemetry({ ...base, cost: { total_cost_usd: 0.01 }, model: { display_name: 'A' } })!;
|
||||
const b = parseStatusTelemetry({ ...base, cost: { total_cost_usd: 9.99 }, model: { display_name: 'B' } })!;
|
||||
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Tests for WorkflowRunWatcher — parses wf_<runId>.json run-state into
|
||||
* WorkflowRunInfo for the ultracode master-detail view.
|
||||
*
|
||||
* Drives the real discover→parse path against a synthetic on-disk fixture in a
|
||||
* temp projects dir (never the shared singleton, never ~/.claude).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { WorkflowRunWatcher } from '../src/workflow-run-watcher.js';
|
||||
import type { WorkflowRunInfo } from '../src/types/workflow-run.js';
|
||||
|
||||
const PROJECT_HASH = '-home-arkon-default-claudeman';
|
||||
const SESSION_UUID = '388113c8-cd01-4e80-93a8-3be66ab1519b';
|
||||
const RUN_ID = 'wf_test1234-abc';
|
||||
|
||||
/** A run JSON shaped like a real (killed) run: all three agent states + the bloat fields. */
|
||||
function sampleRunJson() {
|
||||
return {
|
||||
runId: RUN_ID,
|
||||
timestamp: '2026-06-15T00:00:00.000Z',
|
||||
taskId: 'task_abc',
|
||||
// --- bloat fields that MUST be stripped ---
|
||||
script: 'export const meta = {};\n'.repeat(5000), // ~110KB
|
||||
scriptPath: '/tmp/whatever.js',
|
||||
result: { plan: { huge: 'object' } },
|
||||
logs: ['line1', 'line2'],
|
||||
// --- real fields ---
|
||||
agentCount: 3,
|
||||
durationMs: 795173,
|
||||
summary: 'Deep adversarial review of open PRs',
|
||||
workflowName: 'review-open-prs',
|
||||
status: 'killed',
|
||||
error: 'user stopped the task',
|
||||
startTime: 1781466999000,
|
||||
defaultModel: 'claude-opus-4-8[1m]',
|
||||
totalTokens: 109703,
|
||||
totalToolCalls: 44,
|
||||
phases: [
|
||||
{ title: 'Review', detail: 'one deep reviewer per PR' },
|
||||
{ title: 'Probe', detail: 'targeted security/correctness probes' },
|
||||
{ title: 'Verify', detail: 'adversarially verify each finding' },
|
||||
],
|
||||
workflowProgress: [
|
||||
{ type: 'workflow_phase', index: 0, phaseIndex: 1, phaseTitle: 'Review' },
|
||||
{
|
||||
type: 'workflow_agent',
|
||||
index: 1,
|
||||
label: 'probe:dompurify-config',
|
||||
phaseIndex: 2,
|
||||
phaseTitle: 'Probe',
|
||||
agentId: 'a6c0e282c3f5ac0bf',
|
||||
model: 'claude-opus-4-8[1m]',
|
||||
state: 'done',
|
||||
startedAt: 1781467000002,
|
||||
queuedAt: 1781466999962,
|
||||
attempt: 1,
|
||||
lastToolName: 'StructuredOutput',
|
||||
lastToolSummary: 'Does the profile setting make the allowlist dead config',
|
||||
promptPreview: 'You are reviewing a pull request...',
|
||||
lastProgressAt: 1781467524143,
|
||||
tokens: 104703,
|
||||
toolCalls: 41,
|
||||
durationMs: 524140,
|
||||
resultPreview: '{"verdict":"concern"}',
|
||||
},
|
||||
{
|
||||
type: 'workflow_agent',
|
||||
index: 2,
|
||||
label: 'review:pr-127',
|
||||
phaseIndex: 1,
|
||||
phaseTitle: 'Review',
|
||||
agentId: 'a1234567890abcdef',
|
||||
model: 'claude-opus-4-8[1m]',
|
||||
state: 'progress',
|
||||
startedAt: 1781467010000,
|
||||
queuedAt: 1781466999970,
|
||||
attempt: 1,
|
||||
lastToolName: 'Read',
|
||||
promptPreview: 'Review PR 127...',
|
||||
lastProgressAt: 1781467600000,
|
||||
tokens: 5000,
|
||||
toolCalls: 3,
|
||||
},
|
||||
{
|
||||
type: 'workflow_agent',
|
||||
index: 3,
|
||||
label: 'verify:finding-x',
|
||||
phaseIndex: 3,
|
||||
phaseTitle: 'Verify',
|
||||
model: 'claude-opus-4-8[1m]',
|
||||
state: 'start',
|
||||
queuedAt: 1781466999980,
|
||||
promptPreview: 'Verify finding x...',
|
||||
lastProgressAt: 1781466999980,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('WorkflowRunWatcher', () => {
|
||||
let projectsDir: string;
|
||||
let watcher: WorkflowRunWatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectsDir = await mkdtemp(join(tmpdir(), 'wfw-test-'));
|
||||
const workflowsDir = join(projectsDir, PROJECT_HASH, SESSION_UUID, 'workflows');
|
||||
await mkdir(workflowsDir, { recursive: true });
|
||||
await writeFile(join(workflowsDir, `${RUN_ID}.json`), JSON.stringify(sampleRunJson()), 'utf-8');
|
||||
watcher = new WorkflowRunWatcher(projectsDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
watcher.stop();
|
||||
await rm(projectsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Start the watcher and resolve with the first discovered run. */
|
||||
function firstRun(): Promise<WorkflowRunInfo> {
|
||||
return new Promise<WorkflowRunInfo>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('timed out waiting for run_discovered')), 5000);
|
||||
watcher.once('run_discovered', (info: WorkflowRunInfo) => {
|
||||
clearTimeout(timer);
|
||||
resolve(info);
|
||||
});
|
||||
watcher.start();
|
||||
});
|
||||
}
|
||||
|
||||
it('discovers and parses a run, deriving session/project from the path', async () => {
|
||||
const info = await firstRun();
|
||||
expect(info.runId).toBe(RUN_ID);
|
||||
expect(info.workflowName).toBe('review-open-prs');
|
||||
expect(info.status).toBe('killed');
|
||||
expect(info.error).toBe('user stopped the task');
|
||||
expect(info.sessionUuid).toBe(SESSION_UUID);
|
||||
expect(info.projectHash).toBe(PROJECT_HASH);
|
||||
expect(info.totalTokens).toBe(109703);
|
||||
expect(info.totalToolCalls).toBe(44);
|
||||
});
|
||||
|
||||
it('keeps only workflow_agent entries (drops workflow_phase markers)', async () => {
|
||||
const info = await firstRun();
|
||||
expect(info.agents).toHaveLength(3);
|
||||
expect(info.phases).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('STRIPS the heavyweight script/scriptPath/result/logs fields', async () => {
|
||||
const info = await firstRun();
|
||||
const asAny = info as unknown as Record<string, unknown>;
|
||||
expect('script' in asAny).toBe(false);
|
||||
expect('scriptPath' in asAny).toBe(false);
|
||||
expect('result' in asAny).toBe(false);
|
||||
expect('logs' in asAny).toBe(false);
|
||||
// The serialized run that reaches a client must be small.
|
||||
expect(JSON.stringify(info).length).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('carries tokens/toolCalls/durationMs on a done agent', async () => {
|
||||
const info = await firstRun();
|
||||
const done = info.agents.find((a) => a.state === 'done')!;
|
||||
expect(done.agentId).toBe('a6c0e282c3f5ac0bf');
|
||||
expect(done.tokens).toBe(104703);
|
||||
expect(done.toolCalls).toBe(41);
|
||||
expect(done.durationMs).toBe(524140);
|
||||
expect(done.resultPreview).toBeDefined();
|
||||
});
|
||||
|
||||
it('omits agentId/tokens/toolCalls/durationMs on a start (queued) agent', async () => {
|
||||
const info = await firstRun();
|
||||
const queued = info.agents.find((a) => a.state === 'start')!;
|
||||
expect(queued.agentId).toBeUndefined();
|
||||
expect(queued.tokens).toBeUndefined();
|
||||
expect(queued.toolCalls).toBeUndefined();
|
||||
expect(queued.durationMs).toBeUndefined();
|
||||
expect(queued.label).toBe('verify:finding-x');
|
||||
});
|
||||
|
||||
it('a progress agent has tokens but no durationMs (live discriminator)', async () => {
|
||||
const info = await firstRun();
|
||||
const running = info.agents.find((a) => a.state === 'progress')!;
|
||||
expect(running.tokens).toBe(5000);
|
||||
expect(running.toolCalls).toBe(3);
|
||||
expect(running.durationMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('phase join: agent.phaseIndex-1 indexes run.phases', async () => {
|
||||
const info = await firstRun();
|
||||
for (const agent of info.agents) {
|
||||
expect(info.phases[agent.phaseIndex - 1].title).toBe(agent.phaseTitle);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes the run via getAllRuns/getRun after discovery', async () => {
|
||||
await firstRun();
|
||||
expect(watcher.getAllRuns()).toHaveLength(1);
|
||||
expect(watcher.getRun(RUN_ID)?.runId).toBe(RUN_ID);
|
||||
expect(watcher.getStats().agentCount).toBe(3);
|
||||
});
|
||||
|
||||
it('getRecentRunSummaries omits agents[] (lightweight snapshot)', async () => {
|
||||
await firstRun();
|
||||
const summaries = watcher.getRecentRunSummaries(100000);
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect('agents' in summaries[0]).toBe(false);
|
||||
expect(summaries[0].runId).toBe(RUN_ID);
|
||||
expect(summaries[0].agentCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* In-flight runs: the Workflow runtime writes the completion wf_<id>.json only when
|
||||
* a run FINISHES, so while it is live the only on-disk state is its
|
||||
* subagents/workflows/wf_<id>/ transcript dir. The watcher synthesizes a minimal
|
||||
* ACTIVE run from that dir so the floating window pops DURING the run.
|
||||
*/
|
||||
describe('WorkflowRunWatcher — in-flight (live) runs', () => {
|
||||
const LIVE_RUN_ID = 'wf_live5678-xyz';
|
||||
let projectsDir: string;
|
||||
let liveDir: string;
|
||||
let watcher: WorkflowRunWatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectsDir = await mkdtemp(join(tmpdir(), 'wfw-live-'));
|
||||
liveDir = join(projectsDir, PROJECT_HASH, SESSION_UUID, 'subagents', 'workflows', LIVE_RUN_ID);
|
||||
await mkdir(liveDir, { recursive: true });
|
||||
// Two agents started; one already produced a result (journal `result` line).
|
||||
await writeFile(
|
||||
join(liveDir, 'agent-aaa111.meta.json'),
|
||||
JSON.stringify({ agentType: 'workflow-subagent' }),
|
||||
'utf-8'
|
||||
);
|
||||
await writeFile(join(liveDir, 'agent-aaa111.jsonl'), '{"type":"assistant"}\n', 'utf-8');
|
||||
await writeFile(
|
||||
join(liveDir, 'agent-bbb222.meta.json'),
|
||||
JSON.stringify({ agentType: 'workflow-subagent' }),
|
||||
'utf-8'
|
||||
);
|
||||
await writeFile(join(liveDir, 'agent-bbb222.jsonl'), '{"type":"assistant"}\n', 'utf-8');
|
||||
await writeFile(
|
||||
join(liveDir, 'journal.jsonl'),
|
||||
'{"type":"started","agentId":"aaa111"}\n{"type":"started","agentId":"bbb222"}\n{"type":"result","agentId":"aaa111","result":{}}\n',
|
||||
'utf-8'
|
||||
);
|
||||
watcher = new WorkflowRunWatcher(projectsDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
watcher.stop();
|
||||
await rm(projectsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function firstRun(): Promise<WorkflowRunInfo> {
|
||||
return new Promise<WorkflowRunInfo>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('timed out waiting for run_discovered')), 5000);
|
||||
watcher.once('run_discovered', (info: WorkflowRunInfo) => {
|
||||
clearTimeout(timer);
|
||||
resolve(info);
|
||||
});
|
||||
watcher.start();
|
||||
});
|
||||
}
|
||||
|
||||
it('synthesizes an ACTIVE run from the transcript dir when no completion file exists', async () => {
|
||||
const info = await firstRun();
|
||||
expect(info.runId).toBe(LIVE_RUN_ID);
|
||||
expect(info.status).toBe('running'); // active → frontend pops a floating window
|
||||
expect(info.sessionUuid).toBe(SESSION_UUID);
|
||||
expect(info.projectHash).toBe(PROJECT_HASH);
|
||||
expect(info.agents).toHaveLength(2);
|
||||
expect(info.agentCount).toBe(2);
|
||||
expect(info.lastActivityAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('preserves agentId per slot (so the card→transcript click join still works)', async () => {
|
||||
const info = await firstRun();
|
||||
const ids = info.agents.map((a) => a.agentId).sort();
|
||||
expect(ids).toEqual(['aaa111', 'bbb222']);
|
||||
});
|
||||
|
||||
it('marks an agent done/progress from journal result lines', async () => {
|
||||
const info = await firstRun();
|
||||
expect(info.agents.find((a) => a.agentId === 'aaa111')!.state).toBe('done'); // has a result line
|
||||
expect(info.agents.find((a) => a.agentId === 'bbb222')!.state).toBe('progress'); // started, no result yet
|
||||
});
|
||||
|
||||
it('counts the live run as running in getStats', async () => {
|
||||
await firstRun();
|
||||
expect(watcher.getStats().running).toBe(1);
|
||||
});
|
||||
|
||||
it('does NOT surface a live dir that has no agent files yet', async () => {
|
||||
const empty = join(projectsDir, PROJECT_HASH, SESSION_UUID, 'subagents', 'workflows', 'wf_empty0000-noo');
|
||||
await mkdir(empty, { recursive: true });
|
||||
await firstRun(); // resolves on the real (populated) live run
|
||||
// The empty run id must never enter the cache.
|
||||
expect(watcher.getRun('wf_empty0000-noo')).toBeUndefined();
|
||||
expect(watcher.getAllRuns().map((r) => r.runId)).toEqual([LIVE_RUN_ID]);
|
||||
});
|
||||
|
||||
it('a completion wf_*.json supersedes the live dir for the same runId (real status wins)', async () => {
|
||||
const workflowsDir = join(projectsDir, PROJECT_HASH, SESSION_UUID, 'workflows');
|
||||
await mkdir(workflowsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(workflowsDir, `${LIVE_RUN_ID}.json`),
|
||||
JSON.stringify({ runId: LIVE_RUN_ID, status: 'completed', durationMs: 1234, phases: [], workflowProgress: [] }),
|
||||
'utf-8'
|
||||
);
|
||||
const info = await firstRun();
|
||||
expect(info.runId).toBe(LIVE_RUN_ID);
|
||||
expect(info.status).toBe('completed'); // real completion file wins, not synthesized 'running'
|
||||
expect(info.durationMs).toBe(1234);
|
||||
// Only one cached entry for the runId — no live/real duplication.
|
||||
expect(watcher.getAllRuns()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user