Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4295faefc9 | ||
|
|
93e1ba5110 | ||
|
|
abbbf9e90a | ||
|
|
3a41de7b57 | ||
|
|
8267edc6fe | ||
|
|
78c568e5f7 | ||
|
|
cc624d2575 | ||
|
|
5844720525 | ||
|
|
ceaf4624a1 | ||
|
|
a6597e4a9a | ||
|
|
f869e823af | ||
|
|
8d0b179f94 | ||
|
|
98fa55b7b2 | ||
|
|
c46ac30631 | ||
|
|
dfcc14bfd2 | ||
|
|
a068008409 | ||
|
|
0aa31f100e | ||
|
|
314a160458 | ||
|
|
e7ee5595c5 | ||
|
|
625d4976d3 | ||
|
|
d02cddece6 | ||
|
|
abbc4b13fd | ||
|
|
a14e47e19c | ||
|
|
754a966b53 | ||
|
|
28dfc279d4 | ||
|
|
da85e9738b | ||
|
|
8b8907c4ec | ||
|
|
2329dab240 | ||
|
|
31ce7405a6 | ||
|
|
06f7d40c42 | ||
|
|
05eba70598 | ||
|
|
d27974ff6e | ||
|
|
3cca5380ba | ||
|
|
6d7efc13e6 | ||
|
|
63f86807ad | ||
|
|
2e4e646c06 | ||
|
|
507423b776 | ||
|
|
67d0b0b538 | ||
|
|
26cfd8b7ef | ||
|
|
208e6bc175 | ||
|
|
6d52b16edc | ||
|
|
4988e85901 | ||
|
|
e799c83b39 | ||
|
|
0717cfbfec | ||
|
|
b7b2555dc0 | ||
|
|
a609c435fa | ||
|
|
3268a12e5e | ||
|
|
1f30ed445c | ||
|
|
415f02e680 | ||
|
|
d5814947d6 | ||
|
|
b620511d0e | ||
|
|
525f02f502 | ||
|
|
e07c59477d | ||
|
|
2b8a522cbd | ||
|
|
4abe055182 | ||
|
|
15a3b3996b | ||
|
|
1b76e6e2e2 |
@@ -1,5 +1,97 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add WebSocket terminal I/O with server-side DEC 2026 synchronized update markers. Replaces per-keystroke HTTP POST + SSE terminal output with a single bidirectional WebSocket connection for dramatically lower input latency. Server-side 8ms micro-batching with 16KB flush threshold groups rapid PTY events into single WS frames wrapped in DEC 2026 markers for flicker-free atomic rendering. Includes 30s ping/pong heartbeat with 10s timeout for stale connection detection through tunnels. Existing SSE + HTTP POST paths remain fully functional as transparent fallback. Resize messages validated to match HTTP route bounds (cols 1-500, rows 1-200, integers only). 16 automated route tests added for WS endpoint. Also patches 5 dependency vulnerabilities (basic-ftp, fastify, minimatch, serialize-javascript).
|
||||
|
||||
## 0.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ### Session Resume & History
|
||||
- Add `resumeSessionId` support for conversation resume after reboot
|
||||
- Add history session resume UI and API with route shell sessions routing fix
|
||||
- Improve session resume reliability and persist user settings across refresh
|
||||
- Correct `claudeSessionId` for resumed sessions
|
||||
|
||||
### Terminal & Frontend
|
||||
- Upgrade xterm.js 5.3 → 6.0 with native DEC 2026 synchronized output
|
||||
- Increase terminal scrollback from 5,000 to 20,000 lines
|
||||
- Reduce default font size and persist tab state across refresh
|
||||
- Resolve terminal resize scrollback ghost renders
|
||||
- Hide subagent monitor panel by default
|
||||
|
||||
### Installer
|
||||
- Auto-detect existing install and run update instead of fresh install
|
||||
- Auto-restart codeman-web service after update if running
|
||||
- Show restart command when codeman-web is not a systemd service
|
||||
- Fix one-liner restart command for background processes
|
||||
|
||||
### Codebase Quality
|
||||
- Remove dead code, consolidate imports, extract constants
|
||||
- Repair 15 pre-existing subagent-watcher test failures
|
||||
- Clean up DEC sync dead code
|
||||
|
||||
## 0.3.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- - feat: upgrade xterm.js from 5.3 to 6.0 with native DEC 2026 synchronized output support
|
||||
- feat: add history session resume UI and API — resume Claude conversations after reboot
|
||||
- feat: add resumeSessionId support for conversation resume across session restarts
|
||||
- feat: persist active tabs across page refresh
|
||||
- feat: improve session resume reliability and persist user settings
|
||||
- perf: increase terminal scrollback from 5,000 to 20,000 lines
|
||||
- fix: resolve terminal resize scrollback ghost renders
|
||||
- fix: route shell sessions to correct endpoint on tab click
|
||||
- fix: correct claudeSessionId for resumed sessions (use original Claude conversation ID)
|
||||
- fix: increase default desktop font size from 12 to 14
|
||||
- refactor: extract shared \_fetchHistorySessions() method to eliminate duplication
|
||||
- refactor: remove dead DEC 2026 sync code (extractSyncSegments, DEC_SYNC_START/END constants)
|
||||
|
||||
## 0.3.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add content-hash cache busting for static assets — build step now renames JS/CSS files with MD5 content hashes (e.g. app.js → app.94b71235.js) and rewrites index.html references. HTML served with Cache-Control: no-cache so browsers always revalidate and pick up new hashed filenames after deploys. Hashed assets keep immutable 1-year cache. Eliminates the need for manual hard refresh (Ctrl+Shift+R) after deployments.
|
||||
|
||||
Refactor path traversal validation into shared validatePathWithinBase() helper in route-helpers.ts, replacing 6 duplicate inline checks across case-routes, plan-routes, and session-routes.
|
||||
|
||||
Deduplicate stripAnsi in bash-tool-parser.ts — use shared utility from utils/index.ts instead of private method.
|
||||
|
||||
## 0.3.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add tunnel status indicator with control panel — green pulsing dot in header when Cloudflare tunnel is active, dropdown with URL, remote clients, auth sessions, and start/stop/QR/revoke controls
|
||||
|
||||
## 0.3.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Operation Lightspeed: 5 parallel performance optimizations — multi-layer backpressure to prevent terminal write freezes, TERMINAL_TAIL_SIZE constant with client-drop recovery, tab switching SSE gating, and local echo improvements
|
||||
- Codebase cleanup: remove dead code (unused token validation exports, PlanPhase alias), add execPattern() regex helper to eliminate repetitive .lastIndex resets, centralize 11 magic number constants into config files, fix CLAUDE.md inaccuracies, and add 316 new tests for utilities, respawn helpers, and system-routes
|
||||
|
||||
## 0.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Re-enable WebGL renderer with 48KB/frame flush cap protection against GPU stalls
|
||||
|
||||
## 0.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Chrome "page unresponsive" crashes caused by xterm.js WebGL renderer GPU stalls during heavy terminal output. Disable WebGL by default (canvas renderer used instead), gate SSE terminal writes during tab switches, and add crash diagnostics with server-side breadcrumb collection.
|
||||
|
||||
## 0.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix Chrome tab freeze from flicker filter buffer accumulation during active sessions, and fix shell mode feedback delay by excluding shell sessions from cursor-up filter
|
||||
|
||||
## 0.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -44,7 +44,7 @@ When user says "COM":
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Description of changes
|
||||
Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag)
|
||||
CHANGESET
|
||||
```
|
||||
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
|
||||
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
|
||||
**Version**: 0.3.3 (must match `package.json`)
|
||||
**Version**: 0.3.12 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -80,7 +80,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master (Node 22). Tests excluded (they spawn tmux).
|
||||
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Does not lint `app.js` or `scripts/**/*.mjs`.
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `tools/**`, `remotion/**`.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
@@ -98,7 +98,7 @@ 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` | |
|
||||
| **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` | |
|
||||
| **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 |
|
||||
@@ -108,9 +108,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **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` | |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 modules), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` ★ (~11.8K lines) + 10 JS modules (incl. `sw.js` service worker) | |
|
||||
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (12 route modules + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` ★ (~12.5K lines) + 9 JS modules (incl. `sw.js` service worker) | |
|
||||
| **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||
|
||||
@@ -170,7 +170,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~111 handlers across 13 route files in `src/web/routes/`: system (35), sessions (24), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1). Each file has `@fileoverview` with endpoint details.
|
||||
~111 handlers across 12 route files in `src/web/routes/`: system (35), sessions (24), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
## Adding Features
|
||||
|
||||
@@ -179,7 +179,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
|
||||
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
|
||||
- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`
|
||||
- **New test**: Pick unique port (search `const PORT =`). Integration: ports 3099-3211. Route tests: `app.inject()` — see `test/routes/_route-test-utils.ts`.
|
||||
- **New test**: Pick unique port (search `const PORT =`). Route tests use `app.inject()` (no port needed) — see `test/routes/_route-test-utils.ts`.
|
||||
|
||||
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
|
||||
|
||||
@@ -238,7 +238,6 @@ Key: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh` (tunnel sta
|
||||
## Common Workflows
|
||||
|
||||
**Bug investigation**: Dev server → reproduce in browser → check terminal + `~/.codeman/state.json`.
|
||||
**API endpoint**: Types in `src/types/*.ts` → route in `src/web/routes/*-routes.ts` → SSE event if needed → handle in `app.js`.
|
||||
**Respawn changes**: Read `docs/respawn-state-machine.md` first. Use `MockSession` from `test/respawn-test-utils.ts`.
|
||||
|
||||
## Tunnel
|
||||
|
||||
@@ -35,11 +35,6 @@ codeman web
|
||||
# Open http://localhost:3000 — press Ctrl+Enter to start your first session
|
||||
```
|
||||
|
||||
**Update to latest version:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash -s update
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Run as a background service</strong></summary>
|
||||
|
||||
@@ -143,6 +138,10 @@ Watch background agents work in real-time. Codeman monitors agent activity and d
|
||||
|
||||
## Zero-Lag Input Overlay
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/zerolag-demo.gif" alt="Zerolag Demo — local echo vs server echo side-by-side" width="900">
|
||||
</p>
|
||||
|
||||
When accessing your coding agent remotely (VPN, Tailscale, SSH tunnel), every keystroke normally takes 200-300ms to round-trip. Codeman implements a **Mosh-inspired local echo system** that makes typing feel instant regardless of latency.
|
||||
|
||||
A pixel-perfect DOM overlay inside xterm.js renders keystrokes at 0ms. Background forwarding silently sends every character to the PTY in 50ms debounced batches, so Tab completion, `Ctrl+R` history search, and all shell features work normally. When the server echo arrives 200-300ms later, the overlay seamlessly disappears and the real terminal text takes over — the transition is invisible.
|
||||
|
||||
|
After Width: | Height: | Size: 806 KiB |
@@ -0,0 +1,423 @@
|
||||
# Performance Analysis & Optimization Opportunities
|
||||
|
||||
**Date**: 2026-03-07
|
||||
**Scope**: Full-stack performance analysis — backend PTY handling, SSE broadcasting, frontend terminal rendering, local echo overlay, DOM updates, config/scaling limits.
|
||||
**Constraint**: All recommendations preserve existing functionality including local echo, backpressure, anti-flicker pipeline, and mobile support.
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The codebase is already well-optimized in critical paths. The multi-layer backpressure system, adaptive terminal batching, DEC 2026 sync markers, and incremental state serialization are strong. The main opportunities are in **reducing unnecessary work** (SSE filtering, DOM rebuilds, lazy terminal init) rather than algorithmic changes.
|
||||
|
||||
**Top 5 high-impact opportunities:**
|
||||
|
||||
| # | Optimization | Impact | Risk | Effort |
|
||||
|---|-------------|--------|------|--------|
|
||||
| 1 | Session-scoped SSE subscriptions | Bandwidth -60-80%, CPU -40% | Medium | Medium |
|
||||
| 2 | Lazy xterm.js for minimized subagent windows | Memory -3.5MB at 50 agents | Low | Low |
|
||||
| 3 | Targeted badge update (skip full tab rebuild) | Eliminates O(n) reflow on badge change | Low | Low |
|
||||
| 4 | Conditional SSE padding (tunnel-only, terminal-only) | Bandwidth -70% when tunneled | Low | Low |
|
||||
| 5 | Canvas renderer on mobile | GPU pressure reduction, battery savings | Low | Low |
|
||||
|
||||
---
|
||||
|
||||
## 1. SSE Broadcasting
|
||||
|
||||
### Current State
|
||||
- **92 event types** broadcast to all connected clients (max 100)
|
||||
- Single `JSON.stringify()` per event, shared across all clients (efficient)
|
||||
- **No per-client filtering** — every client receives every event regardless of which session they're viewing
|
||||
- 8KB padding appended to **every** event when tunnel is active (forces Cloudflare proxy flush)
|
||||
- Backpressure: clients marked as backpressured if `reply.raw.write()` returns false; recovery via `session:needsRefresh`
|
||||
|
||||
### Bottlenecks
|
||||
|
||||
**B1: No session-scoped SSE subscriptions** (`server.ts:1986`)
|
||||
- Client viewing session A still receives all events for sessions B through T
|
||||
- With 20 active sessions, ~95% of terminal events are irrelevant to any given client
|
||||
- Cost: wasted bandwidth, CPU for JSON parsing, and event handler dispatch on client
|
||||
|
||||
**B2: Unconditional 8KB padding** (`server.ts:1977`)
|
||||
- Every event gets 8KB comment padding when tunnel is active
|
||||
- A `task:updated` event (~200 bytes payload) becomes ~8.2KB
|
||||
- High-frequency events like `session:terminal` need the padding; low-frequency events like `session:created` don't
|
||||
|
||||
### Recommendations
|
||||
|
||||
**R1: Session-scoped SSE subscriptions** (High impact)
|
||||
- Add `?sessions=id1,id2` query param to `/api/events` SSE endpoint
|
||||
- Server filters events by session ID before broadcasting
|
||||
- Client subscribes to active session + "global" events (session lifecycle, system)
|
||||
- Re-subscribes on tab switch (or subscribe to all with client-side filter as fallback)
|
||||
- **Savings**: ~80% bandwidth reduction for single-session viewers; ~60% for multi-session dashboards
|
||||
|
||||
**R2: Tiered SSE padding** (Medium impact)
|
||||
- Only pad `session:terminal` events and SSE heartbeats (the two that need proxy flush)
|
||||
- Skip padding for low-frequency structural events (`session:created`, `task:updated`, etc.)
|
||||
- **Savings**: ~70% padding overhead reduction; terminal events already large enough to flush
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminal Rendering
|
||||
|
||||
### Current State (Well-Optimized)
|
||||
- **6-layer anti-flicker pipeline**: Server batching (adaptive 16-50ms) → DEC 2026 sync wrap → single JSON serialize → client rAF batching → sync segment parser → chunked buffer loading (32KB/frame)
|
||||
- **64KB/frame write budget** with DEC 2026 sync-segment awareness (prevents 141KB single-frame freezes)
|
||||
- **3-layer backpressure**: SSE cap (128KB queued → drop + refresh), frame budget (64KB/frame), chunked restore (32KB/frame)
|
||||
- WebGL renderer enabled by default with canvas fallback on context loss
|
||||
- Typical latency: 16-32ms; worst case: ~115ms (50ms server batch + 50ms sync wait + 16ms rAF)
|
||||
|
||||
### Bottlenecks
|
||||
|
||||
**B3: WebGL on mobile** (`app.js:627-637`)
|
||||
- Mobile GPUs are weaker; WebGL context loss more likely on low-end devices
|
||||
- Canvas renderer is sufficient for mobile (typically 1 session, smaller viewport)
|
||||
|
||||
**B4: Static scrollback for all sessions** (`app.js:572`)
|
||||
- Default 5000 lines scrollback for all sessions regardless of activity level
|
||||
- Heavy output sessions (build logs, test runners) accumulate large scroll buffers
|
||||
|
||||
**B5: No addon lazy loading**
|
||||
- FitAddon, Unicode11Addon, and WebGLAddon all loaded at terminal init
|
||||
- Unicode11Addon only needed for CJK content; WebGLAddon is large
|
||||
|
||||
### Recommendations
|
||||
|
||||
**R3: Force canvas renderer on mobile** (Low risk)
|
||||
- Detect `MobileDetection.isMobile()` and skip WebGL addon loading
|
||||
- Reduces GPU memory pressure, prevents context loss crashes
|
||||
- Mobile typically has 1-2 sessions — canvas performance is more than adequate
|
||||
|
||||
**R4: Dynamic scrollback based on session activity** (Low risk)
|
||||
- Active sessions (working state): 5000 lines (current default)
|
||||
- Inactive/idle sessions: reduce to 2000 lines
|
||||
- Restore on session select (fetch from server buffer)
|
||||
- **Savings**: ~60% scrollback memory for idle sessions
|
||||
|
||||
**R5: Lazy-load Unicode11Addon** (Low risk)
|
||||
- Only load when CJK content is detected in terminal output
|
||||
- Detection: check for characters in CJK Unicode ranges during ANSI stripping (already iterating)
|
||||
- Most sessions never need it
|
||||
|
||||
---
|
||||
|
||||
## 3. DOM & Session Tab Rendering
|
||||
|
||||
### Current State
|
||||
- Session tabs use **intelligent incremental updates** with debounced 100ms rendering
|
||||
- Incremental path: only updates changed properties (classes, textContent, badges) when session list is stable
|
||||
- Full rebuild path: triggered when sessions added/removed **or badge count changes**
|
||||
- Subagent windows: per-window xterm.js instances, even when minimized
|
||||
|
||||
### Bottlenecks
|
||||
|
||||
**B6: Badge count change triggers full tab rebuild** (`app.js:3207-3209`)
|
||||
- A single subagent badge increment on one tab triggers `_fullRenderSessionTabs()` — rebuilds entire sidebar HTML via `innerHTML =`
|
||||
- With 20 sessions, this is an O(n) reflow for a single badge number change
|
||||
- Badge changes are frequent during active subagent work
|
||||
|
||||
**B7: Minimized subagent windows retain xterm.js instances** (`subagent-windows.js`)
|
||||
- 50 subagent windows × ~75KB per xterm.js instance = ~3.75MB DOM memory
|
||||
- Minimized windows are invisible but their terminals remain in DOM
|
||||
- xterm.js instances continue processing resize events even when hidden
|
||||
|
||||
**B8: `backdrop-filter: blur()` on overlays** (`styles.css:2246-2247, 3098`)
|
||||
- Forces new stacking context, disables browser compositing optimizations
|
||||
- 50-100ms layout thrashing on modal open/close
|
||||
- Only 2 uses, but they're on frequently toggled overlays
|
||||
|
||||
### Recommendations
|
||||
|
||||
**R6: Targeted badge update without full rebuild** (Low risk)
|
||||
- When badge count changes but session list is stable, update only the badge `<span>` textContent
|
||||
- Keep incremental path for badge changes; only use full rebuild for structural changes (add/remove sessions)
|
||||
- **Savings**: Eliminates O(n) reflow per badge change; reduces to O(1) targeted update
|
||||
|
||||
**R7: Lazy xterm.js initialization for subagent windows** (Medium impact)
|
||||
- Only create xterm.js Terminal instance when window is restored/maximized
|
||||
- On minimize: serialize terminal buffer, dispose Terminal instance, keep buffer in memory
|
||||
- On restore: create new Terminal, write buffer back
|
||||
- **Savings**: ~3.5MB DOM reduction at 50 minimized agents; eliminates hidden resize processing
|
||||
- **Trade-off**: ~200-500ms restore delay (buffer write), mitigated by chunked loading
|
||||
|
||||
**R8: Replace `backdrop-filter: blur()` with `background: rgba()`** (Low risk)
|
||||
- Use semi-transparent background instead of blur effect
|
||||
- Or use `will-change: transform` hint if blur is kept
|
||||
- **Savings**: Eliminates forced recomposition layer; 50-100ms faster overlay open
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend PTY & State Management
|
||||
|
||||
### Current State (Excellent)
|
||||
- **BufferAccumulator**: Array-based chunking with lazy join on read — avoids O(n) string concatenation
|
||||
- **ANSI stripping**: Throttled at 150ms intervals with lazy evaluation (not per-chunk)
|
||||
- **State persistence**: 500ms debounce + incremental JSON caching per session (only dirty sessions re-serialized)
|
||||
- **Expensive parsers**: Throttled to 150ms window, accumulated data capped at 64KB
|
||||
- **Memory**: All buffers have hard limits (2MB terminal, 1MB text, 1000 messages, 64KB line buffer)
|
||||
|
||||
### Bottlenecks
|
||||
|
||||
**B9: Pending clean data cap at 64KB** (`session.ts:1097-1133`)
|
||||
- Between 150ms processing windows, raw PTY data accumulates in `_pendingCleanData`
|
||||
- Capped at 64KB — excess data rolls off (old data discarded)
|
||||
- During heavy output (large build logs), this means parsers may miss content
|
||||
- Acceptable trade-off for performance, but worth documenting
|
||||
|
||||
**B10: `LRUMap.delete()` is O(n) worst case** (`utils/lru-map.ts:137-138`)
|
||||
- When deleting the newest entry, iterates all keys to find new newest
|
||||
- Rare in practice (delete is uncommon; set/get are hot paths)
|
||||
- Could matter during mass cleanup of 500 agents
|
||||
|
||||
### Recommendations
|
||||
|
||||
**R9: Consider adaptive pending data cap** (Low priority)
|
||||
- During idle detection (critical to get right), increase cap to 128KB
|
||||
- During active working state, keep at 64KB (parsers less critical)
|
||||
- **Benefit**: More accurate idle detection during heavy output
|
||||
|
||||
**R10: Track second-newest in LRUMap** (Low priority)
|
||||
- Maintain a `_secondNewestKey` alongside `_newestKey`
|
||||
- On delete of newest, promote second-newest without iteration
|
||||
- Only matters at scale (500+ agents with frequent eviction)
|
||||
|
||||
---
|
||||
|
||||
## 5. Local Echo & Input Path
|
||||
|
||||
### Current State (Well-Designed)
|
||||
- **DOM overlay approach** — `<span>` elements in `.xterm-screen` at z-index 7, completely independent of `terminal.write()`
|
||||
- **Render caching**: `_lastRenderKey` includes text, position, column offsets — skips redundant re-renders
|
||||
- **Input flow**: Char accumulation → Enter triggers flush → 80ms delay before `\r` (ensures text reaches PTY first)
|
||||
- **Tab completion**: Baseline snapshot → detect buffer change → 300ms fallback timer
|
||||
- **CJK support**: Per-character width detection with `terminal.unicode.getStringCellWidth()` preferred, manual fallback
|
||||
- **Prompt detection**: Bottom-up line scan, O(rows) — cached position, column-lock prevents jitter
|
||||
|
||||
### Bottlenecks
|
||||
|
||||
**B11: tmux send-keys latency** (~50-100ms per input)
|
||||
- Each `writeViaMux()` spawns a child process (`tmux send-keys`)
|
||||
- Text and Enter sent separately with 50ms delay between
|
||||
- For rapid typing: characters batch before Enter, so overhead is per-command not per-keystroke
|
||||
- **Acceptable trade-off** for session persistence (tmux survives server restarts)
|
||||
|
||||
**B12: 80ms delay between text flush and Enter** (`app.js:872-875`)
|
||||
- Intentional: ensures text reaches PTY before Enter, preventing Ink from processing empty input
|
||||
- Adds 80ms to perceived Enter-to-response latency
|
||||
- Could potentially be reduced with acknowledgment-based approach
|
||||
|
||||
**B13: Scroll listener on terminal viewport** (`zerolag-input-addon.ts:139`)
|
||||
- 50ms debounced re-render on scroll — acceptable but fires frequently during heavy output
|
||||
- Overlay hidden when scrolled up (correct behavior), shown when at bottom
|
||||
|
||||
### Recommendations
|
||||
|
||||
**R11: Reduce Enter delay from 80ms to 50ms** (Low risk, test carefully)
|
||||
- The tmux `send-keys` already has 50ms internal delay
|
||||
- Combined with network latency, 80ms client-side may be excessive
|
||||
- Test with Ink-heavy sessions (Claude Code's status bar) — if text arrives before Enter at 50ms, reduce
|
||||
- **Savings**: 30ms perceived latency reduction per command
|
||||
|
||||
**R12: Batch tmux send-keys via stdin pipe** (Medium effort, high impact for rapid input)
|
||||
- Instead of spawning `tmux send-keys` per input, maintain a persistent connection
|
||||
- Use `tmux -C` (control mode) for programmatic interaction without child process spawning
|
||||
- **Savings**: Eliminate ~50-100ms process spawn overhead per input
|
||||
- **Risk**: Control mode has different semantics; needs careful testing with session persistence
|
||||
|
||||
**R13: Skip overlay re-render during heavy output scroll** (Low risk)
|
||||
- When terminal is receiving >10KB/s output, hide overlay entirely (user isn't typing during heavy output)
|
||||
- Re-show overlay after 500ms of output silence
|
||||
- **Savings**: Eliminates unnecessary DOM overlay re-renders during build logs / test output
|
||||
|
||||
---
|
||||
|
||||
## 6. Polling & File Watchers
|
||||
|
||||
### Current State
|
||||
- **SubagentWatcher**: 1s base poll, full scan throttled to every 5s, fs.watch() on known directories
|
||||
- **TranscriptWatcher**: 1 per session, fs.watch() primary with 1s poll fallback
|
||||
- **ImageWatcher**: chokidar per session with 100ms stability poll, burst limit 20/10s
|
||||
- **TeamWatcher**: chokidar primary with 30s poll fallback, LRU caches (50 teams, 200 tasks)
|
||||
- **RalphTracker**: Todo cleanup every 5 minutes
|
||||
|
||||
### Scaling Profile (20 sessions)
|
||||
| Component | Instances | Frequency | Total ops/sec |
|
||||
|-----------|-----------|-----------|---------------|
|
||||
| SubagentWatcher | 1 (global) | Full scan every 5s | 0.2/s |
|
||||
| TranscriptWatcher | 20 | 1s poll (fallback) | 20/s max |
|
||||
| ImageWatcher | 20 | 100ms poll (during writes only) | 200/s burst |
|
||||
| TeamWatcher | 1 (global) | 30s poll (fallback) | 0.03/s |
|
||||
| SSE heartbeat | 1 (global) | 15s | 0.07/s |
|
||||
| SSE dead client check | 1 (global) | 30s | 0.03/s |
|
||||
| Mux stats collection | 1 (global) | 2s | 0.5/s |
|
||||
| **Total steady-state** | | | **~21/s** |
|
||||
|
||||
### Recommendations
|
||||
|
||||
**R14: Increase TranscriptWatcher poll interval to 2s** (Low risk)
|
||||
- Transcript changes are infrequent (new messages every few seconds at most)
|
||||
- fs.watch() is the primary mechanism; polling is fallback
|
||||
- **Savings**: Halves fallback filesystem checks (20/s → 10/s for 20 sessions)
|
||||
|
||||
**R15: Share chokidar instances for co-located session directories** (Medium effort)
|
||||
- Sessions in the same parent directory could share a single chokidar watcher with depth:3
|
||||
- Common case: multiple sessions in `~/projects/foo/` — one watcher covers all
|
||||
- **Savings**: Reduce chokidar instances from 20 to ~5-10 for typical workloads
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend Asset Delivery
|
||||
|
||||
### Current State
|
||||
- **app.js**: 12,027 lines (source) → esbuild minified → gzip/brotli compressed (~30-40KB gzipped)
|
||||
- **Static caching**: `maxAge: '1y'` via `@fastify/static`
|
||||
- **Service worker**: Push notification handler only — no asset caching
|
||||
- **No code splitting**: Single monolithic app.js bundle
|
||||
|
||||
### Bottlenecks
|
||||
|
||||
**B14: No cache-busting mechanism**
|
||||
- `maxAge: '1y'` means browsers cache aggressively
|
||||
- After deployment, users need `Ctrl+Shift+R` to see updates
|
||||
- No content hash in filenames or ETags for automatic invalidation
|
||||
|
||||
**B15: Monolithic app.js**
|
||||
- All 12K lines loaded on initial page load regardless of which features are used
|
||||
- Ralph wizard, plan orchestrator UI, team management — all loaded upfront
|
||||
- Mobile loads the same bundle as desktop
|
||||
|
||||
### Recommendations
|
||||
|
||||
**R16: Add content hash to asset filenames** (Medium impact)
|
||||
- Build step: rename `app.js` → `app.[hash].js`
|
||||
- Generate a manifest or inject hash into HTML template
|
||||
- Keep `maxAge: '1y'` — cache invalidation happens via filename change
|
||||
- **Savings**: Eliminates stale cache issues after deployment; removes need for manual hard refresh
|
||||
|
||||
**R17: Code-split app.js into core + feature modules** (High effort, medium impact)
|
||||
- Core (~4K lines): terminal, SSE, session management, tabs, input handling
|
||||
- Deferred (~8K lines): Ralph wizard, plan UI, team management, subagent windows, image viewer
|
||||
- Load deferred modules on first use via dynamic `import()` or lazy `<script>` injection
|
||||
- **Savings**: ~60% reduction in initial load size; faster time-to-interactive
|
||||
- **Risk**: Complexity increase; need to handle loading states for deferred features
|
||||
- **Note**: May not be worth the effort given the app is already gzipped to ~30-40KB
|
||||
|
||||
---
|
||||
|
||||
## 8. CSS Performance
|
||||
|
||||
### Current State
|
||||
- **styles.css**: 7,153 lines with ~45 box-shadow uses, 2 backdrop-filter uses
|
||||
- Animations: GPU-accelerated keyframes for pulsing alerts, loading spinners
|
||||
- Z-index layering: well-organized (subagent 1000, plan 1100, log 2000, image 3000, overlay 7)
|
||||
|
||||
### Recommendations
|
||||
|
||||
**R18: Replace backdrop-filter with opaque overlay** (Low risk, covered in R8)
|
||||
|
||||
**R19: Use `contain: content` on subagent windows** (Low risk)
|
||||
- Add CSS containment to subagent window containers
|
||||
- Prevents layout changes inside windows from triggering reflow on parent
|
||||
- Especially valuable with 50 windows: changes in one window won't invalidate others
|
||||
- ```css
|
||||
.subagent-window { contain: content; }
|
||||
```
|
||||
- **Savings**: Reduces layout recalculation scope from global to per-window
|
||||
|
||||
**R20: Use `content-visibility: auto` on off-screen subagent windows** (Low risk)
|
||||
- Browser skips rendering of off-screen windows entirely
|
||||
- Combined with `contain-intrinsic-size` to prevent layout shift
|
||||
- ```css
|
||||
.subagent-window.minimized { content-visibility: hidden; }
|
||||
```
|
||||
- **Savings**: Browser skips paint/layout for minimized windows; complements R7
|
||||
|
||||
---
|
||||
|
||||
## 9. Memory & Scaling Limits
|
||||
|
||||
### Current Budget (20 sessions)
|
||||
| Component | Per Session | Total | Status |
|
||||
|-----------|-----------|-------|--------|
|
||||
| Terminal buffer | 2MB | 40MB | Hard-limited, auto-trim |
|
||||
| Text output | 1MB | 20MB | Hard-limited, auto-trim |
|
||||
| Messages | ~1MB | 20MB | Capped at 1000, trims to 800 |
|
||||
| Respawn buffer | 1MB | 20MB | Hard-limited |
|
||||
| **Buffers total** | | **100MB** | Acceptable |
|
||||
| TranscriptWatcher | ~100KB | 2MB | |
|
||||
| ImageWatcher | ~50KB | 1MB | |
|
||||
| SubagentWatcher | ~500KB | 500KB | Global |
|
||||
| Frontend terminal cache | ~256KB | 5MB | LRU, max 20 entries |
|
||||
| **Total estimated** | | **~110MB** | Comfortable |
|
||||
|
||||
### At Max Scale (50 sessions)
|
||||
- Buffers: ~250MB
|
||||
- Watchers: ~5MB
|
||||
- **Total: ~255MB** + Node.js overhead — acceptable on modern hardware
|
||||
|
||||
### Potential Leak Vectors (All Mitigated)
|
||||
- `_shortIdCache` in server — unbounded Map, but entries are tiny (string→string); grows at O(sessions created), not O(events)
|
||||
- All CleanupManager-registered resources tracked and disposed on session stop
|
||||
- `isStopped` guard prevents new timers after session cleanup
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Priority Matrix
|
||||
|
||||
### Phase 1 — Quick Wins (1-2 hours each, low risk)
|
||||
| # | Optimization | Files to Change |
|
||||
|---|-------------|-----------------|
|
||||
| R6 | Targeted badge update | `app.js` (3207-3209) |
|
||||
| R3 | Canvas renderer on mobile | `app.js` (627-637) |
|
||||
| R8 | Replace backdrop-filter blur | `styles.css` (2246, 3098) |
|
||||
| R19 | CSS containment on subagent windows | `styles.css` |
|
||||
| R20 | `content-visibility: hidden` on minimized windows | `styles.css` |
|
||||
|
||||
### Phase 2 — Medium Effort (half-day each)
|
||||
| # | Optimization | Files to Change |
|
||||
|---|-------------|-----------------|
|
||||
| R2 | Tiered SSE padding | `server.ts` (broadcast function) |
|
||||
| R7 | Lazy xterm.js for minimized subagents | `subagent-windows.js` |
|
||||
| R11 | Reduce Enter delay to 50ms | `app.js` (872-875), test with Ink |
|
||||
| R14 | TranscriptWatcher 2s poll | `transcript-watcher.ts` |
|
||||
| R16 | Content-hash asset filenames | `build.mjs`, `server.ts` |
|
||||
|
||||
### Phase 3 — Larger Initiatives (1-2 days each)
|
||||
| # | Optimization | Files to Change |
|
||||
|---|-------------|-----------------|
|
||||
| R1 | Session-scoped SSE subscriptions | `server.ts`, `app.js` (SSE connect) |
|
||||
| R5 | Lazy Unicode11Addon loading | `app.js`, build pipeline |
|
||||
| R12 | Persistent tmux control mode | `tmux-manager.ts` |
|
||||
| R17 | Code-split app.js | `app.js`, `build.mjs`, HTML template |
|
||||
|
||||
### Not Recommended (Low ROI or High Risk)
|
||||
| # | Why Not |
|
||||
|---|---------|
|
||||
| R4 | Dynamic scrollback adds complexity; memory savings marginal vs total budget |
|
||||
| R9 | Adaptive pending data cap adds state; current 64KB cap rarely matters |
|
||||
| R10 | LRUMap.delete() O(n) is theoretical; never triggered at current scale |
|
||||
| R15 | Shared chokidar instances add directory-matching complexity for minimal gain |
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Key File Locations
|
||||
|
||||
| Area | File | Key Lines |
|
||||
|------|------|-----------|
|
||||
| SSE broadcast | `src/web/server.ts` | 1961-1989 (broadcast), 1934-1959 (backpressure) |
|
||||
| Terminal batching | `src/web/server.ts` | 1994-2048 (per-session adaptive batching) |
|
||||
| Frame budget | `src/web/public/app.js` | 1370-1478 (flushPendingWrites, 64KB cap) |
|
||||
| Flicker filter | `src/web/public/app.js` | 1176-1255 (50ms sync wait, 256KB safety) |
|
||||
| Tab rendering | `src/web/public/app.js` | 3108-3357 (incremental + full rebuild) |
|
||||
| Tab switching | `src/web/public/app.js` | 3560-3760 (cache + chunked load + deferred UI) |
|
||||
| Local echo | `packages/xterm-zerolag-input/src/` | All files (overlay, prompt, CJK) |
|
||||
| Local echo integration | `src/web/public/app.js` | 640, 815-988 (input flow) |
|
||||
| Subagent windows | `src/web/public/subagent-windows.js` | Full file (window mgmt, drag, minimize) |
|
||||
| State persistence | `src/state-store.ts` | 161-250 (debounced save, incremental JSON) |
|
||||
| Buffer accumulator | `src/utils/buffer-accumulator.ts` | Full file (array chunks, lazy join) |
|
||||
| PTY handling | `src/session.ts` | 1046-1133 (data flow), 1173-1230 (parsing) |
|
||||
| Config limits | `src/config/` | 9 files (buffer, map, timing, auth, etc.) |
|
||||
| Anti-flicker docs | `docs/terminal-anti-flicker.md` | Architecture reference |
|
||||
| CSS | `src/web/public/styles.css` | 2246 (backdrop-filter), full file |
|
||||
| Build pipeline | `scripts/build.mjs` | 59-68 (minify + compress) |
|
||||
@@ -1283,7 +1283,16 @@ update() {
|
||||
npm run build --quiet 2>/dev/null || npm run build
|
||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||
echo ""
|
||||
echo -e " ${DIM}Restart codeman web to use the new version.${NC}"
|
||||
|
||||
# Auto-restart systemd service if it's running, otherwise tell the user
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null; then
|
||||
info "Restarting codeman-web service..."
|
||||
systemctl --user restart codeman-web.service
|
||||
success "codeman-web service restarted"
|
||||
else
|
||||
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
||||
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
@@ -1355,5 +1364,12 @@ uninstall() {
|
||||
case "${1:-}" in
|
||||
update) update ;;
|
||||
uninstall) uninstall ;;
|
||||
*) main "$@" ;;
|
||||
*)
|
||||
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" ]]; then
|
||||
print_banner
|
||||
update
|
||||
else
|
||||
main "$@"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.11",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -17,8 +17,11 @@
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
@@ -27,10 +30,6 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^10.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-unicode11": "^0.6.0",
|
||||
"xterm-addon-webgl": "^0.16.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bin": {
|
||||
@@ -47,6 +46,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"agent-browser": "^0.6.0",
|
||||
"esbuild": "^0.27.3",
|
||||
@@ -64,6 +64,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -943,6 +947,53 @@
|
||||
"glob": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexify": "^4.1.3",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket/node_modules/duplexify": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1",
|
||||
"stream-shift": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"dev": true,
|
||||
@@ -1432,6 +1483,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
@@ -1861,6 +1913,7 @@
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
@@ -2114,6 +2167,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"dev": true,
|
||||
@@ -2599,6 +2662,33 @@
|
||||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@xtuc/ieee754": {
|
||||
"version": "1.2.0",
|
||||
"dev": true,
|
||||
@@ -2988,7 +3078,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.1.0",
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
|
||||
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4385,7 +4477,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "5.7.4",
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz",
|
||||
"integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4407,7 +4501,7 @@
|
||||
"fast-json-stringify": "^6.0.0",
|
||||
"find-my-way": "^9.0.0",
|
||||
"light-my-request": "^6.0.0",
|
||||
"pino": "^10.1.0",
|
||||
"pino": "^9.14.0 || ^10.1.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"rfdc": "^1.3.1",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
@@ -4570,6 +4664,20 @@
|
||||
"dev": true,
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"dev": true,
|
||||
@@ -5610,7 +5718,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
@@ -6132,6 +6242,21 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"dev": true,
|
||||
@@ -6594,14 +6719,6 @@
|
||||
"version": "4.0.4",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"dev": true,
|
||||
@@ -6992,14 +7109,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-javascript": {
|
||||
"version": "6.0.2",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"randombytes": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"license": "ISC"
|
||||
@@ -7370,14 +7479,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.16",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz",
|
||||
"integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"jest-worker": "^27.4.5",
|
||||
"schema-utils": "^4.3.0",
|
||||
"serialize-javascript": "^6.0.2",
|
||||
"terser": "^5.31.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -8457,7 +8567,6 @@
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -8495,31 +8604,6 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
"version": "5.3.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xterm-addon-fit": {
|
||||
"version": "0.8.0",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-unicode11": {
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-webgl": {
|
||||
"version": "0.16.0",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-zerolag-input": {
|
||||
"resolved": "packages/xterm-zerolag-input",
|
||||
"link": true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.12",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -51,8 +51,11 @@
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
@@ -61,10 +64,6 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^10.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-unicode11": "^0.6.0",
|
||||
"xterm-addon-webgl": "^0.16.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -78,6 +77,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"agent-browser": "^0.6.0",
|
||||
"esbuild": "^0.27.3",
|
||||
@@ -93,6 +93,10 @@
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
* 2. Copy static assets (web/public, templates)
|
||||
* 3. Build vendor xterm bundles
|
||||
* 4. Minify frontend assets (app.js, styles.css, mobile.css)
|
||||
* 5. Compress with gzip + brotli
|
||||
* 5. Content-hash cache busting (rename assets, rewrite index.html)
|
||||
* 6. Compress with gzip + brotli
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { appendFileSync } from 'fs';
|
||||
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { join } from 'path';
|
||||
import { join, extname, basename, dirname } from 'path';
|
||||
|
||||
const ROOT = join(fileURLToPath(import.meta.url), '..', '..');
|
||||
|
||||
@@ -27,17 +29,18 @@ function run(label, cmd) {
|
||||
run('tsc', 'tsc');
|
||||
run('chmod dist/index.js', 'chmod +x dist/index.js');
|
||||
|
||||
// 2. Copy static assets
|
||||
// 2. Copy static assets (clean first to remove stale hashed files from previous builds)
|
||||
run('clean public', 'rm -rf dist/web/public');
|
||||
run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
|
||||
run('copy web assets', 'cp -r src/web/public dist/web/');
|
||||
run('copy template', 'cp src/templates/case-template.md dist/templates/');
|
||||
|
||||
// 3. Vendor xterm bundles
|
||||
run('xterm css', 'cp node_modules/xterm/css/xterm.css dist/web/public/vendor/');
|
||||
run('xterm js', 'npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
|
||||
run('xterm-addon-fit', 'npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
|
||||
run('xterm-addon-webgl', 'cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
||||
run('xterm-addon-unicode11', 'npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
||||
// 3. Vendor xterm bundles (xterm.js 6.x — @xterm scoped packages)
|
||||
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
|
||||
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
|
||||
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
|
||||
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
|
||||
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
|
||||
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
|
||||
|
||||
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
|
||||
@@ -56,11 +59,53 @@ appendFileSync(
|
||||
);
|
||||
|
||||
// 4. Minify frontend assets
|
||||
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.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 styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite');
|
||||
run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite');
|
||||
|
||||
// 5. Compress with gzip + brotli
|
||||
// 5. Content-hash cache busting
|
||||
console.log('\n[build] content-hash cache busting');
|
||||
{
|
||||
const distPublic = join(ROOT, 'dist/web/public');
|
||||
const HASHABLE = [
|
||||
'styles.css',
|
||||
'mobile.css',
|
||||
'constants.js',
|
||||
'mobile-handlers.js',
|
||||
'voice-input.js',
|
||||
'notification-manager.js',
|
||||
'keyboard-accessory.js',
|
||||
'app.js',
|
||||
'ralph-wizard.js',
|
||||
'api-client.js',
|
||||
'subagent-windows.js',
|
||||
'vendor/xterm-zerolag-input.js',
|
||||
];
|
||||
const manifest = {};
|
||||
for (const file of HASHABLE) {
|
||||
const filePath = join(distPublic, file);
|
||||
const content = readFileSync(filePath);
|
||||
const hash = createHash('md5').update(content).digest('hex').slice(0, 8);
|
||||
const ext = extname(file);
|
||||
const base = basename(file, ext);
|
||||
const dir = dirname(file);
|
||||
const hashed = dir === '.' ? `${base}.${hash}${ext}` : `${dir}/${base}.${hash}${ext}`;
|
||||
renameSync(filePath, join(distPublic, hashed));
|
||||
manifest[file] = hashed;
|
||||
}
|
||||
// Rewrite index.html to reference hashed filenames
|
||||
let html = readFileSync(join(distPublic, 'index.html'), 'utf8');
|
||||
for (const [original, hashed] of Object.entries(manifest)) {
|
||||
html = html.replaceAll(`"${original}"`, `"${hashed}"`);
|
||||
}
|
||||
writeFileSync(join(distPublic, 'index.html'), html);
|
||||
console.log(' Hashed files:');
|
||||
for (const [orig, hashed] of Object.entries(manifest)) {
|
||||
console.log(` ${orig} -> ${hashed}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Compress with gzip + brotli
|
||||
run(
|
||||
'compress',
|
||||
`for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do` +
|
||||
|
||||
@@ -250,10 +250,10 @@ if (isGlobalInstall) {
|
||||
} else {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const xtermDir = join(require.resolve('xterm'), '..', '..');
|
||||
const fitDir = join(require.resolve('xterm-addon-fit'), '..', '..');
|
||||
const webglDir = join(require.resolve('xterm-addon-webgl'), '..', '..');
|
||||
const unicode11Dir = join(require.resolve('xterm-addon-unicode11'), '..', '..');
|
||||
const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
|
||||
const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
|
||||
const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
|
||||
const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
|
||||
const vendorDir = join(srcDir, 'web', 'public', 'vendor');
|
||||
|
||||
const { mkdirSync, copyFileSync } = await import('fs');
|
||||
@@ -263,19 +263,19 @@ if (isGlobalInstall) {
|
||||
// Minify xterm JS for dev vendor dir (npm packages don't ship .min.js)
|
||||
try {
|
||||
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(fitDir, 'lib', 'xterm-addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
|
||||
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
|
||||
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
|
||||
} catch {
|
||||
// Fallback: copy unminified
|
||||
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
|
||||
copyFileSync(join(fitDir, 'lib', 'xterm-addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
|
||||
copyFileSync(join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
|
||||
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
|
||||
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
|
||||
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
|
||||
}
|
||||
|
||||
// WebGL addon: copy unminified (matches build script behavior)
|
||||
copyFileSync(join(webglDir, 'lib', 'xterm-addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
|
||||
copyFileSync(join(webglDir, 'lib', 'addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
|
||||
|
||||
// xterm-zerolag-input: bundle local package as IIFE for <script> tag loading
|
||||
try {
|
||||
|
||||
@@ -28,8 +28,8 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
||||
import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
|
||||
import { getAugmentedPath, ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
|
||||
import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js';
|
||||
|
||||
// ========== Security Validation ==========
|
||||
|
||||
@@ -534,10 +534,7 @@ export abstract class AiCheckerBase<
|
||||
// P1-005: Exponential backoff for errors
|
||||
// Base cooldown * 2^(consecutiveErrors-1), capped at 5 minutes
|
||||
const backoffMultiplier = Math.pow(2, this.consecutiveErrors - 1);
|
||||
const backoffCooldownMs = Math.min(
|
||||
this.config.errorCooldownMs * backoffMultiplier,
|
||||
5 * 60 * 1000 // Max 5 minutes
|
||||
);
|
||||
const backoffCooldownMs = Math.min(this.config.errorCooldownMs * backoffMultiplier, AI_CHECK_MAX_BACKOFF_MS);
|
||||
this.log(`Exponential backoff: ${Math.round(backoffCooldownMs / 1000)}s (error #${this.consecutiveErrors})`);
|
||||
this.startCooldown(backoffCooldownMs);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,14 @@ import {
|
||||
type AiCheckerResultBase,
|
||||
type AiCheckerStateBase,
|
||||
} from './ai-checker-base.js';
|
||||
import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT } from './config/ai-defaults.js';
|
||||
import {
|
||||
AI_CHECK_MODEL,
|
||||
AI_IDLE_CHECK_MAX_CONTEXT,
|
||||
AI_IDLE_CHECK_TIMEOUT_MS,
|
||||
AI_IDLE_CHECK_COOLDOWN_MS,
|
||||
AI_IDLE_CHECK_ERROR_COOLDOWN_MS,
|
||||
AI_CHECK_MAX_CONSECUTIVE_ERRORS,
|
||||
} from './config/ai-defaults.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -48,10 +55,10 @@ const DEFAULT_AI_CHECK_CONFIG: AiIdleCheckConfig = {
|
||||
enabled: true,
|
||||
model: AI_CHECK_MODEL,
|
||||
maxContextChars: AI_IDLE_CHECK_MAX_CONTEXT,
|
||||
checkTimeoutMs: 90000,
|
||||
cooldownMs: 180000,
|
||||
errorCooldownMs: 60000,
|
||||
maxConsecutiveErrors: 3,
|
||||
checkTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS,
|
||||
cooldownMs: AI_IDLE_CHECK_COOLDOWN_MS,
|
||||
errorCooldownMs: AI_IDLE_CHECK_ERROR_COOLDOWN_MS,
|
||||
maxConsecutiveErrors: AI_CHECK_MAX_CONSECUTIVE_ERRORS,
|
||||
};
|
||||
|
||||
/** Pattern to match IDLE or WORKING as the first word of output */
|
||||
|
||||
@@ -29,7 +29,14 @@ import {
|
||||
type AiCheckerResultBase,
|
||||
type AiCheckerStateBase,
|
||||
} from './ai-checker-base.js';
|
||||
import { AI_CHECK_MODEL, AI_PLAN_CHECK_MAX_CONTEXT } from './config/ai-defaults.js';
|
||||
import {
|
||||
AI_CHECK_MODEL,
|
||||
AI_PLAN_CHECK_MAX_CONTEXT,
|
||||
AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
AI_PLAN_CHECK_ERROR_COOLDOWN_MS,
|
||||
AI_CHECK_MAX_CONSECUTIVE_ERRORS,
|
||||
} from './config/ai-defaults.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -47,10 +54,10 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
|
||||
enabled: true,
|
||||
model: AI_CHECK_MODEL,
|
||||
maxContextChars: AI_PLAN_CHECK_MAX_CONTEXT,
|
||||
checkTimeoutMs: 60000,
|
||||
cooldownMs: 30000,
|
||||
errorCooldownMs: 30000,
|
||||
maxConsecutiveErrors: 3,
|
||||
checkTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
cooldownMs: AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
errorCooldownMs: AI_PLAN_CHECK_ERROR_COOLDOWN_MS,
|
||||
maxConsecutiveErrors: AI_CHECK_MAX_CONSECUTIVE_ERRORS,
|
||||
};
|
||||
|
||||
/** Pattern to match PLAN_MODE or NOT_PLAN_MODE as the first word(s) of output */
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ActiveBashTool } from './types.js';
|
||||
import { CleanupManager, Debouncer } from './utils/index.js';
|
||||
import { CleanupManager, Debouncer, stripAnsi } from './utils/index.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
|
||||
@@ -462,7 +462,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
* Process a single line of terminal output (raw — will strip ANSI).
|
||||
*/
|
||||
private processLine(line: string): void {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
const cleanLine = stripAnsi(line);
|
||||
this.processCleanLine(cleanLine);
|
||||
}
|
||||
|
||||
@@ -668,15 +668,6 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
return this.deduplicatePaths(rawPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from a string.
|
||||
*/
|
||||
private stripAnsi(str: string): string {
|
||||
// Comprehensive ANSI pattern
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced update emission.
|
||||
*/
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* @fileoverview Default model and context limits for AI-powered checkers.
|
||||
* @fileoverview Default model, context limits, and timing for AI-powered checkers.
|
||||
*
|
||||
* Centralizes the AI model identifier and context window sizes used by
|
||||
* the idle checker, plan checker, respawn controller defaults, and
|
||||
* respawn route fallbacks. Change the model here when upgrading.
|
||||
* Centralizes the AI model identifier, context window sizes, and timeout/cooldown
|
||||
* defaults used by the idle checker, plan checker, respawn controller defaults,
|
||||
* and respawn route fallbacks. Change values here when tuning AI check behavior.
|
||||
*
|
||||
* @module config/ai-defaults
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Model & Context
|
||||
// ============================================================================
|
||||
|
||||
/** Default model for AI idle and plan checkers */
|
||||
export const AI_CHECK_MODEL = 'claude-opus-4-5-20251101';
|
||||
|
||||
@@ -16,3 +20,39 @@ export const AI_IDLE_CHECK_MAX_CONTEXT = 16000;
|
||||
|
||||
/** Max context chars for plan checker (~2k tokens, plan mode UI is compact) */
|
||||
export const AI_PLAN_CHECK_MAX_CONTEXT = 8000;
|
||||
|
||||
// ============================================================================
|
||||
// AI Idle Checker Timing
|
||||
// ============================================================================
|
||||
|
||||
/** Timeout for AI idle check (90 seconds — thinking can be slow) */
|
||||
export const AI_IDLE_CHECK_TIMEOUT_MS = 90_000;
|
||||
|
||||
/** Cooldown after WORKING verdict (3 minutes) */
|
||||
export const AI_IDLE_CHECK_COOLDOWN_MS = 180_000;
|
||||
|
||||
/** Cooldown after AI idle check error (1 minute) */
|
||||
export const AI_IDLE_CHECK_ERROR_COOLDOWN_MS = 60_000;
|
||||
|
||||
// ============================================================================
|
||||
// AI Plan Checker Timing
|
||||
// ============================================================================
|
||||
|
||||
/** Timeout for AI plan check (60 seconds — allows time for thinking) */
|
||||
export const AI_PLAN_CHECK_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Cooldown after NOT_PLAN_MODE verdict (30 seconds) */
|
||||
export const AI_PLAN_CHECK_COOLDOWN_MS = 30_000;
|
||||
|
||||
/** Cooldown after AI plan check error (30 seconds) */
|
||||
export const AI_PLAN_CHECK_ERROR_COOLDOWN_MS = 30_000;
|
||||
|
||||
// ============================================================================
|
||||
// Shared AI Checker Limits
|
||||
// ============================================================================
|
||||
|
||||
/** Max consecutive errors before disabling an AI checker */
|
||||
export const AI_CHECK_MAX_CONSECUTIVE_ERRORS = 3;
|
||||
|
||||
/** Maximum exponential backoff cap for AI checker errors (5 minutes) */
|
||||
export const AI_CHECK_MAX_BACKOFF_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -56,11 +56,6 @@ export const TRIM_TEXT_TO = 768 * 1024; // 768KB
|
||||
*/
|
||||
export const MAX_MESSAGES = 1000;
|
||||
|
||||
/**
|
||||
* Number of messages to keep when trimming (80% of max).
|
||||
*/
|
||||
export const TRIM_MESSAGES_TO = 800;
|
||||
|
||||
// ============================================================================
|
||||
// Line Buffer Limits
|
||||
// ============================================================================
|
||||
@@ -85,3 +80,14 @@ export const MAX_RESPAWN_BUFFER_SIZE = 1 * 1024 * 1024; // 1MB
|
||||
* Size to trim respawn buffer to when max is exceeded.
|
||||
*/
|
||||
export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB
|
||||
|
||||
// ============================================================================
|
||||
// File Peek Limits
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maximum bytes to read when peeking at the beginning of a file.
|
||||
* Used with `createReadStream({ end })` (inclusive) to read the first 8KB,
|
||||
* which is enough to extract metadata from the first few JSONL lines.
|
||||
*/
|
||||
export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
|
||||
@@ -73,3 +73,16 @@ export const MAX_CONSECUTIVE_ERRORS = 5;
|
||||
|
||||
/** Error counter reset interval — forgives errors after quiet period (ms) */
|
||||
export const ERROR_RESET_MS = 60_000;
|
||||
|
||||
// ============================================================================
|
||||
// Common Cleanup Intervals
|
||||
// ============================================================================
|
||||
|
||||
/** Standard 1-minute cleanup/check interval used by multiple subsystems (ms) */
|
||||
export const CLEANUP_CHECK_INTERVAL_MS = 60_000;
|
||||
|
||||
/** Standard 1-hour max age for stale/completed data (ms) */
|
||||
export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
|
||||
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -11,11 +11,5 @@
|
||||
/** Max input length per API request (bytes) */
|
||||
export const MAX_INPUT_LENGTH = 64 * 1024;
|
||||
|
||||
/** Max terminal columns for resize requests */
|
||||
export const MAX_TERMINAL_COLS = 500;
|
||||
|
||||
/** Max terminal rows for resize requests */
|
||||
export const MAX_TERMINAL_ROWS = 200;
|
||||
|
||||
/** Max session name length (chars) */
|
||||
export const MAX_SESSION_NAME_LENGTH = 128;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { existsSync, statSync, realpathSync } from 'node:fs';
|
||||
import { resolve, relative, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
|
||||
@@ -39,7 +40,7 @@ const MAX_STREAMS_PER_SESSION = 5;
|
||||
* Inactivity timeout for streams (5 minutes).
|
||||
* Streams with no data for this long will be auto-closed.
|
||||
*/
|
||||
const STREAM_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const STREAM_INACTIVITY_TIMEOUT_MS = INACTIVITY_TIMEOUT_MS;
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -129,7 +130,7 @@ export class FileStreamManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
// Start cleanup timer for inactive streams
|
||||
this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), 60 * 1000);
|
||||
this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), CLEANUP_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ========== Public Methods ==========
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface CreateSessionOptions {
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/** Options for respawning a dead pane. */
|
||||
@@ -73,6 +75,8 @@ export interface RespawnPaneOptions {
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -521,7 +521,7 @@ export class PlanOrchestrator {
|
||||
} finally {
|
||||
// Always clean up session and progress interval — centralizing here
|
||||
// prevents the race where cancel() and catch both try to manage the set
|
||||
await session.stop().catch(() => {});
|
||||
await session.stop().catch(() => {}); // Ignore - session cleanup is best-effort in finally block
|
||||
this.runningSessions.delete(session);
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
@@ -651,7 +651,7 @@ export class PlanOrchestrator {
|
||||
} finally {
|
||||
// Always clean up session and progress interval — centralizing here
|
||||
// prevents the race where cancel() and catch both try to manage the set
|
||||
await session.stop().catch(() => {});
|
||||
await session.stop().catch(() => {}); // Ignore - session cleanup is best-effort in finally block
|
||||
this.runningSessions.delete(session);
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execPattern } from './utils/index.js';
|
||||
|
||||
// Pattern to extract completion phrase from CLAUDE.md
|
||||
// Matches <promise>PHRASE</promise> with optional whitespace
|
||||
@@ -83,9 +84,7 @@ export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfi
|
||||
};
|
||||
|
||||
// Parse each YAML line
|
||||
let match;
|
||||
YAML_LINE_PATTERN.lastIndex = 0;
|
||||
while ((match = YAML_LINE_PATTERN.exec(yaml)) !== null) {
|
||||
execPattern(YAML_LINE_PATTERN, yaml, (match) => {
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2].trim();
|
||||
|
||||
@@ -103,7 +102,7 @@ export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfi
|
||||
config.completionPromise = value.toUpperCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { CLEANUP_CHECK_INTERVAL_MS } from './config/server-timing.js';
|
||||
|
||||
/**
|
||||
* RalphStallDetector - Detects iteration stalls in the Ralph loop.
|
||||
@@ -57,7 +58,7 @@ export class RalphStallDetector extends EventEmitter {
|
||||
// Check every minute
|
||||
this._iterationStallTimer = setInterval(() => {
|
||||
this.checkIterationStall();
|
||||
}, 60 * 1000);
|
||||
}, CLEANUP_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
stringSimilarity,
|
||||
Debouncer,
|
||||
CleanupManager,
|
||||
execPattern,
|
||||
} from './utils/index.js';
|
||||
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
|
||||
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
|
||||
@@ -63,6 +64,7 @@ import type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js
|
||||
import { RalphFixPlanWatcher, generateFixPlanMarkdown, importFixPlanMarkdown } from './ralph-fix-plan-watcher.js';
|
||||
import { RalphStallDetector } from './ralph-stall-detector.js';
|
||||
import { RalphStatusParser } from './ralph-status-parser.js';
|
||||
import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
|
||||
|
||||
// Re-export sub-module types for backward compatibility
|
||||
export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js';
|
||||
@@ -72,9 +74,9 @@ export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js
|
||||
|
||||
/**
|
||||
* Todo items older than this duration (in milliseconds) will be auto-expired.
|
||||
* Default: 1 hour (60 * 60 * 1000)
|
||||
* Default: 1 hour
|
||||
*/
|
||||
const TODO_EXPIRY_MS = 60 * 60 * 1000;
|
||||
const TODO_EXPIRY_MS = STALE_DATA_MAX_AGE_MS;
|
||||
|
||||
/**
|
||||
* Minimum interval between on-demand cleanup checks (in milliseconds).
|
||||
@@ -88,7 +90,7 @@ const CLEANUP_THROTTLE_MS = 30 * 1000;
|
||||
* Actively purges expired todos even when no terminal data is flowing.
|
||||
* Default: 5 minutes
|
||||
*/
|
||||
const TODO_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const TODO_CLEANUP_INTERVAL_MS = INACTIVITY_TIMEOUT_MS;
|
||||
|
||||
/**
|
||||
* Similarity threshold for todo deduplication.
|
||||
@@ -1666,35 +1668,32 @@ export class RalphTracker extends EventEmitter {
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
if (hasCheckbox) {
|
||||
TODO_CHECKBOX_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_CHECKBOX_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_CHECKBOX_PATTERN, line, (match) => {
|
||||
const checked = match[1].toLowerCase() === 'x';
|
||||
const content = match[2].trim();
|
||||
const status: RalphTodoStatus = checked ? 'completed' : 'pending';
|
||||
this.upsertTodo(content, status);
|
||||
updated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasTodoIndicator) {
|
||||
TODO_INDICATOR_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_INDICATOR_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_INDICATOR_PATTERN, line, (match) => {
|
||||
const icon = match[1];
|
||||
const content = match[2].trim();
|
||||
const status = this.iconToStatus(icon);
|
||||
this.upsertTodo(content, status);
|
||||
updated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasStatus) {
|
||||
TODO_STATUS_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_STATUS_PATTERN, line, (match) => {
|
||||
const content = match[1].trim();
|
||||
const status = match[2] as RalphTodoStatus;
|
||||
this.upsertTodo(content, status);
|
||||
updated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasNativeCheckbox) {
|
||||
@@ -1715,8 +1714,7 @@ export class RalphTracker extends EventEmitter {
|
||||
}
|
||||
|
||||
if (hasCheckmark) {
|
||||
TODO_TASK_CREATED_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_TASK_CREATED_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_TASK_CREATED_PATTERN, line, (match) => {
|
||||
const taskNum = parseInt(match[1], 10);
|
||||
const content = match[2].trim();
|
||||
if (content.length >= 5) {
|
||||
@@ -1725,10 +1723,9 @@ export class RalphTracker extends EventEmitter {
|
||||
this.upsertTodo(content, 'pending');
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
TODO_TASK_SUMMARY_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_TASK_SUMMARY_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_TASK_SUMMARY_PATTERN, line, (match) => {
|
||||
const taskNum = parseInt(match[1], 10);
|
||||
const content = match[2].trim();
|
||||
if (content.length >= 5) {
|
||||
@@ -1739,10 +1736,9 @@ export class RalphTracker extends EventEmitter {
|
||||
this.upsertTodo(this._taskNumberToContent.get(taskNum) || content, 'pending');
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
TODO_TASK_STATUS_PATTERN.lastIndex = 0;
|
||||
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
|
||||
execPattern(TODO_TASK_STATUS_PATTERN, line, (match) => {
|
||||
const taskNum = parseInt(match[1], 10);
|
||||
const statusStr = match[2].trim();
|
||||
const status: RalphTodoStatus =
|
||||
@@ -1752,7 +1748,7 @@ export class RalphTracker extends EventEmitter {
|
||||
this.upsertTodo(content, status);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0;
|
||||
|
||||
@@ -49,8 +49,7 @@ import { Session } from './session.js';
|
||||
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
|
||||
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
||||
import type { TeamWatcher } from './team-watcher.js';
|
||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||
import { ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
|
||||
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
|
||||
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||
import {
|
||||
isCompletionMessage,
|
||||
@@ -62,7 +61,15 @@ import {
|
||||
import { RespawnAdaptiveTiming } from './respawn-adaptive-timing.js';
|
||||
import { RespawnCycleMetricsTracker } from './respawn-metrics.js';
|
||||
import { calculateHealthScore, shouldSkipClear, type HealthInputs } from './respawn-health.js';
|
||||
import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT, AI_PLAN_CHECK_MAX_CONTEXT } from './config/ai-defaults.js';
|
||||
import {
|
||||
AI_CHECK_MODEL,
|
||||
AI_IDLE_CHECK_MAX_CONTEXT,
|
||||
AI_PLAN_CHECK_MAX_CONTEXT,
|
||||
AI_IDLE_CHECK_TIMEOUT_MS,
|
||||
AI_IDLE_CHECK_COOLDOWN_MS,
|
||||
AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
} from './config/ai-defaults.js';
|
||||
import type {
|
||||
RespawnCycleMetrics,
|
||||
RespawnAggregateMetrics,
|
||||
@@ -559,13 +566,13 @@ const DEFAULT_CONFIG: RespawnConfig = {
|
||||
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
||||
aiIdleCheckModel: AI_CHECK_MODEL,
|
||||
aiIdleCheckMaxContext: AI_IDLE_CHECK_MAX_CONTEXT,
|
||||
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
|
||||
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
|
||||
aiIdleCheckTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS,
|
||||
aiIdleCheckCooldownMs: AI_IDLE_CHECK_COOLDOWN_MS,
|
||||
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
|
||||
aiPlanCheckModel: AI_CHECK_MODEL,
|
||||
aiPlanCheckMaxContext: AI_PLAN_CHECK_MAX_CONTEXT,
|
||||
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
|
||||
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
|
||||
aiPlanCheckTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
aiPlanCheckCooldownMs: AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
stuckStateDetectionEnabled: true, // detect stuck states
|
||||
stuckStateWarningMs: 300000, // 5 minutes warning threshold
|
||||
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
RunSummaryStats,
|
||||
createInitialRunSummaryStats,
|
||||
} from './types.js';
|
||||
import { CLEANUP_CHECK_INTERVAL_MS } from './config/server-timing.js';
|
||||
|
||||
/** Maximum events to keep per session (FIFO trimming) */
|
||||
const MAX_EVENTS = 1000;
|
||||
@@ -36,7 +37,7 @@ const TOKEN_MILESTONE_INTERVAL = 50000;
|
||||
const STATE_STUCK_WARNING_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/** State stuck check interval (ms) */
|
||||
const STATE_STUCK_CHECK_INTERVAL = 60 * 1000; // 1 minute
|
||||
const STATE_STUCK_CHECK_INTERVAL = CLEANUP_CHECK_INTERVAL_MS;
|
||||
|
||||
/**
|
||||
* Tracks events and statistics for a session's run summary.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { ClaudeMode } from './types.js';
|
||||
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
||||
import { getAugmentedPath } from './utils/index.js';
|
||||
|
||||
/**
|
||||
* Build Claude CLI permission flags based on the configured mode.
|
||||
|
||||
@@ -48,8 +48,14 @@ import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { RalphTracker } from './ralph-tracker.js';
|
||||
import { BashToolParser } from './bash-tool-parser.js';
|
||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||
import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js';
|
||||
import {
|
||||
BufferAccumulator,
|
||||
ANSI_ESCAPE_PATTERN_FULL,
|
||||
TOKEN_PATTERN,
|
||||
SPINNER_PATTERN,
|
||||
MAX_SESSION_TOKENS,
|
||||
execPattern,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
|
||||
@@ -318,6 +324,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// OpenCode configuration (only for mode === 'opencode')
|
||||
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Session color for visual differentiation
|
||||
private _color: import('./types.js').SessionColor = 'default';
|
||||
@@ -376,6 +383,8 @@ export class Session extends EventEmitter {
|
||||
allowedTools?: string;
|
||||
/** OpenCode configuration (only for mode === 'opencode') */
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -392,12 +401,10 @@ export class Session extends EventEmitter {
|
||||
this.createdAt = config.createdAt || Date.now();
|
||||
this.mode = config.mode || 'claude';
|
||||
this._name = config.name || '';
|
||||
this._resumeSessionId = config.resumeSessionId;
|
||||
this._lastActivityAt = this.createdAt;
|
||||
// Set claudeSessionId immediately — Codeman always passes --session-id ${this.id}
|
||||
// to Claude CLI, so the Claude session ID always matches the Codeman session ID.
|
||||
// This ensures subagent matching works even for recovered sessions (where
|
||||
// startInteractive() hasn't been called yet).
|
||||
this._claudeSessionId = this.id;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = config.resumeSessionId || this.id;
|
||||
this._mux = config.mux || null;
|
||||
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
|
||||
this._muxSession = config.muxSession || null;
|
||||
@@ -786,6 +793,7 @@ export class Session extends EventEmitter {
|
||||
cliAccountType: this._cliAccountType || undefined,
|
||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -904,6 +912,7 @@ export class Session extends EventEmitter {
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
});
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
@@ -930,6 +939,7 @@ export class Session extends EventEmitter {
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
});
|
||||
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
@@ -949,9 +959,8 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
);
|
||||
|
||||
// Set claudeSessionId immediately since we passed --session-id to Claude
|
||||
// The mux manager passes --session-id ${sessionId} to Claude
|
||||
this._claudeSessionId = this.id;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = this._resumeSessionId || this.id;
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn PTY for mux attachment:', spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
@@ -1036,9 +1045,8 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Set the claudeSessionId immediately since we passed --session-id
|
||||
// This ensures subagent matching works without waiting for JSON messages
|
||||
this._claudeSessionId = this.id;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = this._resumeSessionId || this.id;
|
||||
|
||||
this._pid = this.ptyProcess.pid;
|
||||
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
||||
@@ -1692,16 +1700,12 @@ export class Session extends EventEmitter {
|
||||
// Quick pre-check: skip expensive regex if no common tool patterns present
|
||||
if (!cleanLine.includes('(') || !cleanLine.includes(')')) return;
|
||||
|
||||
// Reset regex lastIndex for global pattern
|
||||
TASK_TOOL_PATTERN.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = TASK_TOOL_PATTERN.exec(cleanLine)) !== null) {
|
||||
execPattern(TASK_TOOL_PATTERN, cleanLine, (match) => {
|
||||
const description = match[2].trim();
|
||||
if (description && description.length > 0) {
|
||||
this._taskCache.add(Date.now(), description);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID).
|
||||
*
|
||||
* @dependencies config/map-limits (MAX_TRACKED_AGENTS, PENDING_TOOL_CALL_TTL_MS),
|
||||
* utils (CleanupManager, KeyedDebouncer)
|
||||
* config/buffer-limits (FILE_PEEK_BYTES), utils (CleanupManager, KeyedDebouncer)
|
||||
* @consumedby web/server (SSE broadcast), session (subagent-session correlation)
|
||||
* @emits subagent:discovered, subagent:updated, subagent:tool_call, subagent:tool_result,
|
||||
* subagent:progress, subagent:message, subagent:completed
|
||||
@@ -34,6 +34,8 @@ import { join, basename } 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';
|
||||
import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
|
||||
import { FILE_PEEK_BYTES } from './config/buffer-limits.js';
|
||||
import { CleanupManager, KeyedDebouncer } from './utils/index.js';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -151,7 +153,7 @@ const POLL_INTERVAL_MS = 1000; // Base poll interval (lightweight checks)
|
||||
const FULL_SCAN_EVERY_N_POLLS = 5; // Full directory traversal every 5th poll (5s)
|
||||
const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s
|
||||
const FILE_ALIVE_THRESHOLD_MS = 30000; // File mtime within 30s = agent alive (primary check)
|
||||
const STALE_COMPLETED_MAX_AGE_MS = 60 * 60 * 1000; // Remove completed agents older than 1 hour
|
||||
const STALE_COMPLETED_MAX_AGE_MS = STALE_DATA_MAX_AGE_MS; // Remove completed agents older than 1 hour
|
||||
const STALE_IDLE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // Remove idle agents older than 4 hours
|
||||
const STARTUP_MAX_FILE_AGE_MS = 4 * 60 * 60 * 1000; // Only load files modified in last 4 hours on startup
|
||||
|
||||
@@ -1008,7 +1010,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
private async extractDescriptionFromFile(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
// Only read the first 8KB — more than enough for 5 JSONL lines
|
||||
const stream = createReadStream(filePath, { end: 8191 });
|
||||
const stream = createReadStream(filePath, { end: FILE_PEEK_BYTES });
|
||||
const rl = createInterface({ input: stream });
|
||||
|
||||
return await new Promise<string | undefined>((resolve) => {
|
||||
@@ -1140,10 +1142,10 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
if (this.fileAgentContext.has(filePath)) {
|
||||
// Known file — handle content change
|
||||
this.handleFileChange(filePath).catch(() => {});
|
||||
this.handleFileChange(filePath).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
|
||||
} else {
|
||||
// New file — register it
|
||||
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {});
|
||||
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { assertNever } from './utils/index.js';
|
||||
import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
|
||||
@@ -36,7 +37,7 @@ const MAX_COMPLETED_TASKS = 100;
|
||||
* Entries older than this are cleaned up to prevent unbounded growth.
|
||||
* Default: 1 hour
|
||||
*/
|
||||
const PENDING_TOOL_USE_MAX_AGE_MS = 60 * 60 * 1000;
|
||||
const PENDING_TOOL_USE_MAX_AGE_MS = STALE_DATA_MAX_AGE_MS;
|
||||
|
||||
/**
|
||||
* Maximum number of pending tool uses to allow.
|
||||
|
||||
@@ -61,7 +61,7 @@ export class TeamWatcher extends EventEmitter {
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
const teamsHandler = () => this.pollAsync().catch(() => {});
|
||||
const teamsHandler = () => this.pollAsync().catch(() => {}); // Ignore - poll errors are non-fatal, next poll will retry
|
||||
this.teamsWatcher.on('add', teamsHandler);
|
||||
this.teamsWatcher.on('change', teamsHandler);
|
||||
this.teamsWatcher.on('unlink', teamsHandler);
|
||||
@@ -82,8 +82,8 @@ export class TeamWatcher extends EventEmitter {
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {}));
|
||||
this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {}));
|
||||
this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {})); // Ignore - poll errors are non-fatal, next poll will retry
|
||||
this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {})); // Ignore - poll errors are non-fatal, next poll will retry
|
||||
this.tasksWatcher.on('error', (err) => {
|
||||
console.warn('[TeamWatcher] chokidar tasks watcher error:', err);
|
||||
});
|
||||
@@ -95,11 +95,11 @@ export class TeamWatcher extends EventEmitter {
|
||||
stop(): void {
|
||||
// Close chokidar watchers
|
||||
if (this.teamsWatcher) {
|
||||
this.teamsWatcher.close().catch(() => {});
|
||||
this.teamsWatcher.close().catch(() => {}); // Ignore - watcher cleanup is best-effort during shutdown
|
||||
this.teamsWatcher = null;
|
||||
}
|
||||
if (this.tasksWatcher) {
|
||||
this.tasksWatcher.close().catch(() => {});
|
||||
this.tasksWatcher.close().catch(() => {}); // Ignore - watcher cleanup is best-effort during shutdown
|
||||
this.tasksWatcher = null;
|
||||
}
|
||||
if (this.pollTimer) {
|
||||
|
||||
@@ -40,8 +40,7 @@ import {
|
||||
type SessionMode,
|
||||
type OpenCodeConfig,
|
||||
} from './types.js';
|
||||
import { wrapWithNice } from './utils/nice-wrapper.js';
|
||||
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
|
||||
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
MuxSession,
|
||||
@@ -50,11 +49,6 @@ import type {
|
||||
RespawnPaneOptions,
|
||||
} from './mux-interface.js';
|
||||
|
||||
// Claude CLI PATH resolution — shared utility
|
||||
import { findClaudeDir } from './utils/claude-cli-resolver.js';
|
||||
// OpenCode CLI PATH resolution
|
||||
import { resolveOpenCodeDir } from './utils/opencode-cli-resolver.js';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
// ============================================================================
|
||||
@@ -200,12 +194,24 @@ function buildSpawnCommand(options: {
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
resumeSessionId?: string;
|
||||
}): string {
|
||||
if (options.mode === 'claude') {
|
||||
// Validate model to prevent command injection
|
||||
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
|
||||
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
|
||||
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
|
||||
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
|
||||
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
|
||||
// fall back to a new session with --session-id so the pane doesn't die.
|
||||
const safeResumeId =
|
||||
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
|
||||
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
|
||||
if (safeResumeId) {
|
||||
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}`;
|
||||
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
|
||||
return `${resumeCmd} || ${fallbackCmd}`;
|
||||
}
|
||||
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
|
||||
}
|
||||
if (options.mode === 'opencode') {
|
||||
return buildOpenCodeCommand(options.openCodeConfig);
|
||||
@@ -370,7 +376,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* In test mode: creates an in-memory session only (no real tmux session).
|
||||
*/
|
||||
async createSession(options: CreateSessionOptions): Promise<MuxSession> {
|
||||
const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
|
||||
const {
|
||||
sessionId,
|
||||
workingDir,
|
||||
mode,
|
||||
name,
|
||||
niceConfig,
|
||||
model,
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
} = options;
|
||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
if (!isValidMuxName(muxName)) {
|
||||
@@ -433,6 +450,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
});
|
||||
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
@@ -605,7 +623,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* preserving the session and its scrollback buffer.
|
||||
*/
|
||||
async respawnPane(options: RespawnPaneOptions): Promise<number | null> {
|
||||
const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
|
||||
const {
|
||||
sessionId,
|
||||
workingDir,
|
||||
mode,
|
||||
niceConfig,
|
||||
model,
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
const muxName = session.muxName;
|
||||
@@ -641,6 +669,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
claudeMode,
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Key exports:
|
||||
* - PlanItem — a single task with priority (P0/P1/P2), TDD phase, dependencies, verification criteria
|
||||
* - PlanTaskStatus — 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'
|
||||
* - TddPhase / PlanPhase — 'setup' | 'test' | 'impl' | 'verify' | 'review'
|
||||
* - TddPhase — 'setup' | 'test' | 'impl' | 'verify' | 'review'
|
||||
*
|
||||
* Used by PlanOrchestrator (`src/plan-orchestrator.ts`) and the plan API routes
|
||||
* (`src/web/routes/plan-routes.ts`). Served at `GET /api/sessions/:id/plan/tasks`.
|
||||
@@ -21,9 +21,6 @@ export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||
/** TDD phase categories */
|
||||
export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review';
|
||||
|
||||
/** Development phase in TDD cycle (alias for TddPhase) */
|
||||
export type PlanPhase = TddPhase;
|
||||
|
||||
/**
|
||||
* A single plan item for plan orchestration.
|
||||
* Moved here from plan-orchestrator.ts to break circular dependency.
|
||||
@@ -42,7 +39,7 @@ export interface PlanItem {
|
||||
lastError?: string;
|
||||
completedAt?: number;
|
||||
complexity?: 'low' | 'medium' | 'high';
|
||||
tddPhase?: PlanPhase;
|
||||
tddPhase?: TddPhase;
|
||||
pairedWith?: string;
|
||||
reviewChecklist?: string[];
|
||||
}
|
||||
|
||||
@@ -143,6 +143,8 @@ export interface SessionState {
|
||||
cliLatestVersion?: string;
|
||||
/** OpenCode-specific configuration (only for mode === 'opencode') */
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,8 +20,9 @@ export {
|
||||
createAnsiPatternSimple,
|
||||
stripAnsi,
|
||||
SAFE_PATH_PATTERN,
|
||||
execPattern,
|
||||
} from './regex-patterns.js';
|
||||
export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js';
|
||||
export { MAX_SESSION_TOKENS } from './token-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
|
||||
@@ -79,3 +79,15 @@ export function stripAnsi(text: string): string {
|
||||
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
|
||||
|
||||
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/;
|
||||
|
||||
/**
|
||||
* Execute a global regex pattern against data, calling the callback for each match.
|
||||
* Automatically resets lastIndex before execution.
|
||||
*/
|
||||
export function execPattern(pattern: RegExp, data: string, callback: (match: RegExpExecArray) => void): void {
|
||||
pattern.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(data)) !== null) {
|
||||
callback(match);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Token validation utilities.
|
||||
* @fileoverview Token validation constants.
|
||||
*
|
||||
* Centralizes token count validation logic used across the codebase.
|
||||
* Claude's context window is ~200k tokens, so 500k is a generous upper bound.
|
||||
*
|
||||
* @module utils/token-validation
|
||||
@@ -12,58 +11,3 @@
|
||||
* Claude's context is ~200k, so 500k is a safe upper bound for validation.
|
||||
*/
|
||||
export const MAX_SESSION_TOKENS = 500_000;
|
||||
|
||||
/**
|
||||
* Validates token counts are within acceptable bounds.
|
||||
* Rejects negative values and values exceeding MAX_SESSION_TOKENS.
|
||||
*
|
||||
* @param inputTokens - Input token count to validate
|
||||
* @param outputTokens - Output token count to validate
|
||||
* @returns Object with isValid flag and optional error reason
|
||||
*/
|
||||
export function validateTokenCounts(inputTokens: number, outputTokens: number): { isValid: boolean; reason?: string } {
|
||||
if (inputTokens < 0 || outputTokens < 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: `Negative token values: input=${inputTokens}, output=${outputTokens}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: `Token values exceed maximum (${MAX_SESSION_TOKENS}): input=${inputTokens}, output=${outputTokens}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates token counts and cost for restoration/persistence.
|
||||
* Returns true if all values are valid.
|
||||
*
|
||||
* @param inputTokens - Input token count
|
||||
* @param outputTokens - Output token count
|
||||
* @param cost - Cost value (must be non-negative)
|
||||
* @returns Object with isValid flag and optional error reason
|
||||
*/
|
||||
export function validateTokensAndCost(
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
cost: number
|
||||
): { isValid: boolean; reason?: string } {
|
||||
const tokenValidation = validateTokenCounts(inputTokens, outputTokens);
|
||||
if (!tokenValidation.isValid) {
|
||||
return tokenValidation;
|
||||
}
|
||||
|
||||
if (cost < 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: `Negative cost value: ${cost}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
@@ -11,4 +11,5 @@ export interface EventPort {
|
||||
batchTerminalData(sessionId: string, data: string): void;
|
||||
broadcastSessionStateDebounced(sessionId: string): void;
|
||||
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
|
||||
getSseClientCount(): number;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* @fileoverview Shared constants, utility functions, and SSE event type registry for all frontend modules.
|
||||
*
|
||||
* This is the first script loaded in index.html. Every other frontend module depends on the
|
||||
* globals defined here: timing constants, Z-index layers, DEC 2026 sync markers, respawn
|
||||
* preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml, extractSyncSegments,
|
||||
* globals defined here: timing constants, Z-index layers, respawn
|
||||
* preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml,
|
||||
* getEventCoords, scheduleBackground, urlBase64ToUint8Array).
|
||||
*
|
||||
* @globals {function} urlBase64ToUint8Array - VAPID key conversion for Web Push
|
||||
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
|
||||
* @globals {function} extractSyncSegments - DEC 2026 terminal sync marker parser
|
||||
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
|
||||
* @globals {function} escapeHtml - XSS-safe HTML escaping
|
||||
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (~73 event types)
|
||||
@@ -42,7 +41,7 @@ function urlBase64ToUint8Array(base64String) {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Default terminal scrollback (can be changed via settings)
|
||||
const DEFAULT_SCROLLBACK = 5000;
|
||||
const DEFAULT_SCROLLBACK = 20000;
|
||||
|
||||
// Timing constants
|
||||
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
|
||||
@@ -52,8 +51,8 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
|
||||
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
||||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal data (smaller to avoid WebGL GPU stalls)
|
||||
const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||||
const TERMINAL_TAIL_SIZE = 128 * 1024; // 128KB tail for initial load
|
||||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||
|
||||
@@ -79,14 +78,8 @@ function scheduleBackground(fn) {
|
||||
else { requestAnimationFrame(fn); }
|
||||
}
|
||||
|
||||
// DEC mode 2026 - Synchronized Output
|
||||
// Wrap terminal writes with these markers to prevent partial-frame flicker.
|
||||
// Terminal buffers all output between markers and renders atomically.
|
||||
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
||||
// xterm.js doesn't support DEC 2026 natively, so we implement buffering ourselves.
|
||||
const DEC_SYNC_START = '\x1b[?2026h';
|
||||
const DEC_SYNC_END = '\x1b[?2026l';
|
||||
// Pre-compiled regex for stripping DEC 2026 markers (single pass instead of two replaceAll calls)
|
||||
// DEC mode 2026 marker stripping — xterm.js 6.0 handles sync natively,
|
||||
// but server-sent terminal buffers may still contain markers from Claude CLI.
|
||||
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
|
||||
|
||||
// Built-in respawn configuration presets
|
||||
@@ -303,59 +296,6 @@ function getEventCoords(e) {
|
||||
return { clientX: e.clientX, clientY: e.clientY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process data containing DEC 2026 sync markers.
|
||||
* Strips markers and returns segments that should be written atomically.
|
||||
* Each returned segment represents content between SYNC_START and SYNC_END.
|
||||
* Content outside sync blocks is returned as-is.
|
||||
*
|
||||
* @param {string} data - Raw terminal data with potential sync markers
|
||||
* @returns {string[]} - Array of content segments to write (markers stripped)
|
||||
*/
|
||||
function extractSyncSegments(data) {
|
||||
const segments = [];
|
||||
let remaining = data;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const startIdx = remaining.indexOf(DEC_SYNC_START);
|
||||
|
||||
if (startIdx === -1) {
|
||||
// No more sync blocks, return rest as-is
|
||||
if (remaining.length > 0) {
|
||||
segments.push(remaining);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Content before sync block (if any)
|
||||
if (startIdx > 0) {
|
||||
segments.push(remaining.slice(0, startIdx));
|
||||
}
|
||||
|
||||
// Find matching end marker
|
||||
const afterStart = remaining.slice(startIdx + DEC_SYNC_START.length);
|
||||
const endIdx = afterStart.indexOf(DEC_SYNC_END);
|
||||
|
||||
if (endIdx === -1) {
|
||||
// No end marker found - sync block continues in next chunk
|
||||
// Include the start marker so it can be handled when more data arrives
|
||||
segments.push(remaining.slice(startIdx));
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract synchronized content (without markers)
|
||||
const syncContent = afterStart.slice(0, endIdx);
|
||||
if (syncContent.length > 0) {
|
||||
segments.push(syncContent);
|
||||
}
|
||||
|
||||
// Continue with content after end marker
|
||||
remaining = afterStart.slice(endIdx + DEC_SYNC_END.length);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// HTML escape utility (shared by NotificationManager, CodemanApp, and ralph-wizard.js)
|
||||
const _htmlEscapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
const _htmlEscapePattern = /[&<>"']/g;
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="description" content="Claude Code session manager with web interface">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="google" content="notranslate">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<title>Codeman</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="styles.css?v=0.1631">
|
||||
<link rel="stylesheet" href="mobile.css?v=0.1631" media="(max-width: 1023px)">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="mobile.css" media="(max-width: 1023px)">
|
||||
<!-- xterm.css loaded async — terminal won't display until xterm.js runs anyway -->
|
||||
<link rel="preload" href="vendor/xterm.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
||||
<noscript><link rel="stylesheet" href="vendor/xterm.css"></noscript>
|
||||
@@ -20,12 +20,12 @@
|
||||
<script defer src="vendor/xterm-addon-fit.min.js"></script>
|
||||
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
|
||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js?v=0.3.2"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.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>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;background:#0a0a0a}
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#0a0a0a}
|
||||
.skeleton-header{height:40px;background:#111;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
|
||||
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
||||
@@ -59,6 +59,9 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
</button>
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
@@ -253,6 +256,10 @@
|
||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
|
||||
</div>
|
||||
<div class="history-sessions" id="historySessions" style="display:none">
|
||||
<h3 class="history-title">Resume Conversation</h3>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
|
||||
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
||||
</div>
|
||||
@@ -323,6 +330,9 @@
|
||||
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
|
||||
<span class="run-mode-dot opencode"></span>OpenCode
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<div class="run-mode-header">Recent Sessions</div>
|
||||
<div class="run-mode-history" id="runModeHistory"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-count-group" title="Instance count">
|
||||
@@ -1674,14 +1684,14 @@
|
||||
<!-- Lines drawn dynamically -->
|
||||
</svg>
|
||||
|
||||
<script defer src="constants.js?v=0.3.2"></script>
|
||||
<script defer src="mobile-handlers.js?v=0.3.2"></script>
|
||||
<script defer src="voice-input.js?v=0.3.2"></script>
|
||||
<script defer src="notification-manager.js?v=0.3.2"></script>
|
||||
<script defer src="keyboard-accessory.js?v=0.3.2"></script>
|
||||
<script defer src="app.js?v=0.3.2"></script>
|
||||
<script defer src="ralph-wizard.js?v=0.3.2"></script>
|
||||
<script defer src="api-client.js?v=0.3.2"></script>
|
||||
<script defer src="subagent-windows.js?v=0.3.2"></script>
|
||||
<script defer src="constants.js"></script>
|
||||
<script defer src="mobile-handlers.js"></script>
|
||||
<script defer src="voice-input.js"></script>
|
||||
<script defer src="notification-manager.js"></script>
|
||||
<script defer src="keyboard-accessory.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -98,14 +98,33 @@ const MobileDetection = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Set --app-height CSS variable from visual viewport.
|
||||
* On iPad Safari with tabs, 100vh extends behind the tab bar.
|
||||
* visualViewport.height reflects the actual visible area. */
|
||||
updateAppHeight() {
|
||||
const vh = window.visualViewport?.height || window.innerHeight;
|
||||
document.documentElement.style.setProperty('--app-height', `${vh}px`);
|
||||
},
|
||||
|
||||
/** Initialize mobile detection and set up resize listener */
|
||||
init() {
|
||||
this.updateBodyClass();
|
||||
this.updateAppHeight();
|
||||
|
||||
// Update --app-height on viewport resize (orientation, tab bar toggle)
|
||||
if (window.visualViewport) {
|
||||
this._appHeightHandler = () => this.updateAppHeight();
|
||||
window.visualViewport.addEventListener('resize', this._appHeightHandler);
|
||||
}
|
||||
|
||||
// Debounced resize handler
|
||||
let resizeTimeout;
|
||||
this._resizeHandler = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => this.updateBodyClass(), 100);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
this.updateBodyClass();
|
||||
this.updateAppHeight();
|
||||
}, 100);
|
||||
};
|
||||
window.addEventListener('resize', this._resizeHandler);
|
||||
|
||||
|
||||
@@ -234,6 +234,35 @@ html.mobile-init .file-browser-panel {
|
||||
.subagent-window-body {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Show desktop voice button on tablet (hidden by max-width:1023px in styles.css,
|
||||
mobile .btn-voice-mobile only shows at <430px) */
|
||||
.toolbar-center .btn-toolbar.btn-voice {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* Toolbar — use desktop-style sizing on tablet (plenty of room at 430-768px) */
|
||||
.toolbar {
|
||||
padding: 0 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Instance count controls are hidden on tablet, so toolbar-group needs gap
|
||||
to space out Run / Stop / Run Shell (desktop uses gap:0 because -1+ separates them) */
|
||||
.toolbar-group {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-toolbar {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
min-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
@@ -498,6 +527,13 @@ html.mobile-init .file-browser-panel {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* iOS Safari with tab bar: position: fixed uses the layout viewport which
|
||||
extends behind the browser chrome. Offset the toolbar upward by the delta
|
||||
between 100vh (layout) and --app-height (visual). */
|
||||
.ios-device.safari-browser .toolbar {
|
||||
bottom: calc(var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
|
||||
}
|
||||
|
||||
/* Show case selector in center */
|
||||
.toolbar-center {
|
||||
display: flex !important;
|
||||
@@ -626,12 +662,20 @@ html.mobile-init .file-browser-panel {
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: 6px;
|
||||
min-width: 140px;
|
||||
min-width: 160px;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
.run-mode-option {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.run-mode-history {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Stop button - visible on mobile, icon-only */
|
||||
@@ -1895,6 +1939,87 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
Keyboard Accessory Bar — all mobile/tablet sizes
|
||||
Visual styles extracted from phone breakpoint so they apply on iPad too.
|
||||
Phone-specific positioning (position: fixed) remains in @media (max-width: 430px).
|
||||
============================================================================ */
|
||||
.keyboard-accessory-bar {
|
||||
display: none;
|
||||
height: 44px;
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.accessory-btn.confirming {
|
||||
background: #6b4f00;
|
||||
border-color: #b8860b;
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.accessory-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #1e3a5f;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
padding: 8px 14px;
|
||||
background: #2a2a2a;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
iOS Safari Specific Fixes
|
||||
============================================================================ */
|
||||
|
||||
@@ -116,6 +116,8 @@ body {
|
||||
background: var(--bg-dark);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: var(--app-height, 100dvh);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -124,6 +126,8 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: var(--app-height, 100dvh);
|
||||
}
|
||||
|
||||
/* Compact Header */
|
||||
@@ -266,6 +270,8 @@ body {
|
||||
will-change: opacity;
|
||||
}
|
||||
.session-tab .tab-status.error { background: var(--red); }
|
||||
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
|
||||
.session-tab[data-ended] { opacity: 0.55; }
|
||||
|
||||
/* Session color coding - left border indicator */
|
||||
.session-tab[data-color="red"] { border-left: 3px solid var(--session-red); }
|
||||
@@ -444,6 +450,249 @@ body {
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Tunnel Indicator */
|
||||
.tunnel-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tunnel-indicator:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tunnel-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 6px #22c55e, 0 0 12px rgba(34, 197, 94, 0.4);
|
||||
animation: tunnel-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tunnel-indicator.connecting .tunnel-dot {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 6px #f59e0b, 0 0 12px rgba(245, 158, 11, 0.4);
|
||||
animation: tunnel-glow-amber 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tunnel-glow {
|
||||
0%, 100% { box-shadow: 0 0 6px #22c55e, 0 0 12px rgba(34, 197, 94, 0.4); }
|
||||
50% { box-shadow: 0 0 8px #22c55e, 0 0 18px rgba(34, 197, 94, 0.6); }
|
||||
}
|
||||
|
||||
@keyframes tunnel-glow-amber {
|
||||
0%, 100% { box-shadow: 0 0 6px #f59e0b, 0 0 12px rgba(245, 158, 11, 0.4); opacity: 1; }
|
||||
50% { box-shadow: 0 0 4px #f59e0b; opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Tunnel Panel Dropdown */
|
||||
.tunnel-panel {
|
||||
position: fixed;
|
||||
top: 36px;
|
||||
right: 12px;
|
||||
width: 320px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tunnel-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tunnel-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tunnel-panel-status {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.tunnel-panel-status.offline {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tunnel-panel-body {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.tunnel-panel-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tunnel-panel-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tunnel-panel-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tunnel-panel-url {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent-hover);
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tunnel-panel-url:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tunnel-panel-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tunnel-panel-stat-value {
|
||||
color: var(--text);
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tunnel-panel-session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tunnel-panel-session-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tunnel-panel-session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tunnel-panel-session-method {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tunnel-panel-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-start {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-start:hover {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-stop {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-stop:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-qr {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-qr:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-revoke {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-revoke:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.tunnel-panel-empty {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.header-tokens {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -1837,6 +2086,70 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* History sessions panel (welcome overlay) */
|
||||
.history-sessions {
|
||||
margin-top: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.history-item-dir {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-item-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-item-size {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
min-width: 45px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
@@ -2017,7 +2330,8 @@ body {
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
min-width: 150px;
|
||||
min-width: 180px;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
.run-mode-menu.active {
|
||||
@@ -2054,6 +2368,40 @@ body {
|
||||
.run-mode-dot.claude { background: #3b82f6; }
|
||||
.run-mode-dot.opencode { background: #10b981; }
|
||||
|
||||
.run-mode-sep {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.run-mode-header {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 0.72em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.run-mode-history {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.run-mode-option .hist-dir {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.run-mode-option .hist-meta {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.run-mode-hist-empty {
|
||||
padding: 6px 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Shell Button - Special styling */
|
||||
.btn-toolbar.btn-shell {
|
||||
background: linear-gradient(135deg, #1a2e1a 0%, #234023 100%);
|
||||
@@ -4204,6 +4552,7 @@ kbd {
|
||||
right: 0;
|
||||
width: 340px;
|
||||
height: calc(100vh - var(--header-height));
|
||||
height: calc(100dvh - var(--header-height));
|
||||
background: var(--bg-card);
|
||||
border-left: 1px solid var(--border);
|
||||
z-index: 10001;
|
||||
@@ -5572,6 +5921,7 @@ kbd {
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
pointer-events: none;
|
||||
z-index: 999; /* Below windows (1000+), above panels (91) */
|
||||
}
|
||||
@@ -5799,6 +6149,7 @@ kbd {
|
||||
right: 20px;
|
||||
width: 280px;
|
||||
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
max-height: 600px;
|
||||
min-width: 200px;
|
||||
min-height: 300px;
|
||||
|
||||
@@ -121,7 +121,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!windowData.minimized) {
|
||||
openWindows.push({
|
||||
agentId,
|
||||
position: windowData.position || null
|
||||
position: windowData.position || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -163,8 +163,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Use the PERSISTENT parent map (THE source of truth)
|
||||
// Fall back to saved sessionId only if it exists in current sessions
|
||||
const parentFromMap = this.subagentParentMap.get(agentId);
|
||||
const correctSessionId = parentFromMap ||
|
||||
(this.sessions.has(savedSessionId) ? savedSessionId : null);
|
||||
const correctSessionId = parentFromMap || (this.sessions.has(savedSessionId) ? savedSessionId : null);
|
||||
|
||||
if (correctSessionId) {
|
||||
// Ensure the parent map has this association
|
||||
@@ -184,7 +183,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Restore open windows (for recent, non-completed agents only)
|
||||
const now = Date.now();
|
||||
const maxAgeMs = 10 * 60 * 1000; // 10 minutes - don't restore windows for old agents
|
||||
for (const { agentId, position } of (states.open || [])) {
|
||||
for (const { agentId, position } of states.open || []) {
|
||||
const agent = this.subagents.get(agentId);
|
||||
// Only restore window if agent exists, is recent, and is still active/idle
|
||||
const agentAge = agent?.startedAt ? now - agent.startedAt : Infinity;
|
||||
@@ -458,6 +457,83 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Lazy Terminal Lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Teammate terminal windows use xterm.js Terminal instances that consume
|
||||
// ~75KB of DOM memory each. With 50 agents minimized, that's ~3.75MB of
|
||||
// invisible terminals. To avoid this, we dispose the Terminal when a
|
||||
// window is minimized and lazily re-create it when restored.
|
||||
//
|
||||
// Flow:
|
||||
// minimize → _disposeTeammateTerminalForMinimize() → sets _lazyTerminal flag
|
||||
// restore → _restoreTeammateTerminalFromLazy() → re-creates Terminal
|
||||
// create (hidden/minimized) → skip initTeammateTerminal, set _lazyTerminal
|
||||
//
|
||||
// The tmux pane buffer is re-fetched from the API on restore. Regular
|
||||
// (non-teammate) subagent windows use activity HTML and are unaffected
|
||||
// by this optimization.
|
||||
|
||||
/**
|
||||
* Dispose a teammate terminal when its window is minimized.
|
||||
* Saves pane metadata so the terminal can be re-created on restore.
|
||||
* No-op if the window has no teammate terminal.
|
||||
*/
|
||||
_disposeTeammateTerminalForMinimize(agentId) {
|
||||
const termData = this.teammateTerminals.get(agentId);
|
||||
if (!termData) return; // Not a teammate terminal window
|
||||
|
||||
const windowData = this.subagentWindows.get(agentId);
|
||||
|
||||
// Save pane metadata needed to re-create the terminal on restore
|
||||
if (windowData) {
|
||||
windowData._lazyTerminal = true;
|
||||
windowData._lazyPaneTarget = termData.paneTarget;
|
||||
windowData._lazySessionId = termData.sessionId;
|
||||
}
|
||||
|
||||
// Dispose the resize observer
|
||||
if (termData.resizeObserver) {
|
||||
termData.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
// Dispose the xterm.js Terminal instance (frees DOM nodes and internal buffers)
|
||||
if (termData.terminal) {
|
||||
try {
|
||||
termData.terminal.dispose();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Remove from teammateTerminals map so renderSubagentWindowContent won't skip this window
|
||||
// (the activity HTML can serve as a lightweight placeholder while minimized)
|
||||
this.teammateTerminals.delete(agentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-create a teammate terminal when its window is restored from minimized state.
|
||||
* Fetches the current pane buffer from the API (tmux is the source of truth).
|
||||
* No-op if the window doesn't have the _lazyTerminal flag.
|
||||
*/
|
||||
_restoreTeammateTerminalFromLazy(agentId) {
|
||||
const windowData = this.subagentWindows.get(agentId);
|
||||
if (!windowData || !windowData._lazyTerminal) return;
|
||||
|
||||
const paneTarget = windowData._lazyPaneTarget;
|
||||
const sessionId = windowData._lazySessionId;
|
||||
|
||||
// Clear lazy state
|
||||
windowData._lazyTerminal = false;
|
||||
windowData._lazyPaneTarget = null;
|
||||
windowData._lazySessionId = null;
|
||||
|
||||
if (!paneTarget || !sessionId) return;
|
||||
|
||||
// Re-create the terminal using the same initTeammateTerminal flow
|
||||
const paneInfo = { paneTarget, sessionId };
|
||||
this.initTeammateTerminal(agentId, paneInfo, windowData.element);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Floating Windows
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -495,7 +571,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Only open windows for agents that belong to a Codeman-managed session tab.
|
||||
// Agents from external Claude sessions (not tracked by Codeman) should not pop up.
|
||||
if (agent.sessionId) {
|
||||
const hasMatchingTab = Array.from(this.sessions.values()).some(s => s.claudeSessionId === agent.sessionId);
|
||||
const hasMatchingTab = Array.from(this.sessions.values()).some((s) => s.claudeSessionId === agent.sessionId);
|
||||
if (!hasMatchingTab) return;
|
||||
}
|
||||
|
||||
@@ -600,9 +676,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Get parent TAB element for spawn animation
|
||||
const parentTab = parentSessionId
|
||||
? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`)
|
||||
: null;
|
||||
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
|
||||
|
||||
// Create window element
|
||||
const win = document.createElement('div');
|
||||
@@ -611,17 +685,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
win.style.zIndex = ++this.subagentWindowZIndex;
|
||||
|
||||
// Build parent header if we have parent info
|
||||
const parentHeader = parentSessionId && parentSessionName
|
||||
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
|
||||
const parentHeader =
|
||||
parentSessionId && parentSessionName
|
||||
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
|
||||
<span class="parent-label">from</span>
|
||||
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentSessionName)}</span>
|
||||
</div>`
|
||||
: '';
|
||||
: '';
|
||||
|
||||
const teammateInfo = this.getTeammateInfo(agent);
|
||||
const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7));
|
||||
const windowTitle = teammateInfo ? teammateInfo.name : agent.description || agentId.substring(0, 7);
|
||||
const maxTitleLen = isMobile ? 30 : 50;
|
||||
const truncatedTitle = windowTitle.length > maxTitleLen ? windowTitle.substring(0, maxTitleLen) + '...' : windowTitle;
|
||||
const truncatedTitle =
|
||||
windowTitle.length > maxTitleLen ? windowTitle.substring(0, maxTitleLen) + '...' : windowTitle;
|
||||
const modelBadge = agent.modelShort
|
||||
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
|
||||
: '';
|
||||
@@ -695,7 +771,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Render content — check if this teammate has a tmux pane
|
||||
const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null;
|
||||
if (paneInfo) {
|
||||
this.initTeammateTerminal(agentId, paneInfo, win);
|
||||
if (shouldHide) {
|
||||
// Window starts hidden — defer terminal creation until visible (lazy init).
|
||||
// Saves ~75KB of DOM memory per hidden teammate terminal window.
|
||||
const windowEntry = this.subagentWindows.get(agentId);
|
||||
if (windowEntry) {
|
||||
windowEntry._lazyTerminal = true;
|
||||
windowEntry._lazyPaneTarget = paneInfo.paneTarget;
|
||||
windowEntry._lazySessionId = paneInfo.sessionId;
|
||||
}
|
||||
} else {
|
||||
this.initTeammateTerminal(agentId, paneInfo, win);
|
||||
}
|
||||
} else {
|
||||
this.renderSubagentWindowContent(agentId);
|
||||
}
|
||||
@@ -755,6 +842,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.setAgentParentSessionId(agentId, parentSessionId);
|
||||
}
|
||||
|
||||
// Dispose teammate terminal on minimize to free DOM/memory (~75KB per instance).
|
||||
// The terminal will be lazily re-created on restore via initTeammateTerminal().
|
||||
this._disposeTeammateTerminalForMinimize(agentId);
|
||||
|
||||
// Always minimize to tab
|
||||
windowData.element.style.display = 'none';
|
||||
windowData.minimized = true;
|
||||
@@ -829,7 +920,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (const [, termData] of this.teammateTerminals) {
|
||||
if (termData.resizeObserver) termData.resizeObserver.disconnect();
|
||||
if (termData.terminal) {
|
||||
try { termData.terminal.dispose(); } catch {}
|
||||
try {
|
||||
termData.terminal.dispose();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this.teammateTerminals.clear();
|
||||
@@ -959,6 +1052,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
windowData.hidden = false;
|
||||
}
|
||||
windowData.minimized = false;
|
||||
|
||||
// Lazily re-create teammate terminal if it was disposed on minimize.
|
||||
// Only re-create when the window is actually becoming visible.
|
||||
if (shouldShow && windowData._lazyTerminal) {
|
||||
this._restoreTeammateTerminalFromLazy(agentId);
|
||||
}
|
||||
|
||||
this.updateConnectionLines();
|
||||
// Restack all visible mobile windows so restored ones don't overlap
|
||||
this.relayoutMobileSubagentWindows();
|
||||
@@ -1067,7 +1167,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!dropdown || dropdown.classList.contains('open')) return;
|
||||
|
||||
// Close other dropdowns first
|
||||
document.querySelectorAll('.subagent-dropdown.open').forEach(d => {
|
||||
document.querySelectorAll('.subagent-dropdown.open').forEach((d) => {
|
||||
d.classList.remove('open', 'pinned');
|
||||
if (d.parentElement === document.body && d._originalParent) {
|
||||
d._originalParent.appendChild(d);
|
||||
@@ -1089,8 +1189,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Schedule hide after delay (allows moving mouse to dropdown)
|
||||
scheduleHideSubagentDropdown(badgeEl) {
|
||||
this._subagentHideTimeout = setTimeout(() => {
|
||||
const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') ||
|
||||
document.querySelector('.subagent-dropdown.open');
|
||||
const dropdown =
|
||||
badgeEl?.querySelector?.('.subagent-dropdown') || document.querySelector('.subagent-dropdown.open');
|
||||
if (dropdown && !dropdown.classList.contains('pinned')) {
|
||||
dropdown.classList.remove('open');
|
||||
if (dropdown._originalParent) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* that replaces ~43 inline not-found checks across route handlers.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../types.js';
|
||||
@@ -18,6 +18,20 @@ import type { EventPort } from './ports/event-port.js';
|
||||
export const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
export const SETTINGS_PATH = join(homedir(), '.codeman', 'settings.json');
|
||||
|
||||
/**
|
||||
* Validates that a path component doesn't escape the base directory.
|
||||
* Returns the resolved full path, or null if the path is a traversal attempt.
|
||||
*/
|
||||
export function validatePathWithinBase(name: string, baseDir: string): string | null {
|
||||
const fullPath = resolve(join(baseDir, name));
|
||||
const resolvedBase = resolve(baseDir);
|
||||
const relPath = relative(resolvedBase, fullPath);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
return null;
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
// Maximum hook data size (prevents oversized SSE broadcasts)
|
||||
const MAX_HOOK_DATA_SIZE = 8 * 1024;
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR } from '../route-helpers.js';
|
||||
import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
|
||||
@@ -74,13 +74,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
const { name, description } = result.data;
|
||||
|
||||
const casePath = join(CASES_DIR, name);
|
||||
|
||||
// Security: Path traversal protection - use relative path check
|
||||
const resolvedPath = resolve(casePath);
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedPath);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
const casePath = validatePathWithinBase(name, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
|
||||
@@ -167,11 +162,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.get('/api/cases/:name', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
// Security: Path traversal protection
|
||||
const resolvedPath = resolve(join(CASES_DIR, name));
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedPath);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
@@ -210,11 +201,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
app.get('/api/cases/:name/fix-plan', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
|
||||
// Security: Path traversal protection
|
||||
const resolvedPath = resolve(join(CASES_DIR, name));
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedPath);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
if (!validatePathWithinBase(name, CASES_DIR)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
@@ -334,13 +321,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
|
||||
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||||
const { caseName } = req.params as { caseName: string };
|
||||
let casePath = join(CASES_DIR, caseName);
|
||||
|
||||
// Security: Path traversal protection - use relative path check
|
||||
const resolvedCase = resolve(casePath);
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedCase);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
let casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
@@ -394,21 +376,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// Cache disabled to ensure fresh prompts when starting new plan generations
|
||||
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
||||
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
|
||||
let casePath = join(CASES_DIR, caseName);
|
||||
let casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Prevent browser caching - prompts change between plan generations
|
||||
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
reply.header('Pragma', 'no-cache');
|
||||
reply.header('Expires', '0');
|
||||
|
||||
// Security: Path traversal protection for case name - use relative path check
|
||||
const resolvedCase = resolve(casePath);
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedCase);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
// Check linked cases if path doesn't exist
|
||||
if (!existsSync(casePath)) {
|
||||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
||||
|
||||
@@ -14,3 +14,4 @@ export { registerSessionRoutes } from './session-routes.js';
|
||||
export { registerRespawnRoutes } from './respawn-routes.js';
|
||||
export { registerRalphRoutes } from './ralph-routes.js';
|
||||
export { registerPlanRoutes } from './plan-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { Session } from '../../session.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
PlanTaskUpdateSchema,
|
||||
PlanTaskAddSchema,
|
||||
} from '../schemas.js';
|
||||
import { findSessionOrFail, CASES_DIR } from '../route-helpers.js';
|
||||
import { findSessionOrFail, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
@@ -232,12 +232,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Determine output directory for saving wizard results
|
||||
let outputDir: string | undefined;
|
||||
if (caseName) {
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
// Security: Path traversal protection - use relative path check
|
||||
const resolvedCase = resolve(casePath);
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedCase);
|
||||
if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) {
|
||||
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (casePath && existsSync(casePath)) {
|
||||
outputDir = join(casePath, 'ralph-wizard');
|
||||
|
||||
// Clear old ralph-wizard directory to ensure fresh prompts for each generation
|
||||
|
||||
@@ -505,7 +505,7 @@ export function registerRalphRoutes(
|
||||
settings.lastUsedCase = caseName;
|
||||
const dir = dirname(SETTINGS_PATH);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {});
|
||||
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
|
||||
@@ -11,7 +11,15 @@ import { SseEvent } from '../sse-events.js';
|
||||
import { findSessionOrFail, autoConfigureRalph } from '../route-helpers.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT, AI_PLAN_CHECK_MAX_CONTEXT } from '../../config/ai-defaults.js';
|
||||
import {
|
||||
AI_CHECK_MODEL,
|
||||
AI_IDLE_CHECK_MAX_CONTEXT,
|
||||
AI_PLAN_CHECK_MAX_CONTEXT,
|
||||
AI_IDLE_CHECK_TIMEOUT_MS,
|
||||
AI_IDLE_CHECK_COOLDOWN_MS,
|
||||
AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
} from '../../config/ai-defaults.js';
|
||||
|
||||
/** No-op EventPort used to suppress broadcasts during pre-start ralph configuration. */
|
||||
const noopEventPort: EventPort = {
|
||||
@@ -20,6 +28,7 @@ const noopEventPort: EventPort = {
|
||||
batchTerminalData: () => {},
|
||||
broadcastSessionStateDebounced: () => {},
|
||||
batchTaskUpdate: () => {},
|
||||
getSseClientCount: () => 0,
|
||||
};
|
||||
|
||||
export function registerRespawnRoutes(
|
||||
@@ -188,14 +197,18 @@ export function registerRespawnRoutes(
|
||||
aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? AI_CHECK_MODEL,
|
||||
aiIdleCheckMaxContext:
|
||||
config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? AI_IDLE_CHECK_MAX_CONTEXT,
|
||||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000,
|
||||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000,
|
||||
aiIdleCheckTimeoutMs:
|
||||
config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? AI_IDLE_CHECK_TIMEOUT_MS,
|
||||
aiIdleCheckCooldownMs:
|
||||
config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? AI_IDLE_CHECK_COOLDOWN_MS,
|
||||
aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true,
|
||||
aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? AI_CHECK_MODEL,
|
||||
aiPlanCheckMaxContext:
|
||||
config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? AI_PLAN_CHECK_MAX_CONTEXT,
|
||||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000,
|
||||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000,
|
||||
aiPlanCheckTimeoutMs:
|
||||
config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? AI_PLAN_CHECK_TIMEOUT_MS,
|
||||
aiPlanCheckCooldownMs:
|
||||
config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? AI_PLAN_CHECK_COOLDOWN_MS,
|
||||
durationMinutes: currentConfig?.durationMinutes,
|
||||
};
|
||||
ctx.mux.updateRespawnConfig(id, merged);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
QuickRunSchema,
|
||||
QuickStartSchema,
|
||||
} from '../schemas.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, validatePathWithinBase } from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -144,6 +144,7 @@ export function registerSessionRoutes(
|
||||
claudeMode: claudeModeConfig.claudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
resumeSessionId: body.resumeSessionId,
|
||||
});
|
||||
|
||||
ctx.addSession(session);
|
||||
@@ -788,13 +789,8 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const casePath = join(CASES_DIR, caseName);
|
||||
|
||||
// Security: Path traversal protection - use relative path check
|
||||
const resolvedPath = resolve(casePath);
|
||||
const resolvedBase = resolve(CASES_DIR);
|
||||
const relPath = relative(resolvedBase, resolvedPath);
|
||||
if (relPath.startsWith('..') || isAbsolute(relPath)) {
|
||||
const casePath = validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
|
||||
@@ -922,4 +918,91 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// History — list past Claude conversations for resume
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
app.get('/api/history/sessions', async () => {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const results: Array<{
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
projectKey: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const projPath = join(projectsDir, projDir);
|
||||
const stat = await fs.stat(projPath).catch(() => null);
|
||||
if (!stat?.isDirectory()) continue;
|
||||
|
||||
// Decode project key to working dir. The encoding replaces '/' with '-',
|
||||
// which is lossy when path components contain '-'. Do naive decode first,
|
||||
// then verify it exists. Fall back to HOME if the decoded path is invalid.
|
||||
const naiveDecode = projDir.replace(/^-/, '/').replace(/-/g, '/');
|
||||
const dirExists = await fs
|
||||
.access(naiveDecode)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const workingDir = dirExists ? naiveDecode : process.env.HOME || '/tmp';
|
||||
|
||||
const entries = await fs.readdir(projPath);
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.replace('.jsonl', '');
|
||||
// Only valid UUIDs
|
||||
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
|
||||
|
||||
const filePath = join(projPath, entry);
|
||||
const fileStat = await fs.stat(filePath).catch(() => null);
|
||||
if (!fileStat) continue;
|
||||
// Skip files too small to contain real conversation (metadata-only sessions
|
||||
// like file-history-snapshot entries are typically < 4KB)
|
||||
if (fileStat.size < 4000) continue;
|
||||
|
||||
// Quick content check: verify actual conversation data exists.
|
||||
// Sessions with only file-history-snapshot or hook_progress entries have
|
||||
// no "user"/"assistant" messages and will fail claude --resume.
|
||||
// Files > 50KB are almost certainly real conversations (skip the read).
|
||||
if (fileStat.size < 50000) {
|
||||
try {
|
||||
const fd = await fs.open(filePath, 'r');
|
||||
const buf = Buffer.alloc(16384);
|
||||
const { bytesRead } = await fd.read(buf, 0, 16384, 0);
|
||||
await fd.close();
|
||||
const head = buf.toString('utf8', 0, bytesRead);
|
||||
if (
|
||||
!head.includes('"type":"user"') &&
|
||||
!head.includes('"type":"assistant"') &&
|
||||
!head.includes('"type":"summary"')
|
||||
) {
|
||||
continue; // No conversation content — skip
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
sessionId,
|
||||
workingDir,
|
||||
projectKey: projDir,
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Projects dir may not exist
|
||||
}
|
||||
|
||||
// Sort by lastModified descending
|
||||
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
|
||||
return { sessions: results.slice(0, 50) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,6 +104,23 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
|
||||
|
||||
app.get('/api/tunnel/info', async () => {
|
||||
const status = ctx.tunnelManager.getStatus();
|
||||
const sseClients = ctx.getSseClientCount();
|
||||
const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = [];
|
||||
if (ctx.authSessions) {
|
||||
for (const [, record] of ctx.authSessions) {
|
||||
sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method });
|
||||
}
|
||||
}
|
||||
return {
|
||||
...status,
|
||||
sseClients,
|
||||
authEnabled: !!process.env.CODEMAN_PASSWORD,
|
||||
authSessions: sessions,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/tunnel/qr', async (_req, reply) => {
|
||||
const url = ctx.tunnelManager.getUrl();
|
||||
if (!url) {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @fileoverview WebSocket terminal I/O route.
|
||||
*
|
||||
* Provides a low-latency bidirectional channel for terminal input/output,
|
||||
* bypassing the HTTP POST + SSE path that adds per-request middleware overhead.
|
||||
* Auth is checked once on the WebSocket upgrade handshake (cookies are included
|
||||
* automatically by the browser). After upgrade, the connection is raw — no
|
||||
* per-message middleware processing.
|
||||
*
|
||||
* Additive: the existing HTTP POST /api/sessions/:id/input and SSE session:terminal
|
||||
* paths remain fully functional. The frontend opts into WS when available and
|
||||
* falls back transparently.
|
||||
*
|
||||
* Terminal output is micro-batched at 8ms to group Ink's rapid cursor-up redraws
|
||||
* into single frames, preventing flicker from split ANSI sequences. This matches
|
||||
* the SSE path's server-side batching (16-50ms) but at a shorter interval since
|
||||
* WS has no Traefik buffering overhead.
|
||||
*
|
||||
* Protocol (all JSON text frames):
|
||||
* Server -> Client:
|
||||
* {"t":"o","d":"..."} — terminal output
|
||||
* {"t":"c"} — clear terminal
|
||||
* {"t":"r"} — needs refresh (reload buffer)
|
||||
* Client -> Server:
|
||||
* {"t":"i","d":"..."} — input (keystroke or paste)
|
||||
* {"t":"z","c":N,"r":N} — resize terminal
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
|
||||
|
||||
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
|
||||
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
|
||||
const WS_BATCH_INTERVAL_MS = 8;
|
||||
|
||||
/** Flush immediately when batch exceeds this size (bytes) for responsiveness. */
|
||||
const WS_BATCH_FLUSH_THRESHOLD = 16384;
|
||||
|
||||
/** How often to ping each WebSocket client (ms). Detects stale connections that
|
||||
* TCP keepalive won't catch for minutes, especially through tunnels/proxies. */
|
||||
const WS_PING_INTERVAL_MS = 30_000;
|
||||
|
||||
/** If pong isn't received within this window after a ping, terminate the socket. */
|
||||
const WS_PONG_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** DEC 2026 synchronized update markers. Wrapping output in these tells xterm.js
|
||||
* to buffer all content and render atomically in a single frame — eliminates
|
||||
* flicker from cursor-up redraws that Ink sends without its own sync markers
|
||||
* (DA capability negotiation fails through the PTY→server→WS proxy chain). */
|
||||
const DEC_2026_START = '\x1b[?2026h';
|
||||
const DEC_2026_END = '\x1b[?2026l';
|
||||
|
||||
export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
app.get<{ Params: { id: string } }>('/ws/sessions/:id/terminal', { websocket: true }, (socket: WebSocket, req) => {
|
||||
const { id } = req.params;
|
||||
const session = ctx.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
socket.close(4004, 'Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-connection micro-batch state
|
||||
let batchChunks: string[] = [];
|
||||
let batchSize = 0;
|
||||
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flushBatch = () => {
|
||||
batchTimer = null;
|
||||
if (batchChunks.length === 0 || socket.readyState !== 1) {
|
||||
batchChunks = [];
|
||||
batchSize = 0;
|
||||
return;
|
||||
}
|
||||
const data = batchChunks.join('');
|
||||
batchChunks = [];
|
||||
batchSize = 0;
|
||||
socket.send(`{"t":"o","d":${JSON.stringify(DEC_2026_START + data + DEC_2026_END)}}`);
|
||||
};
|
||||
|
||||
// Attach message handler synchronously BEFORE any async work
|
||||
// (@fastify/websocket requirement to avoid dropped messages).
|
||||
socket.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||
session.write(msg.d);
|
||||
} else if (
|
||||
msg.t === 'z' &&
|
||||
Number.isInteger(msg.c) &&
|
||||
Number.isInteger(msg.r) &&
|
||||
msg.c >= 1 &&
|
||||
msg.c <= 500 &&
|
||||
msg.r >= 1 &&
|
||||
msg.r <= 200
|
||||
) {
|
||||
session.resize(msg.c, msg.r);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
});
|
||||
|
||||
// Terminal output -> micro-batched WS send
|
||||
const onTerminal = (data: string) => {
|
||||
batchChunks.push(data);
|
||||
batchSize += data.length;
|
||||
|
||||
// Flush immediately for large batches (responsiveness during bulk output)
|
||||
if (batchSize > WS_BATCH_FLUSH_THRESHOLD) {
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
}
|
||||
flushBatch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start timer if not already running
|
||||
if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBatch, WS_BATCH_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const onClearTerminal = () => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send('{"t":"c"}');
|
||||
}
|
||||
};
|
||||
|
||||
const onNeedsRefresh = () => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send('{"t":"r"}');
|
||||
}
|
||||
};
|
||||
|
||||
session.on('terminal', onTerminal);
|
||||
session.on('clearTerminal', onClearTerminal);
|
||||
session.on('needsRefresh', onNeedsRefresh);
|
||||
|
||||
// Heartbeat: detect stale connections (especially through tunnels where
|
||||
// TCP RST can take minutes to propagate).
|
||||
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let alive = true;
|
||||
|
||||
socket.on('pong', () => {
|
||||
alive = true;
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
if (!alive) {
|
||||
// Previous ping never got a pong — connection is dead
|
||||
socket.terminate();
|
||||
return;
|
||||
}
|
||||
alive = false;
|
||||
socket.ping();
|
||||
pongTimeout = setTimeout(() => {
|
||||
socket.terminate();
|
||||
}, WS_PONG_TIMEOUT_MS);
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
|
||||
socket.on('close', () => {
|
||||
clearInterval(pingInterval);
|
||||
if (pongTimeout) clearTimeout(pongTimeout);
|
||||
if (batchTimer) clearTimeout(batchTimer);
|
||||
batchChunks = [];
|
||||
session.off('terminal', onTerminal);
|
||||
session.off('clearTerminal', onClearTerminal);
|
||||
session.off('needsRefresh', onNeedsRefresh);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN } from '../utils/regex-patterns.js';
|
||||
import { SAFE_PATH_PATTERN } from '../utils/index.js';
|
||||
|
||||
// ========== Path Validation ==========
|
||||
|
||||
@@ -125,6 +125,12 @@ export const CreateSessionSchema = z.object({
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-f0-9-]+$/, 'resumeSessionId must be a valid UUID')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -315,6 +321,30 @@ export const SettingsUpdateSchema = z
|
||||
insertMode: z.string().max(20).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Run mode preference (cross-device sync)
|
||||
runMode: z.string().max(20).optional(),
|
||||
// Custom respawn presets (cross-device sync, replaces localStorage-only storage)
|
||||
respawnPresets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().max(100),
|
||||
name: z.string().max(100),
|
||||
config: z.object({
|
||||
idleTimeoutMs: z.number().optional(),
|
||||
updatePrompt: z.string().max(5000).optional(),
|
||||
interStepDelayMs: z.number().optional(),
|
||||
sendClear: z.boolean().optional(),
|
||||
sendInit: z.boolean().optional(),
|
||||
kickstartPrompt: z.string().max(5000).optional(),
|
||||
autoAcceptPrompts: z.boolean().optional(),
|
||||
}),
|
||||
durationMinutes: z.number().optional(),
|
||||
builtIn: z.boolean().optional(),
|
||||
createdAt: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.max(20)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import fastifyCompress from '@fastify/compress';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
registerRespawnRoutes,
|
||||
registerRalphRoutes,
|
||||
registerPlanRoutes,
|
||||
registerWsRoutes,
|
||||
} from './routes/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -120,15 +122,9 @@ import {
|
||||
ITERATION_PAUSE_MS,
|
||||
BATCH_FLUSH_THRESHOLD,
|
||||
STATS_COLLECTION_INTERVAL_MS,
|
||||
INACTIVITY_TIMEOUT_MS,
|
||||
} from '../config/server-timing.js';
|
||||
|
||||
// DEC mode 2026 - Synchronized Output
|
||||
// When terminal supports this, it buffers all output between start/end markers
|
||||
// and renders atomically, eliminating partial-frame flicker from Ink redraws.
|
||||
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
||||
const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
|
||||
const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
|
||||
|
||||
// SSE padding for Cloudflare tunnel buffer flushing.
|
||||
// Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events.
|
||||
// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
|
||||
@@ -210,7 +206,14 @@ export class WebServer extends EventEmitter {
|
||||
// Store session listener references for explicit cleanup (prevents memory leaks)
|
||||
private sessionListenerRefs: Map<string, SessionListenerRefs> = new Map();
|
||||
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||||
private sseClients: Set<FastifyReply> = new Set();
|
||||
/**
|
||||
* SSE clients mapped to their session subscription filter.
|
||||
* Value is a Set of session IDs the client wants events for,
|
||||
* or `null` meaning "receive all events" (backwards-compatible default).
|
||||
*/
|
||||
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
|
||||
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
|
||||
private remoteSseClients: Set<FastifyReply> = new Set();
|
||||
/** Clients with backpressure — skip writes until 'drain' fires */
|
||||
private backpressuredClients: Set<FastifyReply> = new Set();
|
||||
private store = getStore();
|
||||
@@ -225,7 +228,7 @@ export class WebServer extends EventEmitter {
|
||||
// Adaptive batching: track rapid events to extend batch window (per-session)
|
||||
// StaleExpirationMap auto-cleans entries for sessions that stop generating output
|
||||
private lastTerminalEventTime: StaleExpirationMap<string, number> = new StaleExpirationMap({
|
||||
ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data
|
||||
ttlMs: INACTIVITY_TIMEOUT_MS, // 5 minutes - auto-expire stale session timing data
|
||||
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
|
||||
});
|
||||
// Centralized cleanup for standalone timers (intervals + resettable timeouts)
|
||||
@@ -499,6 +502,7 @@ export class WebServer extends EventEmitter {
|
||||
batchTerminalData: this.batchTerminalData.bind(this),
|
||||
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
||||
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
||||
getSseClientCount: () => this.remoteSseClients.size,
|
||||
// RespawnPort
|
||||
respawnControllers: this.respawnControllers,
|
||||
respawnTimers: this.respawnTimers,
|
||||
@@ -561,6 +565,9 @@ export class WebServer extends EventEmitter {
|
||||
this.qrAuthFailures = authState.qrAuthFailures;
|
||||
}
|
||||
|
||||
// WebSocket support (terminal I/O — low-latency bidirectional channel)
|
||||
await this.app.register(fastifyWebsocket);
|
||||
|
||||
// Security headers + CORS
|
||||
registerSecurityHeaders(this.app, this.https);
|
||||
// Service worker must never be cached — browsers check for SW updates on navigation
|
||||
@@ -572,14 +579,22 @@ export class WebServer extends EventEmitter {
|
||||
.sendFile('sw.js', join(__dirname, 'public'));
|
||||
});
|
||||
|
||||
// Serve static files — versioned assets (?v=X) are immutable, cache aggressively
|
||||
// Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively.
|
||||
// HTML must revalidate every time so browsers pick up new hashed filenames after deploys.
|
||||
// cacheControl disabled so setHeaders has full control (fastify-static's reply.headers() overwrites setHeaders otherwise).
|
||||
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
||||
await this.app.register(fastifyStatic, {
|
||||
root: join(__dirname, 'public'),
|
||||
prefix: '/',
|
||||
maxAge: '1y',
|
||||
immutable: true,
|
||||
cacheControl: false,
|
||||
preCompressed: true,
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// SSE endpoint for real-time updates
|
||||
@@ -590,6 +605,21 @@ export class WebServer extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse optional session subscription filter from query parameter.
|
||||
// /api/events?sessions=id1,id2 — client only receives events for those sessions.
|
||||
// /api/events (no param) — client receives all events (backwards-compatible).
|
||||
const query = req.query as { sessions?: string };
|
||||
let sessionFilter: Set<string> | null = null;
|
||||
if (query.sessions) {
|
||||
const ids = query.sessions
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (ids.length > 0) {
|
||||
sessionFilter = new Set(ids);
|
||||
}
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
@@ -597,7 +627,13 @@ export class WebServer extends EventEmitter {
|
||||
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||||
});
|
||||
|
||||
this.sseClients.add(reply);
|
||||
this.sseClients.set(reply, sessionFilter);
|
||||
|
||||
// Track tunnel clients — cloudflared proxies locally so req.ip is always
|
||||
// 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
|
||||
if (req.headers['cf-connecting-ip']) {
|
||||
this.remoteSseClients.add(reply);
|
||||
}
|
||||
|
||||
// Send initial state
|
||||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||||
@@ -615,6 +651,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
req.raw.on('close', () => {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
});
|
||||
});
|
||||
@@ -630,6 +667,30 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
});
|
||||
|
||||
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
|
||||
let _crashBreadcrumbs = '';
|
||||
this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => {
|
||||
try {
|
||||
done(null, JSON.parse(body as string));
|
||||
} catch {
|
||||
done(null, { data: body });
|
||||
}
|
||||
});
|
||||
this.app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => {
|
||||
try {
|
||||
done(null, JSON.parse(body as string));
|
||||
} catch {
|
||||
done(null, { data: body });
|
||||
}
|
||||
});
|
||||
this.app.post('/api/crash-diag', (req, reply) => {
|
||||
_crashBreadcrumbs = String((req.body as { data?: string })?.data || '');
|
||||
reply.code(204).send();
|
||||
});
|
||||
this.app.get('/api/crash-diag', (_req, reply) => {
|
||||
reply.code(200).send({ breadcrumbs: _crashBreadcrumbs, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
// Register all route modules
|
||||
const ctx = this.createRouteContext();
|
||||
registerPushRoutes(this.app, ctx);
|
||||
@@ -644,6 +705,7 @@ export class WebServer extends EventEmitter {
|
||||
registerRespawnRoutes(this.app, ctx);
|
||||
registerRalphRoutes(this.app, ctx);
|
||||
registerPlanRoutes(this.app, ctx);
|
||||
registerWsRoutes(this.app, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1902,6 +1964,7 @@ export class WebServer extends EventEmitter {
|
||||
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1922,7 +1985,8 @@ export class WebServer extends EventEmitter {
|
||||
// Client may have missed terminal data during backpressure.
|
||||
// Tell it to reload the active session's buffer to recover.
|
||||
try {
|
||||
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n`);
|
||||
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
|
||||
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
@@ -1930,6 +1994,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
}
|
||||
}
|
||||
@@ -1948,9 +2013,12 @@ export class WebServer extends EventEmitter {
|
||||
this.cachedSessionsList = null;
|
||||
}
|
||||
// Performance optimization: serialize JSON once for all clients.
|
||||
// Only append Cloudflare tunnel padding when tunnel is actually active —
|
||||
// direct/Tailscale clients don't need 8KB padding on every event.
|
||||
const padding = this._isTunnelActive ? SSE_PADDING : '';
|
||||
// Only append Cloudflare tunnel padding for latency-sensitive events —
|
||||
// Recovery events need immediate proxy flush; low-frequency metadata events
|
||||
// (session:created, ralph:*, respawn:*, etc.) don't need padding.
|
||||
// Note: session:terminal has its own padding in flushSessionTerminalBatch().
|
||||
const needsPadding = this._isTunnelActive && event === SseEvent.SessionNeedsRefresh;
|
||||
const padding = needsPadding ? SSE_PADDING : '';
|
||||
let message: string;
|
||||
try {
|
||||
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding;
|
||||
@@ -1959,11 +2027,35 @@ export class WebServer extends EventEmitter {
|
||||
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
|
||||
return;
|
||||
}
|
||||
for (const client of this.sseClients) {
|
||||
// Extract sessionId from event data for subscription filtering.
|
||||
const eventSessionId = this.extractSessionId(event, data);
|
||||
|
||||
for (const [client, filter] of this.sseClients) {
|
||||
// No filter (null) = receive everything. Otherwise, skip if event is
|
||||
// session-scoped and the session isn't in the client's subscription set.
|
||||
if (filter && eventSessionId && !filter.has(eventSessionId)) continue;
|
||||
this.sendSSEPreformatted(client, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the session ID from an event's data payload for subscription filtering.
|
||||
* Returns the sessionId string if the event is session-scoped, or null for global events.
|
||||
*/
|
||||
private extractSessionId(event: string, data: unknown): string | null {
|
||||
if (data == null || typeof data !== 'object') return null;
|
||||
const record = data as Record<string, unknown>;
|
||||
|
||||
// Most session-scoped events use `sessionId`
|
||||
if (typeof record.sessionId === 'string') return record.sessionId;
|
||||
|
||||
// Session lifecycle events (session:*) use `id` from the session state object
|
||||
if (typeof record.id === 'string' && event.startsWith('session:')) return record.id;
|
||||
|
||||
// No session ID found — treat as global event (sent to all clients)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Batch terminal data for better performance (60fps)
|
||||
// Uses per-session timers with adaptive intervals to prevent thundering herd:
|
||||
// each session flushes independently rather than all sessions flushing in one burst.
|
||||
@@ -2034,16 +2126,20 @@ export class WebServer extends EventEmitter {
|
||||
if (chunks && chunks.length > 0) {
|
||||
// Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
|
||||
const data = chunks.join('');
|
||||
// Wrap with DEC mode 2026 synchronized output markers
|
||||
// Terminal buffers all output between markers and renders atomically,
|
||||
// eliminating partial-frame flicker from Ink's full-screen redraws.
|
||||
// Unsupported terminals ignore these sequences harmlessly.
|
||||
const syncData = DEC_SYNC_START + data + DEC_SYNC_END;
|
||||
// xterm.js 6.0+ handles DEC 2026 synchronized output natively.
|
||||
// Claude CLI (Ink) already emits its own DEC 2026 markers around redraws.
|
||||
// Do NOT add an outer wrapper — DEC 2026 is not reference-counted, so
|
||||
// the inner 2026l would prematurely exit sync mode, defeating the purpose.
|
||||
// Fast path: build SSE message directly without JSON.stringify on wrapper object.
|
||||
// Only the terminal data string needs escaping; sessionId is a UUID (safe to template).
|
||||
const escapedData = JSON.stringify(syncData);
|
||||
const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n`;
|
||||
for (const client of this.sseClients) {
|
||||
const escapedData = JSON.stringify(data);
|
||||
// Append tunnel padding for immediate Cloudflare proxy flush —
|
||||
// terminal data is high-frequency and latency-sensitive.
|
||||
const padding = this._isTunnelActive ? SSE_PADDING : '';
|
||||
const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n` + padding;
|
||||
for (const [client, filter] of this.sseClients) {
|
||||
// Skip clients that have a session filter and aren't subscribed to this session
|
||||
if (filter && !filter.has(sessionId)) continue;
|
||||
this.sendSSEPreformatted(client, message);
|
||||
}
|
||||
}
|
||||
@@ -2216,7 +2312,7 @@ export class WebServer extends EventEmitter {
|
||||
private cleanupDeadSSEClients(): void {
|
||||
const deadClients: FastifyReply[] = [];
|
||||
|
||||
for (const client of this.sseClients) {
|
||||
for (const [client] of this.sseClients) {
|
||||
try {
|
||||
// Check if the underlying socket is still writable
|
||||
const socket = client.raw.socket;
|
||||
@@ -2238,6 +2334,7 @@ export class WebServer extends EventEmitter {
|
||||
// Remove dead clients
|
||||
for (const client of deadClients) {
|
||||
this.sseClients.delete(client);
|
||||
this.remoteSseClients.delete(client);
|
||||
this.backpressuredClients.delete(client);
|
||||
}
|
||||
|
||||
@@ -2320,7 +2417,7 @@ export class WebServer extends EventEmitter {
|
||||
() => {
|
||||
this.recordPeriodicTokenUsage();
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
INACTIVITY_TIMEOUT_MS,
|
||||
{ description: 'periodic token recording' }
|
||||
);
|
||||
|
||||
@@ -2616,7 +2713,7 @@ export class WebServer extends EventEmitter {
|
||||
this.cleanup.dispose();
|
||||
|
||||
// Gracefully close all SSE connections before clearing
|
||||
for (const client of this.sseClients) {
|
||||
for (const [client] of this.sseClients) {
|
||||
try {
|
||||
// Send a final event to notify clients of shutdown
|
||||
this.sendSSE(client, 'server:shutdown', { reason: 'Server stopping' });
|
||||
@@ -2626,6 +2723,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
this.sseClients.clear();
|
||||
this.remoteSseClients.clear();
|
||||
this.backpressuredClients.clear();
|
||||
|
||||
// Clear per-session batch timers
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { BufferAccumulator } from '../src/utils/buffer-accumulator.js';
|
||||
|
||||
describe('BufferAccumulator', () => {
|
||||
describe('construction', () => {
|
||||
it('creates with simple size parameters', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
expect(buf.isEmpty).toBe(true);
|
||||
expect(buf.length).toBe(0);
|
||||
expect(buf.value).toBe('');
|
||||
});
|
||||
|
||||
it('creates with BufferConfig object', () => {
|
||||
const buf = new BufferAccumulator({ maxSize: 1000, trimSize: 800 });
|
||||
expect(buf.isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('creates with BufferConfig including onTrim', () => {
|
||||
const onTrim = vi.fn();
|
||||
const buf = new BufferAccumulator({ maxSize: 100, trimSize: 50, onTrim });
|
||||
buf.append('x'.repeat(101));
|
||||
expect(onTrim).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('append', () => {
|
||||
it('appends data and updates length', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('hello');
|
||||
expect(buf.length).toBe(5);
|
||||
expect(buf.isEmpty).toBe(false);
|
||||
buf.append(' world');
|
||||
expect(buf.length).toBe(11);
|
||||
expect(buf.value).toBe('hello world');
|
||||
});
|
||||
|
||||
it('ignores empty strings', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('');
|
||||
expect(buf.length).toBe(0);
|
||||
expect(buf.isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('handles multiple chunks efficiently', () => {
|
||||
const buf = new BufferAccumulator(10000, 8000);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
buf.append(`chunk${i}`);
|
||||
}
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
const value = buf.value;
|
||||
expect(value).toContain('chunk0');
|
||||
expect(value).toContain('chunk99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('value', () => {
|
||||
it('returns empty string when empty', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
expect(buf.value).toBe('');
|
||||
});
|
||||
|
||||
it('returns single chunk without joining', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('single');
|
||||
expect(buf.value).toBe('single');
|
||||
});
|
||||
|
||||
it('consolidates chunks on access', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('a');
|
||||
buf.append('b');
|
||||
buf.append('c');
|
||||
expect(buf.value).toBe('abc');
|
||||
// Access again should be same (consolidated)
|
||||
expect(buf.value).toBe('abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('resets buffer to empty', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('some data');
|
||||
buf.clear();
|
||||
expect(buf.isEmpty).toBe(true);
|
||||
expect(buf.length).toBe(0);
|
||||
expect(buf.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('replaces buffer content', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('old data');
|
||||
buf.set('new data');
|
||||
expect(buf.value).toBe('new data');
|
||||
expect(buf.length).toBe(8);
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('some data');
|
||||
buf.set('');
|
||||
expect(buf.isEmpty).toBe(true);
|
||||
expect(buf.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tail', () => {
|
||||
it('returns last N characters', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('hello world');
|
||||
expect(buf.tail(5)).toBe('world');
|
||||
});
|
||||
|
||||
it('returns entire buffer when N >= length', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('short');
|
||||
expect(buf.tail(100)).toBe('short');
|
||||
});
|
||||
|
||||
it('returns empty string on empty buffer', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
expect(buf.tail(5)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('endsWith', () => {
|
||||
it('returns true when buffer ends with suffix', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('hello world');
|
||||
expect(buf.endsWith('world')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when buffer does not end with suffix', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('hello world');
|
||||
expect(buf.endsWith('hello')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for empty suffix', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('hello');
|
||||
expect(buf.endsWith('')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when suffix is longer than buffer', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('hi');
|
||||
expect(buf.endsWith('hello world')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contains', () => {
|
||||
it('finds string pattern in buffer', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('hello world foo bar');
|
||||
expect(buf.contains('world')).toBe(true);
|
||||
expect(buf.contains('baz')).toBe(false);
|
||||
});
|
||||
|
||||
it('finds regex pattern in buffer', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('hello world 123');
|
||||
expect(buf.contains(/\d+/)).toBe(true);
|
||||
expect(buf.contains(/[A-Z]{3}/)).toBe(false);
|
||||
});
|
||||
|
||||
it('searches within last N characters when fromEnd provided', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('hello world');
|
||||
expect(buf.contains('hello', 5)).toBe(false); // last 5 = 'world'
|
||||
expect(buf.contains('world', 5)).toBe(true);
|
||||
});
|
||||
|
||||
it('regex search with fromEnd', () => {
|
||||
const buf = new BufferAccumulator(1000, 800);
|
||||
buf.append('abc 123 xyz');
|
||||
expect(buf.contains(/\d+/, 4)).toBe(false); // last 4 = ' xyz'
|
||||
expect(buf.contains(/\d+/, 8)).toBe(true); // last 8 = '123 xyz'
|
||||
});
|
||||
});
|
||||
|
||||
describe('trimming', () => {
|
||||
it('trims when maxSize is exceeded', () => {
|
||||
const buf = new BufferAccumulator(100, 50);
|
||||
buf.append('x'.repeat(60));
|
||||
buf.append('y'.repeat(50)); // total 110, triggers trim
|
||||
expect(buf.length).toBeLessThanOrEqual(100);
|
||||
// After trim, should keep most recent data
|
||||
expect(buf.value).toContain('y');
|
||||
});
|
||||
|
||||
it('calls onTrim callback with trimmed byte count', () => {
|
||||
const onTrim = vi.fn();
|
||||
const buf = new BufferAccumulator({ maxSize: 100, trimSize: 50, onTrim });
|
||||
buf.append('x'.repeat(101)); // triggers trim
|
||||
expect(onTrim).toHaveBeenCalledTimes(1);
|
||||
expect(onTrim).toHaveBeenCalledWith(expect.any(Number));
|
||||
const trimmedBytes = onTrim.mock.calls[0][0] as number;
|
||||
expect(trimmedBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps most recent data after trim', () => {
|
||||
const buf = new BufferAccumulator(100, 50);
|
||||
buf.append('A'.repeat(60));
|
||||
buf.append('B'.repeat(50)); // total 110, triggers trim to ~50 chars
|
||||
const value = buf.value;
|
||||
// The end should be B's
|
||||
expect(value.endsWith('B')).toBe(true);
|
||||
});
|
||||
|
||||
it('advances past first newline within 4KB to avoid mid-ANSI-escape', () => {
|
||||
const buf = new BufferAccumulator(100, 80);
|
||||
// Create content that when trimmed, has a newline near the start of the kept portion
|
||||
const prefix = 'A'.repeat(30);
|
||||
const newlineSection = 'X'.repeat(10) + '\n' + 'Y'.repeat(10);
|
||||
const suffix = 'B'.repeat(60);
|
||||
buf.append(prefix + newlineSection + suffix);
|
||||
// After trimming, the buffer should start after the first newline in the kept portion
|
||||
const value = buf.value;
|
||||
// Should not start with partial X's before the newline
|
||||
if (value.includes('\n')) {
|
||||
// If the newline fell within the kept window, it should be skipped
|
||||
expect(value.startsWith('X')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles trim with no newline in first 4KB', () => {
|
||||
const buf = new BufferAccumulator(100, 50);
|
||||
// No newlines at all
|
||||
buf.append('A'.repeat(110));
|
||||
// Should still trim without error
|
||||
expect(buf.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('does not call onTrim when trimmedBytes is 0 or negative', () => {
|
||||
const onTrim = vi.fn();
|
||||
// trimSize equal to maxSize means nothing actually gets trimmed in bytes
|
||||
// But this scenario is unusual; just test that onTrim is only called with positive values
|
||||
const buf = new BufferAccumulator({ maxSize: 50, trimSize: 50, onTrim });
|
||||
buf.append('x'.repeat(51)); // triggers trim, but trimSize == 50 so 1 byte trimmed
|
||||
// The trim logic: full.length(51) - trimSize(50) = 1 > 0, so onTrim IS called
|
||||
expect(onTrim).toHaveBeenCalledWith(expect.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('works with very small maxSize', () => {
|
||||
const buf = new BufferAccumulator(5, 3);
|
||||
buf.append('abcdef'); // 6 chars, triggers trim to 3
|
||||
expect(buf.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('handles rapid appends', () => {
|
||||
const buf = new BufferAccumulator(10000, 8000);
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
buf.append('data');
|
||||
}
|
||||
expect(buf.length).toBeLessThanOrEqual(10000);
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('set after trim works correctly', () => {
|
||||
const buf = new BufferAccumulator(100, 50);
|
||||
buf.append('x'.repeat(110)); // triggers trim
|
||||
buf.set('fresh start');
|
||||
expect(buf.value).toBe('fresh start');
|
||||
expect(buf.length).toBe(11);
|
||||
});
|
||||
|
||||
it('clear after trim works correctly', () => {
|
||||
const buf = new BufferAccumulator(100, 50);
|
||||
buf.append('x'.repeat(110)); // triggers trim
|
||||
buf.clear();
|
||||
expect(buf.isEmpty).toBe(true);
|
||||
expect(buf.value).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { LRUMap } from '../src/utils/lru-map.js';
|
||||
|
||||
describe('LRUMap', () => {
|
||||
describe('construction', () => {
|
||||
it('creates an empty map with maxSize', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 5 });
|
||||
expect(map.size).toBe(0);
|
||||
expect(map.maxEntries).toBe(5);
|
||||
expect(map.freeSlots).toBe(5);
|
||||
});
|
||||
|
||||
it('accepts an onEvict callback', () => {
|
||||
const onEvict = vi.fn();
|
||||
const map = new LRUMap<string, number>({ maxSize: 2, onEvict });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('c', 3); // evicts 'a'
|
||||
expect(onEvict).toHaveBeenCalledWith('a', 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('set/get', () => {
|
||||
it('stores and retrieves values', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
expect(map.get('a')).toBe(1);
|
||||
expect(map.get('b')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns undefined for missing keys', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
expect(map.get('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('overwrites existing key and refreshes position', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('a', 10); // refresh 'a' position
|
||||
expect(map.get('a')).toBe(10);
|
||||
expect(map.keysInOrder()).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('supports chaining on set', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
const result = map.set('a', 1).set('b', 2);
|
||||
expect(result).toBe(map);
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('has', () => {
|
||||
it('returns true for existing keys', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
expect(map.has('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for missing keys', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
expect(map.has('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not refresh position', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.has('a'); // should NOT refresh 'a'
|
||||
expect(map.keysInOrder()).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('removes an existing key', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
expect(map.delete('a')).toBe(true);
|
||||
expect(map.has('a')).toBe(false);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns false for non-existent key', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
expect(map.delete('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('updates newestKey when deleting the newest entry', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('c', 3);
|
||||
map.delete('c'); // delete newest
|
||||
expect(map.newest()).toEqual(['b', 2]);
|
||||
});
|
||||
|
||||
it('handles deleting the only entry', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
map.delete('a');
|
||||
expect(map.newest()).toBeUndefined();
|
||||
expect(map.oldest()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes all entries', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.clear();
|
||||
expect(map.size).toBe(0);
|
||||
expect(map.newest()).toBeUndefined();
|
||||
expect(map.oldest()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('eviction', () => {
|
||||
it('evicts oldest entry when exceeding maxSize', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 2 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('c', 3); // evicts 'a'
|
||||
expect(map.has('a')).toBe(false);
|
||||
expect(map.has('b')).toBe(true);
|
||||
expect(map.has('c')).toBe(true);
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
|
||||
it('evicts multiple entries to stay within maxSize', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 1 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2); // evicts 'a'
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.has('a')).toBe(false);
|
||||
expect(map.get('b')).toBe(2);
|
||||
});
|
||||
|
||||
it('calls onEvict for each evicted entry', () => {
|
||||
const onEvict = vi.fn();
|
||||
const map = new LRUMap<string, number>({ maxSize: 2, onEvict });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('c', 3); // evicts 'a'
|
||||
map.set('d', 4); // evicts 'b'
|
||||
expect(onEvict).toHaveBeenCalledTimes(2);
|
||||
expect(onEvict).toHaveBeenCalledWith('a', 1);
|
||||
expect(onEvict).toHaveBeenCalledWith('b', 2);
|
||||
});
|
||||
|
||||
it('get() refreshes position and changes eviction order', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 2 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.get('a'); // refresh 'a' — now 'b' is oldest
|
||||
map.set('c', 3); // evicts 'b', not 'a'
|
||||
expect(map.has('a')).toBe(true);
|
||||
expect(map.has('b')).toBe(false);
|
||||
expect(map.has('c')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('peek', () => {
|
||||
it('returns value without refreshing position', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 2 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
expect(map.peek('a')).toBe(1);
|
||||
// 'a' should still be oldest since peek doesn't refresh
|
||||
map.set('c', 3); // should evict 'a'
|
||||
expect(map.has('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns undefined for missing keys', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
expect(map.peek('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('oldest/newest', () => {
|
||||
it('returns undefined on empty map', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
expect(map.oldest()).toBeUndefined();
|
||||
expect(map.newest()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns correct oldest and newest', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('c', 3);
|
||||
expect(map.oldest()).toEqual(['a', 1]);
|
||||
expect(map.newest()).toEqual(['c', 3]);
|
||||
});
|
||||
|
||||
it('newest updates after get()', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.get('a'); // 'a' becomes newest
|
||||
expect(map.newest()).toEqual(['a', 1]);
|
||||
expect(map.oldest()).toEqual(['b', 2]);
|
||||
});
|
||||
|
||||
it('newest updates after set() overwrites', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('a', 10); // 'a' refreshed to newest
|
||||
expect(map.newest()).toEqual(['a', 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expireOlderThan', () => {
|
||||
it('removes entries older than cutoff', () => {
|
||||
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
|
||||
const now = Date.now();
|
||||
map.set('old1', { ts: now - 10000 });
|
||||
map.set('old2', { ts: now - 8000 });
|
||||
map.set('new1', { ts: now - 1000 });
|
||||
|
||||
const evicted = map.expireOlderThan(5000, (v) => v.ts);
|
||||
expect(evicted).toBe(2);
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.has('new1')).toBe(true);
|
||||
});
|
||||
|
||||
it('calls onEvict for expired entries', () => {
|
||||
const onEvict = vi.fn();
|
||||
const map = new LRUMap<string, { ts: number }>({ maxSize: 5, onEvict });
|
||||
const now = Date.now();
|
||||
map.set('old', { ts: now - 10000 });
|
||||
map.set('new', { ts: now - 100 });
|
||||
|
||||
map.expireOlderThan(5000, (v) => v.ts);
|
||||
expect(onEvict).toHaveBeenCalledTimes(1);
|
||||
expect(onEvict).toHaveBeenCalledWith('old', { ts: now - 10000 });
|
||||
});
|
||||
|
||||
it('returns 0 when nothing to expire', () => {
|
||||
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
|
||||
const now = Date.now();
|
||||
map.set('a', { ts: now });
|
||||
expect(map.expireOlderThan(5000, (v) => v.ts)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles empty map', () => {
|
||||
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
|
||||
expect(map.expireOlderThan(5000, (v) => v.ts)).toBe(0);
|
||||
});
|
||||
|
||||
it('updates newestKey when all entries expired', () => {
|
||||
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
|
||||
const now = Date.now();
|
||||
map.set('a', { ts: now - 20000 });
|
||||
map.set('b', { ts: now - 10000 });
|
||||
map.expireOlderThan(5000, (v) => v.ts);
|
||||
expect(map.newest()).toBeUndefined();
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keysInOrder/valuesInOrder', () => {
|
||||
it('returns keys from oldest to newest', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 5 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('c', 3);
|
||||
expect(map.keysInOrder()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('returns values from oldest to newest', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 5 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('c', 3);
|
||||
expect(map.valuesInOrder()).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('reflects refreshed order', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 5 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('c', 3);
|
||||
map.get('a'); // 'a' moves to end
|
||||
expect(map.keysInOrder()).toEqual(['b', 'c', 'a']);
|
||||
expect(map.valuesInOrder()).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
it('returns empty arrays for empty map', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 5 });
|
||||
expect(map.keysInOrder()).toEqual([]);
|
||||
expect(map.valuesInOrder()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxEntries/freeSlots', () => {
|
||||
it('freeSlots decreases as entries are added', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
expect(map.freeSlots).toBe(3);
|
||||
map.set('a', 1);
|
||||
expect(map.freeSlots).toBe(2);
|
||||
map.set('b', 2);
|
||||
expect(map.freeSlots).toBe(1);
|
||||
map.set('c', 3);
|
||||
expect(map.freeSlots).toBe(0);
|
||||
});
|
||||
|
||||
it('freeSlots does not go below 0 after eviction', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 2 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.set('c', 3); // evicts 'a'
|
||||
expect(map.freeSlots).toBe(0);
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('iteration', () => {
|
||||
it('iterates entries via for..of', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 3 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
const entries: [string, number][] = [];
|
||||
for (const [k, v] of map) {
|
||||
entries.push([k, v]);
|
||||
}
|
||||
expect(entries).toEqual([
|
||||
['a', 1],
|
||||
['b', 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('works with maxSize of 1', () => {
|
||||
const map = new LRUMap<string, number>({ maxSize: 1 });
|
||||
map.set('a', 1);
|
||||
expect(map.get('a')).toBe(1);
|
||||
map.set('b', 2);
|
||||
expect(map.has('a')).toBe(false);
|
||||
expect(map.get('b')).toBe(2);
|
||||
expect(map.size).toBe(1);
|
||||
});
|
||||
|
||||
it('handles non-string keys', () => {
|
||||
const map = new LRUMap<number, string>({ maxSize: 3 });
|
||||
map.set(1, 'one');
|
||||
map.set(2, 'two');
|
||||
expect(map.get(1)).toBe('one');
|
||||
// get(1) refreshes key 1, so order is now [2, 1]
|
||||
expect(map.keysInOrder()).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
batchTerminalData: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
batchTaskUpdate: vi.fn(),
|
||||
getSseClientCount: vi.fn(() => 0),
|
||||
|
||||
// -- RespawnPort --
|
||||
respawnControllers: new Map(),
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RespawnAdaptiveTiming, AdaptiveTimingConfig } from '../src/respawn-adaptive-timing.js';
|
||||
|
||||
describe('RespawnAdaptiveTiming', () => {
|
||||
let timing: RespawnAdaptiveTiming;
|
||||
const defaultConfig: AdaptiveTimingConfig = {
|
||||
adaptiveMinConfirmMs: 5000,
|
||||
adaptiveMaxConfirmMs: 30000,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
timing = new RespawnAdaptiveTiming(defaultConfig);
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should start with default 10000ms completion confirm', () => {
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000);
|
||||
});
|
||||
|
||||
it('should start with empty timing history', () => {
|
||||
const history = timing.getTimingHistory();
|
||||
expect(history.recentIdleDetectionMs).toEqual([]);
|
||||
expect(history.recentCycleDurationMs).toEqual([]);
|
||||
expect(history.sampleCount).toBe(0);
|
||||
expect(history.maxSamples).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordTimingData', () => {
|
||||
it('should add samples to rolling windows', () => {
|
||||
timing.recordTimingData(1000, 5000);
|
||||
|
||||
const history = timing.getTimingHistory();
|
||||
expect(history.recentIdleDetectionMs).toEqual([1000]);
|
||||
expect(history.recentCycleDurationMs).toEqual([5000]);
|
||||
expect(history.sampleCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should accumulate multiple samples', () => {
|
||||
timing.recordTimingData(1000, 5000);
|
||||
timing.recordTimingData(2000, 6000);
|
||||
timing.recordTimingData(3000, 7000);
|
||||
|
||||
const history = timing.getTimingHistory();
|
||||
expect(history.recentIdleDetectionMs).toEqual([1000, 2000, 3000]);
|
||||
expect(history.recentCycleDurationMs).toEqual([5000, 6000, 7000]);
|
||||
expect(history.sampleCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should trim to maxSamples when exceeded', () => {
|
||||
// Add 22 samples (exceeds maxSamples of 20)
|
||||
for (let i = 1; i <= 22; i++) {
|
||||
timing.recordTimingData(i * 100, i * 500);
|
||||
}
|
||||
|
||||
const history = timing.getTimingHistory();
|
||||
expect(history.recentIdleDetectionMs.length).toBe(20);
|
||||
expect(history.recentCycleDurationMs.length).toBe(20);
|
||||
// First two should have been shifted off
|
||||
expect(history.recentIdleDetectionMs[0]).toBe(300); // 3rd sample
|
||||
expect(history.recentCycleDurationMs[0]).toBe(1500);
|
||||
expect(history.sampleCount).toBe(20);
|
||||
});
|
||||
|
||||
it('should update lastUpdatedAt timestamp', () => {
|
||||
const before = Date.now();
|
||||
timing.recordTimingData(1000, 5000);
|
||||
const after = Date.now();
|
||||
|
||||
const history = timing.getTimingHistory();
|
||||
expect(history.lastUpdatedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(history.lastUpdatedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adaptive timing calculation', () => {
|
||||
it('should not recalculate with fewer than 5 samples', () => {
|
||||
// Add 4 samples -- below the threshold
|
||||
for (let i = 0; i < 4; i++) {
|
||||
timing.recordTimingData(8000, 20000);
|
||||
}
|
||||
|
||||
// Should still be the default
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000);
|
||||
});
|
||||
|
||||
it('should recalculate at exactly 5 samples', () => {
|
||||
// All the same value = easy to predict
|
||||
for (let i = 0; i < 5; i++) {
|
||||
timing.recordTimingData(10000, 20000);
|
||||
}
|
||||
|
||||
// P75 of [10000, 10000, 10000, 10000, 10000] = 10000
|
||||
// With 20% buffer = 12000, clamped to [5000, 30000] = 12000
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(12000);
|
||||
});
|
||||
|
||||
it('should use 75th percentile with 20% buffer', () => {
|
||||
// 5 sorted values: 1000, 2000, 3000, 4000, 5000
|
||||
timing.recordTimingData(3000, 10000);
|
||||
timing.recordTimingData(1000, 10000);
|
||||
timing.recordTimingData(5000, 10000);
|
||||
timing.recordTimingData(2000, 10000);
|
||||
timing.recordTimingData(4000, 10000);
|
||||
|
||||
// Sorted: [1000, 2000, 3000, 4000, 5000]
|
||||
// P75 index = floor(5 * 0.75) = 3 -> value = 4000
|
||||
// With 20% buffer: 4000 * 1.2 = 4800
|
||||
// Clamped to min 5000
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000);
|
||||
});
|
||||
|
||||
it('should clamp to minimum configured value', () => {
|
||||
// Very small idle detection times
|
||||
for (let i = 0; i < 5; i++) {
|
||||
timing.recordTimingData(1000, 5000);
|
||||
}
|
||||
|
||||
// P75 = 1000, with buffer = 1200, clamped to min 5000
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000);
|
||||
});
|
||||
|
||||
it('should clamp to maximum configured value', () => {
|
||||
// Very large idle detection times
|
||||
for (let i = 0; i < 5; i++) {
|
||||
timing.recordTimingData(50000, 100000);
|
||||
}
|
||||
|
||||
// P75 = 50000, with buffer = 60000, clamped to max 30000
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(30000);
|
||||
});
|
||||
|
||||
it('should handle varying values correctly', () => {
|
||||
// 10 values, spread out
|
||||
const values = [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000];
|
||||
for (const v of values) {
|
||||
timing.recordTimingData(v, v * 2);
|
||||
}
|
||||
|
||||
// Sorted: [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000]
|
||||
// P75 index = floor(10 * 0.75) = 7 -> value = 16000
|
||||
// With 20% buffer: 16000 * 1.2 = 19200
|
||||
// Clamped to [5000, 30000] = 19200
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(19200);
|
||||
});
|
||||
|
||||
it('should adapt as new data arrives', () => {
|
||||
// Start with small values
|
||||
for (let i = 0; i < 5; i++) {
|
||||
timing.recordTimingData(3000, 10000);
|
||||
}
|
||||
// P75 = 3000, buffer = 3600, clamped to min 5000
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000);
|
||||
|
||||
// Now add larger values that shift the P75 up
|
||||
for (let i = 0; i < 10; i++) {
|
||||
timing.recordTimingData(20000, 40000);
|
||||
}
|
||||
// P75 of mostly-20000 values = 20000, buffer = 24000
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(24000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimingHistory', () => {
|
||||
it('should return a new object each call (shallow copy)', () => {
|
||||
timing.recordTimingData(1000, 5000);
|
||||
const history1 = timing.getTimingHistory();
|
||||
const history2 = timing.getTimingHistory();
|
||||
|
||||
// Different object references each time
|
||||
expect(history1).not.toBe(history2);
|
||||
// But equal content
|
||||
expect(history1).toEqual(history2);
|
||||
});
|
||||
|
||||
it('should allow overwriting top-level fields on the copy without affecting source', () => {
|
||||
timing.recordTimingData(1000, 5000);
|
||||
const copy = timing.getTimingHistory();
|
||||
copy.sampleCount = 999;
|
||||
|
||||
expect(timing.getTimingHistory().sampleCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear all timing history', () => {
|
||||
// Add some data
|
||||
for (let i = 0; i < 10; i++) {
|
||||
timing.recordTimingData(i * 1000, i * 5000);
|
||||
}
|
||||
|
||||
timing.reset();
|
||||
|
||||
const history = timing.getTimingHistory();
|
||||
expect(history.recentIdleDetectionMs).toEqual([]);
|
||||
expect(history.recentCycleDurationMs).toEqual([]);
|
||||
expect(history.adaptiveCompletionConfirmMs).toBe(10000);
|
||||
expect(history.sampleCount).toBe(0);
|
||||
expect(history.maxSamples).toBe(20);
|
||||
});
|
||||
|
||||
it('should allow re-recording after reset', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
timing.recordTimingData(10000, 20000);
|
||||
}
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(12000);
|
||||
|
||||
timing.reset();
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000);
|
||||
|
||||
// Record new data -- below 5 samples, should stay at default
|
||||
timing.recordTimingData(5000, 10000);
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle zero idle detection times', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
timing.recordTimingData(0, 5000);
|
||||
}
|
||||
// P75 = 0, buffer = 0, clamped to min 5000
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(5000);
|
||||
});
|
||||
|
||||
it('should handle identical values', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
timing.recordTimingData(15000, 30000);
|
||||
}
|
||||
// P75 = 15000, buffer = 18000
|
||||
expect(timing.getAdaptiveCompletionConfirmMs()).toBe(18000);
|
||||
});
|
||||
|
||||
it('should handle config with very narrow bounds', () => {
|
||||
const narrowTiming = new RespawnAdaptiveTiming({
|
||||
adaptiveMinConfirmMs: 10000,
|
||||
adaptiveMaxConfirmMs: 10000,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
narrowTiming.recordTimingData(50000, 100000);
|
||||
}
|
||||
|
||||
// Always clamped to 10000 regardless of data
|
||||
expect(narrowTiming.getAdaptiveCompletionConfirmMs()).toBe(10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,517 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateHealthScore, shouldSkipClear, HealthInputs } from '../src/respawn-health.js';
|
||||
import type { RespawnAggregateMetrics, CircuitBreakerStatus } from '../src/types/index.js';
|
||||
|
||||
/**
|
||||
* Helper to create a default healthy HealthInputs object.
|
||||
* Override specific fields as needed.
|
||||
*/
|
||||
function createHealthInputs(overrides: Partial<HealthInputs> = {}): HealthInputs {
|
||||
return {
|
||||
aggregateMetrics: {
|
||||
totalCycles: 10,
|
||||
successfulCycles: 10,
|
||||
stuckRecoveryCycles: 0,
|
||||
blockedCycles: 0,
|
||||
errorCycles: 0,
|
||||
avgCycleDurationMs: 5000,
|
||||
avgIdleDetectionMs: 1000,
|
||||
p90CycleDurationMs: 8000,
|
||||
successRate: 100,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
circuitBreakerStatus: null,
|
||||
iterationStallMetrics: null,
|
||||
aiCheckerState: { status: 'ready', consecutiveErrors: 0 },
|
||||
stuckRecoveryCount: 0,
|
||||
maxStuckRecoveries: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createCircuitBreakerStatus(state: 'CLOSED' | 'HALF_OPEN' | 'OPEN'): CircuitBreakerStatus {
|
||||
return {
|
||||
state,
|
||||
consecutiveNoProgress: state === 'OPEN' ? 5 : state === 'HALF_OPEN' ? 2 : 0,
|
||||
consecutiveSameError: 0,
|
||||
consecutiveTestsFailure: 0,
|
||||
lastProgressIteration: 0,
|
||||
reason: `State: ${state}`,
|
||||
reasonCode: 'no_progress',
|
||||
lastTransitionAt: Date.now(),
|
||||
lastErrorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('calculateHealthScore', () => {
|
||||
describe('overall score calculation', () => {
|
||||
it('should return 100 for perfectly healthy system', () => {
|
||||
const result = calculateHealthScore(createHealthInputs());
|
||||
|
||||
expect(result.score).toBe(100);
|
||||
expect(result.status).toBe('excellent');
|
||||
expect(result.components.cycleSuccess).toBe(100);
|
||||
expect(result.components.circuitBreaker).toBe(100);
|
||||
expect(result.components.iterationProgress).toBe(100);
|
||||
expect(result.components.aiChecker).toBe(100);
|
||||
expect(result.components.stuckRecovery).toBe(100);
|
||||
});
|
||||
|
||||
it('should return excellent for score >= 90', () => {
|
||||
const result = calculateHealthScore(createHealthInputs());
|
||||
expect(result.score).toBeGreaterThanOrEqual(90);
|
||||
expect(result.status).toBe('excellent');
|
||||
});
|
||||
|
||||
it('should return good for score >= 70 and < 90', () => {
|
||||
// AI checker disabled = 30 (weight 0.15), everything else 100
|
||||
// Score = 100*0.35 + 100*0.2 + 100*0.2 + 30*0.15 + 100*0.1 = 35+20+20+4.5+10 = 89.5 -> 90
|
||||
// That's still excellent. Let's use circuitBreaker half-open for 50 (weight 0.2):
|
||||
// Score = 100*0.35 + 50*0.2 + 100*0.2 + 100*0.15 + 100*0.1 = 35+10+20+15+10 = 90
|
||||
// Still excellent. Use a lower cycleSuccess:
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
aggregateMetrics: {
|
||||
totalCycles: 10,
|
||||
successfulCycles: 8,
|
||||
stuckRecoveryCycles: 1,
|
||||
blockedCycles: 1,
|
||||
errorCycles: 0,
|
||||
avgCycleDurationMs: 5000,
|
||||
avgIdleDetectionMs: 1000,
|
||||
p90CycleDurationMs: 8000,
|
||||
successRate: 80,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN'),
|
||||
})
|
||||
);
|
||||
// cycleSuccess=80, circuitBreaker=50, iterationProgress=100, aiChecker=100, stuckRecovery=100
|
||||
// 80*0.35 + 50*0.2 + 100*0.2 + 100*0.15 + 100*0.1 = 28+10+20+15+10 = 83
|
||||
expect(result.score).toBe(83);
|
||||
expect(result.status).toBe('good');
|
||||
});
|
||||
|
||||
it('should return degraded for score >= 50 and < 70', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
aggregateMetrics: {
|
||||
totalCycles: 10,
|
||||
successfulCycles: 5,
|
||||
stuckRecoveryCycles: 2,
|
||||
blockedCycles: 2,
|
||||
errorCycles: 1,
|
||||
avgCycleDurationMs: 5000,
|
||||
avgIdleDetectionMs: 1000,
|
||||
p90CycleDurationMs: 8000,
|
||||
successRate: 50,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN'),
|
||||
aiCheckerState: { status: 'cooldown', consecutiveErrors: 0 },
|
||||
})
|
||||
);
|
||||
// cycleSuccess=50, circuitBreaker=50, iterationProgress=100, aiChecker=70, stuckRecovery=100
|
||||
// 50*0.35 + 50*0.2 + 100*0.2 + 70*0.15 + 100*0.1 = 17.5+10+20+10.5+10 = 68
|
||||
expect(result.score).toBe(68);
|
||||
expect(result.status).toBe('degraded');
|
||||
});
|
||||
|
||||
it('should return critical for score < 50', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
aggregateMetrics: {
|
||||
totalCycles: 10,
|
||||
successfulCycles: 0,
|
||||
stuckRecoveryCycles: 3,
|
||||
blockedCycles: 4,
|
||||
errorCycles: 3,
|
||||
avgCycleDurationMs: 5000,
|
||||
avgIdleDetectionMs: 1000,
|
||||
p90CycleDurationMs: 8000,
|
||||
successRate: 0,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
|
||||
iterationStallMetrics: {
|
||||
stallDurationMs: 600000,
|
||||
warningThresholdMs: 300000,
|
||||
criticalThresholdMs: 600000,
|
||||
},
|
||||
aiCheckerState: { status: 'error', consecutiveErrors: 3 },
|
||||
stuckRecoveryCount: 5,
|
||||
maxStuckRecoveries: 5,
|
||||
})
|
||||
);
|
||||
// cycleSuccess=0, circuitBreaker=0, iterationProgress=0, aiChecker=50, stuckRecovery=0
|
||||
// 0*0.35 + 0*0.2 + 0*0.2 + 50*0.15 + 0*0.1 = 7.5 -> 8
|
||||
expect(result.score).toBeLessThan(50);
|
||||
expect(result.status).toBe('critical');
|
||||
});
|
||||
});
|
||||
|
||||
describe('component: cycleSuccess', () => {
|
||||
it('should return 100 when no cycles have been tracked', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
aggregateMetrics: {
|
||||
totalCycles: 0,
|
||||
successfulCycles: 0,
|
||||
stuckRecoveryCycles: 0,
|
||||
blockedCycles: 0,
|
||||
errorCycles: 0,
|
||||
avgCycleDurationMs: 0,
|
||||
avgIdleDetectionMs: 0,
|
||||
p90CycleDurationMs: 0,
|
||||
successRate: 100,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.components.cycleSuccess).toBe(100);
|
||||
});
|
||||
|
||||
it('should use successRate from aggregate metrics', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
aggregateMetrics: {
|
||||
totalCycles: 20,
|
||||
successfulCycles: 15,
|
||||
stuckRecoveryCycles: 3,
|
||||
blockedCycles: 1,
|
||||
errorCycles: 1,
|
||||
avgCycleDurationMs: 5000,
|
||||
avgIdleDetectionMs: 1000,
|
||||
p90CycleDurationMs: 8000,
|
||||
successRate: 75,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.components.cycleSuccess).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('component: circuitBreaker', () => {
|
||||
it('should return 100 when circuit breaker is null', () => {
|
||||
const result = calculateHealthScore(createHealthInputs({ circuitBreakerStatus: null }));
|
||||
expect(result.components.circuitBreaker).toBe(100);
|
||||
});
|
||||
|
||||
it('should return 100 for CLOSED state', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('CLOSED') })
|
||||
);
|
||||
expect(result.components.circuitBreaker).toBe(100);
|
||||
});
|
||||
|
||||
it('should return 50 for HALF_OPEN state', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('HALF_OPEN') })
|
||||
);
|
||||
expect(result.components.circuitBreaker).toBe(50);
|
||||
});
|
||||
|
||||
it('should return 0 for OPEN state', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({ circuitBreakerStatus: createCircuitBreakerStatus('OPEN') })
|
||||
);
|
||||
expect(result.components.circuitBreaker).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('component: iterationProgress', () => {
|
||||
it('should return 100 when stall metrics are null', () => {
|
||||
const result = calculateHealthScore(createHealthInputs({ iterationStallMetrics: null }));
|
||||
expect(result.components.iterationProgress).toBe(100);
|
||||
});
|
||||
|
||||
it('should return 100 when stall duration is below half the warning threshold', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
iterationStallMetrics: {
|
||||
stallDurationMs: 10000,
|
||||
warningThresholdMs: 300000,
|
||||
criticalThresholdMs: 600000,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.components.iterationProgress).toBe(100);
|
||||
});
|
||||
|
||||
it('should return 70 when stall duration is at half the warning threshold', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
iterationStallMetrics: {
|
||||
stallDurationMs: 150000,
|
||||
warningThresholdMs: 300000,
|
||||
criticalThresholdMs: 600000,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.components.iterationProgress).toBe(70);
|
||||
});
|
||||
|
||||
it('should return 30 when stall duration hits warning threshold', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
iterationStallMetrics: {
|
||||
stallDurationMs: 300000,
|
||||
warningThresholdMs: 300000,
|
||||
criticalThresholdMs: 600000,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.components.iterationProgress).toBe(30);
|
||||
});
|
||||
|
||||
it('should return 0 when stall duration hits critical threshold', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
iterationStallMetrics: {
|
||||
stallDurationMs: 600000,
|
||||
warningThresholdMs: 300000,
|
||||
criticalThresholdMs: 600000,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.components.iterationProgress).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 when stall duration exceeds critical threshold', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
iterationStallMetrics: {
|
||||
stallDurationMs: 900000,
|
||||
warningThresholdMs: 300000,
|
||||
criticalThresholdMs: 600000,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.components.iterationProgress).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('component: aiChecker', () => {
|
||||
it('should return 100 for ready status with no errors', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({ aiCheckerState: { status: 'ready', consecutiveErrors: 0 } })
|
||||
);
|
||||
expect(result.components.aiChecker).toBe(100);
|
||||
});
|
||||
|
||||
it('should return 30 for disabled status', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({ aiCheckerState: { status: 'disabled', consecutiveErrors: 0 } })
|
||||
);
|
||||
expect(result.components.aiChecker).toBe(30);
|
||||
});
|
||||
|
||||
it('should return 70 for cooldown status', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({ aiCheckerState: { status: 'cooldown', consecutiveErrors: 0 } })
|
||||
);
|
||||
expect(result.components.aiChecker).toBe(70);
|
||||
});
|
||||
|
||||
it('should return 50 for ready status with consecutive errors', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({ aiCheckerState: { status: 'ready', consecutiveErrors: 2 } })
|
||||
);
|
||||
expect(result.components.aiChecker).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('component: stuckRecovery', () => {
|
||||
it('should return 100 when no stuck recoveries', () => {
|
||||
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 0, maxStuckRecoveries: 5 }));
|
||||
expect(result.components.stuckRecovery).toBe(100);
|
||||
});
|
||||
|
||||
it('should return 0 when stuck recoveries reach max', () => {
|
||||
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 5, maxStuckRecoveries: 5 }));
|
||||
expect(result.components.stuckRecovery).toBe(0);
|
||||
});
|
||||
|
||||
it('should return proportional score for partial recoveries', () => {
|
||||
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 2, maxStuckRecoveries: 5 }));
|
||||
// 100 - (2/5) * 100 = 60
|
||||
expect(result.components.stuckRecovery).toBe(60);
|
||||
});
|
||||
|
||||
it('should return 0 when stuck recoveries exceed max', () => {
|
||||
const result = calculateHealthScore(createHealthInputs({ stuckRecoveryCount: 10, maxStuckRecoveries: 5 }));
|
||||
expect(result.components.stuckRecovery).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recommendations', () => {
|
||||
it('should recommend no action for healthy system', () => {
|
||||
const result = calculateHealthScore(createHealthInputs());
|
||||
expect(result.recommendations).toEqual(['System is healthy. No action needed.']);
|
||||
});
|
||||
|
||||
it('should recommend checking cycle success when low', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
aggregateMetrics: {
|
||||
totalCycles: 10,
|
||||
successfulCycles: 5,
|
||||
stuckRecoveryCycles: 3,
|
||||
blockedCycles: 1,
|
||||
errorCycles: 1,
|
||||
avgCycleDurationMs: 5000,
|
||||
avgIdleDetectionMs: 1000,
|
||||
p90CycleDurationMs: 8000,
|
||||
successRate: 50,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.recommendations).toContainEqual(expect.stringContaining('Cycle success rate is low'));
|
||||
});
|
||||
|
||||
it('should recommend reviewing circuit breaker when open', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
|
||||
})
|
||||
);
|
||||
expect(result.recommendations).toContainEqual(expect.stringContaining('Circuit breaker is open'));
|
||||
});
|
||||
|
||||
it('should recommend checking iteration when stalled', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
iterationStallMetrics: {
|
||||
stallDurationMs: 400000,
|
||||
warningThresholdMs: 300000,
|
||||
criticalThresholdMs: 600000,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.recommendations).toContainEqual(expect.stringContaining('Iteration progress has stalled'));
|
||||
});
|
||||
|
||||
it('should recommend checking AI checker when disabled', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
aiCheckerState: { status: 'disabled', consecutiveErrors: 3 },
|
||||
})
|
||||
);
|
||||
// aiChecker score = 30 (disabled), which is < 50 -> triggers recommendation
|
||||
expect(result.recommendations).toContainEqual(expect.stringContaining('AI idle checker has errors'));
|
||||
});
|
||||
|
||||
it('should recommend adjusting timeouts when stuck recoveries are high', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
stuckRecoveryCount: 4,
|
||||
maxStuckRecoveries: 5,
|
||||
})
|
||||
);
|
||||
expect(result.recommendations).toContainEqual(expect.stringContaining('Multiple stuck-state recoveries'));
|
||||
});
|
||||
|
||||
it('should include multiple recommendations when multiple components are degraded', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
aggregateMetrics: {
|
||||
totalCycles: 10,
|
||||
successfulCycles: 3,
|
||||
stuckRecoveryCycles: 3,
|
||||
blockedCycles: 2,
|
||||
errorCycles: 2,
|
||||
avgCycleDurationMs: 5000,
|
||||
avgIdleDetectionMs: 1000,
|
||||
p90CycleDurationMs: 8000,
|
||||
successRate: 30,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
|
||||
stuckRecoveryCount: 4,
|
||||
maxStuckRecoveries: 5,
|
||||
})
|
||||
);
|
||||
expect(result.recommendations.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summary', () => {
|
||||
it('should include score in summary', () => {
|
||||
const result = calculateHealthScore(createHealthInputs());
|
||||
expect(result.summary).toContain('100/100');
|
||||
});
|
||||
|
||||
it('should describe excellent status', () => {
|
||||
const result = calculateHealthScore(createHealthInputs());
|
||||
expect(result.summary).toContain('excellently');
|
||||
});
|
||||
|
||||
it('should describe critical status with lowest component', () => {
|
||||
const result = calculateHealthScore(
|
||||
createHealthInputs({
|
||||
aggregateMetrics: {
|
||||
totalCycles: 10,
|
||||
successfulCycles: 0,
|
||||
stuckRecoveryCycles: 5,
|
||||
blockedCycles: 3,
|
||||
errorCycles: 2,
|
||||
avgCycleDurationMs: 5000,
|
||||
avgIdleDetectionMs: 1000,
|
||||
p90CycleDurationMs: 8000,
|
||||
successRate: 0,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
circuitBreakerStatus: createCircuitBreakerStatus('OPEN'),
|
||||
})
|
||||
);
|
||||
expect(result.summary).toContain('critical');
|
||||
});
|
||||
|
||||
it('should include calculatedAt timestamp', () => {
|
||||
const before = Date.now();
|
||||
const result = calculateHealthScore(createHealthInputs());
|
||||
const after = Date.now();
|
||||
|
||||
expect(result.calculatedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(result.calculatedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSkipClear', () => {
|
||||
it('should not skip when token count is 0 (unknown)', () => {
|
||||
expect(shouldSkipClear(0, 50, 200000)).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip when usage is below threshold', () => {
|
||||
// 10000 / 200000 = 5% < 50%
|
||||
expect(shouldSkipClear(10000, 50, 200000)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not skip when usage is above threshold', () => {
|
||||
// 150000 / 200000 = 75% > 50%
|
||||
expect(shouldSkipClear(150000, 50, 200000)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not skip when usage equals threshold', () => {
|
||||
// 100000 / 200000 = 50% == 50% -> not less than, so false
|
||||
expect(shouldSkipClear(100000, 50, 200000)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle edge case of very small threshold', () => {
|
||||
// 1000 / 200000 = 0.5% < 1%
|
||||
expect(shouldSkipClear(1000, 1, 200000)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 100% threshold (always skip)', () => {
|
||||
// 190000 / 200000 = 95% < 100%
|
||||
expect(shouldSkipClear(190000, 100, 200000)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle 0% threshold (never skip except when unknown)', () => {
|
||||
// Any positive count / total > 0% so not < 0%
|
||||
expect(shouldSkipClear(1, 0, 200000)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RespawnCycleMetricsTracker } from '../src/respawn-metrics.js';
|
||||
import type { CycleOutcome } from '../src/types/index.js';
|
||||
|
||||
describe('RespawnCycleMetricsTracker', () => {
|
||||
let tracker: RespawnCycleMetricsTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = new RespawnCycleMetricsTracker();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have no current cycle', () => {
|
||||
expect(tracker.getCurrentCycle()).toBeNull();
|
||||
});
|
||||
|
||||
it('should have empty recent metrics', () => {
|
||||
expect(tracker.getRecent()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have zeroed aggregate metrics', () => {
|
||||
const agg = tracker.getAggregate();
|
||||
expect(agg.totalCycles).toBe(0);
|
||||
expect(agg.successfulCycles).toBe(0);
|
||||
expect(agg.stuckRecoveryCycles).toBe(0);
|
||||
expect(agg.blockedCycles).toBe(0);
|
||||
expect(agg.errorCycles).toBe(0);
|
||||
expect(agg.avgCycleDurationMs).toBe(0);
|
||||
expect(agg.avgIdleDetectionMs).toBe(0);
|
||||
expect(agg.p90CycleDurationMs).toBe(0);
|
||||
expect(agg.successRate).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startCycle', () => {
|
||||
it('should create a current cycle with correct fields', () => {
|
||||
const idleDetectionStartTime = Date.now() - 1500;
|
||||
tracker.startCycle('session-1', 1, 'completion_message', idleDetectionStartTime, 50000, 10000);
|
||||
|
||||
const cycle = tracker.getCurrentCycle();
|
||||
expect(cycle).not.toBeNull();
|
||||
expect(cycle!.cycleId).toBe('session-1:1');
|
||||
expect(cycle!.sessionId).toBe('session-1');
|
||||
expect(cycle!.cycleNumber).toBe(1);
|
||||
expect(cycle!.idleReason).toBe('completion_message');
|
||||
expect(cycle!.stepsCompleted).toEqual([]);
|
||||
expect(cycle!.clearSkipped).toBe(false);
|
||||
expect(cycle!.tokenCountAtStart).toBe(50000);
|
||||
expect(cycle!.completionConfirmMsUsed).toBe(10000);
|
||||
// idleDetectionMs should be approximately 1500 (within margin)
|
||||
expect(cycle!.idleDetectionMs).toBeGreaterThanOrEqual(1400);
|
||||
expect(cycle!.idleDetectionMs).toBeLessThanOrEqual(2000);
|
||||
});
|
||||
|
||||
it('should overwrite a previous current cycle', () => {
|
||||
tracker.startCycle('session-1', 1, 'reason1', Date.now(), 10000, 5000);
|
||||
tracker.startCycle('session-1', 2, 'reason2', Date.now(), 20000, 6000);
|
||||
|
||||
const cycle = tracker.getCurrentCycle();
|
||||
expect(cycle!.cycleNumber).toBe(2);
|
||||
expect(cycle!.idleReason).toBe('reason2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordStep', () => {
|
||||
it('should add steps to current cycle', () => {
|
||||
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
|
||||
|
||||
tracker.recordStep('update');
|
||||
tracker.recordStep('clear');
|
||||
tracker.recordStep('init');
|
||||
|
||||
const cycle = tracker.getCurrentCycle();
|
||||
expect(cycle!.stepsCompleted).toEqual(['update', 'clear', 'init']);
|
||||
});
|
||||
|
||||
it('should be a no-op when no cycle is in progress', () => {
|
||||
// Should not throw
|
||||
tracker.recordStep('update');
|
||||
expect(tracker.getCurrentCycle()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markClearSkipped', () => {
|
||||
it('should set clearSkipped flag on current cycle', () => {
|
||||
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
|
||||
|
||||
tracker.markClearSkipped();
|
||||
|
||||
const cycle = tracker.getCurrentCycle();
|
||||
expect(cycle!.clearSkipped).toBe(true);
|
||||
});
|
||||
|
||||
it('should be a no-op when no cycle is in progress', () => {
|
||||
// Should not throw
|
||||
tracker.markClearSkipped();
|
||||
expect(tracker.getCurrentCycle()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeCycle', () => {
|
||||
it('should return null when no cycle is in progress', () => {
|
||||
const result = tracker.completeCycle('success', 60000);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return completed metrics with correct fields', () => {
|
||||
tracker.startCycle('session-1', 1, 'completion_message', Date.now(), 50000, 10000);
|
||||
tracker.recordStep('update');
|
||||
tracker.recordStep('clear');
|
||||
|
||||
const result = tracker.completeCycle('success', 60000);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.outcome).toBe('success');
|
||||
expect(result!.tokenCountAtEnd).toBe(60000);
|
||||
expect(result!.completedAt).toBeGreaterThan(0);
|
||||
expect(result!.durationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(result!.stepsCompleted).toEqual(['update', 'clear']);
|
||||
});
|
||||
|
||||
it('should include error message for error outcome', () => {
|
||||
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
|
||||
|
||||
const result = tracker.completeCycle('error', 0, 'Something went wrong');
|
||||
|
||||
expect(result!.outcome).toBe('error');
|
||||
expect(result!.errorMessage).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('should clear current cycle after completion', () => {
|
||||
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
|
||||
tracker.completeCycle('success', 0);
|
||||
|
||||
expect(tracker.getCurrentCycle()).toBeNull();
|
||||
});
|
||||
|
||||
it('should add to recent metrics', () => {
|
||||
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
|
||||
tracker.completeCycle('success', 0);
|
||||
|
||||
const recent = tracker.getRecent();
|
||||
expect(recent.length).toBe(1);
|
||||
expect(recent[0].outcome).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate metrics updates', () => {
|
||||
function completeOneCycle(outcome: CycleOutcome): void {
|
||||
tracker.startCycle('session-1', tracker.getAggregate().totalCycles + 1, 'test', Date.now(), 0, 10000);
|
||||
tracker.completeCycle(outcome, 0);
|
||||
}
|
||||
|
||||
it('should count successful cycles', () => {
|
||||
completeOneCycle('success');
|
||||
completeOneCycle('success');
|
||||
|
||||
const agg = tracker.getAggregate();
|
||||
expect(agg.totalCycles).toBe(2);
|
||||
expect(agg.successfulCycles).toBe(2);
|
||||
expect(agg.successRate).toBe(100);
|
||||
});
|
||||
|
||||
it('should count stuck_recovery cycles', () => {
|
||||
completeOneCycle('stuck_recovery');
|
||||
|
||||
const agg = tracker.getAggregate();
|
||||
expect(agg.totalCycles).toBe(1);
|
||||
expect(agg.stuckRecoveryCycles).toBe(1);
|
||||
expect(agg.successRate).toBe(0);
|
||||
});
|
||||
|
||||
it('should count blocked cycles', () => {
|
||||
completeOneCycle('blocked');
|
||||
|
||||
const agg = tracker.getAggregate();
|
||||
expect(agg.totalCycles).toBe(1);
|
||||
expect(agg.blockedCycles).toBe(1);
|
||||
});
|
||||
|
||||
it('should count error cycles', () => {
|
||||
completeOneCycle('error');
|
||||
|
||||
const agg = tracker.getAggregate();
|
||||
expect(agg.totalCycles).toBe(1);
|
||||
expect(agg.errorCycles).toBe(1);
|
||||
});
|
||||
|
||||
it('should count cancelled cycles in total but no specific category', () => {
|
||||
completeOneCycle('cancelled');
|
||||
|
||||
const agg = tracker.getAggregate();
|
||||
expect(agg.totalCycles).toBe(1);
|
||||
expect(agg.successfulCycles).toBe(0);
|
||||
expect(agg.stuckRecoveryCycles).toBe(0);
|
||||
expect(agg.blockedCycles).toBe(0);
|
||||
expect(agg.errorCycles).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate success rate correctly', () => {
|
||||
completeOneCycle('success');
|
||||
completeOneCycle('success');
|
||||
completeOneCycle('error');
|
||||
completeOneCycle('success');
|
||||
|
||||
const agg = tracker.getAggregate();
|
||||
expect(agg.totalCycles).toBe(4);
|
||||
expect(agg.successfulCycles).toBe(3);
|
||||
expect(agg.successRate).toBe(75);
|
||||
});
|
||||
|
||||
it('should update lastUpdatedAt timestamp', () => {
|
||||
const before = Date.now();
|
||||
completeOneCycle('success');
|
||||
const after = Date.now();
|
||||
|
||||
const agg = tracker.getAggregate();
|
||||
expect(agg.lastUpdatedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(agg.lastUpdatedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecent', () => {
|
||||
it('should return newest first', () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
tracker.startCycle('session-1', i, `reason-${i}`, Date.now(), 0, 10000);
|
||||
tracker.completeCycle('success', 0);
|
||||
}
|
||||
|
||||
const recent = tracker.getRecent();
|
||||
expect(recent.length).toBe(5);
|
||||
expect(recent[0].cycleNumber).toBe(5);
|
||||
expect(recent[4].cycleNumber).toBe(1);
|
||||
});
|
||||
|
||||
it('should respect limit parameter', () => {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000);
|
||||
tracker.completeCycle('success', 0);
|
||||
}
|
||||
|
||||
const recent = tracker.getRecent(3);
|
||||
expect(recent.length).toBe(3);
|
||||
expect(recent[0].cycleNumber).toBe(10);
|
||||
});
|
||||
|
||||
it('should default to 20 items', () => {
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000);
|
||||
tracker.completeCycle('success', 0);
|
||||
}
|
||||
|
||||
const recent = tracker.getRecent();
|
||||
expect(recent.length).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory limit (MAX_CYCLE_METRICS_IN_MEMORY)', () => {
|
||||
it('should trim recent metrics beyond 100 entries', () => {
|
||||
for (let i = 1; i <= 105; i++) {
|
||||
tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000);
|
||||
tracker.completeCycle('success', 0);
|
||||
}
|
||||
|
||||
// Internal storage should be capped at 100
|
||||
// We can verify via getRecent with a high limit
|
||||
const recent = tracker.getRecent(200);
|
||||
expect(recent.length).toBe(100);
|
||||
// The earliest should be cycle 6 (first 5 were shifted off)
|
||||
expect(recent[recent.length - 1].cycleNumber).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAggregate', () => {
|
||||
it('should return a copy, not a reference', () => {
|
||||
const agg1 = tracker.getAggregate();
|
||||
const agg2 = tracker.getAggregate();
|
||||
|
||||
expect(agg1).not.toBe(agg2);
|
||||
expect(agg1).toEqual(agg2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear all state', () => {
|
||||
// Add some cycles
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000);
|
||||
tracker.completeCycle('success', 0);
|
||||
}
|
||||
|
||||
// Start another cycle
|
||||
tracker.startCycle('session-1', 6, 'test', Date.now(), 0, 10000);
|
||||
|
||||
tracker.reset();
|
||||
|
||||
expect(tracker.getCurrentCycle()).toBeNull();
|
||||
expect(tracker.getRecent()).toEqual([]);
|
||||
|
||||
const agg = tracker.getAggregate();
|
||||
expect(agg.totalCycles).toBe(0);
|
||||
expect(agg.successfulCycles).toBe(0);
|
||||
expect(agg.successRate).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate averages and P90', () => {
|
||||
it('should calculate average cycle duration across recent cycles', () => {
|
||||
// We can't directly control durationMs since it's computed from Date.now(),
|
||||
// but we can verify the aggregate fields are populated after completing cycles
|
||||
tracker.startCycle('session-1', 1, 'test', Date.now(), 0, 10000);
|
||||
tracker.completeCycle('success', 0);
|
||||
|
||||
const agg = tracker.getAggregate();
|
||||
// durationMs should be very small (within the same tick)
|
||||
expect(agg.avgCycleDurationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(agg.p90CycleDurationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should calculate P90 from sorted durations', () => {
|
||||
// Complete multiple cycles to populate P90
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
tracker.startCycle('session-1', i, 'test', Date.now(), 0, 10000);
|
||||
tracker.completeCycle('success', 0);
|
||||
}
|
||||
|
||||
const agg = tracker.getAggregate();
|
||||
// P90 should be >= avg (for uniform values, P90 ~= avg)
|
||||
expect(agg.p90CycleDurationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isCompletionMessage,
|
||||
hasWorkingPattern,
|
||||
extractTokenCount,
|
||||
PROMPT_PATTERNS,
|
||||
WORKING_PATTERNS,
|
||||
} from '../src/respawn-patterns.js';
|
||||
|
||||
describe('isCompletionMessage', () => {
|
||||
describe('valid completion messages', () => {
|
||||
it('should match "Worked for 2m 46s"', () => {
|
||||
expect(isCompletionMessage('Worked for 2m 46s')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match "Worked for 46s"', () => {
|
||||
expect(isCompletionMessage('Worked for 46s')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match "Worked for 1h 2m 3s"', () => {
|
||||
expect(isCompletionMessage('Worked for 1h 2m 3s')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match "Worked for 5m"', () => {
|
||||
expect(isCompletionMessage('Worked for 5m')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match "Worked for 2h"', () => {
|
||||
expect(isCompletionMessage('Worked for 2h')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match case-insensitively', () => {
|
||||
expect(isCompletionMessage('worked for 10s')).toBe(true);
|
||||
expect(isCompletionMessage('WORKED FOR 10s')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match when embedded in larger string', () => {
|
||||
expect(isCompletionMessage('Some prefix text Worked for 5m 30s and more text')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when ANSI escape codes break the word boundary', () => {
|
||||
// The regex uses \b word boundary, so ANSI codes right before "Worked"
|
||||
// break the match. In practice, data is ANSI-stripped before reaching this function.
|
||||
expect(isCompletionMessage('\x1b[32mWorked for 3m 15s\x1b[0m')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match ANSI-stripped output', () => {
|
||||
// After stripping ANSI codes, the pattern works fine
|
||||
expect(isCompletionMessage('Worked for 3m 15s')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid patterns (should not match)', () => {
|
||||
it('should not match "wait for 5s"', () => {
|
||||
expect(isCompletionMessage('wait for 5s')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match "run for 2m"', () => {
|
||||
expect(isCompletionMessage('run for 2m')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match "for 3s the system..."', () => {
|
||||
expect(isCompletionMessage('for 3s the system responded')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match bare time durations', () => {
|
||||
expect(isCompletionMessage('2m 46s')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match empty string', () => {
|
||||
expect(isCompletionMessage('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match "Worked" without time pattern', () => {
|
||||
expect(isCompletionMessage('Worked on the task')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match "Worked for" without time', () => {
|
||||
expect(isCompletionMessage('Worked for a long time')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasWorkingPattern', () => {
|
||||
describe('text working indicators', () => {
|
||||
it('should detect "Thinking" in window', () => {
|
||||
expect(hasWorkingPattern('Claude is Thinking about your request')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "Writing" in window', () => {
|
||||
expect(hasWorkingPattern('Writing to file src/main.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "Reading" in window', () => {
|
||||
expect(hasWorkingPattern('Reading file contents')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "Running" in window', () => {
|
||||
expect(hasWorkingPattern('Running npm install')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "Searching" in window', () => {
|
||||
expect(hasWorkingPattern('Searching for patterns')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "Editing" in window', () => {
|
||||
expect(hasWorkingPattern('Editing src/utils.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect all defined text patterns', () => {
|
||||
const textPatterns = WORKING_PATTERNS.filter((p) => p.length > 2);
|
||||
for (const pattern of textPatterns) {
|
||||
expect(hasWorkingPattern(`Some text with ${pattern} in it`)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spinner characters', () => {
|
||||
it('should detect braille spinner characters', () => {
|
||||
expect(hasWorkingPattern('Loading... \u280B')).toBe(true);
|
||||
expect(hasWorkingPattern('\u2839 processing')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect circle spinner characters', () => {
|
||||
expect(hasWorkingPattern('\u25D0 working')).toBe(true);
|
||||
expect(hasWorkingPattern('status: \u25D3')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect braille block spinners', () => {
|
||||
expect(hasWorkingPattern('\u28FE loading')).toBe(true); // ⣾
|
||||
expect(hasWorkingPattern('\u28FD processing')).toBe(true); // ⣽
|
||||
});
|
||||
});
|
||||
|
||||
describe('negative cases', () => {
|
||||
it('should return false for empty string', () => {
|
||||
expect(hasWorkingPattern('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for prompt characters only', () => {
|
||||
expect(hasWorkingPattern('\u276F ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for regular output text', () => {
|
||||
expect(hasWorkingPattern('Here is the result of the computation')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for completion messages', () => {
|
||||
expect(hasWorkingPattern('Worked for 2m 46s')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTokenCount', () => {
|
||||
describe('basic token patterns', () => {
|
||||
it('should extract plain number tokens', () => {
|
||||
expect(extractTokenCount('500 tokens')).toBe(500);
|
||||
});
|
||||
|
||||
it('should extract decimal number tokens', () => {
|
||||
expect(extractTokenCount('123.4 tokens')).toBe(123);
|
||||
});
|
||||
|
||||
it('should extract k suffix (thousands)', () => {
|
||||
expect(extractTokenCount('123.4k tokens')).toBe(123400);
|
||||
});
|
||||
|
||||
it('should extract K suffix (uppercase)', () => {
|
||||
expect(extractTokenCount('50K tokens')).toBe(50000);
|
||||
});
|
||||
|
||||
it('should extract m suffix (millions)', () => {
|
||||
expect(extractTokenCount('1.5m tokens')).toBe(1500000);
|
||||
});
|
||||
|
||||
it('should extract M suffix (uppercase)', () => {
|
||||
expect(extractTokenCount('2M tokens')).toBe(2000000);
|
||||
});
|
||||
|
||||
it('should handle whitespace between number and suffix', () => {
|
||||
expect(extractTokenCount('123.4 k tokens')).toBe(123400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedded in text', () => {
|
||||
it('should extract from terminal output', () => {
|
||||
expect(extractTokenCount('Current usage: 45.2k tokens remaining')).toBe(45200);
|
||||
});
|
||||
|
||||
it('should extract from completion message', () => {
|
||||
expect(extractTokenCount('Worked for 2m 46s | 150.3k tokens used')).toBe(150300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('negative cases', () => {
|
||||
it('should return null for empty string', () => {
|
||||
expect(extractTokenCount('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for string without token pattern', () => {
|
||||
expect(extractTokenCount('Hello world')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for "tokens" without a number', () => {
|
||||
expect(extractTokenCount('many tokens')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rounding', () => {
|
||||
it('should round to nearest integer', () => {
|
||||
// 1.7k = 1700 (exact, no rounding needed)
|
||||
expect(extractTokenCount('1.7k tokens')).toBe(1700);
|
||||
});
|
||||
|
||||
it('should round fractional results', () => {
|
||||
// 1.23456k = 1234.56 -> rounds to 1235
|
||||
expect(extractTokenCount('1.23456k tokens')).toBe(1235);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROMPT_PATTERNS', () => {
|
||||
it('should contain standard prompt characters', () => {
|
||||
expect(PROMPT_PATTERNS).toContain('\u276F'); // Unicode right-pointing angle
|
||||
expect(PROMPT_PATTERNS).toContain('\u23F5'); // Play button variant
|
||||
});
|
||||
|
||||
it('should be a non-empty array', () => {
|
||||
expect(PROMPT_PATTERNS.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WORKING_PATTERNS', () => {
|
||||
it('should be a non-empty array', () => {
|
||||
expect(WORKING_PATTERNS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should contain common Claude activity indicators', () => {
|
||||
expect(WORKING_PATTERNS).toContain('Thinking');
|
||||
expect(WORKING_PATTERNS).toContain('Writing');
|
||||
expect(WORKING_PATTERNS).toContain('Reading');
|
||||
expect(WORKING_PATTERNS).toContain('Running');
|
||||
});
|
||||
|
||||
it('should contain spinner characters', () => {
|
||||
expect(WORKING_PATTERNS).toContain('\u280B');
|
||||
expect(WORKING_PATTERNS).toContain('\u2819');
|
||||
});
|
||||
|
||||
it('should not contain completion indicators like stars', () => {
|
||||
// Per source comment: "Note: \u273B and \u273D removed - they appear in completion messages too."
|
||||
expect(WORKING_PATTERNS).not.toContain('\u273B');
|
||||
expect(WORKING_PATTERNS).not.toContain('\u273D');
|
||||
});
|
||||
});
|
||||
@@ -75,11 +75,7 @@ describe('session-routes', () => {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.cleanupSession).toHaveBeenCalledWith(
|
||||
harness.ctx._sessionId,
|
||||
true,
|
||||
'user_delete',
|
||||
);
|
||||
expect(harness.ctx.cleanupSession).toHaveBeenCalledWith(harness.ctx._sessionId, true, 'user_delete');
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
@@ -123,10 +119,7 @@ describe('session-routes', () => {
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.name).toBe('new-name');
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
||||
'session:updated',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything());
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
@@ -462,4 +455,109 @@ describe('session-routes', () => {
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/history/sessions ==========
|
||||
|
||||
describe('GET /api/history/sessions', () => {
|
||||
it('returns sessions array', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toHaveProperty('sessions');
|
||||
expect(Array.isArray(body.sessions)).toBe(true);
|
||||
});
|
||||
|
||||
it('sessions have required fields', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions',
|
||||
});
|
||||
const body = JSON.parse(res.body);
|
||||
for (const session of body.sessions) {
|
||||
expect(session).toHaveProperty('sessionId');
|
||||
expect(session).toHaveProperty('workingDir');
|
||||
expect(session).toHaveProperty('projectKey');
|
||||
expect(session).toHaveProperty('sizeBytes');
|
||||
expect(session).toHaveProperty('lastModified');
|
||||
// sessionId must be a valid UUID
|
||||
expect(session.sessionId).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('sessions are sorted by lastModified descending', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions',
|
||||
});
|
||||
const body = JSON.parse(res.body);
|
||||
const dates = body.sessions.map((s: { lastModified: string }) => new Date(s.lastModified).getTime());
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
expect(dates[i - 1]).toBeGreaterThanOrEqual(dates[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns at most 50 sessions', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/history/sessions',
|
||||
});
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.sessions.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions (with resumeSessionId) ==========
|
||||
|
||||
describe('POST /api/sessions with resumeSessionId', () => {
|
||||
it('creates session with valid resumeSessionId', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: {
|
||||
name: 'resume-test',
|
||||
mode: 'claude',
|
||||
workingDir: process.env.HOME || '/tmp',
|
||||
resumeSessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.session).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects invalid resumeSessionId format', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: {
|
||||
name: 'bad-resume',
|
||||
mode: 'claude',
|
||||
workingDir: process.env.HOME || '/tmp',
|
||||
resumeSessionId: 'not-a-uuid',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('creates session without resumeSessionId (optional field)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: {
|
||||
name: 'no-resume',
|
||||
mode: 'claude',
|
||||
workingDir: process.env.HOME || '/tmp',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,20 +4,119 @@
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*
|
||||
* Note: Some system routes access singleton modules (subagentWatcher, imageWatcher)
|
||||
* directly. Those routes are tested lightly here since we can't easily mock singletons
|
||||
* without vi.mock(). Focus is on routes that use the port context.
|
||||
* Mocks: fs (promises + sync), subagentWatcher, imageWatcher,
|
||||
* session-lifecycle-log, and opencode-cli-resolver singletons.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────────────
|
||||
|
||||
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(() => []),
|
||||
getSubagentsForSession: vi.fn(() => []),
|
||||
getSubagent: vi.fn(() => null),
|
||||
getTranscript: vi.fn(async () => []),
|
||||
formatTranscript: vi.fn(() => ''),
|
||||
killSubagent: vi.fn(async () => false),
|
||||
cleanupNow: vi.fn(() => 0),
|
||||
clearAll: vi.fn(() => 0),
|
||||
getStats: vi.fn(() => ({
|
||||
totalAgents: 0,
|
||||
activeAgents: 0,
|
||||
fileDebouncerCount: 0,
|
||||
dirWatcherCount: 0,
|
||||
idleTimerCount: 0,
|
||||
})),
|
||||
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),
|
||||
}));
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { subagentWatcher } from '../../src/subagent-watcher.js';
|
||||
import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
|
||||
import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js';
|
||||
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedSubagentWatcher = vi.mocked(subagentWatcher);
|
||||
const mockedGetLifecycleLog = vi.mocked(getLifecycleLog);
|
||||
const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable);
|
||||
const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir);
|
||||
|
||||
describe('system-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Re-apply defaults after clearAllMocks
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockedWriteFile.mockResolvedValue(undefined);
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedReaddirSync.mockReturnValue([]);
|
||||
mockedSubagentWatcher.getSubagents.mockReturnValue([]);
|
||||
mockedSubagentWatcher.getStats.mockReturnValue({
|
||||
totalAgents: 0,
|
||||
activeAgents: 0,
|
||||
fileDebouncerCount: 0,
|
||||
dirWatcherCount: 0,
|
||||
idleTimerCount: 0,
|
||||
} as never);
|
||||
mockedGetLifecycleLog.mockReturnValue({
|
||||
log: vi.fn(),
|
||||
query: vi.fn(async () => []),
|
||||
} as never);
|
||||
mockedIsOpenCodeAvailable.mockReturnValue(false);
|
||||
mockedResolveOpenCodeDir.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -147,6 +246,13 @@ describe('system-routes', () => {
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('filters by minutes when query param provided', async () => {
|
||||
mockedSubagentWatcher.getRecentSubagents.mockReturnValue([]);
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents?minutes=30' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockedSubagentWatcher.getRecentSubagents).toHaveBeenCalledWith(30);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/auth/revoke ==========
|
||||
@@ -162,5 +268,681 @@ describe('system-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('revokes a specific session token', async () => {
|
||||
const authSessions = new Map();
|
||||
authSessions.set('tok-123', { ip: '1.2.3.4', ua: 'test', createdAt: Date.now(), method: 'basic' });
|
||||
harness.ctx.authSessions = authSessions;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/revoke',
|
||||
payload: { sessionToken: 'tok-123' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
expect(authSessions.has('tok-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears all sessions when no token specified', async () => {
|
||||
const authSessions = new Map();
|
||||
authSessions.set('a', { ip: '1', ua: '', createdAt: 0, method: 'basic' });
|
||||
authSessions.set('b', { ip: '2', ua: '', createdAt: 0, method: 'qr' });
|
||||
harness.ctx.authSessions = authSessions;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/revoke',
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(authSessions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/settings ==========
|
||||
|
||||
describe('GET /api/settings', () => {
|
||||
it('returns empty object when settings file does not exist', async () => {
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parsed settings when file exists', async () => {
|
||||
const settings = { subagentTrackingEnabled: true, showSystemStats: false };
|
||||
mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/settings' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual(settings);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== PUT /api/settings ==========
|
||||
|
||||
describe('PUT /api/settings', () => {
|
||||
it('saves valid settings', async () => {
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { showSystemStats: true, subagentTrackingEnabled: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(mockedWriteFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges with existing settings', async () => {
|
||||
mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true }) as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { showTokenCount: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
|
||||
// Verify writeFile was called with merged content
|
||||
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
|
||||
expect(writtenContent.showCost).toBe(true);
|
||||
expect(writtenContent.showTokenCount).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown settings fields (strict schema)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: { unknownField: 'bad' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-object body', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings',
|
||||
payload: 'not-an-object',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
// Fastify will reject non-object JSON before it reaches the handler
|
||||
expect(res.statusCode).not.toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/subagent-window-states ==========
|
||||
|
||||
describe('GET /api/subagent-window-states', () => {
|
||||
it('returns default when file does not exist', async () => {
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-window-states' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ minimized: {}, open: [] });
|
||||
});
|
||||
|
||||
it('returns persisted window states', async () => {
|
||||
const states = { minimized: { 'agent-1': true }, open: ['agent-2'] };
|
||||
mockedReadFile.mockResolvedValue(JSON.stringify(states) as never);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-window-states' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual(states);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== PUT /api/subagent-window-states ==========
|
||||
|
||||
describe('PUT /api/subagent-window-states', () => {
|
||||
it('saves valid window states', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/subagent-window-states',
|
||||
payload: { minimized: { 'agent-1': true }, open: ['agent-2'] },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
expect(mockedWriteFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts empty state', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/subagent-window-states',
|
||||
payload: { minimized: {}, open: [] },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid minimized values', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/subagent-window-states',
|
||||
payload: { minimized: { 'agent-1': 'not-a-boolean' } },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/subagent-parents ==========
|
||||
|
||||
describe('GET /api/subagent-parents', () => {
|
||||
it('returns empty object when file does not exist', async () => {
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-parents' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns persisted parent map', async () => {
|
||||
const parents = { 'agent-1': 'session-abc', 'agent-2': 'session-xyz' };
|
||||
mockedReadFile.mockResolvedValue(JSON.stringify(parents) as never);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/subagent-parents' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual(parents);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== PUT /api/subagent-parents ==========
|
||||
|
||||
describe('PUT /api/subagent-parents', () => {
|
||||
it('saves valid parent map', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/subagent-parents',
|
||||
payload: { 'agent-1': 'session-abc' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
expect(mockedWriteFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts empty parent map', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/subagent-parents',
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-string values in parent map', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/subagent-parents',
|
||||
payload: { 'agent-1': 123 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/screenshots ==========
|
||||
|
||||
describe('GET /api/screenshots', () => {
|
||||
it('returns empty list when directory does not exist', async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ files: [] });
|
||||
});
|
||||
|
||||
it('returns image files sorted in reverse order', async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
'screenshot_2026-01-01_00-00-00.png',
|
||||
'screenshot_2026-01-02_00-00-00.png',
|
||||
'not-an-image.txt',
|
||||
'screenshot_2026-01-03_00-00-00.jpg',
|
||||
] as never);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.files).toHaveLength(3); // excludes .txt
|
||||
// Sorted reverse: 03, 02, 01
|
||||
expect(body.files[0].name).toBe('screenshot_2026-01-03_00-00-00.jpg');
|
||||
expect(body.files[1].name).toBe('screenshot_2026-01-02_00-00-00.png');
|
||||
expect(body.files[2].name).toBe('screenshot_2026-01-01_00-00-00.png');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/screenshots/:name ==========
|
||||
|
||||
describe('GET /api/screenshots/:name', () => {
|
||||
it('rejects path traversal attempts', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/screenshots/..%2F..%2Fetc%2Fpasswd',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 when screenshot does not exist', async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/screenshots/nonexistent.png',
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('serves existing screenshot with correct content type', async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedReadFile.mockResolvedValue(Buffer.from('fake-png-data') as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/screenshots/test-image.png',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
});
|
||||
|
||||
it('serves jpeg with correct content type', async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedReadFile.mockResolvedValue(Buffer.from('fake-jpg-data') as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/screenshots/photo.jpg',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/jpeg');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/screenshots ==========
|
||||
|
||||
describe('POST /api/screenshots', () => {
|
||||
it('rejects non-multipart content type', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/screenshots',
|
||||
payload: { file: 'data' },
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('multipart');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/session-lifecycle ==========
|
||||
|
||||
describe('GET /api/session-lifecycle', () => {
|
||||
it('returns lifecycle entries', async () => {
|
||||
const mockEntries = [{ event: 'session_created', sessionId: 'test-1', timestamp: Date.now() }];
|
||||
mockedGetLifecycleLog.mockReturnValue({
|
||||
log: vi.fn(),
|
||||
query: vi.fn(async () => mockEntries),
|
||||
} as never);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/session-lifecycle' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.entries).toEqual(mockEntries);
|
||||
});
|
||||
|
||||
it('passes filter query params to lifecycle log', async () => {
|
||||
const queryFn = vi.fn(async () => []);
|
||||
mockedGetLifecycleLog.mockReturnValue({
|
||||
log: vi.fn(),
|
||||
query: queryFn,
|
||||
} as never);
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/session-lifecycle?sessionId=s1&event=session_created&since=1000&limit=50',
|
||||
});
|
||||
|
||||
expect(queryFn).toHaveBeenCalledWith({
|
||||
sessionId: 's1',
|
||||
event: 'session_created',
|
||||
since: 1000,
|
||||
limit: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('caps limit at 1000', async () => {
|
||||
const queryFn = vi.fn(async () => []);
|
||||
mockedGetLifecycleLog.mockReturnValue({
|
||||
log: vi.fn(),
|
||||
query: queryFn,
|
||||
} as never);
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/session-lifecycle?limit=5000',
|
||||
});
|
||||
|
||||
expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ limit: 1000 }));
|
||||
});
|
||||
|
||||
it('defaults limit to 200 when not specified', async () => {
|
||||
const queryFn = vi.fn(async () => []);
|
||||
mockedGetLifecycleLog.mockReturnValue({
|
||||
log: vi.fn(),
|
||||
query: queryFn,
|
||||
} as never);
|
||||
|
||||
await harness.app.inject({ method: 'GET', url: '/api/session-lifecycle' });
|
||||
|
||||
expect(queryFn).toHaveBeenCalledWith(expect.objectContaining({ limit: 200 }));
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/opencode/status ==========
|
||||
|
||||
describe('GET /api/opencode/status', () => {
|
||||
it('returns unavailable when opencode is not installed', async () => {
|
||||
mockedIsOpenCodeAvailable.mockReturnValue(false);
|
||||
mockedResolveOpenCodeDir.mockReturnValue(null);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/opencode/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.available).toBe(false);
|
||||
expect(body.path).toBeNull();
|
||||
});
|
||||
|
||||
it('returns available with path when opencode is installed', async () => {
|
||||
mockedIsOpenCodeAvailable.mockReturnValue(true);
|
||||
mockedResolveOpenCodeDir.mockReturnValue('/usr/local/bin');
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/opencode/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.available).toBe(true);
|
||||
expect(body.path).toBe('/usr/local/bin');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/execution/model-config ==========
|
||||
|
||||
describe('GET /api/execution/model-config', () => {
|
||||
it('returns empty data when settings file does not exist', async () => {
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns model config from settings', async () => {
|
||||
const settings = { modelConfig: { model: 'claude-3', temperature: 0.7 } };
|
||||
mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual(settings.modelConfig);
|
||||
});
|
||||
|
||||
it('returns empty data when settings has no modelConfig', async () => {
|
||||
mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true }) as never);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/execution/model-config' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ========== PUT /api/execution/model-config ==========
|
||||
|
||||
describe('PUT /api/execution/model-config', () => {
|
||||
it('saves valid model config', async () => {
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/execution/model-config',
|
||||
payload: { model: 'claude-3', temperature: 0.5 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
expect(mockedWriteFile).toHaveBeenCalled();
|
||||
|
||||
// Verify the written content contains modelConfig
|
||||
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
|
||||
expect(writtenContent.modelConfig).toEqual({ model: 'claude-3', temperature: 0.5 });
|
||||
});
|
||||
|
||||
it('preserves existing settings when updating model config', async () => {
|
||||
mockedReadFile.mockResolvedValue(JSON.stringify({ showCost: true, other: 'value' }) as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/execution/model-config',
|
||||
payload: { model: 'new-model' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
|
||||
const writtenContent = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
|
||||
expect(writtenContent.showCost).toBe(true);
|
||||
expect(writtenContent.other).toBe('value');
|
||||
expect(writtenContent.modelConfig).toEqual({ model: 'new-model' });
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/subagents/:agentId ==========
|
||||
|
||||
describe('GET /api/subagents/:agentId', () => {
|
||||
it('returns 200 with error for unknown agent', async () => {
|
||||
mockedSubagentWatcher.getSubagent.mockReturnValue(null);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents/unknown-agent' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('returns agent info for known agent', async () => {
|
||||
const agentInfo = { agentId: 'agent-1', status: 'active', pid: 1234 };
|
||||
mockedSubagentWatcher.getSubagent.mockReturnValue(agentInfo as never);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents/agent-1' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual(agentInfo);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/subagents/:agentId/transcript ==========
|
||||
|
||||
describe('GET /api/subagents/:agentId/transcript', () => {
|
||||
it('returns raw transcript by default', async () => {
|
||||
const transcript = [{ role: 'assistant', content: 'hello' }];
|
||||
mockedSubagentWatcher.getTranscript.mockResolvedValue(transcript as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/subagents/agent-1/transcript',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual(transcript);
|
||||
});
|
||||
|
||||
it('returns formatted transcript when format=formatted', async () => {
|
||||
const transcript = [{ role: 'assistant', content: 'hello' }];
|
||||
mockedSubagentWatcher.getTranscript.mockResolvedValue(transcript as never);
|
||||
mockedSubagentWatcher.formatTranscript.mockReturnValue('## Formatted\nhello');
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/subagents/agent-1/transcript?format=formatted',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.formatted).toBe('## Formatted\nhello');
|
||||
expect(body.data.entryCount).toBe(1);
|
||||
});
|
||||
|
||||
it('passes limit parameter', async () => {
|
||||
mockedSubagentWatcher.getTranscript.mockResolvedValue([] as never);
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/subagents/agent-1/transcript?limit=10',
|
||||
});
|
||||
expect(mockedSubagentWatcher.getTranscript).toHaveBeenCalledWith('agent-1', 10);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DELETE /api/subagents/:agentId ==========
|
||||
|
||||
describe('DELETE /api/subagents/:agentId', () => {
|
||||
it('returns error for unknown agent', async () => {
|
||||
mockedSubagentWatcher.getSubagent.mockReturnValue(null);
|
||||
|
||||
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/unknown' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('kills a known agent successfully', async () => {
|
||||
mockedSubagentWatcher.getSubagent.mockReturnValue({ agentId: 'agent-1' } as never);
|
||||
mockedSubagentWatcher.killSubagent.mockResolvedValue(true);
|
||||
|
||||
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/agent-1' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.status).toBe('killed');
|
||||
});
|
||||
|
||||
it('returns error when kill fails', async () => {
|
||||
mockedSubagentWatcher.getSubagent.mockReturnValue({ agentId: 'agent-1' } as never);
|
||||
mockedSubagentWatcher.killSubagent.mockResolvedValue(false);
|
||||
|
||||
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents/agent-1' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('already completed');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/subagents/cleanup ==========
|
||||
|
||||
describe('POST /api/subagents/cleanup', () => {
|
||||
it('triggers cleanup and returns count', async () => {
|
||||
mockedSubagentWatcher.cleanupNow.mockReturnValue(3);
|
||||
mockedSubagentWatcher.getSubagents.mockReturnValue([{ agentId: 'a' }, { agentId: 'b' }] as never);
|
||||
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/subagents/cleanup' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.removed).toBe(3);
|
||||
expect(body.data.remaining).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DELETE /api/subagents ==========
|
||||
|
||||
describe('DELETE /api/subagents', () => {
|
||||
it('clears all subagents', async () => {
|
||||
mockedSubagentWatcher.clearAll.mockReturnValue(5);
|
||||
|
||||
const res = await harness.app.inject({ method: 'DELETE', url: '/api/subagents' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.cleared).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/subagents ==========
|
||||
|
||||
describe('GET /api/sessions/:id/subagents', () => {
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions/nonexistent/subagents',
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns subagents for valid session', async () => {
|
||||
mockedSubagentWatcher.getSubagentsForSession.mockReturnValue([{ agentId: 'sub-1', status: 'active' }] as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/subagents`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/tunnel/qr/regenerate ==========
|
||||
|
||||
describe('POST /api/tunnel/qr/regenerate', () => {
|
||||
it('calls regenerateQrToken on tunnel manager', async () => {
|
||||
// Set up a mock tunnel manager
|
||||
harness.ctx.tunnelManager = {
|
||||
regenerateQrToken: vi.fn(),
|
||||
getStatus: vi.fn(() => ({ running: false })),
|
||||
getUrl: vi.fn(() => null),
|
||||
getQrSvg: vi.fn(),
|
||||
consumeToken: vi.fn(),
|
||||
isRunning: vi.fn(() => false),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as never;
|
||||
|
||||
// Re-create the harness since tunnelManager was null originally
|
||||
await harness.app.close();
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
harness.ctx.tunnelManager = {
|
||||
regenerateQrToken: vi.fn(),
|
||||
getStatus: vi.fn(() => ({ running: false })),
|
||||
getUrl: vi.fn(() => null),
|
||||
getQrSvg: vi.fn(),
|
||||
consumeToken: vi.fn(),
|
||||
isRunning: vi.fn(() => false),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as never;
|
||||
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/tunnel/qr/regenerate' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* @fileoverview Tests for WebSocket terminal I/O route.
|
||||
*
|
||||
* Unlike other route tests that use app.inject(), WebSocket testing requires
|
||||
* a real listening server since inject() doesn't support upgrade requests.
|
||||
* Uses the `ws` package (transitive dep of @fastify/websocket) as the client.
|
||||
*
|
||||
* Port: 3170 (ws-routes tests)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import WebSocket from 'ws';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { registerWsRoutes } from '../../src/web/routes/ws-routes.js';
|
||||
|
||||
const PORT = 3170;
|
||||
|
||||
/** Helper: open a WebSocket connection and wait for it to reach OPEN state. */
|
||||
function connectWs(path: string): Promise<WebSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`);
|
||||
ws.on('open', () => resolve(ws));
|
||||
ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper: wait for the next WS message, parsed as JSON. */
|
||||
function nextMessage(ws: WebSocket, timeoutMs = 2000): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS message timeout')), timeoutMs);
|
||||
ws.once('message', (raw) => {
|
||||
clearTimeout(timer);
|
||||
resolve(JSON.parse(String(raw)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper: wait for WS close event and return { code, reason }. */
|
||||
function waitForClose(ws: WebSocket, timeoutMs = 2000): Promise<{ code: number; reason: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('WS close timeout')), timeoutMs);
|
||||
ws.on('close', (code, reason) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, reason: reason.toString() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('ws-routes', () => {
|
||||
let app: FastifyInstance;
|
||||
let ctx: MockRouteContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
ctx = createMockRouteContext({ sessionId: 'ws-test-session' });
|
||||
registerWsRoutes(app, ctx as never);
|
||||
|
||||
await app.listen({ port: PORT, host: '127.0.0.1' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// ========== Session not found ==========
|
||||
|
||||
describe('session not found', () => {
|
||||
it('closes with 4004 when session does not exist', async () => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/sessions/nonexistent/terminal`);
|
||||
const { code, reason } = await waitForClose(ws);
|
||||
expect(code).toBe(4004);
|
||||
expect(reason).toBe('Session not found');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Terminal output ==========
|
||||
|
||||
describe('terminal output', () => {
|
||||
it('receives terminal output via WS with DEC 2026 sync markers', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
// Emit terminal data from mock session
|
||||
session.emit('terminal', 'hello world');
|
||||
|
||||
// Wait for the micro-batched message (8ms batch interval + margin)
|
||||
const msg = (await nextMessage(ws)) as { t: string; d: string };
|
||||
expect(msg.t).toBe('o');
|
||||
// Should contain DEC 2026 sync markers wrapping the data
|
||||
expect(msg.d).toContain('hello world');
|
||||
expect(msg.d).toMatch(/^\x1b\[\?2026h/); // starts with DEC 2026 start
|
||||
expect(msg.d).toMatch(/\x1b\[\?2026l$/); // ends with DEC 2026 end
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends clearTerminal event as {"t":"c"}', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
ctx._session.emit('clearTerminal');
|
||||
|
||||
const msg = (await nextMessage(ws)) as { t: string };
|
||||
expect(msg.t).toBe('c');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends needsRefresh event as {"t":"r"}', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
ctx._session.emit('needsRefresh');
|
||||
|
||||
const msg = (await nextMessage(ws)) as { t: string };
|
||||
expect(msg.t).toBe('r');
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Client input ==========
|
||||
|
||||
describe('client input', () => {
|
||||
it('forwards input messages to session.write()', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
session.writeBuffer = [];
|
||||
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'ls -la\r' }));
|
||||
|
||||
// Give the message handler time to process
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('ls -la\r');
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores input exceeding MAX_INPUT_LENGTH', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
session.writeBuffer = [];
|
||||
|
||||
// MAX_INPUT_LENGTH is 64KB
|
||||
const hugeInput = 'x'.repeat(65 * 1024);
|
||||
ws.send(JSON.stringify({ t: 'i', d: hugeInput }));
|
||||
|
||||
// Send a valid message after to confirm the connection still works
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'ok' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('ok');
|
||||
});
|
||||
|
||||
// The oversized input should not have been written
|
||||
expect(session.writeBuffer).not.toContain(hugeInput);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores malformed JSON messages', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
session.writeBuffer = [];
|
||||
|
||||
ws.send('not-json{{{');
|
||||
|
||||
// Send valid input to verify connection still alive
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'after-bad' }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('after-bad');
|
||||
});
|
||||
|
||||
// Only 'after-bad' should be in the buffer
|
||||
expect(session.writeBuffer).toHaveLength(1);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Resize validation ==========
|
||||
|
||||
describe('resize validation', () => {
|
||||
it('accepts valid resize within bounds', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(120, 40);
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts resize at minimum bounds (1x1)', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 1, r: 1 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(1, 1);
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts resize at maximum bounds (500x200)', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 500, r: 200 }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(session.resize).toHaveBeenCalledWith(500, 200);
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects resize with cols out of bounds (0 cols)', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 0, r: 40 }));
|
||||
|
||||
// Send a valid message to confirm processing continues
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('sentinel');
|
||||
});
|
||||
|
||||
expect(session.resize).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects resize with cols exceeding 500', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 501, r: 40 }));
|
||||
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('sentinel');
|
||||
});
|
||||
|
||||
expect(session.resize).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects resize with rows exceeding 200', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 80, r: 201 }));
|
||||
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('sentinel');
|
||||
});
|
||||
|
||||
expect(session.resize).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects resize with non-integer values', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: 80.5, r: 24 }));
|
||||
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('sentinel');
|
||||
});
|
||||
|
||||
expect(session.resize).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects resize with negative values', async () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
const session = ctx._session;
|
||||
|
||||
ws.send(JSON.stringify({ t: 'z', c: -1, r: 24 }));
|
||||
|
||||
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
|
||||
await vi.waitFor(() => {
|
||||
expect(session.writeBuffer).toContain('sentinel');
|
||||
});
|
||||
|
||||
expect(session.resize).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Connection cleanup ==========
|
||||
|
||||
describe('connection cleanup', () => {
|
||||
it('removes session event listeners on close', async () => {
|
||||
const session = ctx._session;
|
||||
const listenersBefore = session.listenerCount('terminal');
|
||||
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
|
||||
// A listener was added for 'terminal'
|
||||
expect(session.listenerCount('terminal')).toBe(listenersBefore + 1);
|
||||
expect(session.listenerCount('clearTerminal')).toBeGreaterThanOrEqual(1);
|
||||
expect(session.listenerCount('needsRefresh')).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Close the WS connection
|
||||
ws.close();
|
||||
await waitForClose(ws);
|
||||
|
||||
// Give the server-side close handler time to run
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Listeners should be cleaned up
|
||||
expect(session.listenerCount('terminal')).toBe(listenersBefore);
|
||||
expect(session.listenerCount('clearTerminal')).toBe(0);
|
||||
expect(session.listenerCount('needsRefresh')).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const TEST_PORT = 3212;
|
||||
|
||||
// Helper to parse SSE events from raw text
|
||||
function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> {
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
const lines = text.split('\n');
|
||||
let currentEvent = '';
|
||||
let currentData = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.substring(7);
|
||||
} else if (line.startsWith('data: ')) {
|
||||
currentData = line.substring(6);
|
||||
} else if (line === '') {
|
||||
if (currentEvent && currentData) {
|
||||
try {
|
||||
events.push({ event: currentEvent, data: JSON.parse(currentData) });
|
||||
} catch {
|
||||
events.push({ event: currentEvent, data: currentData });
|
||||
}
|
||||
}
|
||||
currentEvent = '';
|
||||
currentData = '';
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Helper to collect SSE events for a given duration
|
||||
async function collectSSEEvents(
|
||||
baseUrl: string,
|
||||
queryParams: string,
|
||||
durationMs: number
|
||||
): Promise<Array<{ event: string; data: unknown }>> {
|
||||
const controller = new AbortController();
|
||||
let receivedData = '';
|
||||
|
||||
const fetchPromise = fetch(`${baseUrl}/api/events${queryParams}`, {
|
||||
signal: controller.signal,
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
receivedData += new TextDecoder().decode(value);
|
||||
}
|
||||
} catch {
|
||||
/* AbortError expected */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, durationMs));
|
||||
controller.abort();
|
||||
try {
|
||||
await fetchPromise;
|
||||
} catch {
|
||||
/* AbortError expected */
|
||||
}
|
||||
|
||||
return parseSSEEvents(receivedData);
|
||||
}
|
||||
|
||||
describe('SSE Subscription Filtering', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
}, 60000);
|
||||
|
||||
it('should accept sessions query parameter without error', async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 500);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/events?sessions=abc,def`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
|
||||
it('should send init event regardless of session filter', async () => {
|
||||
const events = await collectSSEEvents(baseUrl, '?sessions=nonexistent-id', 500);
|
||||
|
||||
const initEvent = events.find((e) => e.event === 'init');
|
||||
expect(initEvent).toBeDefined();
|
||||
expect((initEvent?.data as any).sessions).toBeDefined();
|
||||
});
|
||||
|
||||
it('should receive all events when no sessions param is provided (backwards-compatible)', async () => {
|
||||
// Start two SSE listeners: one with no filter, one with a filter for a nonexistent session
|
||||
const controller1 = new AbortController();
|
||||
const controller2 = new AbortController();
|
||||
let unfilteredData = '';
|
||||
let filteredData = '';
|
||||
|
||||
const fetch1 = fetch(`${baseUrl}/api/events`, {
|
||||
signal: controller1.signal,
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
unfilteredData += new TextDecoder().decode(value);
|
||||
}
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fetch2 = fetch(`${baseUrl}/api/events?sessions=nonexistent-session`, {
|
||||
signal: controller2.signal,
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
filteredData += new TextDecoder().decode(value);
|
||||
}
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for connections to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Create a session — this emits session:created (a global-ish event that has id but
|
||||
// the nonexistent filter won't match it)
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
const sessionId = createData.session.id;
|
||||
|
||||
// Wait for events to arrive
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Stop listening
|
||||
controller1.abort();
|
||||
controller2.abort();
|
||||
try {
|
||||
await fetch1;
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
try {
|
||||
await fetch2;
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
|
||||
const unfilteredEvents = parseSSEEvents(unfilteredData);
|
||||
const filteredEvents = parseSSEEvents(filteredData);
|
||||
|
||||
// Unfiltered client should receive the session:created event
|
||||
const unfilteredCreated = unfilteredEvents.find((e) => e.event === 'session:created');
|
||||
expect(unfilteredCreated).toBeDefined();
|
||||
expect((unfilteredCreated?.data as any).id).toBe(sessionId);
|
||||
|
||||
// Filtered client (subscribed to nonexistent-session) should NOT receive session:created
|
||||
// because session:created has an `id` field that doesn't match the filter
|
||||
const filteredCreated = filteredEvents.find((e) => e.event === 'session:created');
|
||||
expect(filteredCreated).toBeUndefined();
|
||||
|
||||
// Both should have received the init event (it has no sessionId)
|
||||
expect(unfilteredEvents.find((e) => e.event === 'init')).toBeDefined();
|
||||
expect(filteredEvents.find((e) => e.event === 'init')).toBeDefined();
|
||||
|
||||
// Cleanup
|
||||
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
});
|
||||
|
||||
it('should deliver session events to a client subscribed to that session', async () => {
|
||||
// First create a session so we know the ID
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
const sessionId = createData.session.id;
|
||||
|
||||
// Now connect SSE with the session filter
|
||||
const controller = new AbortController();
|
||||
let receivedData = '';
|
||||
|
||||
const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${sessionId}`, {
|
||||
signal: controller.signal,
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
receivedData += new TextDecoder().decode(value);
|
||||
}
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for connection
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Delete the session — this emits session:deleted with { id: sessionId }
|
||||
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
|
||||
// Wait for events
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
controller.abort();
|
||||
try {
|
||||
await fetchPromise;
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
|
||||
// Should receive session:deleted because we're subscribed to this session
|
||||
const deletedEvent = events.find((e) => e.event === 'session:deleted');
|
||||
expect(deletedEvent).toBeDefined();
|
||||
expect((deletedEvent?.data as any).id).toBe(sessionId);
|
||||
});
|
||||
|
||||
it('should filter out events for sessions not in the subscription', async () => {
|
||||
// Create two sessions
|
||||
const createRes1 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
});
|
||||
const session1 = (await createRes1.json()).session;
|
||||
|
||||
const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
});
|
||||
const session2 = (await createRes2.json()).session;
|
||||
|
||||
// Connect SSE subscribed ONLY to session1
|
||||
const controller = new AbortController();
|
||||
let receivedData = '';
|
||||
|
||||
const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${session1.id}`, {
|
||||
signal: controller.signal,
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
receivedData += new TextDecoder().decode(value);
|
||||
}
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for connection
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Delete both sessions — emits session:deleted for each
|
||||
await fetch(`${baseUrl}/api/sessions/${session1.id}`, { method: 'DELETE' });
|
||||
await fetch(`${baseUrl}/api/sessions/${session2.id}`, { method: 'DELETE' });
|
||||
|
||||
// Wait for events
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
controller.abort();
|
||||
try {
|
||||
await fetchPromise;
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
|
||||
// Should receive session:deleted for session1
|
||||
const deleted1 = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1.id);
|
||||
expect(deleted1).toBeDefined();
|
||||
|
||||
// Should NOT receive session:deleted for session2
|
||||
const deleted2 = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2.id);
|
||||
expect(deleted2).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should support subscribing to multiple sessions', async () => {
|
||||
// Create two sessions
|
||||
const createRes1 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
});
|
||||
const session1 = (await createRes1.json()).session;
|
||||
|
||||
const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
});
|
||||
const session2 = (await createRes2.json()).session;
|
||||
|
||||
// Connect SSE subscribed to both sessions
|
||||
const controller = new AbortController();
|
||||
let receivedData = '';
|
||||
|
||||
const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${session1.id},${session2.id}`, {
|
||||
signal: controller.signal,
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
receivedData += new TextDecoder().decode(value);
|
||||
}
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for connection
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Delete both sessions
|
||||
await fetch(`${baseUrl}/api/sessions/${session1.id}`, { method: 'DELETE' });
|
||||
await fetch(`${baseUrl}/api/sessions/${session2.id}`, { method: 'DELETE' });
|
||||
|
||||
// Wait for events
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
controller.abort();
|
||||
try {
|
||||
await fetchPromise;
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
|
||||
// Should receive session:deleted for both sessions
|
||||
const deleted1 = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1.id);
|
||||
const deleted2 = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2.id);
|
||||
expect(deleted1).toBeDefined();
|
||||
expect(deleted2).toBeDefined();
|
||||
});
|
||||
|
||||
it('should deliver global events (no sessionId) to filtered clients', async () => {
|
||||
// Connect with a filter — global events like case:created should still arrive
|
||||
const controller = new AbortController();
|
||||
let receivedData = '';
|
||||
|
||||
const fetchPromise = fetch(`${baseUrl}/api/events?sessions=some-session-id`, {
|
||||
signal: controller.signal,
|
||||
}).then(async (response) => {
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
receivedData += new TextDecoder().decode(value);
|
||||
}
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for connection
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Create a case — case:created is a global event (no sessionId or id matching a session)
|
||||
const caseName = `test-filter-case-${Date.now()}`;
|
||||
await fetch(`${baseUrl}/api/cases`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: caseName }),
|
||||
});
|
||||
|
||||
// Wait for events
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
controller.abort();
|
||||
try {
|
||||
await fetchPromise;
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
|
||||
const events = parseSSEEvents(receivedData);
|
||||
|
||||
// case:created should arrive because it has no sessionId — it's a global event
|
||||
const caseCreated = events.find((e) => e.event === 'case:created');
|
||||
expect(caseCreated).toBeDefined();
|
||||
expect((caseCreated?.data as any).name).toBe(caseName);
|
||||
|
||||
// Cleanup
|
||||
const { rmSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
const { homedir } = await import('node:os');
|
||||
try {
|
||||
rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true });
|
||||
} catch {
|
||||
/* may not exist */
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { StaleExpirationMap } from '../src/utils/stale-expiration-map.js';
|
||||
|
||||
describe('StaleExpirationMap', () => {
|
||||
let map: StaleExpirationMap<string, number>;
|
||||
|
||||
afterEach(() => {
|
||||
// Always dispose to clear cleanup timers
|
||||
map?.dispose();
|
||||
});
|
||||
|
||||
describe('construction', () => {
|
||||
it('creates an empty map', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
expect(map.size).toBe(0);
|
||||
expect(map.isDisposed).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults refreshOnGet to true', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
// Verify by setting, advancing time partially, getting (refreshes), advancing again
|
||||
vi.useFakeTimers();
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(800); // 800ms of 1000ms TTL
|
||||
map.get('a'); // should refresh
|
||||
vi.advanceTimersByTime(800); // 1600ms total, but only 800ms since last access
|
||||
expect(map.has('a')).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('set/get', () => {
|
||||
beforeEach(() => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
});
|
||||
|
||||
it('stores and retrieves values', () => {
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
expect(map.get('a')).toBe(1);
|
||||
expect(map.get('b')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns undefined for missing keys', () => {
|
||||
expect(map.get('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('overwrites existing values', () => {
|
||||
map.set('a', 1);
|
||||
map.set('a', 10);
|
||||
expect(map.get('a')).toBe(10);
|
||||
});
|
||||
|
||||
it('supports chaining on set', () => {
|
||||
const result = map.set('a', 1).set('b', 2);
|
||||
expect(result).toBe(map);
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores set when disposed', () => {
|
||||
map.dispose();
|
||||
map.set('a', 1);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TTL expiration', () => {
|
||||
it('expires entries after TTL', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(map.get('a')).toBeUndefined();
|
||||
expect(map.size).toBe(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('entries are accessible before TTL', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(map.get('a')).toBe(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('get refreshes TTL when refreshOnGet is true', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000, refreshOnGet: true });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(800);
|
||||
map.get('a'); // refreshes to now
|
||||
vi.advanceTimersByTime(800); // 800ms after refresh, still within TTL
|
||||
expect(map.get('a')).toBe(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('get does not refresh TTL when refreshOnGet is false', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000, refreshOnGet: false });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(800);
|
||||
map.get('a'); // does NOT refresh
|
||||
vi.advanceTimersByTime(300); // 1100ms total, past TTL
|
||||
expect(map.get('a')).toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('has', () => {
|
||||
it('returns true for non-expired keys', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
expect(map.has('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for missing keys', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
expect(map.has('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false and deletes expired keys', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(map.has('a')).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('peek', () => {
|
||||
it('returns value without refreshing TTL', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(map.peek('a')).toBe(1); // does not refresh
|
||||
vi.advanceTimersByTime(300); // 1100ms total
|
||||
expect(map.peek('a')).toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns undefined for missing keys', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
expect(map.peek('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes expired entries on peek', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
map.peek('a');
|
||||
// The internal entry should be deleted
|
||||
expect(map.size).toBe(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('removes an existing key', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
expect(map.delete('a')).toBe(true);
|
||||
expect(map.has('a')).toBe(false);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns false for non-existent key', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
expect(map.delete('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not call onExpire on manual delete', () => {
|
||||
const onExpire = vi.fn();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000, onExpire });
|
||||
map.set('a', 1);
|
||||
map.delete('a');
|
||||
expect(onExpire).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes all entries', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
map.clear();
|
||||
expect(map.size).toBe(0);
|
||||
expect(map.get('a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('touch', () => {
|
||||
it('refreshes TTL without returning value', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(map.touch('a')).toBe(true);
|
||||
vi.advanceTimersByTime(800); // 1600ms total, but 800ms since touch
|
||||
expect(map.has('a')).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns false for non-existent keys', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
expect(map.touch('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for expired keys', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(1001);
|
||||
expect(map.touch('a')).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAge', () => {
|
||||
it('returns age since creation', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 10000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(3000);
|
||||
const age = map.getAge('a');
|
||||
expect(age).toBe(3000);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns undefined for missing keys', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
expect(map.getAge('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemainingTtl', () => {
|
||||
it('returns remaining time before expiration', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(300);
|
||||
const remaining = map.getRemainingTtl('a');
|
||||
expect(remaining).toBe(700);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns 0 for expired entries (before cleanup removes them)', () => {
|
||||
vi.useFakeTimers();
|
||||
// Use a long cleanup interval so the entry is not auto-removed
|
||||
map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 60000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(map.getRemainingTtl('a')).toBe(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns undefined after cleanup removes expired entry', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(2000); // cleanup fires and removes 'a'
|
||||
expect(map.getRemainingTtl('a')).toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns undefined for missing keys', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
expect(map.getRemainingTtl('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('removes expired entries and returns count', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
vi.advanceTimersByTime(500);
|
||||
map.set('c', 3);
|
||||
vi.advanceTimersByTime(600); // 'a' and 'b' at 1100ms, 'c' at 600ms
|
||||
const removed = map.cleanup();
|
||||
expect(removed).toBe(2);
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.has('c')).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('calls onExpire for each expired entry', () => {
|
||||
vi.useFakeTimers();
|
||||
const onExpire = vi.fn();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000, onExpire });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
vi.advanceTimersByTime(1001);
|
||||
map.cleanup();
|
||||
expect(onExpire).toHaveBeenCalledTimes(2);
|
||||
expect(onExpire).toHaveBeenCalledWith('a', 1);
|
||||
expect(onExpire).toHaveBeenCalledWith('b', 2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns 0 when nothing is expired', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
expect(map.cleanup()).toBe(0);
|
||||
});
|
||||
|
||||
it('periodic cleanup runs automatically', () => {
|
||||
vi.useFakeTimers();
|
||||
const onExpire = vi.fn();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 500, onExpire });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(1500); // past TTL, and cleanup interval fires
|
||||
expect(onExpire).toHaveBeenCalledWith('a', 1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('iteration', () => {
|
||||
it('iterates non-expired entries via for..of', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 5000 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
const entries: [string, number][] = [];
|
||||
for (const [k, v] of map) {
|
||||
entries.push([k, v]);
|
||||
}
|
||||
expect(entries).toEqual([
|
||||
['a', 1],
|
||||
['b', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips expired entries during iteration', () => {
|
||||
vi.useFakeTimers();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
vi.advanceTimersByTime(500);
|
||||
map.set('b', 2);
|
||||
vi.advanceTimersByTime(600); // 'a' expired at 1100ms, 'b' at 600ms
|
||||
|
||||
const entries: [string, number][] = [];
|
||||
for (const [k, v] of map) {
|
||||
entries.push([k, v]);
|
||||
}
|
||||
expect(entries).toEqual([['b', 2]]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keys() yields non-expired keys', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 5000 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
expect([...map.keys()]).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('values() yields non-expired values', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 5000 });
|
||||
map.set('a', 1);
|
||||
map.set('b', 2);
|
||||
expect([...map.values()]).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('marks map as disposed', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
expect(map.isDisposed).toBe(false);
|
||||
map.dispose();
|
||||
expect(map.isDisposed).toBe(true);
|
||||
});
|
||||
|
||||
it('clears all entries on dispose', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.set('a', 1);
|
||||
map.dispose();
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
map = new StaleExpirationMap({ ttlMs: 1000 });
|
||||
map.dispose();
|
||||
map.dispose(); // should not throw
|
||||
expect(map.isDisposed).toBe(true);
|
||||
});
|
||||
|
||||
it('stops periodic cleanup after dispose', () => {
|
||||
vi.useFakeTimers();
|
||||
const onExpire = vi.fn();
|
||||
map = new StaleExpirationMap({ ttlMs: 1000, cleanupIntervalMs: 500, onExpire });
|
||||
map.set('a', 1);
|
||||
map.dispose();
|
||||
vi.advanceTimersByTime(2000);
|
||||
// onExpire should not be called because timer was stopped
|
||||
expect(onExpire).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -100,11 +100,7 @@ function createAssistantTextEntry(text: string, timestamp?: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function createToolUseEntry(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
timestamp?: string
|
||||
): string {
|
||||
function createToolUseEntry(toolName: string, input: Record<string, unknown>, timestamp?: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: timestamp || new Date().toISOString(),
|
||||
@@ -134,6 +130,13 @@ function createToolResultEntry(content: string, timestamp?: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a mock readline interface with .close() method */
|
||||
function createMockRl() {
|
||||
const rl = new EventEmitter() as EventEmitter & { close: ReturnType<typeof vi.fn> };
|
||||
rl.close = vi.fn();
|
||||
return rl;
|
||||
}
|
||||
|
||||
describe('SubagentWatcher', () => {
|
||||
let watcher: SubagentWatcher;
|
||||
let mockExistsSync: Mock;
|
||||
@@ -166,12 +169,12 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
// Default mocks - no projects exist
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockStatSync.mockReturnValue({
|
||||
mockStatSync.mockImplementation(() => ({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 0,
|
||||
});
|
||||
}));
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
mockReadFileSync.mockReturnValue('');
|
||||
mockWatch.mockReturnValue({ close: vi.fn(), on: vi.fn(), off: vi.fn() });
|
||||
@@ -239,9 +242,9 @@ describe('SubagentWatcher', () => {
|
||||
const lines = [validEntry];
|
||||
|
||||
// Setup mock readline interface
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
// Setup file discovery
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
@@ -281,16 +284,11 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
it('should skip malformed JSON lines', async () => {
|
||||
const validEntry = createUserEntry('Valid entry');
|
||||
const malformedLines = [
|
||||
'not json at all',
|
||||
'{"incomplete": true',
|
||||
validEntry,
|
||||
'}{bad json}{',
|
||||
];
|
||||
const malformedLines = ['not json at all', '{"incomplete": true', validEntry, '}{bad json}{'];
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -330,9 +328,9 @@ describe('SubagentWatcher', () => {
|
||||
// Simulate partial write where line is incomplete
|
||||
const partialContent = '{"type": "user", "timestamp": "2024-01-01T00:00:00Z"';
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -369,9 +367,9 @@ describe('SubagentWatcher', () => {
|
||||
const validEntry = createUserEntry('Valid');
|
||||
const contentWithEmptyLines = ['', validEntry, ' ', '', validEntry].join('\n');
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -408,9 +406,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
describe('Status Lifecycle', () => {
|
||||
it('should start agents as active', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -442,9 +440,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should transition to idle after IDLE_TIMEOUT_MS (30s)', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -453,12 +451,12 @@ describe('SubagentWatcher', () => {
|
||||
if (path.includes('project1')) return ['session1'];
|
||||
return ['project1'];
|
||||
});
|
||||
mockStatSync.mockReturnValue({
|
||||
mockStatSync.mockImplementation(() => ({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 100,
|
||||
});
|
||||
}));
|
||||
mockReadFileSync.mockReturnValue(createUserEntry('Test subagent task'));
|
||||
|
||||
watcher.start();
|
||||
@@ -481,9 +479,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should reset to active on new activity', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
const mockWatcher = { close: vi.fn(), on: vi.fn(), off: vi.fn() };
|
||||
mockWatch.mockReturnValue(mockWatcher);
|
||||
@@ -495,12 +493,12 @@ describe('SubagentWatcher', () => {
|
||||
if (path.includes('project1')) return ['session1'];
|
||||
return ['project1'];
|
||||
});
|
||||
mockStatSync.mockReturnValue({
|
||||
mockStatSync.mockImplementation(() => ({
|
||||
isDirectory: () => true,
|
||||
birthtime: new Date(),
|
||||
mtime: new Date(),
|
||||
size: 100,
|
||||
});
|
||||
}));
|
||||
mockReadFileSync.mockReturnValue(createUserEntry('Test subagent task'));
|
||||
|
||||
watcher.start();
|
||||
@@ -514,19 +512,20 @@ describe('SubagentWatcher', () => {
|
||||
expect(watcher.getSubagents()[0].status).toBe('idle');
|
||||
|
||||
// Simulate file change event - get the callback from mockWatch
|
||||
const watchCallback = mockWatch.mock.calls.find(
|
||||
(call: unknown[]) => typeof call[1] === 'function'
|
||||
)?.[1];
|
||||
const watchCallback = mockWatch.mock.calls.find((call: unknown[]) => typeof call[1] === 'function')?.[1];
|
||||
|
||||
if (watchCallback) {
|
||||
// Need to reset the readline mock for the new read
|
||||
const newMockRl = new EventEmitter();
|
||||
const newMockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(newMockRl);
|
||||
|
||||
// Trigger file change
|
||||
watchCallback('change', 'agent-reactive.jsonl');
|
||||
|
||||
// Complete the new readline
|
||||
// Advance past the fileDeb debounce (100ms) so handleFileChange runs
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// Complete the new readline (tailFile)
|
||||
newMockRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
@@ -536,9 +535,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should transition to completed when file becomes stale', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -588,9 +587,10 @@ describe('SubagentWatcher', () => {
|
||||
it('should extract tool_use entries', async () => {
|
||||
const toolEntry = createToolUseEntry('WebSearch', { query: 'test query' });
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -612,9 +612,14 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', toolEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', toolEntry);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(toolCallHandler).toHaveBeenCalled();
|
||||
@@ -626,9 +631,10 @@ describe('SubagentWatcher', () => {
|
||||
it('should extract text messages', async () => {
|
||||
const textEntry = createAssistantTextEntry('This is the assistant response');
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -650,9 +656,14 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', textEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', textEntry);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(messageHandler).toHaveBeenCalled();
|
||||
@@ -682,9 +693,9 @@ describe('SubagentWatcher', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -720,9 +731,10 @@ describe('SubagentWatcher', () => {
|
||||
const longText = 'x'.repeat(1000);
|
||||
const textEntry = createAssistantTextEntry(longText);
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -744,9 +756,14 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', textEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', textEntry);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(messageHandler).toHaveBeenCalled();
|
||||
@@ -757,9 +774,10 @@ describe('SubagentWatcher', () => {
|
||||
it('should extract progress events', async () => {
|
||||
const progressEntry = createProgressEntry('query_update', { query: 'searching for files' });
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -781,9 +799,14 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', progressEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', progressEntry);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(progressHandler).toHaveBeenCalled();
|
||||
@@ -795,9 +818,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
describe('Memory Management', () => {
|
||||
it('should track agents in agentInfo map', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -832,9 +855,10 @@ describe('SubagentWatcher', () => {
|
||||
const toolEntry1 = createToolUseEntry('Read', { file_path: '/test1.ts' });
|
||||
const toolEntry2 = createToolUseEntry('Write', { file_path: '/test2.ts' });
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -853,10 +877,15 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', toolEntry1);
|
||||
mockRl.emit('line', toolEntry2);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', toolEntry1);
|
||||
tailRl.emit('line', toolEntry2);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const agent = watcher.getSubagent('toolcount');
|
||||
@@ -871,9 +900,10 @@ describe('SubagentWatcher', () => {
|
||||
createToolUseEntry('Read', { file_path: '/test.ts' }),
|
||||
];
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -892,11 +922,16 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
for (const entry of entries) {
|
||||
mockRl.emit('line', entry);
|
||||
}
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
for (const entry of entries) {
|
||||
tailRl.emit('line', entry);
|
||||
}
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const agent = watcher.getSubagent('entrycount');
|
||||
@@ -907,9 +942,9 @@ describe('SubagentWatcher', () => {
|
||||
// Note: Current implementation has no cleanup/eviction policy
|
||||
// This documents the behavior as a known issue
|
||||
it('should retain all agents indefinitely (no cleanup policy)', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
@@ -949,9 +984,9 @@ describe('SubagentWatcher', () => {
|
||||
createToolUseEntry('Read', { file_path: '/test.ts' }),
|
||||
];
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -979,13 +1014,11 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should limit transcript entries when limit is specified', async () => {
|
||||
const entries = Array.from({ length: 10 }, (_, i) =>
|
||||
createUserEntry(`Message ${i}`)
|
||||
);
|
||||
const entries = Array.from({ length: 10 }, (_, i) => createUserEntry(`Message ${i}`));
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1013,9 +1046,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should return empty array for unknown agent', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockReturnValue([]);
|
||||
@@ -1037,9 +1070,7 @@ describe('SubagentWatcher', () => {
|
||||
sessionId: 'sess1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'WebSearch', input: { query: 'test query' } },
|
||||
],
|
||||
content: [{ type: 'tool_use', name: 'WebSearch', input: { query: 'test query' } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1092,9 +1123,9 @@ describe('SubagentWatcher', () => {
|
||||
it('should extract description from first user message', async () => {
|
||||
const userEntry = createUserEntry('Create comprehensive tests for the module');
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1116,6 +1147,7 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', userEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -1134,9 +1166,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
const userEntry = createUserEntry(longPrompt);
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1158,6 +1190,7 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', userEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
@@ -1169,9 +1202,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should emit subagent:updated when description is extracted from processEntry', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1234,7 +1267,7 @@ describe('SubagentWatcher', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
|
||||
// createReadStream now used for parent transcript reading (stream tail)
|
||||
@@ -1287,9 +1320,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
describe('getRecentSubagents', () => {
|
||||
it('should return only recent subagents', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1317,9 +1350,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should sort by lastActivityAt descending', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1354,9 +1387,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
describe('getSubagentsForSession', () => {
|
||||
it('should filter subagents by working directory', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1392,9 +1425,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should return false for already completed agent', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1428,9 +1461,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should emit completed event when killing active agent', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1485,9 +1518,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should handle readline errors gracefully', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1524,9 +1557,9 @@ describe('SubagentWatcher', () => {
|
||||
// Only directory watchers are created (no per-file watchers)
|
||||
mockWatch.mockReturnValue(mockDirWatcher);
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1556,9 +1589,9 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
it('should clear idle timers on stop', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1596,9 +1629,9 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
describe('Project Hash Conversion', () => {
|
||||
it('should convert working directory to project hash format', async () => {
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
@@ -1640,9 +1673,7 @@ describe('SubagentWatcher', () => {
|
||||
sessionId: 'sess1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'WebSearch', input: { query: 'nodejs best practices' } },
|
||||
],
|
||||
content: [{ type: 'tool_use', name: 'WebSearch', input: { query: 'nodejs best practices' } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1661,9 +1692,7 @@ describe('SubagentWatcher', () => {
|
||||
sessionId: 'sess1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/src/index.ts' } },
|
||||
],
|
||||
content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/src/index.ts' } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1682,9 +1711,7 @@ describe('SubagentWatcher', () => {
|
||||
sessionId: 'sess1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Bash', input: { command: 'npm test' } },
|
||||
],
|
||||
content: [{ type: 'tool_use', name: 'Bash', input: { command: 'npm test' } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1704,9 +1731,7 @@ describe('SubagentWatcher', () => {
|
||||
sessionId: 'sess1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'Bash', input: { command: longCommand } },
|
||||
],
|
||||
content: [{ type: 'tool_use', name: 'Bash', input: { command: longCommand } }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1753,9 +1778,10 @@ describe('SubagentWatcher', () => {
|
||||
it('should emit user messages under 500 chars', async () => {
|
||||
const userEntry = createUserEntry('Short user message');
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
const descRl = createMockRl();
|
||||
const tailRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1777,9 +1803,14 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.start();
|
||||
await flushAsyncScan();
|
||||
mockRl.emit('line', userEntry);
|
||||
mockRl.emit('close');
|
||||
|
||||
// Resolve extractDescriptionFromFile
|
||||
descRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Now tailFile is set up — emit entries on tailRl
|
||||
tailRl.emit('line', userEntry);
|
||||
tailRl.emit('close');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(messageHandler).toHaveBeenCalled();
|
||||
@@ -1790,9 +1821,9 @@ describe('SubagentWatcher', () => {
|
||||
it('should not emit long user messages (over 500 chars)', async () => {
|
||||
const longUserEntry = createUserEntry('x'.repeat(600));
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1820,9 +1851,7 @@ describe('SubagentWatcher', () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Long user messages are filtered out
|
||||
const userMessages = messageHandler.mock.calls.filter(
|
||||
(call) => (call[0] as SubagentMessage).role === 'user'
|
||||
);
|
||||
const userMessages = messageHandler.mock.calls.filter((call) => (call[0] as SubagentMessage).role === 'user');
|
||||
expect(userMessages.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1831,9 +1860,9 @@ describe('SubagentWatcher', () => {
|
||||
it('should not emit message for empty text content', async () => {
|
||||
const emptyTextEntry = createAssistantTextEntry(' ');
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
const mockRl = createMockRl();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -16,69 +16,54 @@ import { IOSKeyboard } from '../components/IOSKeyboard';
|
||||
// ─── Scene timing (frames @ 30fps) ───
|
||||
const TITLE_DUR = 60;
|
||||
const PHONES_DUR = 30;
|
||||
const TYPING_DUR = 468;
|
||||
const HOLD_DUR = 60;
|
||||
const TYPING_DUR = 610;
|
||||
const HOLD_DUR = 50;
|
||||
const OUTRO_DUR = 45;
|
||||
|
||||
const TITLE_START = 0;
|
||||
const PHONES_START = TITLE_DUR; // 60
|
||||
const TYPING_START = PHONES_START + PHONES_DUR; // 90
|
||||
const HOLD_START = TYPING_START + TYPING_DUR; // 558
|
||||
const OUTRO_START = HOLD_START + HOLD_DUR; // 618
|
||||
const HOLD_START = TYPING_START + TYPING_DUR; // 700
|
||||
const OUTRO_START = HOLD_START + HOLD_DUR; // 750
|
||||
|
||||
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 663
|
||||
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 795
|
||||
|
||||
// ─── iPhone 17 Pro safe area ───
|
||||
const SAFE_AREA_TOP = 59; // Below Dynamic Island
|
||||
const PHONE_SCALE = 1.12; // Scale up to fill more of the frame
|
||||
|
||||
// The Codeman screenshot starts content at y=0 (the session tab).
|
||||
// On a real device it would sit below the safe area, so we offset it.
|
||||
const SCREENSHOT_Y_OFFSET = SAFE_AREA_TOP;
|
||||
// Claude Code header: tab bar + session info + prompt context from screenshot
|
||||
const HEADER_H = 120;
|
||||
|
||||
// Terminal typing overlay position (relative to screenshot top)
|
||||
// Session tab is ~44px, then terminal starts. Adding safe area offset:
|
||||
const TERMINAL_TOP = SAFE_AREA_TOP + 52;
|
||||
// Terminal typing overlay: aligned with the ❯ prompt position in the Claude Code screenshot
|
||||
const TERMINAL_TOP = 185;
|
||||
const TERMINAL_LEFT = 14;
|
||||
const TERMINAL_FONT = 22; // Large for video readability
|
||||
const TERMINAL_FONT = 21; // Slightly smaller to fit toolbar below
|
||||
|
||||
// ─── Typing schedule (with typo + backspace correction) ───
|
||||
const CORRECT_TEXT = 'fix the auth bug in the login flow';
|
||||
const FRAME_GAP = 12; // ~400ms between keystrokes
|
||||
const TYPO_INDEX = 28; // After "logi", type "m" instead of "n"
|
||||
// Codeman toolbar from screenshot (bottom section showing /init, /clear, Run, etc.)
|
||||
const TOOLBAR_H = 95;
|
||||
|
||||
// Remote connection lag: 600ms-1.2s+ per char (18-36+ frames)
|
||||
// ─── Typing schedule ───
|
||||
const CORRECT_TEXT =
|
||||
'zerolag technology brings in a visual dom overlay to make typing instant, even if your codeman server is on the other side of the world';
|
||||
const FRAME_GAP = 4; // ~133ms per keystroke (~75 WPM)
|
||||
|
||||
// Remote connection lag: 600ms–2.7s per char (18–80 frames @ 30fps).
|
||||
// Periodic spikes simulate packet loss / retransmission bursts.
|
||||
// TCP head-of-line blocking causes a single spike to freeze all subsequent chars.
|
||||
const LAGGY_DELAYS = [
|
||||
24, 30, 36, 32, 26, 22, 34, 28, 38, 20, 30, 24, 32, 26, 36, 22,
|
||||
30, 24, 32, 28, 34, 26, 30, 22, 28, 36, 24, 30, 32, 26, 34, 28, 24, 30,
|
||||
26, 32, 28, 34,
|
||||
24, 30, 26, 32, 72, 28, 22, 34, 26, 30, 20, 28, 36, 24, 30, 22, 26, 34, 28, 20, 68, 30, 24, 32,
|
||||
26, 22, 28, 34, 30, 26, 32, 24, 80, 22, 30, 26, 28, 34, 24, 30,
|
||||
];
|
||||
|
||||
type KeyAction = { frame: number; action: 'type' | 'backspace'; char: string; lagDelay: number };
|
||||
type KeyAction = { frame: number; char: string; lagDelay: number };
|
||||
|
||||
const buildSchedule = (): KeyAction[] => {
|
||||
const actions: KeyAction[] = [];
|
||||
let idx = 0;
|
||||
const lag = (i: number) => LAGGY_DELAYS[i % LAGGY_DELAYS.length];
|
||||
|
||||
// Type correctly up to typo point: "fix the auth bug in the logi"
|
||||
for (let i = 0; i < TYPO_INDEX; i++) {
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) });
|
||||
idx++;
|
||||
}
|
||||
|
||||
// Typo: type "m" instead of "n"
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: 'm', lagDelay: lag(idx) });
|
||||
idx++;
|
||||
|
||||
// Backspace to fix it
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'backspace', char: '⌫', lagDelay: lag(idx) });
|
||||
idx++;
|
||||
|
||||
// Type correct remaining: "n flow"
|
||||
for (let i = TYPO_INDEX; i < CORRECT_TEXT.length; i++) {
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) });
|
||||
idx++;
|
||||
for (let i = 0; i < CORRECT_TEXT.length; i++) {
|
||||
actions.push({ frame: i * FRAME_GAP, char: CORRECT_TEXT[i], lagDelay: lag(i) });
|
||||
}
|
||||
|
||||
return actions;
|
||||
@@ -86,14 +71,16 @@ const buildSchedule = (): KeyAction[] => {
|
||||
|
||||
const TYPING_SCHEDULE = buildSchedule();
|
||||
|
||||
/** Replay actions in order up to current frame, computing the visible text buffer */
|
||||
/**
|
||||
* Replay actions in order up to current frame, computing the visible text buffer.
|
||||
* TCP-ordered: stops at first unresolved echo (head-of-line blocking).
|
||||
*/
|
||||
const computeVisibleText = (frame: number, withLag: boolean): string => {
|
||||
let buffer = '';
|
||||
for (const a of TYPING_SCHEDULE) {
|
||||
const threshold = withLag ? a.frame + a.lagDelay : a.frame;
|
||||
if (frame < threshold) break; // TCP-ordered: stop at first unresolved
|
||||
if (a.action === 'backspace') buffer = buffer.slice(0, -1);
|
||||
else buffer += a.char;
|
||||
if (frame < threshold) break;
|
||||
buffer += a.char;
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
@@ -128,8 +115,20 @@ const IOSStatusBar: React.FC = () => (
|
||||
</svg>
|
||||
{/* WiFi */}
|
||||
<svg width="16" height="12" viewBox="0 0 16 12">
|
||||
<path d="M4.5 8.5C5.5 7.2 6.7 6.5 8 6.5s2.5.7 3.5 2" stroke="#fff" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
<path d="M1.5 5.5C3.5 3 5.7 1.5 8 1.5s4.5 1.5 6.5 4" stroke="#fff" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
<path
|
||||
d="M4.5 8.5C5.5 7.2 6.7 6.5 8 6.5s2.5.7 3.5 2"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M1.5 5.5C3.5 3 5.7 1.5 8 1.5s4.5 1.5 6.5 4"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="8" cy="11" r="1.5" fill="#fff" />
|
||||
</svg>
|
||||
{/* Battery */}
|
||||
@@ -145,9 +144,8 @@ const IOSStatusBar: React.FC = () => (
|
||||
// ─── Typing overlay ───
|
||||
const TypingOverlay: React.FC<{
|
||||
typed: string;
|
||||
overlayChars?: { char: string; confirmed: boolean }[];
|
||||
cursorVisible: boolean;
|
||||
}> = ({ typed, overlayChars, cursorVisible }) => {
|
||||
}> = ({ typed, cursorVisible }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const cursorOpacity = cursorVisible ? (Math.floor(frame / 18) % 2 === 0 ? 0.85 : 0.5) : 0;
|
||||
const lineH = Math.round(TERMINAL_FONT * 1.4);
|
||||
@@ -167,13 +165,7 @@ const TypingOverlay: React.FC<{
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#339af0', fontWeight: 700 }}>{'❯ '}</span>
|
||||
{overlayChars
|
||||
? overlayChars.map((oc, i) => (
|
||||
<span key={i} style={{ color: oc.confirmed ? '#e0e0e0' : '#666' }}>
|
||||
{oc.char}
|
||||
</span>
|
||||
))
|
||||
: <span style={{ color: '#e0e0e0' }}>{typed}</span>}
|
||||
<span style={{ color: '#e0e0e0' }}>{typed}</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
@@ -188,37 +180,116 @@ const TypingOverlay: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Single phone: iPhone 17 Pro + real screenshot + typing + keyboard ───
|
||||
// ─── Single phone: iPhone 17 Pro + Claude Code header + typing + keyboard ───
|
||||
const MobileCodeman: React.FC<{
|
||||
typed: string;
|
||||
overlayChars?: { char: string; confirmed: boolean }[];
|
||||
cursorVisible: boolean;
|
||||
activeKey?: string;
|
||||
pressAge?: number;
|
||||
showKeyboard?: boolean;
|
||||
noAnimation?: boolean;
|
||||
}> = ({ typed, overlayChars, cursorVisible, activeKey, pressAge, showKeyboard = true, noAnimation }) => (
|
||||
showPromo?: boolean;
|
||||
}> = ({ typed, cursorVisible, activeKey, pressAge, showKeyboard = true, noAnimation, showPromo }) => (
|
||||
<IPhone17ProFrame noAnimation={noAnimation}>
|
||||
<div style={{ width: SCREEN_W, height: SCREEN_H, position: 'relative', overflow: 'hidden', background: '#000' }}>
|
||||
{/* Real Codeman mobile screenshot, pushed down by safe area */}
|
||||
<Img
|
||||
src={staticFile('mobile-claude.png')}
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
height: SCREEN_H - SCREENSHOT_Y_OFFSET,
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top',
|
||||
position: 'absolute',
|
||||
top: SCREENSHOT_Y_OFFSET,
|
||||
left: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{ width: SCREEN_W, height: SCREEN_H, position: 'relative', overflow: 'hidden', background: '#0d0d0d' }}
|
||||
>
|
||||
{/* iOS status bar in the safe area */}
|
||||
<IOSStatusBar />
|
||||
|
||||
{/* Claude Code header from screenshot (tabs + session info) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: SAFE_AREA_TOP,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: HEADER_H,
|
||||
overflow: 'hidden',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile('mobile-claude.png')}
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top left',
|
||||
}}
|
||||
/>
|
||||
{/* Fade to terminal background */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 30,
|
||||
background: 'linear-gradient(transparent, #0d0d0d)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dark mask: hides screenshot text below Claude Code info (e.g. "Try edit...") */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: SAFE_AREA_TOP + 95,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 8,
|
||||
}}
|
||||
>
|
||||
{/* Smooth gradient blend from screenshot into dark terminal */}
|
||||
<div
|
||||
style={{
|
||||
height: 18,
|
||||
background: 'linear-gradient(transparent, #0d0d0d)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, background: '#0d0d0d' }} />
|
||||
</div>
|
||||
|
||||
{/* Typing animation */}
|
||||
<TypingOverlay typed={typed} overlayChars={overlayChars} cursorVisible={cursorVisible} />
|
||||
<TypingOverlay typed={typed} cursorVisible={cursorVisible} />
|
||||
|
||||
{/* Codeman toolbar from screenshot (/init, /clear, /compact, Run, Run Shell, voice) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 232,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: TOOLBAR_H,
|
||||
overflow: 'hidden',
|
||||
zIndex: 15,
|
||||
}}
|
||||
>
|
||||
{/* Gradient blend at top */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 18,
|
||||
background: 'linear-gradient(#0d0d0d, transparent)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Img
|
||||
src={staticFile('mobile-claude.png')}
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Promo cards between text and toolbar (left phone only) */}
|
||||
{showPromo && <PromoBanners />}
|
||||
|
||||
{/* iOS keyboard at bottom */}
|
||||
{showKeyboard && (
|
||||
@@ -230,22 +301,22 @@ const MobileCodeman: React.FC<{
|
||||
</IPhone17ProFrame>
|
||||
);
|
||||
|
||||
// ─── Label beneath phone ───
|
||||
// ─── Label above phone (prominent header) ───
|
||||
const PhoneLabel: React.FC<{
|
||||
title: string;
|
||||
detail: string;
|
||||
dotColor: string;
|
||||
detailColor: string;
|
||||
}> = ({ title, detail, dotColor, detailColor }) => (
|
||||
<div style={{ textAlign: 'center', marginTop: 20 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
fontFamily: fonts.ui,
|
||||
color: '#fff',
|
||||
}}
|
||||
@@ -267,6 +338,62 @@ const PhoneLabel: React.FC<{
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Promo banners (inside left phone, between text and keyboard) ───
|
||||
const PromoBanners: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 332,
|
||||
left: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
zIndex: 15,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<svg width="30" height="30" viewBox="0 0 16 16" fill="#ccc" style={{ flexShrink: 0 }}>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, fontFamily: fonts.ui, color: '#e0e0e0' }}>Codeman</div>
|
||||
<div style={{ fontSize: 12, fontFamily: fonts.mono, color: '#888' }}>github.com/Ark0N/Codeman</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<svg width="30" height="30" viewBox="0 0 16 16" style={{ flexShrink: 0 }}>
|
||||
<rect width="16" height="16" rx="2" fill="#cb3837" />
|
||||
<path d="M3 3h10v10H8V5H5v8H3z" fill="#fff" />
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, fontFamily: fonts.ui, color: '#e0e0e0' }}>xterm-zerolag-input</div>
|
||||
<div style={{ fontSize: 12, fontFamily: fonts.mono, color: '#888' }}>npmjs.com/package/xterm-zerolag-input</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Title scene ───
|
||||
const TitleScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
@@ -357,12 +484,22 @@ const TypingDemo: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
||||
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
||||
<PhoneLabel
|
||||
title="With Zerolag"
|
||||
detail="0ms local echo"
|
||||
dotColor={colors.accent.green}
|
||||
detailColor={colors.accent.green}
|
||||
/>
|
||||
<MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation showPromo />
|
||||
</div>
|
||||
<div>
|
||||
<PhoneLabel
|
||||
title="Without Zerolag"
|
||||
detail="600ms–2.7s server echo"
|
||||
dotColor={colors.accent.red}
|
||||
detailColor={colors.accent.red}
|
||||
/>
|
||||
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
||||
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
@@ -389,12 +526,22 @@ const PanelsEntrance: React.FC = () => {
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<PhoneLabel
|
||||
title="With Zerolag"
|
||||
detail="0ms local echo"
|
||||
dotColor={colors.accent.green}
|
||||
detailColor={colors.accent.green}
|
||||
/>
|
||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
||||
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
||||
</div>
|
||||
<div>
|
||||
<PhoneLabel
|
||||
title="Without Zerolag"
|
||||
detail="600ms–2.7s server echo"
|
||||
dotColor={colors.accent.red}
|
||||
detailColor={colors.accent.red}
|
||||
/>
|
||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
||||
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 390 KiB |
@@ -23,5 +23,5 @@
|
||||
"allowUnusedLabels": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/tui"]
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||