mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a3b3996b | ||
|
|
1b76e6e2e2 | ||
|
|
f8b81b8478 | ||
|
|
d79e25d5d8 | ||
|
|
b1df5319d5 | ||
|
|
fc92d8a8a4 | ||
|
|
c7cd4f9e17 | ||
|
|
9433de75b8 | ||
|
|
49e9b9e8c9 | ||
|
|
2ee9ad72e8 | ||
|
|
14462f7bfe | ||
|
|
efa6487361 | ||
|
|
f7552c7cb5 | ||
|
|
2b61a8db1b | ||
|
|
8764a684ac | ||
|
|
211abe60ca | ||
|
|
888a2d8e9a | ||
|
|
87abc0d301 |
@@ -52,15 +52,15 @@ jobs:
|
||||
OLD_TAG="aicodeman@${VERSION}"
|
||||
NEW_TAG="codeman@${VERSION}"
|
||||
|
||||
# Retag
|
||||
git tag "$NEW_TAG" "$OLD_TAG" 2>/dev/null || true
|
||||
git tag -d "$OLD_TAG" 2>/dev/null || true
|
||||
git push origin "$NEW_TAG" ":refs/tags/$OLD_TAG" 2>/dev/null || true
|
||||
|
||||
# Update the GitHub release to use the new tag
|
||||
RELEASE_ID=$(gh release view "$OLD_TAG" --json id -q .id 2>/dev/null || true)
|
||||
# Update the GitHub release BEFORE deleting the old tag
|
||||
RELEASE_ID=$(gh release view "$OLD_TAG" --json databaseId -q .databaseId 2>/dev/null || true)
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
||||
-f tag_name="$NEW_TAG" \
|
||||
-f name="$NEW_TAG"
|
||||
fi
|
||||
|
||||
# Retag
|
||||
git tag "$NEW_TAG" "$OLD_TAG" 2>/dev/null || true
|
||||
git tag -d "$OLD_TAG" 2>/dev/null || true
|
||||
git push origin "$NEW_TAG" ":refs/tags/$OLD_TAG" 2>/dev/null || true
|
||||
|
||||
@@ -60,3 +60,4 @@ media-assets/
|
||||
commands
|
||||
todo.md
|
||||
@fix_plan.md
|
||||
readme-preview.mjs
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# aicodeman
|
||||
|
||||
## 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
|
||||
|
||||
- fix: eliminate WebGL re-render flicker during tab switch by keeping renderer active instead of toggling it off/on around large buffer writes
|
||||
|
||||
## 0.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Make file browser panel draggable by its header
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- LLM context optimization and performance improvements: compress CLAUDE.md 21%, MEMORY.md 61%; SSE broadcast early return, cached tunnel state, cache invalidation fix, ralph todo cleanup timer; frontend SSE listener leak fix, short ID caching, subagent window handle cleanup; 100% @fileoverview coverage
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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.0 (must match `package.json`)
|
||||
**Version**: 0.3.4 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -64,154 +64,61 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Requirements**: Node.js 18+, Claude CLI, tmux
|
||||
|
||||
## Commands
|
||||
**Git**: Main branch is `master`. SSH session chooser: `sc` (interactive), `sc 2` (quick attach), `sc -l` (list).
|
||||
|
||||
**Note**: `npm run dev` starts the web server (equivalent to `npx tsx src/index.ts web`).
|
||||
## Additional Commands
|
||||
|
||||
**Default port**: `3000` (web UI at `http://localhost:3000`)
|
||||
`npm run dev` = dev server. Default port: `3000`. Commands not in Quick Reference:
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
npm install # Install dependencies
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Dev with TLS | `npx tsx src/index.ts web --https` |
|
||||
| Continuous typecheck | `tsc --noEmit --watch` |
|
||||
| Test coverage | `npm run test:coverage` |
|
||||
| Production start | `npm run start` |
|
||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||
|
||||
# Development
|
||||
npx tsx src/index.ts web # Dev server (RECOMMENDED)
|
||||
npx tsx src/index.ts web --https # With TLS (only needed for remote access)
|
||||
npm run typecheck # Type check
|
||||
tsc --noEmit --watch # Continuous type checking
|
||||
npm run lint # ESLint
|
||||
npm run lint:fix # ESLint with auto-fix
|
||||
npm run format # Prettier format
|
||||
npm run format:check # Prettier check only
|
||||
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master (Node 22). Tests excluded (they spawn tmux).
|
||||
|
||||
# Testing (see "Testing" section for CRITICAL safety warnings)
|
||||
npx vitest run test/<file>.test.ts # Single file (SAFE)
|
||||
npx vitest run -t "pattern" # Tests matching name
|
||||
npm run test:coverage # With coverage report
|
||||
|
||||
# Production
|
||||
npm run build # esbuild via scripts/build.mjs (not tsc)
|
||||
npm run start # node dist/index.js (production)
|
||||
systemctl --user restart codeman-web
|
||||
journalctl --user -u codeman-web -f
|
||||
```
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, and `format:check` on push to master. Tests are intentionally excluded from CI (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`.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text and Enter separately; multi-line breaks Ink
|
||||
- **Don't kill tmux sessions blindly** — Check `$CODEMAN_MUX` first; you might be inside one
|
||||
- **Global regex `lastIndex` sharing** — `ANSI_ESCAPE_PATTERN_FULL/SIMPLE` have `g` flag; use `createAnsiPatternFull/Simple()` factory functions for fresh instances in loops
|
||||
- **DEC 2026 sync blocks** — Never discard incomplete sync blocks (START without END); buffer up to 50ms then flush. See `app.js:extractSyncSegments()`
|
||||
- **Terminal writes during buffer load** — Live SSE writes are queued while `_isLoadingBuffer` is true to prevent interleaving with historical data
|
||||
- **Local echo prompt scanning** — Does NOT use `buffer.cursorY` (Ink moves it); scans buffer bottom-up for visible `>` prompt marker
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink
|
||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
|
||||
- **Global regex `lastIndex`** — Use `createAnsiPatternFull/Simple()` factories, not shared `g`-flag patterns in loops
|
||||
|
||||
## Import Conventions
|
||||
|
||||
- **Utilities**: Import from `./utils` (re-exports all): `import { LRUMap, stripAnsi } from './utils'`
|
||||
- **Types**: Use type imports from barrel: `import type { SessionState } from './types'` (re-exports from `src/types/` domain files)
|
||||
- **Config**: Import from specific files: `import { MAX_TERMINAL_BUFFER_SIZE } from './config/buffer-limits'`
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Files
|
||||
### Core Files (by domain)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | CLI entry point: global error recovery, uncaught exception guard, `MAX_CONSECUTIVE_ERRORS` auto-restart |
|
||||
| `src/session.ts` | PTY wrapper: `runPrompt()`, `startInteractive()`, `startShell()` |
|
||||
| `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type |
|
||||
| `src/mux-factory.ts` | Create tmux multiplexer instance |
|
||||
| `src/tmux-manager.ts` | tmux session management |
|
||||
| `src/session-manager.ts` | Session lifecycle, cleanup |
|
||||
| `src/session-auto-ops.ts` | Automatic session operations (auto-compact, etc.) |
|
||||
| `src/session-cli-builder.ts` | CLI argument construction for session spawning |
|
||||
| `src/session-task-cache.ts` | Task description caching for subagent correlation |
|
||||
| `src/state-store.ts` | State persistence to `~/.codeman/state.json` |
|
||||
| `src/respawn-controller.ts` | State machine for autonomous cycling |
|
||||
| `src/respawn-adaptive-timing.ts` | Adaptive idle timing calculation |
|
||||
| `src/respawn-health.ts` | Health scoring (0-100) for respawn loops |
|
||||
| `src/respawn-metrics.ts` | Per-cycle outcome metrics tracking |
|
||||
| `src/respawn-patterns.ts` | Pattern matching for stuck/error states |
|
||||
| `src/ralph-tracker.ts` | Detects `<promise>PHRASE</promise>`, todos |
|
||||
| `src/ralph-loop.ts` | Autonomous task execution loop (polls queue, assigns tasks) |
|
||||
| `src/ralph-config.ts` | Parses `.claude/ralph-loop.local.md` plugin config |
|
||||
| `src/ralph-fix-plan-watcher.ts` | Watches `@fix_plan.md` for changes |
|
||||
| `src/ralph-plan-tracker.ts` | Plan iteration tracking |
|
||||
| `src/ralph-stall-detector.ts` | Detects stuck Ralph loops |
|
||||
| `src/ralph-status-parser.ts` | Parses Ralph status messages |
|
||||
| `src/task.ts` | Task model for prompt execution |
|
||||
| `src/task-queue.ts` | Priority queue for tasks with dependencies |
|
||||
| `src/task-tracker.ts` | Background task tracker for subagent detection |
|
||||
| `src/subagent-watcher.ts` | Monitors Claude Code's Task tool (background agents) |
|
||||
| `src/team-watcher.ts` | Polls `~/.claude/teams/` for agent team activity; matches teams to sessions via `leadSessionId` |
|
||||
| `src/run-summary.ts` | Timeline events for "what happened while away" |
|
||||
| `src/ai-checker-base.ts` | Base class for AI-powered checkers (shared by idle + plan checkers) |
|
||||
| `src/ai-idle-checker.ts` | AI-powered idle detection |
|
||||
| `src/ai-plan-checker.ts` | AI-powered plan completion checker |
|
||||
| `src/bash-tool-parser.ts` | Parses Claude's bash tool invocations from output |
|
||||
| `src/transcript-watcher.ts` | Watches Claude's transcript files for changes |
|
||||
| `src/hooks-config.ts` | Manages `.claude/settings.local.json` hook configuration |
|
||||
| `src/push-store.ts` | VAPID key auto-gen + push subscription CRUD for Web Push |
|
||||
| `src/session-lifecycle-log.ts` | Append-only JSONL audit log at `~/.codeman/session-lifecycle.jsonl` |
|
||||
| `src/image-watcher.ts` | Watches for image file creation (screenshots, etc.) |
|
||||
| `src/file-stream-manager.ts` | Manages `tail -f` processes for live log viewing |
|
||||
| `src/plan-orchestrator.ts` | 2-agent plan generation: optional research agent → planner agent |
|
||||
| `src/prompts/index.ts` | Barrel export for all agent prompts |
|
||||
| `src/prompts/*.ts` | Agent prompts (research-agent, planner) |
|
||||
| `src/templates/claude-md.ts` | CLAUDE.md generation for new cases |
|
||||
| `src/tunnel-manager.ts` | Manages cloudflared child process for Cloudflare tunnel + QR auth token rotation |
|
||||
| `src/cli.ts` | Command-line interface handlers |
|
||||
| `src/web/server.ts` | Fastify server setup, SSE at `/api/events`, delegates to route modules |
|
||||
| `src/web/routes/*.ts` | 12 domain route modules (session, respawn, ralph, plan, etc.) — each exports `register*Routes()` |
|
||||
| `src/web/ports/*.ts` | Port interfaces (SessionPort, EventPort, etc.) — route modules declare dependencies via intersection types |
|
||||
| `src/web/middleware/auth.ts` | Auth middleware: Basic Auth, session cookies, rate limiting, security headers, CORS |
|
||||
| `src/web/route-helpers.ts` | Shared helper utilities for route modules |
|
||||
| `src/web/schemas.ts` | Zod v4 validation schemas with path/env security allowlists |
|
||||
| `src/web/public/app.js` | Frontend: xterm.js, tab management, subagent windows, mobile support (~12K lines) |
|
||||
| `src/types.ts` | Barrel re-export from `src/types/` — 13 domain files (session, task, respawn, ralph, api, etc.) |
|
||||
| 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` | |
|
||||
| **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 |
|
||||
| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts` | |
|
||||
| **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | |
|
||||
| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | |
|
||||
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||
| **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 |
|
||||
|
||||
**Large files** (>50KB): `app.js`, `ralph-tracker.ts`, `respawn-controller.ts`, `session.ts`, `subagent-watcher.ts` — these contain complex state machines; read `docs/respawn-state-machine.md` before modifying.
|
||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||
|
||||
### Local Packages
|
||||
**Local package**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; copy embedded in `app.js`.
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `packages/xterm-zerolag-input/` | Instant keystroke feedback overlay for xterm.js — eliminates perceived input latency over high-RTT connections. Source of truth for `LocalEchoOverlay`; a copy is embedded in `app.js`. Build: `npm run build` (tsup). |
|
||||
**Config**: `src/config/` — 9 files. Import from specific files, not barrel.
|
||||
|
||||
### Config Files (`src/config/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `buffer-limits.ts` | Terminal/text buffer size limits |
|
||||
| `map-limits.ts` | Global limits for Maps, sessions, watchers |
|
||||
| `exec-timeout.ts` | Execution timeout configuration |
|
||||
| `server-timing.ts` | Web server batching, SSE, scheduled run timing |
|
||||
| `auth-config.ts` | Auth session TTL, rate limits, hook timeout |
|
||||
| `tunnel-config.ts` | QR token rotation, tunnel process lifecycle |
|
||||
| `terminal-limits.ts` | Terminal dimension and input validation limits |
|
||||
| `ai-defaults.ts` | AI checker model and context limits |
|
||||
| `team-config.ts` | Agent Teams polling and cache sizes |
|
||||
|
||||
### Utilities (`src/utils/`)
|
||||
|
||||
Re-exported via `src/utils/index.ts`. Key exports:
|
||||
|
||||
| File | Exports |
|
||||
|------|---------|
|
||||
| `cleanup-manager.ts` | `CleanupManager` — centralized disposal for timers, intervals, watchers, listeners, streams |
|
||||
| `lru-map.ts` | `LRUMap` — bounded cache with eviction |
|
||||
| `stale-expiration-map.ts` | `StaleExpirationMap` — TTL-based map with automatic cleanup |
|
||||
| `regex-patterns.ts` | `ANSI_ESCAPE_PATTERN_FULL/SIMPLE`, `createAnsiPatternFull/Simple()`, `stripAnsi`, `TOKEN_PATTERN`, `SPINNER_PATTERN` |
|
||||
| `buffer-accumulator.ts` | `BufferAccumulator` — batches rapid writes into single flushes |
|
||||
| `claude-cli-resolver.ts` | `findClaudeDir`, `getAugmentedPath` — resolves Claude CLI paths |
|
||||
| `opencode-cli-resolver.ts` | `resolveOpenCodeDir`, `isOpenCodeAvailable` — OpenCode CLI support |
|
||||
| `string-similarity.ts` | `stringSimilarity`, `fuzzyPhraseMatch`, `todoContentHash` |
|
||||
| `token-validation.ts` | `validateTokenCounts`, `validateTokensAndCost` |
|
||||
| `nice-wrapper.ts` | `wrapWithNice` — wraps commands with `nice`/`ionice` for lower priority |
|
||||
| `type-safety.ts` | `assertNever` — exhaustive switch/case guard |
|
||||
| `debouncer.ts` | `Debouncer` — reusable debounce utility |
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`.
|
||||
|
||||
### Data Flow
|
||||
|
||||
@@ -222,375 +129,118 @@ Re-exported via `src/utils/index.ts`. Key exports:
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Input to sessions**: Use `session.writeViaMux()` for programmatic input (respawn, auto-compact). Uses tmux `send-keys -l` (literal text) + `send-keys Enter`. All prompts must be single-line.
|
||||
|
||||
**Terminal multiplexer**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts the backend. `createMultiplexer()` from `src/mux-factory.ts` creates the tmux backend.
|
||||
**Input**: `session.writeViaMux()` for programmatic input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only.
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
**Token tracking**: Interactive mode parses status line ("123.4k tokens"), estimates 60/40 input/output split.
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`.
|
||||
|
||||
**Hook events**: Claude Code hooks trigger notifications via `/api/hook-event`. Key events: `permission_prompt` (tool approval needed), `elicitation_dialog` (Claude asking question), `idle_prompt` (waiting for input), `stop` (response complete), `teammate_idle` (Agent Teams), `task_completed` (Agent Teams). See `src/hooks-config.ts`.
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `agent-teams/`.
|
||||
|
||||
**Web Push**: Layer 5 of the notification system. Service worker (`sw.js`) receives push events and shows OS-level notifications even when the browser tab is closed. VAPID keys auto-generated on first use and persisted to `~/.codeman/push-keys.json`. Per-subscription per-event preferences stored in `~/.codeman/push-subscriptions.json`. Expired subscriptions (410/404) auto-cleaned. Requires HTTPS or localhost. iOS requires PWA installed to home screen. See `src/push-store.ts`.
|
||||
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
|
||||
|
||||
**Agent Teams (experimental)**: `TeamWatcher` polls `~/.claude/teams/` for team configs and matches teams to sessions via `leadSessionId`. Teammates are in-process threads (not separate OS processes) and appear as standard subagents. RespawnController checks `TeamWatcher.hasActiveTeammates()` before triggering respawn. Enable via `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var in `settings.local.json`. See `agent-teams/` for full docs.
|
||||
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
|
||||
|
||||
**Circuit breaker**: Prevents respawn thrashing when Claude is stuck. States: `CLOSED` (normal) → `HALF_OPEN` (testing) → `OPEN` (blocked). Tracks consecutive no-progress, same-error-repeated, and tests-failing-too-long. Reset via API at `/api/sessions/:id/ralph-circuit-breaker/reset`.
|
||||
### Frontend
|
||||
|
||||
**Respawn cycle metrics & health scoring**: `RespawnCycleMetrics` tracks per-cycle outcomes (success, stuck_recovery, blocked, error). `RalphLoopHealthScore` computes 0-100 health with component scores (cycleSuccess, circuitBreaker, iterationProgress, aiChecker, stuckRecovery). Available via respawn status API.
|
||||
|
||||
**Subagent-session correlation**: Session parses Task tool output via `BashToolParser` → `SubagentWatcher` discovers new agent → calls `session.findTaskDescriptionNear()` to match description for window title.
|
||||
|
||||
**Port interfaces**: Route modules declare their dependencies via port interfaces (`src/web/ports/`). `WebServer` implements all ports; routes use TypeScript intersection types (e.g., `SessionPort & EventPort`) to specify only what they need. This enables loose coupling between routes and the server.
|
||||
|
||||
### Frontend Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/web/public/index.html` | HTML entry point with inline critical CSS and async vendor loading |
|
||||
| `src/web/public/constants.js` | Shared constants, timing values, Z-index layers, Web Push utilities |
|
||||
| `src/web/public/api-client.js` | API fetch wrapper (`_api`, `_apiJson`, `_apiPost`, `_apiPut`) |
|
||||
| `src/web/public/mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` objects |
|
||||
| `src/web/public/voice-input.js` | `DeepgramProvider`, `VoiceInput` objects for speech-to-text |
|
||||
| `src/web/public/notification-manager.js` | `NotificationManager` class (5-layer notification system) |
|
||||
| `src/web/public/keyboard-accessory.js` | `KeyboardAccessoryBar` and `FocusTrap` classes |
|
||||
| `src/web/public/subagent-windows.js` | Subagent window management (open, close, drag, connection lines) |
|
||||
| `src/web/public/app.js` | Core UI: xterm.js, tab management, settings |
|
||||
| `src/web/public/ralph-wizard.js` | Ralph Loop wizard UI |
|
||||
| `src/web/public/styles.css` | Main styling (dark theme, layout, components) |
|
||||
| `src/web/public/mobile.css` | Responsive overrides for screens <1024px (loaded conditionally via `media` attribute) |
|
||||
| `src/web/public/upload.html` | Screenshot upload page served at `/upload.html` |
|
||||
| `src/web/public/sw.js` | Service worker for Web Push notifications |
|
||||
| `src/web/public/manifest.json` | Minimal PWA manifest (required for push on Android) |
|
||||
| `src/web/public/vendor/` | Self-hosted xterm.js + addons (eliminates CDN latency) |
|
||||
|
||||
**Script loading order** (index.html): `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`. All modules share global scope — order matters for dependencies.
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
The frontend is split across multiple vanilla JS modules (extracted from the original monolithic `app.js`). Key systems:
|
||||
|
||||
| System | Module | Key Classes/Functions | Purpose |
|
||||
|--------|--------|----------------------|---------|
|
||||
| **Terminal rendering** | `app.js` | `batchTerminalWrite()`, `flushPendingWrites()`, `chunkedTerminalWrite()` | 60fps batched writes with DEC 2026 sync |
|
||||
| **Local echo overlay** | `app.js` | `LocalEchoOverlay` class | DOM overlay for instant mobile keystroke feedback |
|
||||
| **Mobile support** | `mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` | Touch input, viewport adaptation, swipe navigation |
|
||||
| **Keyboard accessory** | `keyboard-accessory.js` | `KeyboardAccessoryBar`, `FocusTrap` | Mobile keyboard toolbar, modal focus management |
|
||||
| **Subagent windows** | `subagent-windows.js` | `openSubagentWindow()`, `closeSubagentWindow()`, `updateConnectionLines()` | Floating terminal windows with parent connection lines |
|
||||
| **Notifications** | `notification-manager.js` | `NotificationManager` class | 5-layer: in-app drawer, tab flash, browser API, web push, audio beep |
|
||||
| **Voice input** | `voice-input.js` | `DeepgramProvider`, `VoiceInput` | Speech-to-text via Deepgram WebSocket |
|
||||
| **SSE connection** | `app.js` | `connectSSE()`, `addListener()` | EventSource with exponential backoff (1-30s), offline queue (64KB) |
|
||||
| **Settings** | `app.js` | `openAppSettings()`, `apply*Visibility()` | Server-backed + localStorage persistence |
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `app.js`(6) → `ralph-wizard.js`(7) → `api-client.js`(8) → `subagent-windows.js`(9).
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7).
|
||||
|
||||
**Built-in respawn presets**: `solo-work` (3s idle, 60min), `subagent-workflow` (45s idle, 240min), `team-lead` (90s idle, 480min), `ralph-todo` (8s idle, 480min, works through @fix_plan.md tasks), `overnight-autonomous` (10s idle, 480min, full reset).
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close panels), Ctrl+? (help), Ctrl+Enter (quick start), Ctrl+W (kill session), Ctrl+Tab (next session), Ctrl+K (kill all), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font size).
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+Enter (quick start), Ctrl+W (kill), Ctrl+Tab (next), Ctrl+K (kill all), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
|
||||
|
||||
### Security
|
||||
|
||||
- **HTTP Basic Auth**: Optional via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` env vars
|
||||
- **QR Auth**: Single-use ephemeral 6-char tokens (60s TTL, 90s grace) for tunnel login without typing passwords. `TunnelManager` rotates tokens, serves cached SVG at `GET /api/tunnel/qr`, validates at `GET /q/:code`. Separate per-IP rate limit (10/15min) + global path limit (30/min). Desktop notification on consumption (QRLjacking detection). Audit logged as `qr_auth` in `session-lifecycle.jsonl`. See `docs/qr-auth-plan.md`.
|
||||
- **Session cookies**: After Basic Auth or QR Auth, a 24h session cookie (`codeman_session`) is issued so credentials aren't re-sent on every request. Active sessions auto-extend. SSE works via same-origin cookie (`EventSource` can't send custom headers). Sessions store device context (IP + User-Agent) for audit via `AuthSessionRecord`.
|
||||
- **Session revocation**: `POST /api/auth/revoke` revokes individual sessions or all sessions.
|
||||
- **Rate limiting**: 10 failed auth attempts per IP triggers 429 rejection (15-minute decay window). Manual `StaleExpirationMap` counter — no `@fastify/rate-limit` needed. QR auth has its own separate rate limiter.
|
||||
- **Hook bypass**: `/api/hook-event` POST is exempt from auth — Claude Code hooks curl this from localhost and can't present credentials. Safe: validated by `HookEventSchema`, only triggers broadcasts.
|
||||
- **CORS**: Restricted to localhost only
|
||||
- **Security headers**: X-Content-Type-Options, X-Frame-Options, CSP; HSTS if HTTPS
|
||||
- **Path validation** (`schemas.ts`): Strict allowlist regex, no shell metacharacters, no traversal, must be absolute
|
||||
- **Env var allowlist**: Only `CLAUDE_CODE_*` prefixes allowed; blocks `PATH`, `LD_PRELOAD`, `NODE_OPTIONS`, `CODEMAN_*` keys
|
||||
- **File streaming TOCTOU protection**: `FileStreamManager` calls `realpathSync()` twice (at validation and before spawn) to catch symlink swaps
|
||||
| Layer | Details |
|
||||
|-------|---------|
|
||||
| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` env vars |
|
||||
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
|
||||
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
||||
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated) |
|
||||
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks) |
|
||||
| **Validation** | Zod schemas, path allowlist regex, `CLAUDE_CODE_*` env prefix allowlist |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
|
||||
### SSE Event Categories
|
||||
### SSE Event Registry
|
||||
|
||||
~100 event types broadcast via `broadcast()`. Key categories:
|
||||
~100 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
|
||||
| Category | Events | Purpose |
|
||||
|----------|--------|---------|
|
||||
| Session | `session:created/updated/deleted/working/idle/exit/error/completion` | Lifecycle |
|
||||
| Terminal | `session:terminal`, `session:clearTerminal`, `session:needsRefresh` | Output streaming |
|
||||
| Respawn | `respawn:stateChanged/cycleStarted/blocked/aiCheck*/planCheck*/timer*` | Respawn state machine |
|
||||
| Subagent | `subagent:discovered/updated/completed/tool_call/progress` | Background agents |
|
||||
| Ralph | `session:ralphLoopUpdate/ralphTodoUpdate/ralphCompletionDetected` | Ralph tracking |
|
||||
| Hooks | `hook:{eventName}` (dynamic) | Claude Code hook events |
|
||||
| Plan | `plan:started/progress/completed/cancelled/subagent` | Plan orchestration |
|
||||
| Mux | `mux:created/killed/died/statsUpdated` | tmux process monitor |
|
||||
| Tunnel | `tunnel:qrRotated/qrRegenerated/qrAuthUsed` | QR token lifecycle |
|
||||
| Image | `image:detected` | Screenshot detection |
|
||||
### API Routes
|
||||
|
||||
### API Route Categories
|
||||
|
||||
~113 route handlers split across `src/web/routes/` domain modules. Key groups:
|
||||
|
||||
| Group | Prefix | Count | Key endpoints |
|
||||
|-------|--------|-------|---------------|
|
||||
| Sessions | `/api/sessions` | 43 | CRUD, input, resize, interactive, shell |
|
||||
| System | `/api/status`, `/api/stats`, `/api/config`, `/api/settings`, `/api/subagents` | 38 | App state, config, subagents |
|
||||
| Ralph | `/api/sessions/:id/ralph-*` | 19 | state, status, config, circuit-breaker |
|
||||
| Respawn | `/api/sessions/:id/respawn` | 17 | start, stop, enable, config |
|
||||
| Plan | `/api/sessions/:id/plan/*` | 12 | task CRUD, checkpoint, history, rollback |
|
||||
| Cases | `/api/cases` | 7 | CRUD, link, fix-plan |
|
||||
| Scheduled | `/api/scheduled` | 6 | CRUD for scheduled runs |
|
||||
| Files | `/api/sessions/:id/file*`, `tail-file` | 5 | Browser, preview, raw, tail stream |
|
||||
| Mux | `/api/mux-sessions` | 5 | tmux management, stats |
|
||||
| Push | `/api/push` | 4 | VAPID key, subscribe, update prefs, unsubscribe |
|
||||
| Hooks | `/api/hook-event` | 4 | Hook event ingestion |
|
||||
| Teams | `/api/teams` | 2 | list teams, get team tasks |
|
||||
~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.
|
||||
|
||||
## Adding Features
|
||||
|
||||
- **API endpoint**: Types in `src/types/` (domain file), route in the appropriate `src/web/routes/*-routes.ts` module, use `createErrorResponse()`. Validate request bodies with Zod schemas in `schemas.ts`.
|
||||
- **SSE event**: Emit via `broadcast()`, handle in `app.js` SSE listener section (search `addListener(`)
|
||||
- **Session setting**: Add to `SessionState` in `types.ts`, include in `session.toState()`, call `persistSessionState()`
|
||||
- **Hook event**: Add to `HookEventType` in `types.ts`, add hook command in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema` in `schemas.ts`
|
||||
- **Mobile feature**: Add to relevant mobile singleton (`KeyboardHandler`, `KeyboardAccessoryBar`, etc.), test with `MobileDetection.isMobile()` guard
|
||||
- **New test**: Pick unique port (search `const PORT =`), add port comment to test file header. Tests use ports 3150+.
|
||||
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`, use `createErrorResponse()`. Validate with Zod schemas in `schemas.ts`.
|
||||
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
|
||||
- **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`.
|
||||
|
||||
**Validation**: Uses Zod v4 for request validation. Define schemas in `schemas.ts` and use `.parse()` or `.safeParse()`. Note: Zod v4 has different API from v3 (e.g., `z.object()` options changed, error formatting differs).
|
||||
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
|
||||
|
||||
## State Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.codeman/state.json` | Sessions, settings, tokens, respawn config |
|
||||
| `~/.codeman/mux-sessions.json` | Tmux session metadata for recovery |
|
||||
| `~/.codeman/settings.json` | User preferences |
|
||||
| `~/.codeman/push-keys.json` | VAPID key pair for Web Push (auto-generated) |
|
||||
| `~/.codeman/push-subscriptions.json` | Registered push notification subscriptions |
|
||||
|
||||
## Default Settings
|
||||
|
||||
UI defaults are set in `src/web/public/app.js` using `??` fallbacks. To change defaults, edit `openAppSettings()` and `apply*Visibility()` functions.
|
||||
|
||||
**Key defaults:** Most panels hidden (monitor, subagents shown), notifications enabled (audio disabled), subagent tracking on, Ralph tracking off.
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` (VAPID), `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log).
|
||||
|
||||
## Testing
|
||||
|
||||
**CRITICAL: You are running inside a Codeman-managed tmux session.** Never run `npx vitest run` (full suite) — it spawns/kills tmux sessions and will crash your own session. Instead:
|
||||
**CRITICAL: You are running inside a Codeman-managed tmux session.** Never run `npx vitest run` (full suite) — it spawns/kills tmux sessions and will crash your own session. Only run individual files:
|
||||
|
||||
```bash
|
||||
# Safe: run individual test files
|
||||
npx vitest run test/<specific-file>.test.ts
|
||||
|
||||
# Safe: run tests matching a pattern
|
||||
npx vitest run -t "pattern"
|
||||
|
||||
# DANGEROUS from inside Codeman — will kill your tmux session:
|
||||
# npx vitest run ← DON'T DO THIS
|
||||
npx vitest run test/<specific-file>.test.ts # Single file (SAFE)
|
||||
npx vitest run -t "pattern" # By name (SAFE)
|
||||
# npx vitest run # DANGEROUS — DON'T DO THIS
|
||||
```
|
||||
|
||||
**Ports**: Unit tests pick unique ports manually. Search `const PORT =` before adding new tests.
|
||||
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s.
|
||||
|
||||
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Unit timeout 30s.
|
||||
**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions and never kills them. Only `registerTestTmuxSession()` sessions get cleaned up.
|
||||
|
||||
**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions at load time and never kills them. Only sessions registered via `registerTestTmuxSession()` get cleaned up.
|
||||
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
|
||||
|
||||
**Respawn tests**: Use MockSession from `test/respawn-test-utils.ts` to avoid spawning real Claude processes.
|
||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `mobile-test/` (135 device profiles).
|
||||
|
||||
**Mobile tests**: Separate Playwright-based suite in `mobile-test/` with 135 device profiles. Run via `npx vitest run --config mobile-test/vitest.config.ts`. See `mobile-test/README.md`.
|
||||
## Screenshots
|
||||
|
||||
## Screenshots ("sc")
|
||||
|
||||
When the user says "check the sc", "screenshot", or "sc", they mean uploaded screenshots from their mobile device. Screenshots are saved to `~/.codeman/screenshots/` and uploaded via `/upload.html` on the Codeman web UI. To view them, use the Read tool on the image files:
|
||||
|
||||
```bash
|
||||
ls ~/.codeman/screenshots/ # List uploaded screenshots
|
||||
# Then use Read tool on individual files — Claude Code can view images natively
|
||||
```
|
||||
|
||||
API: `GET /api/screenshots` (list), `GET /api/screenshots/:name` (serve), `POST /api/screenshots` (upload multipart/form-data). Source: `src/web/public/upload.html`.
|
||||
Mobile screenshots in `~/.codeman/screenshots/`. API: `GET /api/screenshots`, `POST /api/screenshots`.
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
tmux list-sessions # List tmux sessions
|
||||
tmux attach-session -t <name> # Attach (Ctrl+B D to detach)
|
||||
curl localhost:3000/api/sessions # Check sessions
|
||||
curl localhost:3000/api/status | jq # Full app state
|
||||
cat ~/.codeman/state.json | jq # View persisted state
|
||||
curl localhost:3000/api/subagents # List background agents
|
||||
curl localhost:3000/api/sessions/:id/run-summary | jq # Session timeline
|
||||
tmux list-sessions # List tmux sessions
|
||||
curl localhost:3000/api/sessions | jq # Check sessions
|
||||
curl localhost:3000/api/status | jq # Full app state
|
||||
curl localhost:3000/api/subagents | jq # Background agents
|
||||
cat ~/.codeman/state.json | jq # Persisted state
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
## Performance & Limits
|
||||
|
||||
| Problem | Check | Fix |
|
||||
|---------|-------|-----|
|
||||
| Session won't start | `tmux list-sessions` for orphans | Kill orphaned sessions, check Claude CLI installed |
|
||||
| Port 3000 in use | `lsof -i :3000` | Kill conflicting process or use `--port` flag |
|
||||
| SSE not connecting | Browser console for errors | Check CORS, ensure server running |
|
||||
| Respawn not triggering | Session settings → Respawn enabled? | Enable respawn, check idle timeout config |
|
||||
| Terminal blank on tab switch | Network tab for `/api/sessions/:id/buffer` | Check session exists, restart server |
|
||||
| Tests failing on session limits | `tmux list-sessions \| wc -l` | Clean up: `tmux list-sessions \| grep test \| awk -F: '{print $1}' \| xargs -I{} tmux kill-session -t {}` |
|
||||
| State not persisting | `cat ~/.codeman/state.json` | Check file permissions, disk space |
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 2MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
|
||||
## Performance Constraints
|
||||
## References
|
||||
|
||||
The app must stay fast with 20 sessions and 50 agent windows:
|
||||
- 60fps terminal (16ms batching + `requestAnimationFrame`)
|
||||
- Auto-trimming buffers (2MB terminal max)
|
||||
- Debounced state persistence (500ms)
|
||||
- SSE adaptive batching: 16ms (normal), 32ms (moderate), 50ms (rapid); immediate flush at 32KB
|
||||
- SSE backpressure handling: skip writes to backpressured clients, recover via `session:needsRefresh` on drain
|
||||
- Cached endpoints: `/api/sessions` and `/api/status` use 1s TTL caches to avoid expensive serialization
|
||||
- Frontend buffer loads: 128KB chunks via `requestAnimationFrame` to prevent UI jank
|
||||
|
||||
## Terminal Anti-Flicker System
|
||||
|
||||
Claude Code uses Ink (React for terminals), which redraws the screen on every state change. Codeman implements a 6-layer anti-flicker pipeline for smooth 60fps output:
|
||||
|
||||
```
|
||||
PTY Output → Server Batching (16-50ms) → DEC 2026 Wrap → SSE → Client rAF → xterm.js
|
||||
```
|
||||
|
||||
**Key functions:** `server.ts:batchTerminalData()`, `server.ts:flushTerminalBatches()`, `app.js:batchTerminalWrite()`, `app.js:extractSyncSegments()`
|
||||
|
||||
**Typical latency:** 16-32ms. Optional per-session flicker filter adds ~50ms for problematic terminals.
|
||||
|
||||
See `docs/terminal-anti-flicker.md` for full implementation details (adaptive batching, DEC 2026 markers, edge cases).
|
||||
|
||||
## Resource Limits
|
||||
|
||||
Limits are centralized in `src/config/` — see `buffer-limits.ts`, `map-limits.ts`, `server-timing.ts`, `auth-config.ts`, `tunnel-config.ts`, `terminal-limits.ts`, `ai-defaults.ts`, `team-config.ts`.
|
||||
|
||||
**Buffer limits** (per session):
|
||||
| Buffer | Max | Trim To |
|
||||
|--------|-----|---------|
|
||||
| Terminal | 2MB | 1.5MB |
|
||||
| Text output | 1MB | 768KB |
|
||||
| Messages | 1000 | 800 |
|
||||
|
||||
**Map limits** (global):
|
||||
| Resource | Max |
|
||||
|----------|-----|
|
||||
| Tracked agents | 500 |
|
||||
| Concurrent sessions | 50 |
|
||||
| SSE clients total | 100 |
|
||||
| File watchers | 500 |
|
||||
|
||||
Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-based cleanup.
|
||||
|
||||
## Where to Find More Information
|
||||
|
||||
| Topic | Location |
|
||||
|-------|----------|
|
||||
| **Respawn state machine** | `docs/respawn-state-machine.md` |
|
||||
| **Ralph Loop guide** | `docs/ralph-wiggum-guide.md` |
|
||||
| **Claude Code hooks** | `docs/claude-code-hooks-reference.md` |
|
||||
| **Terminal anti-flicker** | `docs/terminal-anti-flicker.md` |
|
||||
| **Agent Teams (experimental)** | `agent-teams/README.md`, `agent-teams/design.md` |
|
||||
| **API routes** | `src/web/routes/` domain modules, or README.md |
|
||||
| **SSE events** | Search `broadcast(` in `server.ts` and route modules |
|
||||
| **Session statuses** | `SessionStatus` in `src/types/session.ts` |
|
||||
| **Error codes** | `createErrorResponse()` in `src/types/api.ts` |
|
||||
| **Refactoring phases** | `docs/phase1-implementation-plan.md` through `docs/phase7-test-infrastructure-plan.md` |
|
||||
| **Test utilities** | `test/respawn-test-utils.ts` |
|
||||
| **Mobile test suite** | `mobile-test/README.md` |
|
||||
| **OpenCode integration** | `docs/opencode-integration.md` |
|
||||
| **Local echo overlay** | `docs/local-echo-overlay-plan.md` |
|
||||
| **Performance investigation** | `docs/performance-investigation-report.md` |
|
||||
| **First-load optimization** | `docs/first-load-optimization-plan.md`, `docs/perf-audit-first-load.md` |
|
||||
| **Codebase quality / refactoring summary** | `docs/code-structure-findings.md` |
|
||||
| **Dead code audit** | `docs/cleanup-findings.md` |
|
||||
| **TypeScript improvements** | `docs/typescript-improvement-suggestions.md` |
|
||||
| **Browser testing** | `docs/browser-testing-guide.md` |
|
||||
| **Mobile testing report** | `docs/mobile-testing-report.md` |
|
||||
| **Voice input** | `docs/voice-input-plan.md` |
|
||||
| **Improvement roadmaps** | `docs/respawn-improvement-plan.md`, `docs/ralph-improvement-plan.md`, `docs/plan-improvement-roadmap.md` |
|
||||
| **Background keystroke forwarding** | `docs/background-keystroke-forwarding-merged-plan.md` |
|
||||
| **QR auth design** | `docs/qr-auth-plan.md` |
|
||||
| **Run summary** | `docs/run-summary-plan.md` |
|
||||
|
||||
Additional design docs and investigation reports are in the `docs/` directory.
|
||||
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`. Agent Teams: `agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/tmux-manager.sh` | Safe tmux session management (use instead of direct kill commands) |
|
||||
| `scripts/monitor-respawn.sh` | Monitor respawn state machine in real-time |
|
||||
| `scripts/watch-subagents.ts` | Real-time subagent transcript watcher (list, follow by session/agent ID) |
|
||||
| `scripts/codeman-web.service` | systemd service file for production deployment |
|
||||
| `scripts/codeman-tunnel.service` | systemd service file for persistent Cloudflare tunnel |
|
||||
| `scripts/tunnel.sh` | Start/stop/check Cloudflare quick tunnel (`./scripts/tunnel.sh start\|stop\|url`) |
|
||||
| `scripts/build.mjs` | esbuild-based production build (called by `npm run build`) |
|
||||
| `scripts/postinstall.js` | npm postinstall hook for setup |
|
||||
|
||||
Additional scripts in `scripts/` for screenshots, demos, Ralph wizards, and browser testing.
|
||||
Key: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh` (tunnel start/stop/url). Production: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`.
|
||||
|
||||
## Memory Leak Prevention
|
||||
|
||||
Frontend runs long (24+ hour sessions); all Maps/timers must be cleaned up.
|
||||
|
||||
### Cleanup Patterns
|
||||
When adding new event listeners or timers:
|
||||
1. Store handler references for later removal
|
||||
2. Add cleanup to appropriate `stop()` or `cleanup*()` method
|
||||
3. For singleton watchers, store refs in class properties and remove in server `stop()`
|
||||
|
||||
**Backend**: Clear Maps in `stop()`, null promise callbacks on error, remove watcher listeners on shutdown. Use `CleanupManager` for centralized disposal — supports timers, intervals, watchers, listeners, streams. Guard async callbacks with `if (this.cleanup.isStopped) return`.
|
||||
|
||||
**Frontend**: Store drag/resize handlers on elements, clean up in `close*()` functions. SSE reconnect calls `handleInit()` which resets state. SSE listeners are tracked in an array and removed on reconnect to prevent accumulation.
|
||||
|
||||
Run `npx vitest run test/memory-leak-prevention.test.ts` to verify patterns.
|
||||
24+ hour sessions: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npx vitest run test/memory-leak-prevention.test.ts`.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**Investigating a bug**: Start dev server (`npx tsx src/index.ts web`), reproduce in browser, check terminal output and `~/.codeman/state.json` for clues.
|
||||
**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`.
|
||||
|
||||
**Adding a new API endpoint**: Define types in the appropriate `src/types/*.ts` domain file, add route in the matching `src/web/routes/*-routes.ts` module, broadcast SSE events if needed, handle in `app.js:handleSSEEvent()`.
|
||||
## Tunnel
|
||||
|
||||
**Modifying respawn behavior**: Study `docs/respawn-state-machine.md` first. The state machine is in `respawn-controller.ts`. Use MockSession from `test/respawn-test-utils.ts` for testing.
|
||||
|
||||
**Modifying mobile behavior**: Mobile singletons (`MobileDetection`, `KeyboardHandler`, `SwipeHandler`, `KeyboardAccessoryBar`) all have `init()`/`cleanup()` lifecycle. KeyboardHandler uses `visualViewport` API for iOS keyboard detection (100px threshold for address bar drift). All mobile handlers are re-initialized after SSE reconnect to prevent stale closures.
|
||||
|
||||
**Adding a file watcher**: Use `ImageWatcher` as a template pattern — chokidar with `awaitWriteFinish`, burst throttling (max 20/10s), debouncing (200ms), and auto-ignore of `node_modules/.git/dist/`.
|
||||
|
||||
## Tunnel Setup (Remote Access)
|
||||
|
||||
Access Codeman from mobile/remote devices via Cloudflare quick tunnel.
|
||||
|
||||
```
|
||||
Browser → Cloudflare Edge (HTTPS) → cloudflared → localhost:3000
|
||||
```
|
||||
|
||||
**Prerequisites**: `cloudflared` installed (`cloudflared --version`), `CODEMAN_PASSWORD` set in environment.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Via CLI
|
||||
./scripts/tunnel.sh start # Start tunnel, prints public URL
|
||||
./scripts/tunnel.sh url # Show current URL
|
||||
./scripts/tunnel.sh stop # Stop tunnel
|
||||
|
||||
# Via web UI: Settings → Tunnel → Toggle On
|
||||
```
|
||||
|
||||
### systemd Service (Persistent)
|
||||
|
||||
```bash
|
||||
# Install and enable
|
||||
cp scripts/codeman-tunnel.service ~/.config/systemd/user/
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now codeman-tunnel
|
||||
|
||||
# Check logs
|
||||
journalctl --user -u codeman-tunnel -f
|
||||
```
|
||||
|
||||
### Auth Flow
|
||||
|
||||
1. First request → browser shows Basic Auth prompt (username: `admin` or `CODEMAN_USERNAME`), or scan QR code from tunnel settings panel
|
||||
2. On success → server issues `codeman_session` HttpOnly cookie (24h TTL, auto-extends on activity)
|
||||
3. Subsequent requests → cookie authenticates silently (no more prompts)
|
||||
4. SSE works automatically — `EventSource` sends same-origin cookies
|
||||
5. 10 failed attempts per IP → 429 rate limit (15-minute decay)
|
||||
|
||||
### Security Requirements
|
||||
|
||||
- **Always set `CODEMAN_PASSWORD`** before exposing via tunnel — without it, anyone with the URL has full access
|
||||
- Session cookies are `Secure` when using `--https` flag; through Cloudflare tunnel without `--https`, cookies are non-Secure but traffic is still encrypted end-to-end via Cloudflare
|
||||
- `/api/hook-event` bypasses auth (localhost-only Claude Code hooks need unauthenticated access)
|
||||
`./scripts/tunnel.sh start|stop|url`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
|
||||
|
||||
@@ -68,11 +68,23 @@ Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/e
|
||||
|
||||
## Mobile-Optimized Web UI
|
||||
|
||||
The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work.
|
||||
The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work — not a desktop UI crammed onto a small screen.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-landing-qr.png" alt="Mobile — landing page with QR auth" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-idle.png" alt="Mobile — idle session with keyboard accessory" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-active.png" alt="Mobile — active agent session" width="260"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Landing page with QR auth</em></td>
|
||||
<td align="center"><em>Keyboard accessory bar</em></td>
|
||||
<td align="center"><em>Agent working in real-time</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td rowspan="8" width="320"><img src="docs/screenshots/mobile-keyboard-open.png" alt="Mobile — keyboard open" width="300"></td>
|
||||
<th>Terminal Apps</th>
|
||||
<th>Codeman Mobile</th>
|
||||
</tr>
|
||||
@@ -82,11 +94,22 @@ The most responsive AI coding agent experience on any phone. Full xterm.js termi
|
||||
<tr><td>No notifications</td><td>Push alerts for approvals and idle</td></tr>
|
||||
<tr><td>Manual reconnect</td><td>tmux persistence</td></tr>
|
||||
<tr><td>No agent visibility</td><td>Background agents in real-time</td></tr>
|
||||
<tr><td>Copy-paste slash commands</td><td>One-tap <code>/init</code></tr>
|
||||
<tr><td>Copy-paste slash commands</td><td>One-tap <code>/init</code>, <code>/clear</code>, <code>/compact</code></td></tr>
|
||||
<tr><td>Password typing on phone</td><td><b>QR code scan — instant auth</b></td></tr>
|
||||
</table>
|
||||
|
||||
- **Swipe navigation** — left/right on the terminal to switch sessions (80px threshold, 300ms)
|
||||
### Secure QR Code Authentication
|
||||
|
||||
Typing passwords on a phone keyboard is miserable. Codeman replaces it with **cryptographically secure single-use QR tokens** — scan the code displayed on your desktop and your phone is authenticated instantly.
|
||||
|
||||
Each QR encodes a URL containing a 6-character short code that maps to a 256-bit secret (`crypto.randomBytes(32)`) on the server. Tokens auto-rotate every **60 seconds**, are **atomically consumed on first scan** (replays always fail), and use **hash-based `Map.get()` lookup** that leaks nothing through response timing. The short code is an opaque pointer — the real secret never appears in browser history, `Referer` headers, or Cloudflare edge logs.
|
||||
|
||||
The security design addresses all 6 critical QR auth flaws identified in ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) (USENIX Security 2025, which found 47 of the top-100 websites vulnerable): single-use enforcement, short TTL, cryptographic randomness, server-side generation, real-time desktop notification on scan (QRLjacking detection), and IP + User-Agent session binding with manual revocation. Dual-layer rate limiting (per-IP + global) makes brute force infeasible across 62^6 = 56.8 billion possible codes. Full security analysis: [`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
### Touch-Optimized Interface
|
||||
|
||||
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard. Destructive commands (`/clear`, `/compact`) require a double-press to confirm — first tap arms the button, second tap executes — so you never fire one by accident on a bumpy commute
|
||||
- **Swipe navigation** — left/right on the terminal to switch sessions (80px threshold, 300ms)
|
||||
- **Smart keyboard handling** — toolbar and terminal shift up when keyboard opens (uses `visualViewport` API with 100px threshold for iOS address bar drift)
|
||||
- **Safe area support** — respects iPhone notch and home indicator via `env(safe-area-inset-*)`
|
||||
- **44px touch targets** — all buttons meet iOS Human Interface Guidelines minimum sizes
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 894 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 576 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
+245
-46
@@ -312,6 +312,32 @@ get_opencode_path() {
|
||||
done
|
||||
}
|
||||
|
||||
check_cloudflared() {
|
||||
# Check ~/.local/bin first (matches tunnel-manager.ts resolution order)
|
||||
if [[ -x "$HOME/.local/bin/cloudflared" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -x "/usr/local/bin/cloudflared" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if command -v cloudflared &>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
get_cloudflared_path() {
|
||||
if [[ -x "$HOME/.local/bin/cloudflared" ]]; then
|
||||
echo "$HOME/.local/bin/cloudflared"
|
||||
return
|
||||
fi
|
||||
if [[ -x "/usr/local/bin/cloudflared" ]]; then
|
||||
echo "/usr/local/bin/cloudflared"
|
||||
return
|
||||
fi
|
||||
command -v cloudflared 2>/dev/null
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Dependency Installation
|
||||
# ============================================================================
|
||||
@@ -541,6 +567,82 @@ install_git_suse() {
|
||||
run_as_root zypper install -y git
|
||||
}
|
||||
|
||||
install_cloudflared_macos() {
|
||||
info "Installing cloudflared via Homebrew..."
|
||||
ensure_homebrew
|
||||
brew install cloudflared
|
||||
}
|
||||
|
||||
install_cloudflared_debian() {
|
||||
info "Installing cloudflared..."
|
||||
ensure_sudo
|
||||
local arch
|
||||
arch="$(dpkg --print-architecture 2>/dev/null || echo "amd64")"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$arch.deb" "$tmp"
|
||||
run_as_root dpkg -i "$tmp"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
install_cloudflared_fedora() {
|
||||
info "Installing cloudflared..."
|
||||
ensure_sudo
|
||||
local arch
|
||||
arch="$(uname -m)"
|
||||
local rpm_arch="$arch"
|
||||
[[ "$arch" == "x86_64" ]] && rpm_arch="x86_64"
|
||||
[[ "$arch" == "aarch64" ]] && rpm_arch="aarch64"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$rpm_arch.rpm" "$tmp"
|
||||
run_as_root rpm -i "$tmp" || run_as_root rpm -U "$tmp"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
install_cloudflared_arch() {
|
||||
info "Installing cloudflared binary..."
|
||||
local arch
|
||||
arch="$(uname -m)"
|
||||
local cf_arch="amd64"
|
||||
[[ "$arch" == "aarch64" ]] && cf_arch="arm64"
|
||||
[[ "$arch" == "armv7l" ]] && cf_arch="arm"
|
||||
ensure_sudo
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$cf_arch" "$tmp"
|
||||
run_as_root mv "$tmp" /usr/local/bin/cloudflared
|
||||
run_as_root chmod +x /usr/local/bin/cloudflared
|
||||
}
|
||||
|
||||
install_cloudflared_alpine() {
|
||||
info "Installing cloudflared binary..."
|
||||
local arch
|
||||
arch="$(uname -m)"
|
||||
local cf_arch="amd64"
|
||||
[[ "$arch" == "aarch64" ]] && cf_arch="arm64"
|
||||
[[ "$arch" == "armv7l" ]] && cf_arch="arm"
|
||||
ensure_sudo
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$cf_arch" "$tmp"
|
||||
run_as_root mv "$tmp" /usr/local/bin/cloudflared
|
||||
run_as_root chmod +x /usr/local/bin/cloudflared
|
||||
}
|
||||
|
||||
install_cloudflared_suse() {
|
||||
info "Installing cloudflared..."
|
||||
ensure_sudo
|
||||
local arch
|
||||
arch="$(uname -m)"
|
||||
local rpm_arch="$arch"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
download "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$rpm_arch.rpm" "$tmp"
|
||||
run_as_root rpm -i "$tmp" || run_as_root rpm -U "$tmp"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Interactive Prompts
|
||||
# ============================================================================
|
||||
@@ -733,6 +835,22 @@ EOF
|
||||
success "Systemd service installed and started"
|
||||
}
|
||||
|
||||
setup_tunnel_service() {
|
||||
local service_dir="$HOME/.config/systemd/user"
|
||||
local service_file="$service_dir/codeman-tunnel.service"
|
||||
|
||||
info "Setting up Cloudflare tunnel systemd service..."
|
||||
|
||||
mkdir -p "$service_dir"
|
||||
cp "$INSTALL_DIR/scripts/codeman-tunnel.service" "$service_file"
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable codeman-tunnel.service 2>/dev/null || true
|
||||
|
||||
success "Tunnel service installed (start with: systemctl --user start codeman-tunnel)"
|
||||
echo -e " ${DIM}Note: Set CODEMAN_PASSWORD env var before starting the tunnel for security.${NC}"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Installation Helpers
|
||||
# ============================================================================
|
||||
@@ -915,6 +1033,24 @@ main() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# cloudflared (optional — for remote/mobile access via Cloudflare Tunnel)
|
||||
info "Checking cloudflared (optional, for remote access)..."
|
||||
if check_cloudflared; then
|
||||
success "cloudflared found at $(get_cloudflared_path)"
|
||||
else
|
||||
if prompt_yes_no "Install cloudflared? (enables remote/mobile access via Cloudflare Tunnel)" "n"; then
|
||||
install_dependency "cloudflared" "$os" "$distro"
|
||||
hash -r 2>/dev/null || true
|
||||
if check_cloudflared; then
|
||||
success "cloudflared installed at $(get_cloudflared_path)"
|
||||
else
|
||||
warn "cloudflared installation failed. You can install it manually later."
|
||||
fi
|
||||
else
|
||||
info "Skipped (you can install cloudflared later for remote access)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ========================================================================
|
||||
@@ -989,18 +1125,7 @@ main() {
|
||||
fi
|
||||
|
||||
# ========================================================================
|
||||
# Systemd Service (Linux only)
|
||||
# ========================================================================
|
||||
|
||||
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
|
||||
echo ""
|
||||
if prompt_yes_no "Set up systemd service for auto-start?" "n"; then
|
||||
setup_systemd_service
|
||||
fi
|
||||
fi
|
||||
|
||||
# ========================================================================
|
||||
# Success!
|
||||
# Launch Options
|
||||
# ========================================================================
|
||||
|
||||
echo ""
|
||||
@@ -1009,13 +1134,73 @@ main() {
|
||||
echo -e "${GREEN}${BOLD}============================================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if systemd service is running (we just started it above)
|
||||
local service_running=false
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null; then
|
||||
service_running=true
|
||||
local launch_choice=""
|
||||
local has_systemd=false
|
||||
|
||||
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
|
||||
has_systemd=true
|
||||
fi
|
||||
|
||||
if [[ "$service_running" == "true" ]]; then
|
||||
if [[ "$has_systemd" == "true" ]]; then
|
||||
echo -e " ${BOLD}How would you like to run Codeman?${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||
echo -e " ${CYAN}2)${NC} Install as systemd service (auto-start on boot)"
|
||||
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
|
||||
echo ""
|
||||
|
||||
if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then
|
||||
launch_choice="3"
|
||||
else
|
||||
while true; do
|
||||
echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2
|
||||
read -r launch_choice
|
||||
case "$launch_choice" in
|
||||
1|2|3) break ;;
|
||||
*) echo "Please enter 1, 2, or 3." >&2 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
else
|
||||
# macOS or no systemd — only offer run now or skip
|
||||
echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||
echo -e " ${CYAN}2)${NC} Don't start — I'll run it later"
|
||||
echo ""
|
||||
|
||||
if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then
|
||||
launch_choice="2"
|
||||
else
|
||||
while true; do
|
||||
echo -en "${CYAN}Choose [1/2]:${NC} " >&2
|
||||
read -r launch_choice
|
||||
case "$launch_choice" in
|
||||
1) break ;;
|
||||
2) break ;;
|
||||
*) echo "Please enter 1 or 2." >&2 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
# Remap: no-systemd choice "2" (skip) → internal "3"
|
||||
[[ "$launch_choice" == "2" ]] && launch_choice="3"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Handle systemd setup
|
||||
if [[ "$launch_choice" == "2" ]]; then
|
||||
setup_systemd_service
|
||||
|
||||
# Offer tunnel service if cloudflared is available
|
||||
if check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||
echo ""
|
||||
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
|
||||
setup_tunnel_service
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}# Open in browser${NC}"
|
||||
@@ -1028,20 +1213,29 @@ main() {
|
||||
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
||||
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
||||
echo ""
|
||||
else
|
||||
fi
|
||||
|
||||
# Show quick-start help for non-service paths
|
||||
if [[ "$launch_choice" != "2" ]]; then
|
||||
echo -e " ${BOLD}Quick Start:${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}# Start the web server${NC}"
|
||||
echo -e " codeman web"
|
||||
echo ""
|
||||
echo -e " ${CYAN}# Start with HTTPS (only needed for remote access)${NC}"
|
||||
echo -e " codeman web --https"
|
||||
echo -e " ${CYAN}codeman web${NC} # Start the web server"
|
||||
echo -e " ${CYAN}codeman web --https${NC} # With HTTPS (for remote access)"
|
||||
echo ""
|
||||
echo -e " ${CYAN}# Open in browser${NC}"
|
||||
echo -e " http://localhost:3000"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if check_cloudflared; then
|
||||
echo -e " ${BOLD}Remote Access (Cloudflare Tunnel):${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}./scripts/tunnel.sh start${NC} # Start tunnel"
|
||||
echo -e " ${CYAN}./scripts/tunnel.sh url${NC} # Show tunnel URL"
|
||||
echo -e " ${CYAN}./scripts/tunnel.sh stop${NC} # Stop tunnel"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e " ${BOLD}Mobile Access (Termius/SSH):${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}sc${NC} # Interactive tmux session chooser"
|
||||
@@ -1060,16 +1254,19 @@ main() {
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check if PATH needs reload in user's shell (only relevant if service not running)
|
||||
if [[ "$service_running" != "true" ]]; then
|
||||
# Run now in foreground (must be last — exec replaces the shell)
|
||||
if [[ "$launch_choice" == "1" ]]; then
|
||||
local profile
|
||||
profile=$(detect_shell_profile)
|
||||
if ! command -v codeman &>/dev/null 2>&1; then
|
||||
echo -e " ${YELLOW}Run this to start using codeman now:${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}source $profile && codeman web${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo -e " ${GREEN}${BOLD}Starting Codeman...${NC}"
|
||||
echo -e " ${DIM}Press Ctrl+C to stop${NC}"
|
||||
echo ""
|
||||
|
||||
# Source profile to pick up PATH changes, then exec codeman
|
||||
# shellcheck disable=SC1090
|
||||
source "$profile" 2>/dev/null || true
|
||||
exec node "$INSTALL_DIR/dist/index.js" web
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1095,21 +1292,23 @@ uninstall() {
|
||||
info "Uninstalling Codeman..."
|
||||
echo ""
|
||||
|
||||
# Stop and remove systemd service
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null; then
|
||||
info "Stopping codeman-web service..."
|
||||
systemctl --user stop codeman-web.service
|
||||
fi
|
||||
if systemctl --user is-enabled codeman-web.service &>/dev/null 2>&1; then
|
||||
info "Disabling codeman-web service..."
|
||||
systemctl --user disable codeman-web.service 2>/dev/null || true
|
||||
fi
|
||||
local service_file="$HOME/.config/systemd/user/codeman-web.service"
|
||||
if [[ -f "$service_file" ]]; then
|
||||
rm -f "$service_file"
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
success "Systemd service removed"
|
||||
fi
|
||||
# Stop and remove systemd services
|
||||
for svc in codeman-web codeman-tunnel; do
|
||||
if systemctl --user is-active "${svc}.service" &>/dev/null; then
|
||||
info "Stopping ${svc} service..."
|
||||
systemctl --user stop "${svc}.service"
|
||||
fi
|
||||
if systemctl --user is-enabled "${svc}.service" &>/dev/null 2>&1; then
|
||||
info "Disabling ${svc} service..."
|
||||
systemctl --user disable "${svc}.service" 2>/dev/null || true
|
||||
fi
|
||||
local svc_file="$HOME/.config/systemd/user/${svc}.service"
|
||||
if [[ -f "$svc_file" ]]; then
|
||||
rm -f "$svc_file"
|
||||
success "Removed ${svc} service"
|
||||
fi
|
||||
done
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
|
||||
# Remove symlinks
|
||||
local symlink_dir="$HOME/.local/bin"
|
||||
|
||||
Generated
+302
-229
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.4",
|
||||
"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,6 +51,8 @@
|
||||
"@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",
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
@@ -72,6 +74,7 @@
|
||||
"@remotion/transitions": "4.0.429",
|
||||
"@types/node": "^20.19.33",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
|
||||
@@ -1,4 +1,50 @@
|
||||
import type { RenderParams, FontStyle } from './types.js';
|
||||
import type { RenderParams, FontStyle, XtermTerminal } from './types.js';
|
||||
|
||||
// ─── CJK / fullwidth character width detection ───────────────────────
|
||||
|
||||
/**
|
||||
* Get visual cell width of a single character.
|
||||
* CJK wide characters occupy 2 cells, others occupy 1.
|
||||
* Prefers the terminal's Unicode addon when available.
|
||||
*/
|
||||
export function charCellWidth(terminal: XtermTerminal | null | undefined, ch: string): number {
|
||||
if (terminal?.unicode?.getStringCellWidth) {
|
||||
return terminal.unicode.getStringCellWidth(ch);
|
||||
}
|
||||
// Fallback: detect CJK wide characters by Unicode range
|
||||
const code = ch.codePointAt(0);
|
||||
if (
|
||||
code !== undefined &&
|
||||
code >= 0x1100 &&
|
||||
(code <= 0x115f || // Hangul Jamo
|
||||
(code >= 0x2e80 && code <= 0x303e) || // CJK Radicals, Kangxi, Ideographic
|
||||
(code >= 0x3040 && code <= 0x33bf) || // Hiragana, Katakana, Bopomofo, CJK Compat
|
||||
(code >= 0x3400 && code <= 0x4dbf) || // CJK Unified Ext A
|
||||
(code >= 0x4e00 && code <= 0xa4cf) || // CJK Unified, Yi
|
||||
(code >= 0xa960 && code <= 0xa97c) || // Hangul Jamo Extended-A
|
||||
(code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables
|
||||
(code >= 0xf900 && code <= 0xfaff) || // CJK Compat Ideographs
|
||||
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compat Forms
|
||||
(code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms
|
||||
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs
|
||||
(code >= 0x1f000 && code <= 0x1fbff) || // Mahjong, Domino, Emoji
|
||||
(code >= 0x20000 && code <= 0x2ffff) || // CJK Unified Ext B-F
|
||||
(code >= 0x30000 && code <= 0x3ffff)) // CJK Unified Ext G+
|
||||
)
|
||||
return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visual cell width of a string (sum of all character widths).
|
||||
*/
|
||||
export function stringCellWidth(terminal: XtermTerminal | null | undefined, str: string): number {
|
||||
let w = 0;
|
||||
for (const ch of str) w += charCellWidth(terminal, ch);
|
||||
return w;
|
||||
}
|
||||
|
||||
// ─── Overlay rendering ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the overlay content into the container element.
|
||||
@@ -6,100 +52,114 @@ import type { RenderParams, FontStyle } from './types.js';
|
||||
* Creates per-character `<span>` elements positioned on an exact grid
|
||||
* matching xterm.js's canvas renderer. This avoids sub-pixel drift that
|
||||
* occurs with normal DOM text flow.
|
||||
*
|
||||
* CJK wide characters are rendered with double-width spans.
|
||||
*/
|
||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||
const { lines, startCol, totalCols, cellW, cellH, charTop, charHeight, promptRow, font, showCursor, cursorColor } = params;
|
||||
const {
|
||||
lines,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
cellH,
|
||||
charTop,
|
||||
charHeight,
|
||||
promptRow,
|
||||
font,
|
||||
showCursor,
|
||||
cursorColor,
|
||||
terminal,
|
||||
} = params;
|
||||
|
||||
// Position container at prompt row.
|
||||
container.style.left = '0px';
|
||||
container.style.top = (promptRow * cellH) + 'px';
|
||||
// Position container at prompt row.
|
||||
container.style.left = '0px';
|
||||
container.style.top = promptRow * cellH + 'px';
|
||||
|
||||
// Clear and rebuild (typically 1-3 line divs, negligible cost)
|
||||
container.innerHTML = '';
|
||||
const fullWidthPx = totalCols * cellW;
|
||||
// Clear and rebuild (typically 1-3 line divs, negligible cost)
|
||||
container.innerHTML = '';
|
||||
const fullWidthPx = totalCols * cellW;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const leftPx = i === 0 ? startCol * cellW : 0;
|
||||
const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font);
|
||||
container.appendChild(lineEl);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const leftPx = i === 0 ? startCol * cellW : 0;
|
||||
const widthPx = i === 0 ? fullWidthPx - leftPx : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
|
||||
// Block cursor at end of last line (use visual width for CJK support)
|
||||
if (showCursor) {
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const lastLineLeft = lines.length === 1 ? startCol : 0;
|
||||
const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine);
|
||||
if (cursorCol < totalCols) {
|
||||
const cursor = document.createElement('span');
|
||||
cursor.style.cssText = 'position:absolute;display:inline-block';
|
||||
cursor.style.left = cursorCol * cellW + 'px';
|
||||
cursor.style.top = (lines.length - 1) * cellH + 'px';
|
||||
cursor.style.width = cellW + 'px';
|
||||
cursor.style.height = cellH + 'px';
|
||||
cursor.style.backgroundColor = cursorColor;
|
||||
container.appendChild(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
// Block cursor at end of last line
|
||||
if (showCursor) {
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const lastLineLeft = lines.length === 1 ? startCol : 0;
|
||||
const cursorCol = lastLineLeft + lastLine.length;
|
||||
if (cursorCol < totalCols) {
|
||||
const cursor = document.createElement('span');
|
||||
cursor.style.cssText = 'position:absolute;display:inline-block';
|
||||
cursor.style.left = (cursorCol * cellW) + 'px';
|
||||
cursor.style.top = ((lines.length - 1) * cellH) + 'px';
|
||||
cursor.style.width = cellW + 'px';
|
||||
cursor.style.height = cellH + 'px';
|
||||
cursor.style.backgroundColor = cursorColor;
|
||||
container.appendChild(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
container.style.display = '';
|
||||
container.style.display = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a styled line `<div>` with per-character grid positioning.
|
||||
*
|
||||
* Each character gets its own `<span>` placed at `i * cellW` pixels.
|
||||
* This matches xterm's canvas renderer where each glyph occupies exactly
|
||||
* one cell width, regardless of the actual glyph metrics.
|
||||
* Each character gets its own `<span>` positioned by visual column offset.
|
||||
* CJK wide characters occupy 2 cell widths.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
leftPx: number,
|
||||
topPx: number,
|
||||
widthPx: number,
|
||||
cellH: number,
|
||||
cellW: number,
|
||||
charTop: number,
|
||||
charHeight: number,
|
||||
font: FontStyle,
|
||||
text: string,
|
||||
leftPx: number,
|
||||
topPx: number,
|
||||
widthPx: number,
|
||||
cellH: number,
|
||||
cellW: number,
|
||||
_charTop: number,
|
||||
_charHeight: number,
|
||||
font: FontStyle,
|
||||
terminal?: XtermTerminal | null
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
el.style.backgroundColor = font.backgroundColor;
|
||||
el.style.left = leftPx + 'px';
|
||||
el.style.top = topPx + 'px';
|
||||
el.style.width = widthPx + 'px';
|
||||
// Extend background 1px past cell boundary to cover the compositing
|
||||
// seam between the overlay layer (z-index:7) and the canvas layer below.
|
||||
// The extra 1px lands in the next row's charTop gap (empty area before
|
||||
// text rendering starts), so no canvas content is obscured.
|
||||
el.style.height = (cellH + 1) + 'px';
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
el.style.backgroundColor = font.backgroundColor;
|
||||
el.style.left = leftPx + 'px';
|
||||
el.style.top = topPx + 'px';
|
||||
el.style.width = widthPx + 'px';
|
||||
// Extend background 1px past cell boundary to cover the compositing
|
||||
// seam between the overlay layer (z-index:7) and the canvas layer below.
|
||||
// The extra 1px lands in the next row's charTop gap (empty area before
|
||||
// text rendering starts), so no canvas content is obscured.
|
||||
el.style.height = cellH + 1 + 'px';
|
||||
|
||||
// Spans fill the full cell height with matching lineHeight for natural
|
||||
// CSS vertical centering. No transform — any sub-pixel overhang past
|
||||
// the line div causes visible anti-aliasing artifacts at the boundary.
|
||||
// The ≤0.5px difference from canvas ceil() rounding is imperceptible.
|
||||
// CJK wide chars occupy 2 cells — position by visual column offset
|
||||
let colOffset = 0;
|
||||
for (const ch of text) {
|
||||
const cw = charCellWidth(terminal, ch);
|
||||
const span = document.createElement('span');
|
||||
// No ligatures — canvas renders each glyph independently.
|
||||
span.style.cssText =
|
||||
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
|
||||
"font-feature-settings:'liga' 0,'calt' 0";
|
||||
span.style.left = colOffset * cellW + 'px';
|
||||
span.style.top = '0px';
|
||||
span.style.width = cw * cellW + 'px';
|
||||
span.style.height = cellH + 'px';
|
||||
span.style.lineHeight = cellH + 'px';
|
||||
span.style.fontFamily = font.fontFamily;
|
||||
span.style.fontSize = font.fontSize;
|
||||
span.style.fontWeight = font.fontWeight;
|
||||
span.style.color = font.color;
|
||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||
span.textContent = ch;
|
||||
el.appendChild(span);
|
||||
colOffset += cw;
|
||||
}
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const span = document.createElement('span');
|
||||
// No ligatures — canvas renders each glyph independently.
|
||||
span.style.cssText =
|
||||
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
|
||||
"font-feature-settings:'liga' 0,'calt' 0";
|
||||
span.style.left = (i * cellW) + 'px';
|
||||
span.style.top = '0px';
|
||||
span.style.width = cellW + 'px';
|
||||
span.style.height = cellH + 'px';
|
||||
span.style.lineHeight = cellH + 'px';
|
||||
span.style.fontFamily = font.fontFamily;
|
||||
span.style.fontSize = font.fontSize;
|
||||
span.style.fontWeight = font.fontWeight;
|
||||
span.style.color = font.color;
|
||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||
span.textContent = text[i];
|
||||
el.appendChild(span);
|
||||
}
|
||||
|
||||
return el;
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -5,28 +5,35 @@
|
||||
* Consumers pass their real Terminal instance — we only use these properties.
|
||||
*/
|
||||
export interface XtermTerminal {
|
||||
readonly element: HTMLElement | undefined;
|
||||
readonly cols: number;
|
||||
readonly rows: number;
|
||||
readonly options: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string | number;
|
||||
theme?: {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
readonly element: HTMLElement | undefined;
|
||||
readonly cols: number;
|
||||
readonly rows: number;
|
||||
readonly options: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string | number;
|
||||
theme?: {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
readonly buffer: {
|
||||
readonly active: {
|
||||
readonly viewportY: number;
|
||||
readonly baseY: number;
|
||||
getLine(y: number): {
|
||||
translateToString(trimRight?: boolean): string;
|
||||
} | undefined;
|
||||
};
|
||||
};
|
||||
readonly buffer: {
|
||||
readonly active: {
|
||||
readonly viewportY: number;
|
||||
readonly baseY: number;
|
||||
getLine(y: number):
|
||||
| {
|
||||
translateToString(trimRight?: boolean): string;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
};
|
||||
/** Unicode addon (e.g. Unicode11Addon) for CJK wide character width */
|
||||
readonly unicode?: {
|
||||
getStringCellWidth(str: string): number;
|
||||
activeVersion?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,18 +42,18 @@ export interface XtermTerminal {
|
||||
* The consumer calls `terminal.loadAddon(addon)` which invokes `activate()`.
|
||||
*/
|
||||
export interface XtermAddon {
|
||||
activate(terminal: XtermTerminal): void;
|
||||
dispose(): void;
|
||||
activate(terminal: XtermTerminal): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position of the prompt in the terminal viewport.
|
||||
*/
|
||||
export interface PromptPosition {
|
||||
/** Viewport-relative row (0 = top of viewport) */
|
||||
row: number;
|
||||
/** Column of the prompt marker character */
|
||||
col: number;
|
||||
/** Viewport-relative row (0 = top of viewport) */
|
||||
row: number;
|
||||
/** Column of the prompt marker character */
|
||||
col: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,112 +67,114 @@ export interface PromptPosition {
|
||||
* - `custom`: Full escape hatch — provide your own finder function
|
||||
*/
|
||||
export type PromptFinder =
|
||||
| { type: 'character'; char: string; offset?: number }
|
||||
| { type: 'regex'; pattern: RegExp; offset?: number }
|
||||
| { type: 'custom'; find: (terminal: XtermTerminal) => PromptPosition | null; offset?: number };
|
||||
| { type: 'character'; char: string; offset?: number }
|
||||
| { type: 'regex'; pattern: RegExp; offset?: number }
|
||||
| { type: 'custom'; find: (terminal: XtermTerminal) => PromptPosition | null; offset?: number };
|
||||
|
||||
/**
|
||||
* Configuration options for ZerolagInputAddon.
|
||||
*/
|
||||
export interface ZerolagInputOptions {
|
||||
/**
|
||||
* How to find the prompt in the terminal buffer.
|
||||
*
|
||||
* The `offset` controls how many characters after the prompt marker
|
||||
* the user input begins (e.g., `"> "` = offset 2).
|
||||
*
|
||||
* @default { type: 'character', char: '>', offset: 2 }
|
||||
*/
|
||||
prompt?: PromptFinder;
|
||||
/**
|
||||
* How to find the prompt in the terminal buffer.
|
||||
*
|
||||
* The `offset` controls how many characters after the prompt marker
|
||||
* the user input begins (e.g., `"> "` = offset 2).
|
||||
*
|
||||
* @default { type: 'character', char: '>', offset: 2 }
|
||||
*/
|
||||
prompt?: PromptFinder;
|
||||
|
||||
/**
|
||||
* Z-index for the overlay element.
|
||||
* @default 7
|
||||
*/
|
||||
zIndex?: number;
|
||||
/**
|
||||
* Z-index for the overlay element.
|
||||
* @default 7
|
||||
*/
|
||||
zIndex?: number;
|
||||
|
||||
/**
|
||||
* Background color for the overlay.
|
||||
* Set to `'transparent'` to disable the opaque background.
|
||||
* @default Read from terminal.options.theme.background
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
* Background color for the overlay.
|
||||
* Set to `'transparent'` to disable the opaque background.
|
||||
* @default Read from terminal.options.theme.background
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
|
||||
/**
|
||||
* Foreground color for overlay text.
|
||||
* @default Read from terminal.options.theme.foreground
|
||||
*/
|
||||
foregroundColor?: string;
|
||||
/**
|
||||
* Foreground color for overlay text.
|
||||
* @default Read from terminal.options.theme.foreground
|
||||
*/
|
||||
foregroundColor?: string;
|
||||
|
||||
/**
|
||||
* Whether to show a block cursor at the end of the overlay text.
|
||||
* @default true
|
||||
*/
|
||||
showCursor?: boolean;
|
||||
/**
|
||||
* Whether to show a block cursor at the end of the overlay text.
|
||||
* @default true
|
||||
*/
|
||||
showCursor?: boolean;
|
||||
|
||||
/**
|
||||
* Cursor color (block cursor at end of text).
|
||||
* @default Read from terminal.options.theme.cursor
|
||||
*/
|
||||
cursorColor?: string;
|
||||
/**
|
||||
* Cursor color (block cursor at end of text).
|
||||
* @default Read from terminal.options.theme.cursor
|
||||
*/
|
||||
cursorColor?: string;
|
||||
|
||||
/**
|
||||
* Scroll debounce time in ms for re-rendering when user scrolls
|
||||
* back to the bottom of the terminal.
|
||||
* @default 50
|
||||
*/
|
||||
scrollDebounceMs?: number;
|
||||
/**
|
||||
* Scroll debounce time in ms for re-rendering when user scrolls
|
||||
* back to the bottom of the terminal.
|
||||
* @default 50
|
||||
*/
|
||||
scrollDebounceMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only state snapshot of the overlay.
|
||||
*/
|
||||
export interface ZerolagInputState {
|
||||
/** Characters typed but not yet acknowledged by the server */
|
||||
pendingText: string;
|
||||
/** Number of characters flushed to PTY but echo not yet received */
|
||||
flushedLength: number;
|
||||
/** Text content of the flushed portion */
|
||||
flushedText: string;
|
||||
/** Whether the overlay is currently visible */
|
||||
visible: boolean;
|
||||
/** Last detected prompt position, if any */
|
||||
promptPosition: PromptPosition | null;
|
||||
/** Characters typed but not yet acknowledged by the server */
|
||||
pendingText: string;
|
||||
/** Number of characters flushed to PTY but echo not yet received */
|
||||
flushedLength: number;
|
||||
/** Text content of the flushed portion */
|
||||
flushedText: string;
|
||||
/** Whether the overlay is currently visible */
|
||||
visible: boolean;
|
||||
/** Last detected prompt position, if any */
|
||||
promptPosition: PromptPosition | null;
|
||||
}
|
||||
|
||||
/** Cell dimensions in CSS pixels. */
|
||||
export interface CellDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Vertical offset (px) from cell top to where characters render. */
|
||||
charTop: number;
|
||||
/** Height of the character rendering area (px). */
|
||||
charHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Vertical offset (px) from cell top to where characters render. */
|
||||
charTop: number;
|
||||
/** Height of the character rendering area (px). */
|
||||
charHeight: number;
|
||||
}
|
||||
|
||||
/** Parameters for the overlay renderer. */
|
||||
export interface RenderParams {
|
||||
lines: string[];
|
||||
startCol: number;
|
||||
totalCols: number;
|
||||
cellW: number;
|
||||
cellH: number;
|
||||
/** Vertical offset (px) from cell top to character rendering area. */
|
||||
charTop: number;
|
||||
/** Height of the character rendering area (px). */
|
||||
charHeight: number;
|
||||
promptRow: number;
|
||||
font: FontStyle;
|
||||
showCursor: boolean;
|
||||
cursorColor: string;
|
||||
lines: string[];
|
||||
startCol: number;
|
||||
totalCols: number;
|
||||
cellW: number;
|
||||
cellH: number;
|
||||
/** Vertical offset (px) from cell top to character rendering area. */
|
||||
charTop: number;
|
||||
/** Height of the character rendering area (px). */
|
||||
charHeight: number;
|
||||
promptRow: number;
|
||||
font: FontStyle;
|
||||
showCursor: boolean;
|
||||
cursorColor: string;
|
||||
/** Terminal instance for CJK wide character width detection */
|
||||
terminal?: XtermTerminal | null;
|
||||
}
|
||||
|
||||
/** Cached font style properties for overlay rendering. */
|
||||
export interface FontStyle {
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
fontWeight: string;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
letterSpacing: string;
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
fontWeight: string;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
letterSpacing: string;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* Comprehensive CJK wide character test plan for PR #30.
|
||||
*
|
||||
* Tests all 7 items from the test plan:
|
||||
* 1. Chinese text input — no character overlap
|
||||
* 2. CJK text renders with correct double-width spacing
|
||||
* 3. Japanese (こんにちは) and Korean (안녕하세요) input
|
||||
* 4. Cursor positions correctly after CJK characters
|
||||
* 5. Long CJK input wraps at correct column boundary
|
||||
* 6. Existing ASCII input is unaffected
|
||||
* 7. Teammate terminal panels render CJK correctly (app.js embedded copy)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
|
||||
import type { RenderParams, FontStyle } from '../src/types.js';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||
|
||||
// ─── Shared fixtures ─────────────────────────────────────────────────
|
||||
|
||||
const FONT: FontStyle = {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
color: '#eeeeee',
|
||||
backgroundColor: '#0d0d0d',
|
||||
letterSpacing: '',
|
||||
};
|
||||
|
||||
function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
|
||||
return {
|
||||
lines: ['hello'],
|
||||
startCol: 2,
|
||||
totalCols: 80,
|
||||
cellW: 10,
|
||||
cellH: 17,
|
||||
charTop: 2,
|
||||
charHeight: 14,
|
||||
promptRow: 0,
|
||||
font: FONT,
|
||||
showCursor: true,
|
||||
cursorColor: '#e0e0e0',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract span data from a rendered line div */
|
||||
function getSpans(lineDiv: HTMLDivElement) {
|
||||
const spans: { text: string; left: number; width: number }[] = [];
|
||||
for (let i = 0; i < lineDiv.children.length; i++) {
|
||||
const span = lineDiv.children[i] as HTMLSpanElement;
|
||||
spans.push({
|
||||
text: span.textContent || '',
|
||||
left: parseFloat(span.style.left),
|
||||
width: parseFloat(span.style.width),
|
||||
});
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
// ─── Addon setup helpers ─────────────────────────────────────────────
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const fn of cleanups) fn();
|
||||
cleanups = [];
|
||||
});
|
||||
|
||||
function tracked(lines: string[] = ['$ '], promptChar = '$') {
|
||||
const mock = createMockTerminal({ buffer: { lines }, cols: 80 });
|
||||
const addon = new ZerolagInputAddon({
|
||||
prompt: { type: 'character', char: promptChar, offset: 2 },
|
||||
});
|
||||
mock.terminal.loadAddon(addon);
|
||||
cleanups.push(() => {
|
||||
addon.dispose();
|
||||
mock.cleanup();
|
||||
});
|
||||
return { addon, mock };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Chinese text input — no character overlap
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Test 1: Chinese text — no character overlap', () => {
|
||||
it('consecutive Chinese characters have contiguous non-overlapping spans', () => {
|
||||
const container = document.createElement('div');
|
||||
const text = '你好世界';
|
||||
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
expect(spans.length).toBe(4);
|
||||
|
||||
// Each span: left = previous span's (left + width), width = 20px (2 cells)
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
expect(spans[i].width).toBe(20); // 2 cells * 10px
|
||||
if (i > 0) {
|
||||
const expectedLeft = spans[i - 1].left + spans[i - 1].width;
|
||||
expect(spans[i].left).toBe(expectedLeft);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('Chinese sentence (simulated pinyin output) renders without gaps or overlaps', () => {
|
||||
const container = document.createElement('div');
|
||||
const text = '我是一个测试';
|
||||
renderOverlay(container, makeParams({ lines: [text], cellW: 8 }));
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
expect(spans.length).toBe(6);
|
||||
|
||||
// Verify contiguous positioning
|
||||
let expectedLeft = 0;
|
||||
for (const span of spans) {
|
||||
expect(span.left).toBe(expectedLeft);
|
||||
expect(span.width).toBe(16); // 2 * 8px
|
||||
expectedLeft += span.width;
|
||||
}
|
||||
|
||||
// Total visual width should be 6 chars * 2 cells * 8px = 96px
|
||||
expect(expectedLeft).toBe(96);
|
||||
});
|
||||
|
||||
it('mixed Chinese + ASCII has no gaps between spans', () => {
|
||||
const container = document.createElement('div');
|
||||
const text = 'hello你好world';
|
||||
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
// h(10) e(10) l(10) l(10) o(10) 你(20) 好(20) w(10) o(10) r(10) l(10) d(10)
|
||||
expect(spans.length).toBe(12);
|
||||
|
||||
// Verify contiguity: no gaps between any adjacent spans
|
||||
for (let i = 1; i < spans.length; i++) {
|
||||
const prevEnd = spans[i - 1].left + spans[i - 1].width;
|
||||
expect(spans[i].left).toBe(prevEnd);
|
||||
}
|
||||
});
|
||||
|
||||
it('addChar with Chinese characters accumulates correctly in addon', () => {
|
||||
const { addon } = tracked();
|
||||
for (const ch of '你好世界') {
|
||||
addon.addChar(ch);
|
||||
}
|
||||
expect(addon.pendingText).toBe('你好世界');
|
||||
expect(addon.hasPending).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: CJK text renders with correct double-width spacing
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Test 2: CJK double-width spacing', () => {
|
||||
it('charCellWidth returns 2 for CJK Unified Ideographs (0x4E00-0x9FFF)', () => {
|
||||
// Common Chinese characters
|
||||
const chars = '中文测试你好世界天地人';
|
||||
for (const ch of chars) {
|
||||
expect(charCellWidth(null, ch)).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('charCellWidth returns 2 for CJK Extension A (0x3400-0x4DBF)', () => {
|
||||
expect(charCellWidth(null, '\u3400')).toBe(2); // First Extension A char
|
||||
expect(charCellWidth(null, '\u4DB5')).toBe(2); // One of the last Extension A chars
|
||||
});
|
||||
|
||||
it('charCellWidth returns 2 for CJK Radicals (0x2E80-0x2EFF)', () => {
|
||||
expect(charCellWidth(null, '\u2E80')).toBe(2); // CJK Radical Repeat
|
||||
});
|
||||
|
||||
it('charCellWidth returns 2 for fullwidth ASCII forms (0xFF01-0xFF5E)', () => {
|
||||
expect(charCellWidth(null, '\uFF01')).toBe(2); // !
|
||||
expect(charCellWidth(null, '\uFF21')).toBe(2); // A
|
||||
expect(charCellWidth(null, '\uFF41')).toBe(2); // a
|
||||
expect(charCellWidth(null, '\uFF10')).toBe(2); // 0
|
||||
});
|
||||
|
||||
it('charCellWidth returns 2 for CJK Compatibility Ideographs (0xF900-0xFAFF)', () => {
|
||||
expect(charCellWidth(null, '\uF900')).toBe(2);
|
||||
});
|
||||
|
||||
it('stringCellWidth calculates correct visual width for CJK strings', () => {
|
||||
expect(stringCellWidth(null, '你好')).toBe(4); // 2 + 2
|
||||
expect(stringCellWidth(null, '你好世界')).toBe(8); // 4 * 2
|
||||
expect(stringCellWidth(null, 'hi你好')).toBe(6); // 1 + 1 + 2 + 2
|
||||
expect(stringCellWidth(null, '你a好b')).toBe(6); // 2 + 1 + 2 + 1
|
||||
expect(stringCellWidth(null, 'abc你好def')).toBe(10); // 3 + 4 + 3
|
||||
});
|
||||
|
||||
it('CJK span widths are exactly 2 * cellW pixels', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.width).toBe('16.8px'); // 2 * 8.4
|
||||
});
|
||||
|
||||
it('ASCII span widths remain 1 * cellW pixels', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.width).toBe('8.4px'); // 1 * 8.4
|
||||
});
|
||||
|
||||
it('terminal unicode addon is preferred over fallback when available', () => {
|
||||
const mockTerminal = {
|
||||
unicode: {
|
||||
getStringCellWidth: (s: string) => {
|
||||
// Custom width: treat 'W' as wide
|
||||
return s === 'W' ? 2 : 1;
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
expect(charCellWidth(mockTerminal, 'W')).toBe(2);
|
||||
expect(charCellWidth(mockTerminal, 'a')).toBe(1);
|
||||
// Fallback ignores terminal addon result for actual CJK
|
||||
expect(charCellWidth(null, '你')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Japanese (こんにちは) and Korean (안녕하세요) input
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Test 3: Japanese and Korean input', () => {
|
||||
describe('Japanese', () => {
|
||||
it('charCellWidth returns 2 for Hiragana (0x3040-0x309F)', () => {
|
||||
const hiragana = 'あいうえおかきくけこさしすせそ';
|
||||
for (const ch of hiragana) {
|
||||
expect(charCellWidth(null, ch)).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('charCellWidth returns 2 for Katakana (0x30A0-0x30FF)', () => {
|
||||
const katakana = 'アイウエオカキクケコサシスセソ';
|
||||
for (const ch of katakana) {
|
||||
expect(charCellWidth(null, ch)).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('Japanese greeting renders with correct span positions', () => {
|
||||
const container = document.createElement('div');
|
||||
const text = 'こんにちは';
|
||||
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
expect(spans.length).toBe(5);
|
||||
// こ(0,20) ん(20,20) に(40,20) ち(60,20) は(80,20)
|
||||
expect(spans[0]).toEqual({ text: 'こ', left: 0, width: 20 });
|
||||
expect(spans[1]).toEqual({ text: 'ん', left: 20, width: 20 });
|
||||
expect(spans[2]).toEqual({ text: 'に', left: 40, width: 20 });
|
||||
expect(spans[3]).toEqual({ text: 'ち', left: 60, width: 20 });
|
||||
expect(spans[4]).toEqual({ text: 'は', left: 80, width: 20 });
|
||||
});
|
||||
|
||||
it('mixed Japanese + ASCII positions correctly', () => {
|
||||
const container = document.createElement('div');
|
||||
const text = 'hello こんにちは';
|
||||
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
// h(0) e(10) l(20) l(30) o(40) space(50) こ(60) ん(80) に(100) ち(120) は(140)
|
||||
expect(spans.length).toBe(11);
|
||||
expect(spans[5]).toEqual({ text: ' ', left: 50, width: 10 }); // space
|
||||
expect(spans[6]).toEqual({ text: 'こ', left: 60, width: 20 }); // first CJK after ASCII
|
||||
});
|
||||
|
||||
it('addon handles Japanese input via addChar', () => {
|
||||
const { addon } = tracked();
|
||||
for (const ch of 'こんにちは') {
|
||||
addon.addChar(ch);
|
||||
}
|
||||
expect(addon.pendingText).toBe('こんにちは');
|
||||
});
|
||||
|
||||
it('addon handles Japanese input via appendText (paste)', () => {
|
||||
const { addon } = tracked();
|
||||
addon.appendText('こんにちは世界');
|
||||
expect(addon.pendingText).toBe('こんにちは世界');
|
||||
expect(addon.hasPending).toBe(true);
|
||||
});
|
||||
|
||||
it('stringCellWidth correct for Japanese greeting', () => {
|
||||
expect(stringCellWidth(null, 'こんにちは')).toBe(10); // 5 * 2
|
||||
});
|
||||
});
|
||||
|
||||
describe('Korean', () => {
|
||||
it('charCellWidth returns 2 for Hangul Syllables (0xAC00-0xD7A3)', () => {
|
||||
const hangul = '가나다라마바사아자차카타파하';
|
||||
for (const ch of hangul) {
|
||||
expect(charCellWidth(null, ch)).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('charCellWidth returns 2 for Hangul Jamo (0x1100-0x115F)', () => {
|
||||
expect(charCellWidth(null, '\u1100')).toBe(2); // ᄀ
|
||||
expect(charCellWidth(null, '\u1112')).toBe(2); // ᄒ
|
||||
});
|
||||
|
||||
it('Korean greeting renders with correct span positions', () => {
|
||||
const container = document.createElement('div');
|
||||
const text = '안녕하세요';
|
||||
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
expect(spans.length).toBe(5);
|
||||
expect(spans[0]).toEqual({ text: '안', left: 0, width: 20 });
|
||||
expect(spans[1]).toEqual({ text: '녕', left: 20, width: 20 });
|
||||
expect(spans[2]).toEqual({ text: '하', left: 40, width: 20 });
|
||||
expect(spans[3]).toEqual({ text: '세', left: 60, width: 20 });
|
||||
expect(spans[4]).toEqual({ text: '요', left: 80, width: 20 });
|
||||
});
|
||||
|
||||
it('addon handles Korean input', () => {
|
||||
const { addon } = tracked();
|
||||
addon.appendText('안녕하세요');
|
||||
expect(addon.pendingText).toBe('안녕하세요');
|
||||
expect(addon.hasPending).toBe(true);
|
||||
});
|
||||
|
||||
it('removeChar removes Korean characters one at a time', () => {
|
||||
const { addon } = tracked();
|
||||
addon.appendText('안녕');
|
||||
addon.removeChar();
|
||||
expect(addon.pendingText).toBe('안');
|
||||
addon.removeChar();
|
||||
expect(addon.pendingText).toBe('');
|
||||
});
|
||||
|
||||
it('stringCellWidth correct for Korean greeting', () => {
|
||||
expect(stringCellWidth(null, '안녕하세요')).toBe(10); // 5 * 2
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed CJK scripts', () => {
|
||||
it('Chinese + Japanese + Korean in one string', () => {
|
||||
const text = '你好こんにちは안녕';
|
||||
expect(stringCellWidth(null, text)).toBe(18); // 9 chars * 2 each
|
||||
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: [text], cellW: 10 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
// All 9 CJK chars: contiguous double-width spans
|
||||
expect(spans.length).toBe(9);
|
||||
let expectedLeft = 0;
|
||||
for (const span of spans) {
|
||||
expect(span.left).toBe(expectedLeft);
|
||||
expect(span.width).toBe(20);
|
||||
expectedLeft += 20;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Cursor positions correctly after CJK characters
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Test 4: Cursor positioning after CJK', () => {
|
||||
it('cursor after Chinese text on first line', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['你好世界'],
|
||||
startCol: 2,
|
||||
cellW: 10,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
// cursorCol = startCol(2) + stringCellWidth('你好世界')(8) = 10
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('100px'); // 10 * 10px
|
||||
});
|
||||
|
||||
it('cursor after Japanese text on first line', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['こんにちは'],
|
||||
startCol: 2,
|
||||
cellW: 10,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
// cursorCol = 2 + 10 = 12
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('120px');
|
||||
});
|
||||
|
||||
it('cursor after Korean text on first line', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['안녕하세요'],
|
||||
startCol: 2,
|
||||
cellW: 10,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
// cursorCol = 2 + 10 = 12
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('120px');
|
||||
});
|
||||
|
||||
it('cursor after mixed ASCII + CJK', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['hi你好'],
|
||||
startCol: 3,
|
||||
cellW: 10,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
// cursorCol = 3 + stringCellWidth('hi你好')(6) = 9
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('90px');
|
||||
});
|
||||
|
||||
it('cursor on wrapped line after CJK text', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['你好世界', '再见'],
|
||||
startCol: 2,
|
||||
cellW: 10,
|
||||
cellH: 20,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
// Last line is '再见', starts at col 0 (wrapped line), width = 4
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('40px'); // 0 + 4 = 4, * 10 = 40
|
||||
expect(cursor.style.top).toBe('20px'); // row 1 * cellH
|
||||
});
|
||||
|
||||
it('cursor hidden when it would exceed totalCols', () => {
|
||||
const container = document.createElement('div');
|
||||
// 4 CJK chars = 8 visual cols, startCol=73, cursorCol = 73 + 8 = 81 > 80
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['你好世界'],
|
||||
startCol: 73,
|
||||
totalCols: 80,
|
||||
cellW: 10,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
// Should be 1 line div, no cursor span (cursor at col 81 >= totalCols 80)
|
||||
// Actually cursor check is cursorCol < totalCols, so at 81 it's hidden
|
||||
const children = container.children;
|
||||
// If cursor is rendered, last child would be a cursor span
|
||||
// With cursorCol 81 >= 80, cursor should NOT be rendered
|
||||
expect(children.length).toBe(1); // only line div
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TEST 5: Long CJK input wraps at correct column boundary
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Test 5: CJK line wrapping at column boundaries', () => {
|
||||
it('CJK chars wrap when they would overflow first line', () => {
|
||||
const container = document.createElement('div');
|
||||
// totalCols=10, startCol=2 → firstLineCols=8
|
||||
// Each CJK char = 2 cols → first line fits 4 chars (8 cols)
|
||||
// 5th char wraps to second line
|
||||
const text = '你好世界啊'; // 5 chars = 10 visual cols
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['你好世界', '啊'],
|
||||
startCol: 2,
|
||||
totalCols: 10,
|
||||
cellW: 10,
|
||||
cellH: 20,
|
||||
})
|
||||
);
|
||||
|
||||
expect(container.children.length).toBe(3); // 2 line divs + cursor
|
||||
|
||||
const line1 = container.children[0] as HTMLDivElement;
|
||||
expect(line1.children.length).toBe(4); // 你好世界
|
||||
expect(line1.style.left).toBe('20px'); // startCol * cellW
|
||||
|
||||
const line2 = container.children[1] as HTMLDivElement;
|
||||
expect(line2.children.length).toBe(1); // 啊
|
||||
expect(line2.style.left).toBe('0px'); // wrapped line starts at col 0
|
||||
expect(line2.style.top).toBe('20px'); // second row
|
||||
});
|
||||
|
||||
it('CJK char that would partially overflow stays on next line', () => {
|
||||
// totalCols=9, startCol=2 → firstLineCols=7
|
||||
// CJK chars are 2-wide. 3 chars = 6 cols (fits). 4th char = 8 cols > 7. Wraps.
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['你好世', '界'],
|
||||
startCol: 2,
|
||||
totalCols: 9,
|
||||
cellW: 10,
|
||||
})
|
||||
);
|
||||
|
||||
const line1 = container.children[0] as HTMLDivElement;
|
||||
expect(line1.children.length).toBe(3); // 3 CJK chars fit (6 cols <= 7)
|
||||
const line2 = container.children[1] as HTMLDivElement;
|
||||
expect(line2.children.length).toBe(1); // 界 wraps
|
||||
});
|
||||
|
||||
it('addon _render splits CJK text into visual lines correctly', () => {
|
||||
// Narrow terminal: 10 cols, startCol=2 → firstLineCols=8 → 4 CJK chars
|
||||
const mock = createMockTerminal({
|
||||
buffer: { lines: ['$ '] },
|
||||
cols: 10,
|
||||
});
|
||||
const addon = new ZerolagInputAddon({
|
||||
prompt: { type: 'character', char: '$', offset: 2 },
|
||||
});
|
||||
mock.terminal.loadAddon(addon);
|
||||
cleanups.push(() => {
|
||||
addon.dispose();
|
||||
mock.cleanup();
|
||||
});
|
||||
|
||||
// Type 6 CJK chars (12 visual cols)
|
||||
for (const ch of '你好世界再见') {
|
||||
addon.addChar(ch);
|
||||
}
|
||||
expect(addon.pendingText).toBe('你好世界再见');
|
||||
expect(addon.hasPending).toBe(true);
|
||||
});
|
||||
|
||||
it('mixed ASCII + CJK wraps correctly', () => {
|
||||
const container = document.createElement('div');
|
||||
// totalCols=10, startCol=2 → firstLineCols=8
|
||||
// 'ab' = 2 cols, '你好' = 4 cols, 'cd' = 2 cols → total 8 cols (fits line 1)
|
||||
// '世' = 2 cols → wraps to line 2
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['ab你好cd', '世'],
|
||||
startCol: 2,
|
||||
totalCols: 10,
|
||||
cellW: 10,
|
||||
})
|
||||
);
|
||||
|
||||
const line1 = container.children[0] as HTMLDivElement;
|
||||
expect(line1.children.length).toBe(6); // a,b,你,好,c,d
|
||||
const line2 = container.children[1] as HTMLDivElement;
|
||||
expect(line2.children.length).toBe(1); // 世
|
||||
});
|
||||
|
||||
it('odd column count: CJK char does not split across boundary', () => {
|
||||
// totalCols=11, startCol=2 → firstLineCols=9
|
||||
// 4 CJK chars = 8 cols (fits). 5th CJK = 10 cols > 9 (wraps).
|
||||
// 1 empty column remains on first line (CJK can't fit in 1 col).
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['你好世界', '啊'],
|
||||
startCol: 2,
|
||||
totalCols: 11,
|
||||
cellW: 10,
|
||||
})
|
||||
);
|
||||
|
||||
const line1 = container.children[0] as HTMLDivElement;
|
||||
expect(line1.children.length).toBe(4); // 4 chars = 8 cols, 5th would need 10 > 9
|
||||
const line2 = container.children[1] as HTMLDivElement;
|
||||
expect(line2.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('CJK fills entire wrapped line', () => {
|
||||
const container = document.createElement('div');
|
||||
// totalCols=6, startCol=0 → firstLineCols=6
|
||||
// 3 CJK chars = 6 cols (fills line 1), next 3 fill line 2
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['你好世', '界再见'],
|
||||
startCol: 0,
|
||||
totalCols: 6,
|
||||
cellW: 10,
|
||||
})
|
||||
);
|
||||
|
||||
const line1 = container.children[0] as HTMLDivElement;
|
||||
expect(line1.children.length).toBe(3);
|
||||
const line2 = container.children[1] as HTMLDivElement;
|
||||
expect(line2.children.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TEST 6: Existing ASCII input is unaffected
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Test 6: ASCII input — no regression', () => {
|
||||
it('ASCII characters still get 1-cell-wide spans', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abcdef'], cellW: 10 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
for (const span of spans) {
|
||||
expect(span.width).toBe(10); // 1 * cellW
|
||||
}
|
||||
});
|
||||
|
||||
it('ASCII span positions are sequential at 1-cell intervals', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['xyz'], cellW: 8.4 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
expect(spans[0].left).toBe(0);
|
||||
expect(spans[1].left).toBeCloseTo(8.4, 5);
|
||||
expect(spans[2].left).toBeCloseTo(16.8, 5);
|
||||
});
|
||||
|
||||
it('ASCII cursor positions at correct column', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['hello'],
|
||||
startCol: 3,
|
||||
cellW: 10,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
// cursor at startCol(3) + 5 = 8
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('80px');
|
||||
});
|
||||
|
||||
it('charCellWidth returns 1 for all printable ASCII', () => {
|
||||
for (let code = 32; code < 127; code++) {
|
||||
const ch = String.fromCharCode(code);
|
||||
expect(charCellWidth(null, ch)).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('stringCellWidth equals length for pure ASCII', () => {
|
||||
expect(stringCellWidth(null, 'hello world')).toBe(11);
|
||||
expect(stringCellWidth(null, 'test123!@#')).toBe(10);
|
||||
expect(stringCellWidth(null, '')).toBe(0);
|
||||
});
|
||||
|
||||
it('ASCII multi-line wrapping is unaffected', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['abcde', 'fgh'],
|
||||
startCol: 5,
|
||||
totalCols: 10,
|
||||
cellW: 10,
|
||||
cellH: 20,
|
||||
})
|
||||
);
|
||||
|
||||
expect(container.children.length).toBe(3); // 2 lines + cursor
|
||||
const line1 = container.children[0] as HTMLDivElement;
|
||||
const line2 = container.children[1] as HTMLDivElement;
|
||||
expect(line1.children.length).toBe(5);
|
||||
expect(line2.children.length).toBe(3);
|
||||
expect(line1.style.left).toBe('50px'); // startCol * cellW
|
||||
expect(line2.style.left).toBe('0px');
|
||||
});
|
||||
|
||||
it('addon addChar/removeChar/clear work for ASCII', () => {
|
||||
const { addon } = tracked();
|
||||
addon.addChar('h');
|
||||
addon.addChar('e');
|
||||
addon.addChar('l');
|
||||
addon.addChar('l');
|
||||
addon.addChar('o');
|
||||
expect(addon.pendingText).toBe('hello');
|
||||
|
||||
addon.removeChar();
|
||||
expect(addon.pendingText).toBe('hell');
|
||||
|
||||
addon.clear();
|
||||
expect(addon.pendingText).toBe('');
|
||||
expect(addon.hasPending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TEST 7: Teammate terminal panels render CJK correctly
|
||||
//
|
||||
// Teammate panels share the global xterm-zerolag-input.js vendor bundle
|
||||
// (loaded via <script> in index.html). This is the IIFE build of the
|
||||
// same package source tested in tests 1-6. The build pipeline is:
|
||||
// src/overlay-renderer.ts → tsup → dist/index.global.js → vendor copy
|
||||
//
|
||||
// Since all terminals (main + teammate) use the same LocalEchoOverlay
|
||||
// class from the global scope, CJK correctness is guaranteed by:
|
||||
// (a) The package source handles CJK correctly (tests 1-6 above)
|
||||
// (b) The IIFE build bundles the exact same charCellWidth / makeLine code
|
||||
//
|
||||
// These tests verify the exported module includes CJK-aware functions
|
||||
// and that teammate-style terminal instances work identically.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Test 7: Teammate terminal panels render CJK correctly', () => {
|
||||
it('package exports charCellWidth and stringCellWidth', () => {
|
||||
// These are the CJK-aware functions that the IIFE build exposes
|
||||
expect(typeof charCellWidth).toBe('function');
|
||||
expect(typeof stringCellWidth).toBe('function');
|
||||
});
|
||||
|
||||
it('ZerolagInputAddon (used by LocalEchoOverlay) handles CJK in teammate terminals', () => {
|
||||
// Simulate a teammate terminal panel: separate terminal instance,
|
||||
// same addon class, different prompt character
|
||||
const mock = createMockTerminal({
|
||||
buffer: { lines: ['❯ '] },
|
||||
cols: 40,
|
||||
});
|
||||
const addon = new ZerolagInputAddon({
|
||||
prompt: { type: 'character', char: '❯', offset: 2 },
|
||||
});
|
||||
mock.terminal.loadAddon(addon);
|
||||
cleanups.push(() => {
|
||||
addon.dispose();
|
||||
mock.cleanup();
|
||||
});
|
||||
|
||||
// Type CJK in teammate terminal
|
||||
for (const ch of '你好世界') {
|
||||
addon.addChar(ch);
|
||||
}
|
||||
expect(addon.pendingText).toBe('你好世界');
|
||||
expect(addon.hasPending).toBe(true);
|
||||
});
|
||||
|
||||
it('teammate terminal with narrow width wraps CJK correctly', () => {
|
||||
// Teammate panels are often narrower (sidebar, split view)
|
||||
const mock = createMockTerminal({
|
||||
buffer: { lines: ['❯ '] },
|
||||
cols: 12, // narrow panel
|
||||
});
|
||||
const addon = new ZerolagInputAddon({
|
||||
prompt: { type: 'character', char: '❯', offset: 2 },
|
||||
});
|
||||
mock.terminal.loadAddon(addon);
|
||||
cleanups.push(() => {
|
||||
addon.dispose();
|
||||
mock.cleanup();
|
||||
});
|
||||
|
||||
// Type 6 CJK chars (12 visual cols) with startCol=2 → only 10 available
|
||||
// First line: 5 CJK = 10 cols. 6th wraps.
|
||||
for (const ch of '你好世界再见') {
|
||||
addon.addChar(ch);
|
||||
}
|
||||
expect(addon.pendingText).toBe('你好世界再见');
|
||||
});
|
||||
|
||||
it('mixed CJK scripts render identically in teammate and main terminals', () => {
|
||||
// Verify that the same input produces the same overlay output
|
||||
// regardless of which terminal instance it's on
|
||||
const text = '你好こんにちは안녕';
|
||||
|
||||
// "Main" terminal
|
||||
const container1 = document.createElement('div');
|
||||
renderOverlay(container1, makeParams({ lines: [text], cellW: 10 }));
|
||||
const line1 = container1.children[0] as HTMLDivElement;
|
||||
const spans1 = getSpans(line1);
|
||||
|
||||
// "Teammate" terminal (same params — same bundle)
|
||||
const container2 = document.createElement('div');
|
||||
renderOverlay(container2, makeParams({ lines: [text], cellW: 10 }));
|
||||
const line2 = container2.children[0] as HTMLDivElement;
|
||||
const spans2 = getSpans(line2);
|
||||
|
||||
// Identical rendering
|
||||
expect(spans1.length).toBe(spans2.length);
|
||||
for (let i = 0; i < spans1.length; i++) {
|
||||
expect(spans1[i]).toEqual(spans2[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('CJK rendering correct in overlay with teammate-typical prompt (❯)', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['こんにちは世界'],
|
||||
startCol: 2, // after ❯ prompt
|
||||
cellW: 10,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
expect(spans.length).toBe(7);
|
||||
|
||||
// All CJK, all double-width, contiguous
|
||||
let expectedLeft = 0;
|
||||
for (const span of spans) {
|
||||
expect(span.left).toBe(expectedLeft);
|
||||
expect(span.width).toBe(20);
|
||||
expectedLeft += 20;
|
||||
}
|
||||
|
||||
// Cursor position: startCol(2) + 14 visual cols = 16
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('160px');
|
||||
});
|
||||
});
|
||||
@@ -1,300 +1,420 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderOverlay } from '../src/overlay-renderer.js';
|
||||
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
|
||||
import type { RenderParams, FontStyle } from '../src/types.js';
|
||||
|
||||
const FONT: FontStyle = {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
color: '#eeeeee',
|
||||
backgroundColor: '#0d0d0d',
|
||||
letterSpacing: '',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
color: '#eeeeee',
|
||||
backgroundColor: '#0d0d0d',
|
||||
letterSpacing: '',
|
||||
};
|
||||
|
||||
function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
|
||||
return {
|
||||
lines: ['hello'],
|
||||
startCol: 2,
|
||||
totalCols: 80,
|
||||
cellW: 8.4,
|
||||
cellH: 17,
|
||||
charTop: 2,
|
||||
charHeight: 14,
|
||||
promptRow: 10,
|
||||
font: FONT,
|
||||
showCursor: true,
|
||||
cursorColor: '#e0e0e0',
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
lines: ['hello'],
|
||||
startCol: 2,
|
||||
totalCols: 80,
|
||||
cellW: 8.4,
|
||||
cellH: 17,
|
||||
charTop: 2,
|
||||
charHeight: 14,
|
||||
promptRow: 10,
|
||||
font: FONT,
|
||||
showCursor: true,
|
||||
cursorColor: '#e0e0e0',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('renderOverlay', () => {
|
||||
it('positions container at prompt row', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ promptRow: 5 }));
|
||||
expect(container.style.top).toBe((5 * 17) + 'px');
|
||||
expect(container.style.left).toBe('0px');
|
||||
});
|
||||
it('positions container at prompt row', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ promptRow: 5 }));
|
||||
expect(container.style.top).toBe(5 * 17 + 'px');
|
||||
expect(container.style.left).toBe('0px');
|
||||
});
|
||||
|
||||
it('creates per-character spans in a line div', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abc'] }));
|
||||
it('creates per-character spans in a line div', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abc'] }));
|
||||
|
||||
// Line div + cursor span
|
||||
expect(container.children.length).toBe(2);
|
||||
// Line div + cursor span
|
||||
expect(container.children.length).toBe(2);
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(3); // a, b, c
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(3); // a, b, c
|
||||
|
||||
const spanA = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(spanA.textContent).toBe('a');
|
||||
expect(spanA.style.left).toBe('0px');
|
||||
const spanA = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(spanA.textContent).toBe('a');
|
||||
expect(spanA.style.left).toBe('0px');
|
||||
|
||||
const spanB = lineDiv.children[1] as HTMLSpanElement;
|
||||
expect(spanB.textContent).toBe('b');
|
||||
expect(spanB.style.left).toBe('8.4px');
|
||||
const spanB = lineDiv.children[1] as HTMLSpanElement;
|
||||
expect(spanB.textContent).toBe('b');
|
||||
expect(spanB.style.left).toBe('8.4px');
|
||||
|
||||
const spanC = lineDiv.children[2] as HTMLSpanElement;
|
||||
expect(spanC.textContent).toBe('c');
|
||||
expect(spanC.style.left).toBe('16.8px');
|
||||
});
|
||||
const spanC = lineDiv.children[2] as HTMLSpanElement;
|
||||
expect(spanC.textContent).toBe('c');
|
||||
expect(spanC.style.left).toBe('16.8px');
|
||||
});
|
||||
|
||||
it('sets span width to cellW', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['x'], cellW: 9.5 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.width).toBe('9.5px');
|
||||
});
|
||||
it('sets span width to cellW', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['x'], cellW: 9.5 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.width).toBe('9.5px');
|
||||
});
|
||||
|
||||
it('applies font styles to spans', () => {
|
||||
const font: FontStyle = {
|
||||
fontFamily: 'Fira Code',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#ff0000',
|
||||
backgroundColor: '#000000',
|
||||
letterSpacing: '0.5px',
|
||||
};
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['A'], font }));
|
||||
it('applies font styles to spans', () => {
|
||||
const font: FontStyle = {
|
||||
fontFamily: 'Fira Code',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#ff0000',
|
||||
backgroundColor: '#000000',
|
||||
letterSpacing: '0.5px',
|
||||
};
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['A'], font }));
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// jsdom normalizes hex to rgb()
|
||||
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// jsdom normalizes hex to rgb()
|
||||
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
|
||||
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.fontFamily).toBe('Fira Code');
|
||||
expect(span.style.fontSize).toBe('16px');
|
||||
expect(span.style.fontWeight).toBe('bold');
|
||||
expect(span.style.color).toBe('rgb(255, 0, 0)');
|
||||
expect(span.style.letterSpacing).toBe('0.5px');
|
||||
});
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.fontFamily).toBe('Fira Code');
|
||||
expect(span.style.fontSize).toBe('16px');
|
||||
expect(span.style.fontWeight).toBe('bold');
|
||||
expect(span.style.color).toBe('rgb(255, 0, 0)');
|
||||
expect(span.style.letterSpacing).toBe('0.5px');
|
||||
});
|
||||
|
||||
it('offsets first line by startCol', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['hi'], startCol: 5, cellW: 10 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// First line left = startCol * cellW
|
||||
expect(lineDiv.style.left).toBe('50px');
|
||||
});
|
||||
it('offsets first line by startCol', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['hi'], startCol: 5, cellW: 10 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// First line left = startCol * cellW
|
||||
expect(lineDiv.style.left).toBe('50px');
|
||||
});
|
||||
|
||||
it('renders cursor at end of text', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({
|
||||
lines: ['ab'],
|
||||
startCol: 3,
|
||||
cellW: 10,
|
||||
cellH: 20,
|
||||
showCursor: true,
|
||||
cursorColor: '#ff00ff',
|
||||
}));
|
||||
it('renders cursor at end of text', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['ab'],
|
||||
startCol: 3,
|
||||
cellW: 10,
|
||||
cellH: 20,
|
||||
showCursor: true,
|
||||
cursorColor: '#ff00ff',
|
||||
})
|
||||
);
|
||||
|
||||
// Last child is cursor (after line div)
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
// cursorCol = startCol(3) + text.length(2) = 5
|
||||
expect(cursor.style.left).toBe('50px');
|
||||
expect(cursor.style.width).toBe('10px');
|
||||
expect(cursor.style.height).toBe('20px');
|
||||
// jsdom normalizes hex to rgb()
|
||||
expect(cursor.style.backgroundColor).toBe('rgb(255, 0, 255)');
|
||||
});
|
||||
// Last child is cursor (after line div)
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
// cursorCol = startCol(3) + text.length(2) = 5
|
||||
expect(cursor.style.left).toBe('50px');
|
||||
expect(cursor.style.width).toBe('10px');
|
||||
expect(cursor.style.height).toBe('20px');
|
||||
// jsdom normalizes hex to rgb()
|
||||
expect(cursor.style.backgroundColor).toBe('rgb(255, 0, 255)');
|
||||
});
|
||||
|
||||
it('does not render cursor when showCursor is false', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
|
||||
// Only line div, no cursor
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
it('does not render cursor when showCursor is false', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
|
||||
// Only line div, no cursor
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('renders multi-line text', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({
|
||||
lines: ['first', 'second'],
|
||||
startCol: 5,
|
||||
cellW: 10,
|
||||
cellH: 20,
|
||||
}));
|
||||
it('renders multi-line text', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['first', 'second'],
|
||||
startCol: 5,
|
||||
cellW: 10,
|
||||
cellH: 20,
|
||||
})
|
||||
);
|
||||
|
||||
// 2 line divs + cursor
|
||||
expect(container.children.length).toBe(3);
|
||||
// 2 line divs + cursor
|
||||
expect(container.children.length).toBe(3);
|
||||
|
||||
const line1 = container.children[0] as HTMLDivElement;
|
||||
expect(line1.style.left).toBe('50px'); // startCol * cellW
|
||||
expect(line1.style.top).toBe('0px');
|
||||
expect(line1.children.length).toBe(5); // 'first'
|
||||
const line1 = container.children[0] as HTMLDivElement;
|
||||
expect(line1.style.left).toBe('50px'); // startCol * cellW
|
||||
expect(line1.style.top).toBe('0px');
|
||||
expect(line1.children.length).toBe(5); // 'first'
|
||||
|
||||
const line2 = container.children[1] as HTMLDivElement;
|
||||
expect(line2.style.left).toBe('0px'); // wrapped lines start at col 0
|
||||
expect(line2.style.top).toBe('20px'); // second row
|
||||
expect(line2.children.length).toBe(6); // 'second'
|
||||
});
|
||||
const line2 = container.children[1] as HTMLDivElement;
|
||||
expect(line2.style.left).toBe('0px'); // wrapped lines start at col 0
|
||||
expect(line2.style.top).toBe('20px'); // second row
|
||||
expect(line2.children.length).toBe(6); // 'second'
|
||||
});
|
||||
|
||||
it('clears previous content on re-render', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abc'] }));
|
||||
expect(container.children.length).toBe(2); // line + cursor
|
||||
it('clears previous content on re-render', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abc'] }));
|
||||
expect(container.children.length).toBe(2); // line + cursor
|
||||
|
||||
renderOverlay(container, makeParams({ lines: ['xy'] }));
|
||||
expect(container.children.length).toBe(2); // line + cursor (rebuilt)
|
||||
renderOverlay(container, makeParams({ lines: ['xy'] }));
|
||||
expect(container.children.length).toBe(2); // line + cursor (rebuilt)
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(2); // x, y
|
||||
});
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(2); // x, y
|
||||
});
|
||||
|
||||
it('shows container (display not none)', () => {
|
||||
const container = document.createElement('div');
|
||||
container.style.display = 'none';
|
||||
renderOverlay(container, makeParams());
|
||||
expect(container.style.display).toBe('');
|
||||
});
|
||||
it('shows container (display not none)', () => {
|
||||
const container = document.createElement('div');
|
||||
container.style.display = 'none';
|
||||
renderOverlay(container, makeParams());
|
||||
expect(container.style.display).toBe('');
|
||||
});
|
||||
|
||||
// ─── Anti-flicker / compositing seam tests ────────────────────
|
||||
// ─── Anti-flicker / compositing seam tests ────────────────────
|
||||
|
||||
it('line div height extends 1px past cellH to cover compositing seam', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// cellH + 1 = 20px — the extra 1px covers the compositing seam
|
||||
expect(lineDiv.style.height).toBe('20px');
|
||||
});
|
||||
it('line div height extends 1px past cellH to cover compositing seam', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// cellH + 1 = 20px — the extra 1px covers the compositing seam
|
||||
expect(lineDiv.style.height).toBe('20px');
|
||||
});
|
||||
|
||||
it('line div height is cellH+1 for various cell heights', () => {
|
||||
for (const cellH of [15, 17, 19, 22]) {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['x'], cellH }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.style.height).toBe((cellH + 1) + 'px');
|
||||
}
|
||||
});
|
||||
it('line div height is cellH+1 for various cell heights', () => {
|
||||
for (const cellH of [15, 17, 19, 22]) {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['x'], cellH }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.style.height).toBe(cellH + 1 + 'px');
|
||||
}
|
||||
});
|
||||
|
||||
it('multi-line overlay has cellH+1 height on each line div', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({
|
||||
lines: ['first', 'second'],
|
||||
cellH: 19,
|
||||
}));
|
||||
const line1 = container.children[0] as HTMLDivElement;
|
||||
const line2 = container.children[1] as HTMLDivElement;
|
||||
expect(line1.style.height).toBe('20px');
|
||||
expect(line2.style.height).toBe('20px');
|
||||
});
|
||||
it('multi-line overlay has cellH+1 height on each line div', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['first', 'second'],
|
||||
cellH: 19,
|
||||
})
|
||||
);
|
||||
const line1 = container.children[0] as HTMLDivElement;
|
||||
const line2 = container.children[1] as HTMLDivElement;
|
||||
expect(line1.style.height).toBe('20px');
|
||||
expect(line2.style.height).toBe('20px');
|
||||
});
|
||||
|
||||
// ─── Span vertical centering tests ────────────────────────────
|
||||
// ─── Span vertical centering tests ────────────────────────────
|
||||
|
||||
it('span uses full cellH for height and lineHeight (CSS centering)', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.height).toBe('19px');
|
||||
expect(span.style.lineHeight).toBe('19px');
|
||||
});
|
||||
it('span uses full cellH for height and lineHeight (CSS centering)', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.height).toBe('19px');
|
||||
expect(span.style.lineHeight).toBe('19px');
|
||||
});
|
||||
|
||||
it('span top is 0px (no vertical offset / no transform)', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.top).toBe('0px');
|
||||
// No translateY transform — sub-pixel overhang causes artifacts
|
||||
expect(span.style.transform).toBe('');
|
||||
});
|
||||
it('span top is 0px (no vertical offset / no transform)', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.top).toBe('0px');
|
||||
// No translateY transform — sub-pixel overhang causes artifacts
|
||||
expect(span.style.transform).toBe('');
|
||||
});
|
||||
|
||||
// ─── Font rendering tests ─────────────────────────────────────
|
||||
// ─── Font rendering tests ─────────────────────────────────────
|
||||
|
||||
it('span disables ligatures via font-feature-settings', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['fi'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
// Check cssText includes the ligature-disabling settings
|
||||
// jsdom may normalize whitespace; check that both liga and calt are disabled
|
||||
expect(span.style.cssText).toContain("font-feature-settings:");
|
||||
expect(span.style.cssText).toContain("'liga' 0");
|
||||
expect(span.style.cssText).toContain("'calt' 0");
|
||||
});
|
||||
it('span disables ligatures via font-feature-settings', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['fi'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
// Check cssText includes the ligature-disabling settings
|
||||
// jsdom may normalize whitespace; check that both liga and calt are disabled
|
||||
expect(span.style.cssText).toContain('font-feature-settings:');
|
||||
expect(span.style.cssText).toContain("'liga' 0");
|
||||
expect(span.style.cssText).toContain("'calt' 0");
|
||||
});
|
||||
|
||||
it('span has text-align: center for glyph centering', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['m'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.textAlign).toBe('center');
|
||||
});
|
||||
it('span has text-align: center for glyph centering', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['m'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.textAlign).toBe('center');
|
||||
});
|
||||
|
||||
it('span has pointer-events: none', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.pointerEvents).toBe('none');
|
||||
});
|
||||
it('span has pointer-events: none', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.pointerEvents).toBe('none');
|
||||
});
|
||||
|
||||
// ─── Multi-line cursor positioning ────────────────────────────
|
||||
// ─── Multi-line cursor positioning ────────────────────────────
|
||||
|
||||
it('cursor on wrapped line uses col 0 as base', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({
|
||||
lines: ['first', 'ab'],
|
||||
startCol: 5,
|
||||
cellW: 10,
|
||||
cellH: 20,
|
||||
showCursor: true,
|
||||
}));
|
||||
// Cursor at end of second line: col = 0 + 2 = 2
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('20px'); // 2 * 10
|
||||
expect(cursor.style.top).toBe('20px'); // row 1 * cellH
|
||||
});
|
||||
it('cursor on wrapped line uses col 0 as base', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['first', 'ab'],
|
||||
startCol: 5,
|
||||
cellW: 10,
|
||||
cellH: 20,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
// Cursor at end of second line: col = 0 + 2 = 2
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('20px'); // 2 * 10
|
||||
expect(cursor.style.top).toBe('20px'); // row 1 * cellH
|
||||
});
|
||||
|
||||
// ─── charTop/charHeight passed through ────────────────────────
|
||||
// ─── charTop/charHeight passed through ────────────────────────
|
||||
|
||||
it('accepts charTop and charHeight params without error', () => {
|
||||
const container = document.createElement('div');
|
||||
expect(() => renderOverlay(container, makeParams({
|
||||
lines: ['test'],
|
||||
charTop: 2,
|
||||
charHeight: 14,
|
||||
}))).not.toThrow();
|
||||
expect(container.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('accepts charTop and charHeight params without error', () => {
|
||||
const container = document.createElement('div');
|
||||
expect(() =>
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['test'],
|
||||
charTop: 2,
|
||||
charHeight: 14,
|
||||
})
|
||||
)
|
||||
).not.toThrow();
|
||||
expect(container.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ─── Line div positioning regression ──────────────────────────
|
||||
// ─── Line div positioning regression ──────────────────────────
|
||||
|
||||
it('line div background color matches font.backgroundColor', () => {
|
||||
const container = document.createElement('div');
|
||||
const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' };
|
||||
renderOverlay(container, makeParams({ lines: ['x'], font }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// jsdom normalizes hex to rgb()
|
||||
expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)');
|
||||
});
|
||||
it('line div background color matches font.backgroundColor', () => {
|
||||
const container = document.createElement('div');
|
||||
const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' };
|
||||
renderOverlay(container, makeParams({ lines: ['x'], font }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// jsdom normalizes hex to rgb()
|
||||
expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)');
|
||||
});
|
||||
|
||||
it('empty line produces line div with no spans', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: [''] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(0);
|
||||
});
|
||||
it('empty line produces line div with no spans', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: [''] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(0);
|
||||
});
|
||||
|
||||
// ─── CJK wide character support ───────────────────────────────
|
||||
|
||||
it('CJK characters get double-width spans', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a你b'], cellW: 10 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(3);
|
||||
|
||||
const spanA = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(spanA.textContent).toBe('a');
|
||||
expect(spanA.style.left).toBe('0px');
|
||||
expect(spanA.style.width).toBe('10px'); // 1 cell
|
||||
|
||||
const spanCJK = lineDiv.children[1] as HTMLSpanElement;
|
||||
expect(spanCJK.textContent).toBe('你');
|
||||
expect(spanCJK.style.left).toBe('10px'); // col 1
|
||||
expect(spanCJK.style.width).toBe('20px'); // 2 cells
|
||||
|
||||
const spanB = lineDiv.children[2] as HTMLSpanElement;
|
||||
expect(spanB.textContent).toBe('b');
|
||||
expect(spanB.style.left).toBe('30px'); // col 3
|
||||
expect(spanB.style.width).toBe('10px'); // 1 cell
|
||||
});
|
||||
|
||||
it('cursor position accounts for CJK width', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['你好'],
|
||||
startCol: 2,
|
||||
cellW: 10,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
// 你(2) + 好(2) = 4 visual cols, cursor at startCol(2) + 4 = 6
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('60px');
|
||||
});
|
||||
|
||||
it('mixed ASCII and CJK characters position correctly', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// h(col 0), i(col 1), 你(col 2, width 2)
|
||||
const spanH = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(spanH.style.left).toBe('0px');
|
||||
const spanI = lineDiv.children[1] as HTMLSpanElement;
|
||||
expect(spanI.style.left).toBe('8px');
|
||||
const spanCJK = lineDiv.children[2] as HTMLSpanElement;
|
||||
expect(spanCJK.style.left).toBe('16px');
|
||||
expect(spanCJK.style.width).toBe('16px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('charCellWidth', () => {
|
||||
it('returns 1 for ASCII characters', () => {
|
||||
expect(charCellWidth(null, 'a')).toBe(1);
|
||||
expect(charCellWidth(null, '!')).toBe(1);
|
||||
expect(charCellWidth(null, ' ')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 2 for CJK ideographs', () => {
|
||||
expect(charCellWidth(null, '你')).toBe(2);
|
||||
expect(charCellWidth(null, '好')).toBe(2);
|
||||
expect(charCellWidth(null, '中')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 2 for Japanese hiragana', () => {
|
||||
expect(charCellWidth(null, 'こ')).toBe(2);
|
||||
expect(charCellWidth(null, 'ん')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 2 for Korean syllables', () => {
|
||||
expect(charCellWidth(null, '안')).toBe(2);
|
||||
expect(charCellWidth(null, '녕')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 2 for fullwidth forms', () => {
|
||||
expect(charCellWidth(null, '\uff01')).toBe(2); // !
|
||||
expect(charCellWidth(null, '\uff21')).toBe(2); // A
|
||||
});
|
||||
|
||||
it('uses terminal unicode addon when available', () => {
|
||||
const mockTerminal = {
|
||||
unicode: { getStringCellWidth: (s: string) => (s === 'W' ? 2 : 1) },
|
||||
} as any;
|
||||
expect(charCellWidth(mockTerminal, 'W')).toBe(2);
|
||||
expect(charCellWidth(mockTerminal, 'n')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringCellWidth', () => {
|
||||
it('sums individual character widths', () => {
|
||||
expect(stringCellWidth(null, 'abc')).toBe(3);
|
||||
expect(stringCellWidth(null, '你好')).toBe(4);
|
||||
expect(stringCellWidth(null, 'a你b')).toBe(4);
|
||||
});
|
||||
|
||||
it('returns 0 for empty string', () => {
|
||||
expect(stringCellWidth(null, '')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { appendFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -37,6 +38,22 @@ run('xterm js', 'npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=
|
||||
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');
|
||||
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)`
|
||||
appendFileSync(
|
||||
join(ROOT, 'dist/web/public/vendor/xterm-zerolag-input.js'),
|
||||
'\n// Global aliases for browser usage\n' +
|
||||
'if(typeof window!=="undefined"){' +
|
||||
'window.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
|
||||
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
|
||||
'constructor(terminal){' +
|
||||
'super({prompt:{type:"character",char:"\\u276f",offset:2}});' +
|
||||
'this.activate(terminal);' +
|
||||
'}' +
|
||||
'};' +
|
||||
'}\n'
|
||||
);
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -276,6 +276,34 @@ if (isGlobalInstall) {
|
||||
|
||||
// WebGL addon: copy unminified (matches build script behavior)
|
||||
copyFileSync(join(webglDir, 'lib', 'xterm-addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
|
||||
|
||||
// xterm-zerolag-input: bundle local package as IIFE for <script> tag loading
|
||||
try {
|
||||
const zerolagSrc = join(import.meta.dirname, '..', 'packages', 'xterm-zerolag-input', 'src', 'zerolag-input-addon.ts');
|
||||
const zerolagOut = join(vendorDir, 'xterm-zerolag-input.js');
|
||||
execSync(
|
||||
`npx esbuild "${zerolagSrc}" --bundle --format=iife --global-name=XtermZerolagInput --outfile="${zerolagOut}"`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
|
||||
const { appendFileSync } = await import('fs');
|
||||
appendFileSync(
|
||||
zerolagOut,
|
||||
'\n// Global aliases for browser usage\n' +
|
||||
'if(typeof window!=="undefined"){' +
|
||||
'window.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
|
||||
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
|
||||
'constructor(terminal){' +
|
||||
'super({prompt:{type:"character",char:"\\u276f",offset:2}});' +
|
||||
'this.activate(terminal);' +
|
||||
'}' +
|
||||
'};' +
|
||||
'}\n'
|
||||
);
|
||||
console.log(colors.green('✓ xterm-zerolag-input bundled to vendor/'));
|
||||
} catch {
|
||||
console.log(colors.yellow('⚠ Failed to bundle xterm-zerolag-input — overlay may not work in dev mode'));
|
||||
}
|
||||
} catch (err) {
|
||||
hasWarnings = true;
|
||||
console.log(colors.yellow('⚠ Failed to copy xterm vendor files'));
|
||||
@@ -284,6 +312,36 @@ if (isGlobalInstall) {
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 5. Install git pre-commit hook (format check)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
if (!isGlobalInstall) {
|
||||
try {
|
||||
const { writeFileSync, mkdirSync } = await import('fs');
|
||||
const gitHooksDir = join(import.meta.dirname, '..', '.git', 'hooks');
|
||||
if (existsSync(join(import.meta.dirname, '..', '.git'))) {
|
||||
mkdirSync(gitHooksDir, { recursive: true });
|
||||
const hook = `#!/bin/bash
|
||||
# Auto-installed by postinstall — prevents CI format failures
|
||||
staged_ts=$(git diff --cached --name-only --diff-filter=ACM -- '*.ts')
|
||||
[ -z "$staged_ts" ] && exit 0
|
||||
echo "$staged_ts" | xargs npx prettier --check 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "Pre-commit: Prettier check failed. Run 'npm run format' to fix."
|
||||
exit 1
|
||||
fi
|
||||
`;
|
||||
const hookPath = join(gitHooksDir, 'pre-commit');
|
||||
writeFileSync(hookPath, hook, { mode: 0o755 });
|
||||
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — git hook is a convenience
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Summary
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -21,8 +21,15 @@ export const BATCH_FLUSH_THRESHOLD = 32 * 1024;
|
||||
/** Task event batching interval (ms) */
|
||||
export const TASK_UPDATE_BATCH_INTERVAL = 100;
|
||||
|
||||
/** SSE client health check interval (ms) */
|
||||
export const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000;
|
||||
/** SSE heartbeat interval — sends padded keepalive to flush proxy buffers (ms).
|
||||
* 15s is fast enough to keep Cloudflare tunnel buffers flushed while avoiding
|
||||
* excessive bandwidth. Also serves as dead-client detection. */
|
||||
export const SSE_HEARTBEAT_INTERVAL = 15 * 1000;
|
||||
|
||||
/** SSE padding size (bytes). Cloudflare quick tunnels buffer small SSE events;
|
||||
* appending ~8KB of SSE comment padding forces the proxy to flush immediately.
|
||||
* SSE comments (lines starting with ':') are silently ignored by EventSource. */
|
||||
export const SSE_PADDING_SIZE = 8 * 1024;
|
||||
|
||||
// ============================================================================
|
||||
// State Persistence
|
||||
|
||||
+21
-6
@@ -1,11 +1,26 @@
|
||||
/**
|
||||
* @fileoverview Claude Code hooks configuration generator
|
||||
* @fileoverview Claude Code hooks configuration generator.
|
||||
*
|
||||
* Generates .claude/settings.local.json with hook definitions that POST
|
||||
* to Codeman's /api/hook-event endpoint when Claude Code fires
|
||||
* notification or stop hooks. Uses $CODEMAN_API_URL and
|
||||
* $CODEMAN_SESSION_ID env vars (set on every managed session) so the
|
||||
* config is static per case directory.
|
||||
* Generates `.claude/settings.local.json` with hook definitions that POST
|
||||
* to Codeman's `/api/hook-event` endpoint when Claude Code fires hooks.
|
||||
* Uses `$CODEMAN_API_URL` and `$CODEMAN_SESSION_ID` env vars (set on every
|
||||
* managed session) so the config is static per case directory.
|
||||
*
|
||||
* Key exports:
|
||||
* - `generateHooksConfig()` — returns hooks object for settings.local.json
|
||||
* - `writeHooksConfig(casePath)` — writes hooks + env config to disk
|
||||
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
|
||||
*
|
||||
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
|
||||
* `stop`, `teammate_idle`, `task_completed`
|
||||
*
|
||||
* Hook categories: `Notification` (3 matchers), `Stop` (1), `TeammateIdle` (1),
|
||||
* `TaskCompleted` (1)
|
||||
*
|
||||
* @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_MS)
|
||||
* @consumedby web/server (session creation), session-cli-builder (env setup)
|
||||
*
|
||||
* @module hooks-config
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
* Planner Prompt - Single agent for TDD plan generation
|
||||
*
|
||||
* Combines what was previously 5 separate agents:
|
||||
* - Requirements Analyst (redundant)
|
||||
* - Architecture Planner (redundant)
|
||||
* - Testing Specialist (kept - TDD focus)
|
||||
* - Risk Analyst (redundant)
|
||||
* - Verification Expert (kept - structure)
|
||||
* @fileoverview Planner Prompt — single TDD plan generator combining
|
||||
* requirements analysis, architecture, testing, risk, and verification
|
||||
* into one agent (previously 5 separate agents).
|
||||
*
|
||||
* Placeholders: {TASK}, {RESEARCH_CONTEXT}
|
||||
*
|
||||
* @dependencies none (pure template)
|
||||
* @consumedby prompts/index (re-export), plan-orchestrator
|
||||
* @module prompts/planner
|
||||
*/
|
||||
|
||||
export const PLANNER_PROMPT = `You are a TDD Plan Generator. Create a complete implementation plan with test-first approach.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Research Agent Prompt
|
||||
*
|
||||
* Gathers external resources, codebase patterns, and technical context
|
||||
* before other agents analyze the task.
|
||||
* @fileoverview Research Agent Prompt — gathers codebase patterns, external
|
||||
* resources, and technical context before the planner analyzes the task.
|
||||
*
|
||||
* Placeholders: {TASK}, {WORKING_DIR}
|
||||
*
|
||||
* @dependencies none (pure template)
|
||||
* @consumedby prompts/index (re-export), plan-orchestrator
|
||||
* @module prompts/research-agent
|
||||
*/
|
||||
|
||||
export const RESEARCH_AGENT_PROMPT = `You are a Research Specialist preparing context for an implementation task. Your job is to gather all relevant information that will help the development team succeed.
|
||||
|
||||
+14
-4
@@ -1,14 +1,24 @@
|
||||
/**
|
||||
* @fileoverview Ralph Loop - Autonomous task execution engine
|
||||
* @fileoverview Ralph Loop - Autonomous task execution engine.
|
||||
*
|
||||
* The Ralph Loop orchestrates autonomous Claude sessions by:
|
||||
* Orchestrates autonomous Claude sessions by:
|
||||
* - Polling for available tasks from the task queue
|
||||
* - Assigning tasks to idle sessions
|
||||
* - Monitoring completion and handling failures
|
||||
* - Auto-generating follow-up tasks when min duration not reached
|
||||
*
|
||||
* Named after Ralph Wiggum's persistence ("I'm in danger!"),
|
||||
* this loop keeps Claude working until all tasks are done.
|
||||
* Key exports:
|
||||
* - `RalphLoop` class — the loop engine, extends EventEmitter
|
||||
* - `RalphLoopEvents` interface — typed event map
|
||||
* - `RalphLoopOptions` interface — configuration options
|
||||
*
|
||||
* Lifecycle: `start()` → poll loop → `stop()` (when all tasks done + min duration met)
|
||||
*
|
||||
* @dependencies session-manager (session lifecycle), task-queue (task FIFO),
|
||||
* state-store (persistence), session (PTY execution), task (task model)
|
||||
* @consumedby web/server (ralph routes, SSE)
|
||||
* @emits started, stopped, taskAssigned, taskCompleted, taskFailed, error
|
||||
* @persistence Ralph loop state saved to `~/.codeman/state.json` (ralphLoop key)
|
||||
*
|
||||
* @module ralph-loop
|
||||
*/
|
||||
|
||||
+46
-12
@@ -1,20 +1,35 @@
|
||||
/**
|
||||
* @fileoverview Ralph Tracker - Detects Ralph Wiggum loops, todos, and completion phrases
|
||||
* @fileoverview Ralph Tracker - Detects Ralph Wiggum loops, todos, and completion phrases.
|
||||
*
|
||||
* This module parses terminal output from Claude Code sessions to detect:
|
||||
* - Ralph Wiggum loop state (active, completion phrase, iteration count)
|
||||
* - Todo list items from the TodoWrite tool
|
||||
* Parses terminal output from Claude Code sessions to detect:
|
||||
* - Ralph loop state (active, completion phrase, iteration count)
|
||||
* - Todo items from the TodoWrite tool (with deduplication and expiry)
|
||||
* - Completion phrases signaling loop completion
|
||||
* - Circuit breaker state (CLOSED/HALF_OPEN/OPEN)
|
||||
*
|
||||
* The tracker is DISABLED by default and auto-enables when Ralph-related
|
||||
* patterns are detected in the output stream, reducing overhead for
|
||||
* sessions not using autonomous loops.
|
||||
* DISABLED by default — auto-enables when Ralph-related patterns appear,
|
||||
* reducing overhead for non-autonomous sessions.
|
||||
*
|
||||
* Composed of four sub-modules:
|
||||
* - RalphPlanTracker: Plan task management, checkpoints, versioning
|
||||
* - RalphFixPlanWatcher: @fix_plan.md file watching and parsing
|
||||
* - RalphStallDetector: Iteration stall detection
|
||||
* - RalphStatusParser: RALPH_STATUS block parsing, circuit breaker
|
||||
* - `RalphPlanTracker`: Plan task management, checkpoints, versioning
|
||||
* - `RalphFixPlanWatcher`: @fix_plan.md file watching and parsing
|
||||
* - `RalphStallDetector`: Iteration stall detection
|
||||
* - `RalphStatusParser`: RALPH_STATUS block parsing, circuit breaker
|
||||
*
|
||||
* Key exports:
|
||||
* - `RalphTracker` class — main tracker, extends EventEmitter
|
||||
* - `RalphTrackerEvents` interface — typed event map
|
||||
* - Re-exports: `EnhancedPlanTask`, `CheckpointReview` from ralph-plan-tracker
|
||||
*
|
||||
* Key methods: `processData(data)` — feed terminal output, `getState()`,
|
||||
* `getTodos()`, `getCompletionHistory()`, `getPlanTasks()`, `reset()`
|
||||
*
|
||||
* @dependencies types (RalphTrackerState, RalphTodoItem, CircuitBreakerStatus),
|
||||
* ralph-plan-tracker, ralph-fix-plan-watcher, ralph-stall-detector, ralph-status-parser,
|
||||
* config/buffer-limits, config/map-limits
|
||||
* @consumedby session (owns one RalphTracker per session), web/server (SSE events)
|
||||
* @emits ralphStateChanged, todoUpdated, completionDetected, statusBlockParsed,
|
||||
* circuitBreakerChanged, exitGateMet, planTaskUpdated, planCheckpoint
|
||||
*
|
||||
* @module ralph-tracker
|
||||
*/
|
||||
@@ -39,6 +54,7 @@ import {
|
||||
todoContentHash,
|
||||
stringSimilarity,
|
||||
Debouncer,
|
||||
CleanupManager,
|
||||
} from './utils/index.js';
|
||||
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
|
||||
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
|
||||
@@ -61,12 +77,19 @@ export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js
|
||||
const TODO_EXPIRY_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Minimum interval between cleanup checks (in milliseconds).
|
||||
* Minimum interval between on-demand cleanup checks (in milliseconds).
|
||||
* Prevents running cleanup on every data chunk.
|
||||
* Default: 30 seconds
|
||||
*/
|
||||
const CLEANUP_THROTTLE_MS = 30 * 1000;
|
||||
|
||||
/**
|
||||
* Interval for periodic todo expiry cleanup (in milliseconds).
|
||||
* Actively purges expired todos even when no terminal data is flowing.
|
||||
* Default: 5 minutes
|
||||
*/
|
||||
const TODO_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Similarity threshold for todo deduplication.
|
||||
* Todos with similarity >= this value are considered duplicates.
|
||||
@@ -502,6 +525,9 @@ export class RalphTracker extends EventEmitter {
|
||||
/** Last calculated completion confidence */
|
||||
private _lastCompletionConfidence: CompletionConfidence | undefined;
|
||||
|
||||
/** Manages periodic cleanup timers (todo expiry) */
|
||||
private cleanup = new CleanupManager();
|
||||
|
||||
/** Confidence threshold for triggering completion (0-100) */
|
||||
private static readonly COMPLETION_CONFIDENCE_THRESHOLD = 70;
|
||||
|
||||
@@ -521,6 +547,12 @@ export class RalphTracker extends EventEmitter {
|
||||
|
||||
// Wire sub-module events
|
||||
this._wireSubModuleEvents();
|
||||
|
||||
// Periodic cleanup of expired todos — ensures stale entries are purged
|
||||
// even when no terminal data is flowing (e.g., idle sessions)
|
||||
this.cleanup.setInterval(() => this.cleanupExpiredTodos(), TODO_CLEANUP_INTERVAL_MS, {
|
||||
description: 'ralph todo expiry cleanup',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2189,6 +2221,7 @@ export class RalphTracker extends EventEmitter {
|
||||
if (toDelete.length > 0) {
|
||||
for (const id of toDelete) {
|
||||
this._todos.delete(id);
|
||||
this._todoStartTimes.delete(id);
|
||||
}
|
||||
this.emit('todoUpdate', this.todos);
|
||||
}
|
||||
@@ -2333,6 +2366,7 @@ export class RalphTracker extends EventEmitter {
|
||||
* Clean up all resources and release memory.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.cleanup.dispose();
|
||||
this._todoDeb.dispose();
|
||||
this._loopDeb.dispose();
|
||||
this.fixPlanWatcher.destroy();
|
||||
|
||||
+23
-14
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Respawn Controller for autonomous Claude Code session cycling
|
||||
* @fileoverview Respawn Controller for autonomous Claude Code session cycling.
|
||||
*
|
||||
* The RespawnController manages automatic respawning of Claude Code sessions.
|
||||
* When Claude finishes working (detected by completion message + output silence),
|
||||
* it automatically cycles through update → clear → init steps to keep the session productive.
|
||||
* Manages automatic respawning of Claude Code sessions. When Claude finishes
|
||||
* working (detected by completion message + output silence), it cycles through
|
||||
* update → clear → init steps to keep the session productive.
|
||||
*
|
||||
* ## State Machine
|
||||
*
|
||||
@@ -17,19 +17,28 @@
|
||||
* └──────────────────────── SENDING_KICKSTART → WAITING_KICKSTART ──┘
|
||||
* ```
|
||||
*
|
||||
* ## Idle Detection (Updated for Claude Code 2024+)
|
||||
* ## Idle Detection (multi-layer)
|
||||
* - Layer 0: Stop hook / idle_prompt notification (definitive)
|
||||
* - Layer 1: Completion message pattern "for Xm Xs"
|
||||
* - Layer 2: AI idle check via `AiIdleChecker` (optional)
|
||||
* - Layer 3: No-output timeout fallback
|
||||
*
|
||||
* Primary detection: Completion message pattern "for Xm Xs" (e.g., "✻ Worked for 2m 46s")
|
||||
* Confirmation: No new output for configurable duration (default 5s)
|
||||
* Fallback: No output at all for extended period (default 30s)
|
||||
* Key exports:
|
||||
* - `RespawnController` class — state machine, extends EventEmitter
|
||||
* - `RespawnConfig` interface — all configuration options
|
||||
* - `RespawnState` type — union of all state machine states
|
||||
* - `DetectionStatus`, `ActiveTimerInfo`, `RespawnEvents` — status/event types
|
||||
*
|
||||
* ## Configuration
|
||||
* Key methods: `start()`, `stop()`, `getStatus()`, `getConfig()`,
|
||||
* `getDetectionStatus()`, `getActiveTimers()`, `getAggregateMetrics()`,
|
||||
* `getTimingHistory()`, `getHealthScore()`
|
||||
*
|
||||
* - `sendClear`: Whether to send /clear after update (default: true)
|
||||
* - `sendInit`: Whether to send /init after clear (default: true)
|
||||
* - `kickstartPrompt`: Optional prompt if /init doesn't trigger work
|
||||
* - `completionConfirmMs`: Time to wait after completion message (default: 10000)
|
||||
* - `noOutputTimeoutMs`: Fallback timeout with no output at all (default: 30000)
|
||||
* @dependencies session (PTY output), ai-idle-checker, ai-plan-checker,
|
||||
* respawn-patterns, respawn-adaptive-timing, respawn-metrics, respawn-health,
|
||||
* team-watcher (blocks respawn if teammates active)
|
||||
* @consumedby web/server (respawn routes, SSE), ralph-loop
|
||||
* @emits respawn:stateChanged, respawn:started, respawn:stopped, respawn:cycleStarted,
|
||||
* respawn:cycleCompleted, respawn:detectionUpdate, respawn:aiCheck*, respawn:log
|
||||
*
|
||||
* @module respawn-controller
|
||||
*/
|
||||
|
||||
+17
-5
@@ -1,11 +1,23 @@
|
||||
/**
|
||||
* @fileoverview Session Manager for coordinating multiple Claude sessions
|
||||
* @fileoverview Session Manager for coordinating multiple Claude sessions.
|
||||
*
|
||||
* Provides lifecycle management for Claude CLI sessions:
|
||||
* - Session creation with working directory configuration
|
||||
* - Event forwarding from individual sessions
|
||||
* Lifecycle management for Claude CLI sessions:
|
||||
* - Session creation with working directory, concurrent session limits (mutex-guarded)
|
||||
* - Event forwarding from individual sessions to subscribers
|
||||
* - State persistence via StateStore
|
||||
* - Concurrent session limits
|
||||
* - Graceful shutdown of all sessions
|
||||
*
|
||||
* Key exports:
|
||||
* - `SessionManager` class — coordinator, extends EventEmitter
|
||||
* - `SessionManagerEvents` interface — typed event map
|
||||
* - `getSessionManager()` — singleton accessor
|
||||
*
|
||||
* Key methods: `createSession(workingDir)`, `getSession(id)`, `getAllSessions()`,
|
||||
* `removeSession(id)`, `stopAll()`
|
||||
*
|
||||
* @dependencies session (Session class), state-store (persistence), types (SessionState)
|
||||
* @consumedby web/server, ralph-loop, respawn-controller
|
||||
* @emits sessionStarted, sessionStopped, sessionError, sessionOutput, sessionCompletion
|
||||
*
|
||||
* @module session-manager
|
||||
*/
|
||||
|
||||
+21
-8
@@ -1,16 +1,29 @@
|
||||
/**
|
||||
* @fileoverview Core PTY session wrapper for Claude CLI interactions.
|
||||
*
|
||||
* This module provides the Session class which manages a PTY (pseudo-terminal)
|
||||
* process running the Claude CLI. It supports three operation modes:
|
||||
* Manages a PTY (pseudo-terminal) process running Claude CLI or OpenCode CLI.
|
||||
* Three operation modes:
|
||||
* 1. **One-shot** (`runPrompt`): Single prompt → JSON response
|
||||
* 2. **Interactive** (`startInteractive`): Persistent interactive session
|
||||
* 3. **Shell** (`startShell`): Plain bash shell for debugging
|
||||
*
|
||||
* 1. **One-shot mode** (`runPrompt`): Execute a single prompt and get JSON response
|
||||
* 2. **Interactive mode** (`startInteractive`): Start an interactive Claude session
|
||||
* 3. **Shell mode**: Run a plain bash shell for debugging/testing
|
||||
* Optionally wraps in a tmux session for persistence across disconnects.
|
||||
* Tracks tokens, costs, background tasks, and auto-compact/clear.
|
||||
*
|
||||
* The session can optionally run inside a tmux session for persistence across disconnects.
|
||||
* It tracks tokens, costs, background tasks, and supports
|
||||
* auto-clear/auto-compact functionality when token limits are approached.
|
||||
* Key exports:
|
||||
* - `Session` class — main entity, extends EventEmitter
|
||||
* - `ClaudeMessage` interface — parsed JSON messages from Claude output
|
||||
* - `SessionEvents` interface — typed event map
|
||||
*
|
||||
* Key methods: `runPrompt()`, `startInteractive()`, `startShell()`,
|
||||
* `writeViaMux()`, `toState()`, `stop()`, `resize()`, `isIdle()`,
|
||||
* `setAutoCompact()`, `findTaskDescriptionNear()`, `getTerminalBuffer()`
|
||||
*
|
||||
* @dependencies session-cli-builder (args/env), session-auto-ops (auto-compact/clear),
|
||||
* ralph-tracker (todo/completion parsing), bash-tool-parser (tool invocation tracking),
|
||||
* task-tracker (background tasks), mux-interface (tmux abstraction)
|
||||
* @consumedby session-manager, web/server, respawn-controller
|
||||
* @emits session:terminal, session:idle, session:working, session:completion, session:exit
|
||||
*
|
||||
* @module session
|
||||
*/
|
||||
|
||||
+17
-7
@@ -1,15 +1,25 @@
|
||||
/**
|
||||
* @fileoverview Persistent JSON state storage for Codeman.
|
||||
*
|
||||
* This module provides the StateStore class which persists application state
|
||||
* to `~/.codeman/state.json` with debounced writes to prevent excessive disk I/O.
|
||||
*
|
||||
* Persists application state with debounced writes (500ms) to prevent excessive disk I/O.
|
||||
* State is split into two files:
|
||||
* - `state.json`: Main app state (sessions, tasks, config)
|
||||
* - `state-inner.json`: Inner loop state (todos, Ralph loop state per session)
|
||||
* - `~/.codeman/state.json` — main app state (sessions, tasks, config, global stats)
|
||||
* - `~/.codeman/state-inner.json` — Ralph loop state per session (changes rapidly)
|
||||
*
|
||||
* The separation reduces write frequency since Ralph state changes rapidly
|
||||
* during Ralph Wiggum loops.
|
||||
* Key exports:
|
||||
* - `StateStore` class — singleton store with circuit breaker for save failures
|
||||
* - `getStore(filePath?)` — factory/singleton accessor
|
||||
*
|
||||
* Key methods: `getState()`, `getSessions()`, `setSession()`, `getConfig()`,
|
||||
* `setConfig()`, `getGlobalStats()`, `getAggregateStats()`, `getTokenStats()`,
|
||||
* `getDailyStats()`, `getRalphState()`, `setRalphState()`, `save()`, `saveNow()`
|
||||
*
|
||||
* Auto-migrates legacy `~/.claudeman/` → `~/.codeman/` on first load.
|
||||
*
|
||||
* @dependencies types (AppState, RalphSessionState, GlobalStats, TokenStats),
|
||||
* utils (Debouncer, MAX_SESSION_TOKENS)
|
||||
* @consumedby session-manager, ralph-loop, web/server, respawn-controller,
|
||||
* hooks-config, and most subsystems
|
||||
*
|
||||
* @module state-store
|
||||
*/
|
||||
|
||||
+23
-3
@@ -1,8 +1,28 @@
|
||||
/**
|
||||
* @fileoverview Subagent Watcher - Real-time monitoring of Claude Code background agents
|
||||
* @fileoverview Subagent Watcher - Real-time monitoring of Claude Code background agents.
|
||||
*
|
||||
* Watches ~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl files
|
||||
* and emits structured events for tool calls, progress, and messages.
|
||||
* Watches `~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl` files
|
||||
* and emits structured events for tool calls, progress, messages, and tool results.
|
||||
* Also detects Agent Teams teammates (distinguished by `<teammate-message>` in description).
|
||||
*
|
||||
* Key exports:
|
||||
* - `SubagentWatcher` class — singleton watcher, extends EventEmitter
|
||||
* - `subagentWatcher` — pre-instantiated singleton instance
|
||||
* - `SubagentInfo`, `SubagentToolCall`, `SubagentProgress`, `SubagentMessage`,
|
||||
* `SubagentToolResult`, `SubagentTranscriptEntry` — data interfaces
|
||||
* - `SubagentEvents` — typed event map
|
||||
*
|
||||
* Watched patterns: `~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl`
|
||||
* Parses JSONL entries: user/assistant messages, tool_use/tool_result blocks, progress events.
|
||||
* Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID).
|
||||
*
|
||||
* @dependencies config/map-limits (MAX_TRACKED_AGENTS, PENDING_TOOL_CALL_TTL_MS),
|
||||
* 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
|
||||
*
|
||||
* @module subagent-watcher
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
@@ -362,8 +362,7 @@ export class TunnelManager extends EventEmitter {
|
||||
if (!code) throw new Error('No QR token available');
|
||||
if (this.cachedQrSvg?.shortCode === code) return this.cachedQrSvg.svg;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic optional dependency
|
||||
const QRCode = require('qrcode');
|
||||
const QRCode = await import('qrcode');
|
||||
const svg: string = await QRCode.toString(`${tunnelUrl}/q/${code}`, {
|
||||
type: 'svg',
|
||||
margin: 2,
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
/**
|
||||
* @fileoverview Convenience re-export of all type definitions.
|
||||
*
|
||||
* This root-level barrel allows shorter imports throughout the codebase:
|
||||
* `import type { SessionState } from './types'`
|
||||
* instead of:
|
||||
* `import type { SessionState } from './types/index.js'`
|
||||
*
|
||||
* All types are defined in `src/types/` domain modules — see `src/types/index.ts`
|
||||
* for the full module map and cross-domain relationship notes.
|
||||
*/
|
||||
export * from './types/index.js';
|
||||
|
||||
+16
-1
@@ -1,5 +1,20 @@
|
||||
/**
|
||||
* @fileoverview API types and error handling
|
||||
* @fileoverview API types and error handling.
|
||||
*
|
||||
* Defines the standardized API response envelope (ApiResponse), error codes,
|
||||
* hook event types from Claude Code's hooks system, and utility functions
|
||||
* for error message extraction. Used by all route modules in `src/web/routes/`.
|
||||
*
|
||||
* Key exports:
|
||||
* - ApiResponse<T> — discriminated union envelope (success with data or error with code)
|
||||
* - ApiErrorCode — enum of standard error codes with user-friendly messages
|
||||
* - HookEventType — union of Claude Code hook event names (idle_prompt, stop, etc.)
|
||||
* - createErrorResponse() — factory for consistent error responses
|
||||
* - getErrorMessage() — safe extraction from unknown catch values
|
||||
* - CaseInfo, QuickStartResponse — case folder metadata types
|
||||
*
|
||||
* No dependencies on other domain modules. Consumed by all route modules
|
||||
* and validated via Zod schemas in `src/web/schemas.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
+18
-1
@@ -1,5 +1,22 @@
|
||||
/**
|
||||
* @fileoverview Application state type definitions
|
||||
* @fileoverview Application state type definitions.
|
||||
*
|
||||
* Defines the top-level persisted state structure (AppState) which composes
|
||||
* types from multiple domains: SessionState (session), TaskState (task),
|
||||
* RalphLoopState (ralph), and RespawnConfig (respawn).
|
||||
*
|
||||
* Key exports:
|
||||
* - AppState — root state object (sessions, tasks, ralphLoop, config, globalStats, tokenStats)
|
||||
* - AppConfig — app configuration including default RespawnConfig
|
||||
* - GlobalStats — cumulative usage stats across all sessions (lifetime)
|
||||
* - TokenStats / TokenUsageEntry — daily token usage history
|
||||
* - DEFAULT_CONFIG — default AppConfig values
|
||||
* - createInitialState() — factory for fresh AppState
|
||||
*
|
||||
* Persisted to `~/.codeman/state.json` via StateStore (debounced 500ms writes).
|
||||
* Served at `GET /api/status` (full state) and `GET /api/config` (config subset).
|
||||
*
|
||||
* Cross-domain imports: SessionState, TaskState, RalphLoopState, RespawnConfig.
|
||||
*/
|
||||
|
||||
import type { SessionState } from './session.js';
|
||||
|
||||
+10
-1
@@ -1,5 +1,14 @@
|
||||
/**
|
||||
* @fileoverview Common/shared type definitions
|
||||
* @fileoverview Common/shared type definitions.
|
||||
*
|
||||
* Base types used across multiple domains. No dependencies on other domain modules.
|
||||
*
|
||||
* Key exports:
|
||||
* - Disposable — interface for objects requiring explicit cleanup (timers, watchers)
|
||||
* - BufferConfig — size-limited storage config (terminal: 2MB, text: 1MB)
|
||||
* - CleanupRegistration / CleanupResourceType — entries for the centralized CleanupManager
|
||||
* - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice`
|
||||
* - ProcessStats — memory/CPU/child-count snapshot for resource monitoring
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
+50
-2
@@ -1,6 +1,54 @@
|
||||
/**
|
||||
* @fileoverview Barrel re-export for all type definitions.
|
||||
* Split into domain modules for maintainability.
|
||||
* @fileoverview Barrel re-export for all Codeman type definitions.
|
||||
*
|
||||
* The type system is split into 13 domain modules for maintainability.
|
||||
* Import from `'./types'` (or `'./types/index.js'`) to access any type:
|
||||
*
|
||||
* ```ts
|
||||
* import type { SessionState, AppState, RespawnConfig } from './types';
|
||||
* import { createErrorResponse, ApiErrorCode } from './types';
|
||||
* ```
|
||||
*
|
||||
* ## Domain modules
|
||||
*
|
||||
* | Module | Key exports | Persistence / API |
|
||||
* |--------------|-----------------------------------------------------------------------|-------------------------------------------------|
|
||||
* | common | Disposable, BufferConfig, CleanupRegistration, NiceConfig, ProcessStats | In-memory only |
|
||||
* | session | SessionState, SessionConfig, SessionStatus, SessionMode, ClaudeMode, OpenCodeConfig | `~/.codeman/state.json` → `GET /api/sessions` |
|
||||
* | task | TaskDefinition, TaskState, TaskStatus | `~/.codeman/state.json` → `GET /api/tasks` |
|
||||
* | app-state | AppState, AppConfig, GlobalStats, TokenStats, DEFAULT_CONFIG | `~/.codeman/state.json` → `GET /api/status` |
|
||||
* | respawn | RespawnConfig, RespawnPreset, RespawnCycleMetrics, RalphLoopHealthScore, TimingHistory | Per-session in state.json → `GET /api/sessions/:id/respawn` |
|
||||
* | ralph | RalphTrackerState, RalphTodoItem, CircuitBreakerStatus, RalphStatusBlock, RalphSessionState | Per-session → `GET /api/sessions/:id/ralph-state` |
|
||||
* | api | ApiResponse, ApiErrorCode, HookEventType, CaseInfo, createErrorResponse, getErrorMessage | Used by all route handlers |
|
||||
* | lifecycle | LifecycleEntry, LifecycleEventType | `~/.codeman/session-lifecycle.jsonl` (append-only) |
|
||||
* | run-summary | RunSummary, RunSummaryEvent, RunSummaryStats | In-memory → `GET /api/sessions/:id/run-summary` |
|
||||
* | tools | ActiveBashTool, ImageDetectedEvent | In-memory, broadcast via SSE |
|
||||
* | teams | TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo | `~/.claude/teams/`, `~/.claude/tasks/` → `GET /api/teams` |
|
||||
* | push | PushSubscriptionRecord, VapidKeys | `~/.codeman/push-keys.json`, `~/.codeman/push-subscriptions.json` |
|
||||
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
||||
*
|
||||
* ## Cross-domain relationship map
|
||||
*
|
||||
* ```
|
||||
* AppState (app-state)
|
||||
* ├── sessions: Record<id, SessionState> ← session domain
|
||||
* │ ├── respawnConfig?: RespawnConfig ← respawn domain (per-session settings)
|
||||
* │ ├── ralphEnabled?: boolean ← toggles ralph tracking
|
||||
* │ └── id ← referenced by:
|
||||
* │ ├── RalphSessionState.sessionId ← ralph domain
|
||||
* │ ├── RunSummary.sessionId ← run-summary domain
|
||||
* │ ├── ActiveBashTool.sessionId ← tools domain
|
||||
* │ ├── RespawnCycleMetrics.sessionId ← respawn domain
|
||||
* │ └── TeamConfig.leadSessionId ← teams domain
|
||||
* ├── tasks: Record<id, TaskState> ← task domain
|
||||
* │ └── assignedSessionId → SessionState.id
|
||||
* ├── ralphLoop: RalphLoopState ← ralph domain (global loop state)
|
||||
* └── config: AppConfig
|
||||
* └── respawn: RespawnConfig ← respawn domain (global defaults)
|
||||
*
|
||||
* RalphLoopHealthScore (respawn)
|
||||
* └── components.circuitBreaker ← derived from CircuitBreakerStatus (ralph)
|
||||
* ```
|
||||
*/
|
||||
|
||||
export * from './common.js';
|
||||
|
||||
+12
-1
@@ -1,5 +1,16 @@
|
||||
/**
|
||||
* @fileoverview Session lifecycle audit types
|
||||
* @fileoverview Session lifecycle audit types.
|
||||
*
|
||||
* Types for the append-only JSONL audit log at `~/.codeman/session-lifecycle.jsonl`.
|
||||
* Records session creation, PTY launch/exit, server start/stop, tmux recovery,
|
||||
* and QR auth events. Written by `SessionLifecycleLog`, read for debugging.
|
||||
*
|
||||
* Key exports:
|
||||
* - LifecycleEventType — union of 11 event types (created, started, exit, qr_auth, etc.)
|
||||
* - LifecycleEntry — a single timestamped log entry with event, sessionId, and optional metadata
|
||||
*
|
||||
* No dependencies on other domain modules. LifecycleEntry.sessionId links
|
||||
* back to SessionState.id for correlation.
|
||||
*/
|
||||
|
||||
/** Types of session lifecycle events recorded to the audit log */
|
||||
|
||||
+14
-1
@@ -1,5 +1,18 @@
|
||||
/**
|
||||
* @fileoverview Plan orchestrator type definitions
|
||||
* @fileoverview Plan orchestrator type definitions.
|
||||
*
|
||||
* Types for the 2-agent plan generation system (optional research agent → planner agent).
|
||||
*
|
||||
* 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'
|
||||
*
|
||||
* 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`.
|
||||
*
|
||||
* PlanItem was moved here from plan-orchestrator.ts to break a circular dependency.
|
||||
* No dependencies on other domain modules.
|
||||
*/
|
||||
|
||||
/** Task execution status for plan tracking */
|
||||
|
||||
+14
-1
@@ -1,5 +1,18 @@
|
||||
/**
|
||||
* @fileoverview Web Push notification type definitions
|
||||
* @fileoverview Web Push notification type definitions.
|
||||
*
|
||||
* Types for the Web Push notification layer (layer 4 of the 5-layer notification system).
|
||||
*
|
||||
* Key exports:
|
||||
* - PushSubscriptionRecord — a registered push endpoint with per-event preferences
|
||||
* - VapidKeys — VAPID key pair (public + private) for Web Push authentication
|
||||
*
|
||||
* Persistence:
|
||||
* - VAPID keys: `~/.codeman/push-keys.json` (auto-generated on first use)
|
||||
* - Subscriptions: `~/.codeman/push-subscriptions.json` (expired auto-cleaned on 410/404)
|
||||
*
|
||||
* Managed by PushStore (`src/push-store.ts`). Served at `GET /api/push/vapid-key`,
|
||||
* `POST /api/push/subscribe`. No dependencies on other domain modules.
|
||||
*/
|
||||
|
||||
/** A registered push subscription */
|
||||
|
||||
+22
-1
@@ -1,5 +1,26 @@
|
||||
/**
|
||||
* @fileoverview Ralph Loop / todo tracking type definitions
|
||||
* @fileoverview Ralph Loop / todo tracking type definitions.
|
||||
*
|
||||
* Covers the autonomous task execution system: loop state, todo items,
|
||||
* completion confidence scoring, RALPH_STATUS block parsing, and the
|
||||
* circuit breaker for stuck-loop detection.
|
||||
*
|
||||
* Key exports:
|
||||
* - RalphLoopState / RalphLoopStatus — global loop controller state (embedded in AppState)
|
||||
* - RalphTrackerState — per-session loop tracking (cycle count, completion phrase, plan version)
|
||||
* - RalphTodoItem / RalphTodoProgress — detected todo items with priority and progress estimation
|
||||
* - RalphSessionState — composite per-session state (loop + todos), linked via sessionId
|
||||
* - CompletionConfidence — multi-signal scoring for completion detection (0-100)
|
||||
* - RalphStatusBlock — parsed RALPH_STATUS block from Claude output (status, tests, exit signal)
|
||||
* - CircuitBreakerStatus / CircuitBreakerState — stuck-loop detection state machine (CLOSED → HALF_OPEN → OPEN)
|
||||
* - Factory functions: createInitialCircuitBreakerStatus(), createInitialRalphTrackerState(), createInitialRalphSessionState()
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - RalphLoopState is embedded in AppState.ralphLoop (app-state domain)
|
||||
* - RalphSessionState.sessionId links to SessionState.id (session domain)
|
||||
* - CircuitBreakerStatus feeds into RalphLoopHealthScore.components.circuitBreaker (respawn domain)
|
||||
*
|
||||
* Served at `GET /api/sessions/:id/ralph-state` and `GET /api/sessions/:id/ralph-status`.
|
||||
*/
|
||||
|
||||
/** Status of the Ralph Loop controller */
|
||||
|
||||
+22
-1
@@ -1,5 +1,26 @@
|
||||
/**
|
||||
* @fileoverview Respawn controller type definitions
|
||||
* @fileoverview Respawn controller type definitions.
|
||||
*
|
||||
* Covers the autonomous session cycling system: configuration, presets,
|
||||
* per-cycle metrics, aggregate health scoring, and adaptive timing.
|
||||
*
|
||||
* Key exports:
|
||||
* - RespawnConfig — full respawn settings (idle timeout, AI checks, adaptive timing, skip-clear)
|
||||
* - PersistedRespawnConfig — subset saved to disk for mux session recovery
|
||||
* - RespawnPreset — named preset for quick setup (solo-work, team-lead, overnight-autonomous, etc.)
|
||||
* - RespawnCycleMetrics — per-cycle outcome tracking (duration, idle reason, steps, tokens)
|
||||
* - RespawnAggregateMetrics — aggregate stats across cycles (success rate, p90 duration)
|
||||
* - RalphLoopHealthScore — composite 0-100 health score with 5 component scores
|
||||
* - HealthStatus — 'excellent' | 'good' | 'degraded' | 'critical'
|
||||
* - TimingHistory — rolling window of timing data for adaptive adjustments
|
||||
* - CycleOutcome — 'success' | 'stuck_recovery' | 'blocked' | 'error' | 'cancelled'
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - RespawnConfig is embedded in AppConfig.respawn (app-state) and SessionState.respawnConfig (session)
|
||||
* - RalphLoopHealthScore.components.circuitBreaker derives from CircuitBreakerStatus (ralph domain)
|
||||
* - RespawnCycleMetrics.sessionId links to SessionState.id (session domain)
|
||||
*
|
||||
* Served at `GET /api/sessions/:id/respawn` (config + state).
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
/**
|
||||
* @fileoverview Run summary type definitions
|
||||
* @fileoverview Run summary type definitions.
|
||||
*
|
||||
* Types for the "what happened while away" session timeline. RunSummary
|
||||
* aggregates events (respawn cycles, errors, token milestones, AI checks)
|
||||
* into a per-session historical view.
|
||||
*
|
||||
* Key exports:
|
||||
* - RunSummary — complete per-session summary (events timeline + aggregated stats)
|
||||
* - RunSummaryEvent — a single timestamped event (16 event types, 4 severity levels)
|
||||
* - RunSummaryStats — aggregated metrics (cycles, tokens, active/idle time, error count)
|
||||
* - RunSummaryEventType — union of event types (session_started, respawn_cycle_*, error, etc.)
|
||||
* - RunSummaryEventSeverity — 'info' | 'warning' | 'error' | 'success'
|
||||
* - createInitialRunSummaryStats() — factory for fresh stats
|
||||
*
|
||||
* Cross-domain: RunSummary.sessionId links to SessionState.id (session domain).
|
||||
* In-memory only (not persisted to disk). Served at `GET /api/sessions/:id/run-summary`.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
+23
-1
@@ -1,5 +1,27 @@
|
||||
/**
|
||||
* @fileoverview Session type definitions
|
||||
* @fileoverview Session type definitions.
|
||||
*
|
||||
* Core domain type — SessionState is the primary entity in the system.
|
||||
*
|
||||
* Key exports:
|
||||
* - SessionState — full session state (status, tokens, respawn, ralph, CLI metadata)
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
||||
* - SessionState.id is referenced by: RalphSessionState.sessionId (ralph),
|
||||
* RunSummary.sessionId (run-summary), ActiveBashTool.sessionId (tools),
|
||||
* TeamConfig.leadSessionId (teams), RespawnCycleMetrics.sessionId (respawn),
|
||||
* TaskState.assignedSessionId (task)
|
||||
*
|
||||
* Persisted to `~/.codeman/state.json`. Served at `GET /api/sessions` and
|
||||
* `GET /api/sessions/:id`.
|
||||
*/
|
||||
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
|
||||
+15
-1
@@ -1,5 +1,19 @@
|
||||
/**
|
||||
* @fileoverview Task queue type definitions
|
||||
* @fileoverview Task queue type definitions.
|
||||
*
|
||||
* Types for the prompt execution queue: TaskDefinition (input) and
|
||||
* TaskState (persisted state with execution details).
|
||||
*
|
||||
* Key exports:
|
||||
* - TaskDefinition — input type for creating a task (prompt, workingDir, priority, dependencies)
|
||||
* - TaskState — persisted state with execution details (status, assignedSessionId, output, error)
|
||||
* - TaskStatus — 'pending' | 'running' | 'completed' | 'failed'
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - TaskState.assignedSessionId links to SessionState.id (session domain)
|
||||
* - TaskState is stored in AppState.tasks (app-state domain)
|
||||
*
|
||||
* Persisted to `~/.codeman/state.json`. No dependencies on other domain modules.
|
||||
*/
|
||||
|
||||
/** Status of a task in the queue */
|
||||
|
||||
+19
-1
@@ -1,5 +1,23 @@
|
||||
/**
|
||||
* @fileoverview Agent Teams type definitions
|
||||
* @fileoverview Agent Teams type definitions (experimental).
|
||||
*
|
||||
* Types for Claude Code's Agent Teams feature: team configuration,
|
||||
* member metadata, task tracking, and inbox messaging.
|
||||
*
|
||||
* Key exports:
|
||||
* - TeamConfig — team from `~/.claude/teams/{name}/config.json` (name, leadSessionId, members[])
|
||||
* - TeamMember — member entry (agentId, name, agentType, color)
|
||||
* - TeamTask — task from `~/.claude/tasks/{name}/{N}.json` (subject, status, blocks/blockedBy, owner)
|
||||
* - InboxMessage — message from `~/.claude/teams/{name}/inboxes/{member}.json`
|
||||
* - PaneInfo — tmux pane metadata for teammate pane management
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - TeamConfig.leadSessionId links to SessionState.id (session domain)
|
||||
* - Teammates appear as standard subagents (detected by SubagentWatcher)
|
||||
*
|
||||
* Served at `GET /api/teams` (list) and `GET /api/teams/:name/tasks`.
|
||||
* Requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var.
|
||||
* No dependencies on other domain modules.
|
||||
*/
|
||||
|
||||
/** Team configuration from ~/.claude/teams/{name}/config.json */
|
||||
|
||||
+16
-1
@@ -1,5 +1,20 @@
|
||||
/**
|
||||
* @fileoverview Tool-related type definitions
|
||||
* @fileoverview Tool-related type definitions.
|
||||
*
|
||||
* Types for tracking Claude's tool invocations in real-time.
|
||||
*
|
||||
* Key exports:
|
||||
* - ActiveBashTool — a live bash command with extracted file paths and status
|
||||
* - ActiveBashToolStatus — 'running' | 'completed'
|
||||
* - ImageDetectedEvent — screenshot/image file detection trigger for UI popup
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - ActiveBashTool.sessionId links to SessionState.id (session domain)
|
||||
* - ImageDetectedEvent.sessionId links to SessionState.id (session domain)
|
||||
*
|
||||
* Both types are in-memory only (not persisted). Broadcast via SSE events
|
||||
* `subagent:tool_call` and `image:detected`. Parsed by BashToolParser
|
||||
* (`src/bash-tool-parser.ts`).
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* @fileoverview Centralized API fetch helpers mixed into CodemanApp.prototype.
|
||||
*
|
||||
* Provides _api(), _apiJson(), _apiPost(), _apiPut(), _apiDelete() methods that handle
|
||||
* JSON serialization, Content-Type headers, and error swallowing. All API calls in the
|
||||
* frontend route through these helpers.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class must be defined)
|
||||
* @loadorder 8 of 9 — loaded after app.js
|
||||
*/
|
||||
|
||||
// Codeman — Centralized API fetch helpers for CodemanApp
|
||||
// Loaded after app.js (needs CodemanApp class defined)
|
||||
|
||||
|
||||
+1487
-1098
File diff suppressed because it is too large
Load Diff
+134
-7
@@ -1,8 +1,29 @@
|
||||
/**
|
||||
* @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,
|
||||
* 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)
|
||||
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
|
||||
*
|
||||
* @dependency None (first in load order)
|
||||
* @loadorder 1 of 9 — 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
|
||||
*/
|
||||
|
||||
// Codeman — Shared constants and utility functions for frontend modules
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Web Push Utilities
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Convert a base64-encoded VAPID key to Uint8Array for pushManager.subscribe() */
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
@@ -16,9 +37,9 @@ function urlBase64ToUint8Array(base64String) {
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Constants
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Default terminal scrollback (can be changed via settings)
|
||||
const DEFAULT_SCROLLBACK = 5000;
|
||||
@@ -31,7 +52,7 @@ 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 = 128 * 1024; // 128KB chunks for terminal data
|
||||
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 SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||
@@ -157,9 +178,115 @@ const BUILTIN_RESPAWN_PRESETS = [
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SSE Event Types
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** @type {Record<string, string>} Centralized SSE event type constants */
|
||||
const SSE_EVENTS = {
|
||||
// Core
|
||||
INIT: 'init',
|
||||
|
||||
// Session lifecycle
|
||||
SESSION_CREATED: 'session:created',
|
||||
SESSION_UPDATED: 'session:updated',
|
||||
SESSION_DELETED: 'session:deleted',
|
||||
SESSION_TERMINAL: 'session:terminal',
|
||||
SESSION_NEEDS_REFRESH: 'session:needsRefresh',
|
||||
SESSION_CLEAR_TERMINAL: 'session:clearTerminal',
|
||||
SESSION_COMPLETION: 'session:completion',
|
||||
SESSION_ERROR: 'session:error',
|
||||
SESSION_EXIT: 'session:exit',
|
||||
SESSION_IDLE: 'session:idle',
|
||||
SESSION_WORKING: 'session:working',
|
||||
SESSION_AUTO_CLEAR: 'session:autoClear',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
|
||||
// Scheduled runs
|
||||
SCHEDULED_CREATED: 'scheduled:created',
|
||||
SCHEDULED_UPDATED: 'scheduled:updated',
|
||||
SCHEDULED_COMPLETED: 'scheduled:completed',
|
||||
SCHEDULED_STOPPED: 'scheduled:stopped',
|
||||
|
||||
// Respawn
|
||||
RESPAWN_STARTED: 'respawn:started',
|
||||
RESPAWN_STOPPED: 'respawn:stopped',
|
||||
RESPAWN_STATE_CHANGED: 'respawn:stateChanged',
|
||||
RESPAWN_CYCLE_STARTED: 'respawn:cycleStarted',
|
||||
RESPAWN_BLOCKED: 'respawn:blocked',
|
||||
RESPAWN_AUTO_ACCEPT_SENT: 'respawn:autoAcceptSent',
|
||||
RESPAWN_DETECTION_UPDATE: 'respawn:detectionUpdate',
|
||||
RESPAWN_TIMER_STARTED: 'respawn:timerStarted',
|
||||
RESPAWN_TIMER_CANCELLED: 'respawn:timerCancelled',
|
||||
RESPAWN_TIMER_COMPLETED: 'respawn:timerCompleted',
|
||||
RESPAWN_ERROR: 'respawn:error',
|
||||
RESPAWN_ACTION_LOG: 'respawn:actionLog',
|
||||
|
||||
// Tasks
|
||||
TASK_CREATED: 'task:created',
|
||||
TASK_COMPLETED: 'task:completed',
|
||||
TASK_FAILED: 'task:failed',
|
||||
TASK_UPDATED: 'task:updated',
|
||||
|
||||
// Mux (tmux)
|
||||
MUX_CREATED: 'mux:created',
|
||||
MUX_KILLED: 'mux:killed',
|
||||
MUX_DIED: 'mux:died',
|
||||
MUX_STATS_UPDATED: 'mux:statsUpdated',
|
||||
|
||||
// Ralph
|
||||
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
|
||||
SESSION_RALPH_TODO_UPDATE: 'session:ralphTodoUpdate',
|
||||
SESSION_RALPH_COMPLETION_DETECTED: 'session:ralphCompletionDetected',
|
||||
SESSION_RALPH_STATUS_UPDATE: 'session:ralphStatusUpdate',
|
||||
SESSION_CIRCUIT_BREAKER_UPDATE: 'session:circuitBreakerUpdate',
|
||||
SESSION_EXIT_GATE_MET: 'session:exitGateMet',
|
||||
|
||||
// Bash tools
|
||||
SESSION_BASH_TOOL_START: 'session:bashToolStart',
|
||||
SESSION_BASH_TOOL_END: 'session:bashToolEnd',
|
||||
SESSION_BASH_TOOLS_UPDATE: 'session:bashToolsUpdate',
|
||||
|
||||
// Hooks (Claude Code hook events)
|
||||
HOOK_IDLE_PROMPT: 'hook:idle_prompt',
|
||||
HOOK_PERMISSION_PROMPT: 'hook:permission_prompt',
|
||||
HOOK_ELICITATION_DIALOG: 'hook:elicitation_dialog',
|
||||
HOOK_STOP: 'hook:stop',
|
||||
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
||||
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
||||
|
||||
// Subagents (Claude Code background agents)
|
||||
SUBAGENT_DISCOVERED: 'subagent:discovered',
|
||||
SUBAGENT_UPDATED: 'subagent:updated',
|
||||
SUBAGENT_TOOL_CALL: 'subagent:tool_call',
|
||||
SUBAGENT_PROGRESS: 'subagent:progress',
|
||||
SUBAGENT_MESSAGE: 'subagent:message',
|
||||
SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
|
||||
SUBAGENT_COMPLETED: 'subagent:completed',
|
||||
|
||||
// Images
|
||||
IMAGE_DETECTED: 'image:detected',
|
||||
|
||||
// Tunnel
|
||||
TUNNEL_STARTED: 'tunnel:started',
|
||||
TUNNEL_STOPPED: 'tunnel:stopped',
|
||||
TUNNEL_PROGRESS: 'tunnel:progress',
|
||||
TUNNEL_ERROR: 'tunnel:error',
|
||||
TUNNEL_QR_ROTATED: 'tunnel:qrRotated',
|
||||
TUNNEL_QR_REGENERATED: 'tunnel:qrRegenerated',
|
||||
TUNNEL_QR_AUTH_USED: 'tunnel:qrAuthUsed',
|
||||
|
||||
// Plan orchestration
|
||||
PLAN_SUBAGENT: 'plan:subagent',
|
||||
PLAN_PROGRESS: 'plan:progress',
|
||||
PLAN_STARTED: 'plan:started',
|
||||
PLAN_CANCELLED: 'plan:cancelled',
|
||||
PLAN_COMPLETED: 'plan:completed',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Get unified coordinates from mouse or touch event.
|
||||
|
||||
+14
-10
@@ -20,7 +20,7 @@
|
||||
<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.2.9"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js?v=0.3.2"></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) -->
|
||||
@@ -961,6 +961,10 @@
|
||||
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code" style="font-size:16px; padding:2px 6px; background:none; border:1px solid var(--border); border-radius:4px; cursor:pointer; color:var(--text-secondary)">⊞</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item" id="tunnelUploadUrlRow" style="display:none">
|
||||
<span class="settings-item-label">Upload URL</span>
|
||||
<span id="tunnelUploadUrlDisplay" class="settings-item-value" style="cursor:pointer; text-decoration:underline; font-family:monospace; font-size:12px" title="Click to copy"></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1670,14 +1674,14 @@
|
||||
<!-- Lines drawn dynamically -->
|
||||
</svg>
|
||||
|
||||
<script defer src="constants.js?v=0.2.9"></script>
|
||||
<script defer src="mobile-handlers.js?v=0.2.9"></script>
|
||||
<script defer src="voice-input.js?v=0.2.9"></script>
|
||||
<script defer src="notification-manager.js?v=0.2.9"></script>
|
||||
<script defer src="keyboard-accessory.js?v=0.2.9"></script>
|
||||
<script defer src="app.js?v=0.2.9"></script>
|
||||
<script defer src="ralph-wizard.js?v=0.2.9"></script>
|
||||
<script defer src="api-client.js?v=0.2.9"></script>
|
||||
<script defer src="subagent-windows.js?v=0.2.9"></script>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
/**
|
||||
* @fileoverview Mobile keyboard accessory bar and modal focus trap.
|
||||
*
|
||||
* Defines two exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
*
|
||||
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
|
||||
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
|
||||
* and other modal dialogs.
|
||||
*
|
||||
* @globals {object} KeyboardAccessoryBar
|
||||
* @globals {class} FocusTrap
|
||||
*
|
||||
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
|
||||
* @dependency app.js (uses global `app` for sendInput, activeSessionId, terminal)
|
||||
* @loadorder 5 of 9 — loaded after notification-manager.js, before app.js
|
||||
*/
|
||||
|
||||
// Codeman — Keyboard accessory bar and focus trap for modals
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Keyboard Accessory Bar
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
@@ -209,9 +232,9 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Accessibility: Focus Trap for Modals
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* FocusTrap - Traps keyboard focus within an element (typically a modal).
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
/**
|
||||
* @fileoverview Mobile device support: detection, keyboard handling, and swipe navigation.
|
||||
*
|
||||
* Defines three singleton objects that manage mobile-specific behavior:
|
||||
*
|
||||
* - MobileDetection — Device type detection (mobile/tablet/desktop), touch capability,
|
||||
* iOS/Safari identification, and body class management for CSS targeting.
|
||||
* - KeyboardHandler — Virtual keyboard show/hide detection via visualViewport API,
|
||||
* toolbar/accessory bar repositioning, terminal resize on keyboard open/close,
|
||||
* and input scroll-into-view. Uses 100px threshold for iOS address bar drift.
|
||||
* - SwipeHandler — Horizontal swipe detection on the terminal area for session switching.
|
||||
* 80px minimum distance, 300ms maximum time, 100px max vertical drift.
|
||||
*
|
||||
* All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE
|
||||
* reconnect (in handleInit) to prevent stale closures.
|
||||
*
|
||||
* @globals {object} MobileDetection
|
||||
* @globals {object} KeyboardHandler
|
||||
* @globals {object} SwipeHandler
|
||||
*
|
||||
* @dependency keyboard-accessory.js (KeyboardAccessoryBar reference in KeyboardHandler.onKeyboardShow, soft — guarded with typeof check)
|
||||
* @loadorder 2 of 9 — loaded after constants.js, before voice-input.js
|
||||
*/
|
||||
|
||||
// Codeman — Mobile detection, keyboard handling, and swipe navigation
|
||||
// Loaded after constants.js, before app.js
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Detection
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* MobileDetection - Detects device type and touch capability.
|
||||
@@ -109,9 +133,9 @@ const MobileDetection = {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Keyboard Handler
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears.
|
||||
@@ -387,9 +411,9 @@ const KeyboardHandler = {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Swipe Handler
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* SwipeHandler - Detects horizontal swipes on terminal to switch sessions.
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
/**
|
||||
* @fileoverview Five-layer notification system for session events and alerts.
|
||||
*
|
||||
* The NotificationManager class implements five notification layers:
|
||||
* 1. In-app notification drawer (slide-out panel with grouped notifications)
|
||||
* 2. Tab title flash (alternating "(*) Codeman" when tab is hidden)
|
||||
* 3. Browser Notification API (desktop push with auto-close after 8s)
|
||||
* 4. Web Push via service worker (OS-level notifications when tab is closed)
|
||||
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
||||
*
|
||||
* Features:
|
||||
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v4 migration
|
||||
* - Device-specific defaults (notifications disabled on mobile by default)
|
||||
* - 5s notification grouping window to batch rapid-fire events
|
||||
* - 100-notification cap with oldest eviction
|
||||
* - Rate limiting: 3s between browser notifications
|
||||
* - Visibility tracking (pauses title flash when tab becomes visible)
|
||||
* - iOS Safari bfcache support via pageshow event
|
||||
*
|
||||
* @class NotificationManager
|
||||
* @param {CodemanApp} app - Reference to the main app instance
|
||||
*
|
||||
* @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants)
|
||||
* @dependency mobile-handlers.js (MobileDetection.getDeviceType for device-specific defaults)
|
||||
* @loadorder 4 of 9 — loaded after voice-input.js, before keyboard-accessory.js
|
||||
*/
|
||||
|
||||
// Codeman — Multi-layer notification system
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
/**
|
||||
* Ralph Loop Wizard — extracted from app.js for maintainability.
|
||||
* Extends CodemanApp.prototype with wizard methods.
|
||||
* Loaded after app.js in index.html.
|
||||
* @fileoverview Ralph Loop Wizard — multi-step modal for configuring autonomous task loops.
|
||||
*
|
||||
* Extends CodemanApp.prototype with wizard methods for the Ralph Loop setup flow:
|
||||
* Step 1: Task description, completion phrase, iteration limit, case selection
|
||||
* Step 2: AI-powered plan generation (optional) with research agent → planner agent pipeline
|
||||
* Step 3: Respawn configuration (idle timeout, kickstart prompt, auto-clear/init)
|
||||
* Step 4: Review and launch
|
||||
*
|
||||
* Features:
|
||||
* - Plan generation via POST /api/sessions/:id/plan/generate with SSE progress streaming
|
||||
* - Existing @fix_plan.md detection and reuse
|
||||
* - Plan detail level selection (brief/detailed/comprehensive)
|
||||
* - Case selector population from /api/cases
|
||||
* - Focus trap for modal accessibility
|
||||
* - Abort controller for cancelling in-flight plan generation
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class must be defined)
|
||||
* @dependency keyboard-accessory.js (FocusTrap class for modal focus management)
|
||||
* @dependency constants.js (escapeHtml)
|
||||
* @loadorder 7 of 9 — loaded after app.js, before api-client.js
|
||||
*/
|
||||
|
||||
// ========== Ralph Loop Wizard ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Loop Wizard
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
@@ -388,7 +408,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Plan Generation ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Generation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
resetPlanGenerationUI() {
|
||||
// Hide all plan generation states
|
||||
@@ -780,7 +802,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closePlanSubagentWindows();
|
||||
},
|
||||
|
||||
// ========== Plan Subagent Windows ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Subagent Windows
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
handlePlanSubagentEvent(event) {
|
||||
if (this.planGenerationStopped) return;
|
||||
|
||||
@@ -5825,6 +5825,8 @@ kbd {
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-browser-title {
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
/**
|
||||
* @fileoverview Subagent floating window management mixed into CodemanApp.prototype.
|
||||
*
|
||||
* Extends CodemanApp with methods for managing floating terminal windows that display
|
||||
* Claude Code background agent (subagent) output. Each subagent window has its own
|
||||
* xterm.js terminal instance, drag/resize handles, minimize/close controls, and
|
||||
* connection lines drawn to the parent session tab.
|
||||
*
|
||||
* Key functionality:
|
||||
* - Tab badge dropdown showing minimized agents per session
|
||||
* - Minimize/restore/permanently-close lifecycle for subagent windows
|
||||
* - Cross-browser state persistence (localStorage + server-backed PUT /api/subagent-window-states)
|
||||
* - Window state saved on every minimize/restore/close action
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class, this.subagents, this.subagentWindows, this.minimizedSubagents)
|
||||
* @dependency constants.js (escapeHtml)
|
||||
* @loadorder 9 of 9 — loaded last, after api-client.js
|
||||
*/
|
||||
|
||||
// Codeman — Subagent window management for CodemanApp
|
||||
// Loaded after app.js (needs CodemanApp class defined)
|
||||
|
||||
@@ -80,7 +100,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.saveSubagentWindowStates();
|
||||
},
|
||||
|
||||
// ========== Subagent Window State Persistence ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Window State Persistence
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Save subagent window states (minimized/open) to server for cross-browser persistence.
|
||||
@@ -198,7 +220,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
// ========== Subagent Connection Lines ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Connection Lines
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Connection lines are drawn from agent windows to their parent TABs.
|
||||
// The parent TAB is determined by the PERSISTENT subagentParentMap.
|
||||
@@ -434,7 +458,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Subagent Floating Windows ==========
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Floating Windows
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
openSubagentWindow(agentId) {
|
||||
// If window already exists, focus it
|
||||
@@ -986,19 +1012,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
// Named handle-level listeners (stored for explicit cleanup on window close)
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
startDrag(e.clientX, e.clientY);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Touch events
|
||||
handle.addEventListener('touchstart', (e) => {
|
||||
};
|
||||
const handleTouchStart = (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
const touch = e.touches[0];
|
||||
startDrag(touch.clientX, touch.clientY);
|
||||
}, { passive: true });
|
||||
};
|
||||
|
||||
handle.addEventListener('mousedown', handleMouseDown);
|
||||
handle.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
|
||||
// Store references to document-level listeners so they can be removed on window close
|
||||
const moveListener = (e) => {
|
||||
@@ -1022,8 +1049,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.addEventListener('touchend', upListener);
|
||||
document.addEventListener('touchcancel', upListener);
|
||||
|
||||
// Return listener references for cleanup
|
||||
return { move: moveListener, up: upListener, touchMove: touchMoveListener };
|
||||
// Return all listener references for cleanup (both handle-level and document-level)
|
||||
return {
|
||||
move: moveListener,
|
||||
up: upListener,
|
||||
touchMove: touchMoveListener,
|
||||
handle,
|
||||
handleMouseDown,
|
||||
handleTouchStart,
|
||||
};
|
||||
},
|
||||
|
||||
// Show subagent dropdown on hover
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
/**
|
||||
* @fileoverview Service worker for Web Push notifications.
|
||||
*
|
||||
* Receives push events from the Codeman server (via web-push library) and displays
|
||||
* OS-level notifications. Handles notification clicks to focus an existing Codeman
|
||||
* tab or open a new one. Supports action buttons, per-session deep linking, and
|
||||
* critical notification persistence (requireInteraction).
|
||||
*
|
||||
* Lifecycle: skipWaiting on install, claim clients on activate — ensures the latest
|
||||
* service worker takes control immediately without waiting for tab refresh.
|
||||
*
|
||||
* @dependency None (runs in ServiceWorkerGlobalScope, isolated from page scripts)
|
||||
* @see src/push-store.ts — server-side VAPID key management and subscription CRUD
|
||||
*/
|
||||
|
||||
// Codeman Service Worker — Web Push notifications
|
||||
// This service worker receives push events from the server and displays OS-level notifications.
|
||||
// It also handles notification clicks to focus or open the Codeman tab.
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
/**
|
||||
* @fileoverview Voice input with Deepgram Nova-3 (primary) and Web Speech API (fallback).
|
||||
*
|
||||
* Defines two singleton objects:
|
||||
*
|
||||
* - DeepgramProvider — Direct browser-to-Deepgram WebSocket connection for speech-to-text.
|
||||
* Captures audio via MediaRecorder, streams chunks every 250ms, handles KeepAlive pings,
|
||||
* auto-detects MIME type (opus/webm/mp4), and supports custom key terms for dev vocabulary.
|
||||
*
|
||||
* - VoiceInput — High-level voice input controller. Toggle mode: tap mic to start, tap
|
||||
* again to stop. Auto-stops after 3s silence. Shows floating preview overlay with recording
|
||||
* indicator, level meter (AnalyserNode), and elapsed timer. Two insert modes: "direct"
|
||||
* (inject into local echo overlay or PTY) and "compose" (editable textarea overlay).
|
||||
* Includes a temporary green Send button that replaces the settings gear icon after voice input.
|
||||
* Web Speech API has auto-retry (up to 2x) for premature onend and iOS Safari stability check.
|
||||
*
|
||||
* @globals {object} DeepgramProvider
|
||||
* @globals {object} VoiceInput
|
||||
*
|
||||
* @dependency mobile-handlers.js (MobileDetection for device checks)
|
||||
* @dependency app.js (uses global `app` for sendInput, showToast, terminal focus)
|
||||
* @loadorder 3 of 9 — loaded after mobile-handlers.js, before notification-manager.js
|
||||
*/
|
||||
|
||||
// Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Voice Input (Deepgram Nova-3 + Web Speech API fallback)
|
||||
// ============================================================================
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* DeepgramProvider - Speech-to-text via Deepgram Nova-3 WebSocket API.
|
||||
|
||||
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../types.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { SessionPort } from './ports/session-port.js';
|
||||
import type { EventPort } from './ports/event-port.js';
|
||||
|
||||
@@ -131,7 +132,7 @@ export function autoConfigureRalph(session: Session, workingDir: string, ctx: Ev
|
||||
console.log(
|
||||
`[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}`
|
||||
);
|
||||
ctx.broadcast('session:ralphLoopUpdate', {
|
||||
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||
sessionId: session.id,
|
||||
state: session.ralphTracker.loopState,
|
||||
});
|
||||
@@ -146,7 +147,7 @@ export function autoConfigureRalph(session: Session, workingDir: string, ctx: Ev
|
||||
session.ralphTracker.enable();
|
||||
session.ralphTracker.startLoop(completionPhrase);
|
||||
console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`);
|
||||
ctx.broadcast('session:ralphLoopUpdate', {
|
||||
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||
sessionId: session.id,
|
||||
state: session.ralphTracker.loopState,
|
||||
});
|
||||
|
||||
@@ -15,10 +15,15 @@ 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 { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
|
||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
||||
// ============ Case CRUD ============
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case CRUD (list, create, link, detail, fix-plan)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== List Cases ==========
|
||||
|
||||
app.get('/api/cases', async (): Promise<CaseInfo[]> => {
|
||||
const cases: CaseInfo[] = [];
|
||||
@@ -95,7 +100,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
await writeHooksConfig(casePath);
|
||||
|
||||
ctx.broadcast('case:created', { name, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
|
||||
return { success: true, data: { case: { name, path: casePath } } };
|
||||
} catch (err) {
|
||||
@@ -152,7 +157,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
mkdirSync(codemanDir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
|
||||
ctx.broadcast('case:linked', { name, path: expandedPath });
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
|
||||
return { success: true, data: { case: { name, path: expandedPath } } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
@@ -321,7 +326,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Ralph Wizard File Endpoints ============
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Wizard Files (per-case prompt/result serving)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== List Wizard Files ==========
|
||||
|
||||
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||||
const { caseName } = req.params as { caseName: string };
|
||||
|
||||
@@ -18,10 +18,15 @@ import {
|
||||
PlanTaskAddSchema,
|
||||
} from '../schemas.js';
|
||||
import { findSessionOrFail, CASES_DIR } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort): void {
|
||||
// ============ Plan Generation Endpoints ============
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Generation (simple AI + detailed orchestration)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Generate Plan (Simple) ==========
|
||||
|
||||
app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
||||
const gpResult = GeneratePlanSchema.safeParse(req.body);
|
||||
@@ -215,8 +220,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
});
|
||||
|
||||
// Generate detailed implementation plan using subagent orchestration
|
||||
// This spawns multiple specialist subagents in parallel for thorough analysis
|
||||
// ========== Generate Plan (Detailed Orchestration) ==========
|
||||
|
||||
app.post('/api/generate-plan-detailed', async (req): Promise<ApiResponse> => {
|
||||
const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body);
|
||||
if (!gpdResult.success) {
|
||||
@@ -257,7 +262,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
ctx.activePlanOrchestrators.set(orchestratorId, orchestrator);
|
||||
|
||||
// Broadcast the orchestrator ID so frontend can cancel if needed
|
||||
ctx.broadcast('plan:started', { orchestratorId });
|
||||
ctx.broadcast(SseEvent.PlanStarted, { orchestratorId });
|
||||
|
||||
// Track progress for SSE updates
|
||||
const progressUpdates: Array<{ phase: string; detail: string; timestamp: number }> = [];
|
||||
@@ -265,7 +270,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
const update = { phase, detail, timestamp: Date.now() };
|
||||
progressUpdates.push(update);
|
||||
// Broadcast progress to connected clients
|
||||
ctx.broadcast('plan:progress', update);
|
||||
ctx.broadcast(SseEvent.PlanProgress, update);
|
||||
};
|
||||
|
||||
// Broadcast plan subagent events for UI visibility
|
||||
@@ -280,7 +285,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}) => {
|
||||
ctx.broadcast('plan:subagent', event);
|
||||
ctx.broadcast(SseEvent.PlanSubagent, event);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -292,7 +297,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
// Clean up orchestrator from active map
|
||||
ctx.activePlanOrchestrators.delete(orchestratorId);
|
||||
ctx.broadcast('plan:completed', { orchestratorId, success: result.success });
|
||||
ctx.broadcast(SseEvent.PlanCompleted, { orchestratorId, success: result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed');
|
||||
@@ -311,7 +316,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
} catch (err) {
|
||||
// Clean up on error too
|
||||
ctx.activePlanOrchestrators.delete(orchestratorId);
|
||||
ctx.broadcast('plan:completed', {
|
||||
ctx.broadcast(SseEvent.PlanCompleted, {
|
||||
orchestratorId,
|
||||
success: false,
|
||||
error: getErrorMessage(err),
|
||||
@@ -323,7 +328,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel active plan generation
|
||||
// ========== Cancel Plan Generation ==========
|
||||
|
||||
app.post('/api/cancel-plan-generation', async (req): Promise<ApiResponse> => {
|
||||
const cpResult = CancelPlanSchema.safeParse(req.body);
|
||||
if (!cpResult.success) {
|
||||
@@ -340,7 +346,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
console.log(`[API] Cancelling plan generation ${orchestratorId}`);
|
||||
await orchestrator.cancel();
|
||||
ctx.activePlanOrchestrators.delete(orchestratorId);
|
||||
ctx.broadcast('plan:cancelled', { orchestratorId });
|
||||
ctx.broadcast(SseEvent.PlanCancelled, { orchestratorId });
|
||||
return { success: true, data: { cancelled: orchestratorId } };
|
||||
}
|
||||
|
||||
@@ -350,17 +356,19 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
console.log(`[API] Cancelling plan generation ${id}`);
|
||||
await orchestrator.cancel();
|
||||
cancelled.push(id);
|
||||
ctx.broadcast('plan:cancelled', { orchestratorId: id });
|
||||
ctx.broadcast(SseEvent.PlanCancelled, { orchestratorId: id });
|
||||
}
|
||||
ctx.activePlanOrchestrators.clear();
|
||||
|
||||
return { success: true, data: { cancelled } };
|
||||
});
|
||||
|
||||
// ============ Plan Management Endpoints ============
|
||||
// These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Plan Management (task CRUD, checkpoints, version history, rollback)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Update Plan Task ==========
|
||||
|
||||
// Update a specific plan task (status, attempts, errors)
|
||||
app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => {
|
||||
const { id, taskId } = req.params as { id: string; taskId: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -385,11 +393,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found');
|
||||
}
|
||||
|
||||
ctx.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task });
|
||||
ctx.broadcast(SseEvent.SessionPlanTaskUpdate, { sessionId: id, taskId, update: result.task });
|
||||
return { success: true, data: result.task };
|
||||
});
|
||||
|
||||
// Trigger a checkpoint review (at iterations 5, 10, 20, etc.)
|
||||
// ========== Create Checkpoint ==========
|
||||
|
||||
app.post('/api/sessions/:id/plan/checkpoint', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -400,11 +409,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
const checkpoint = tracker.generateCheckpointReview();
|
||||
ctx.broadcast('session:planCheckpoint', { sessionId: id, checkpoint });
|
||||
ctx.broadcast(SseEvent.SessionPlanCheckpoint, { sessionId: id, checkpoint });
|
||||
return { success: true, data: checkpoint };
|
||||
});
|
||||
|
||||
// Get plan version history
|
||||
// ========== Get Version History ==========
|
||||
|
||||
app.get('/api/sessions/:id/plan/history', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -417,7 +427,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return { success: true, data: tracker.getPlanHistory() };
|
||||
});
|
||||
|
||||
// Rollback to a previous plan version
|
||||
// ========== Rollback to Version ==========
|
||||
|
||||
app.post('/api/sessions/:id/plan/rollback/:version', async (req) => {
|
||||
const { id, version } = req.params as { id: string; version: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -432,11 +443,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found');
|
||||
}
|
||||
|
||||
ctx.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) });
|
||||
ctx.broadcast(SseEvent.SessionPlanRollback, { sessionId: id, version: parseInt(version, 10) });
|
||||
return { success: true, data: result.plan };
|
||||
});
|
||||
|
||||
// Add a new task to the plan (for runtime adaptation)
|
||||
// ========== Add Plan Task ==========
|
||||
|
||||
app.post('/api/sessions/:id/plan/task', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
@@ -453,7 +465,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
const task = ptaResult.data;
|
||||
|
||||
const result = tracker.addPlanTask(task);
|
||||
ctx.broadcast('session:planTaskAdded', { sessionId: id, task: result.task });
|
||||
ctx.broadcast(SseEvent.SessionPlanTaskAdded, { sessionId: id, task: result.task });
|
||||
return { success: true, data: result.task };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse }
|
||||
import { Session } from '../../session.js';
|
||||
import { RespawnController } from '../../respawn-controller.js';
|
||||
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -23,6 +24,10 @@ export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Tracker Configuration & Status
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Configure Ralph tracker for a session
|
||||
app.post('/api/sessions/:id/ralph-config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -95,7 +100,7 @@ export function registerRalphRoutes(
|
||||
|
||||
// Persist and broadcast the update
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:ralphLoopUpdate', {
|
||||
ctx.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||
sessionId: id,
|
||||
state: session.ralphLoopState,
|
||||
});
|
||||
@@ -136,6 +141,10 @@ export function registerRalphRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Fix Plan CRUD (@fix_plan.md generation, import, read/write)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Generate @fix_plan.md content from todos
|
||||
app.get('/api/sessions/:id/fix-plan', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -249,6 +258,10 @@ export function registerRalphRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Ralph Prompt & Loop (prompt write, loop start)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Write Ralph prompt to file in session's working directory
|
||||
// This avoids mux input escaping issues with long multi-line prompts
|
||||
app.post('/api/sessions/:id/ralph-prompt/write', async (req) => {
|
||||
@@ -320,7 +333,7 @@ export function registerRalphRoutes(
|
||||
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||
await writeHooksConfig(casePath);
|
||||
ctx.broadcast('case:created', { name: caseName, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
@@ -441,7 +454,7 @@ export function registerRalphRoutes(
|
||||
name: session.name,
|
||||
reason: 'ralph_loop_start',
|
||||
});
|
||||
ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
// Start interactive mode
|
||||
try {
|
||||
@@ -452,8 +465,8 @@ export function registerRalphRoutes(
|
||||
name: session.name,
|
||||
mode: 'claude',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id: session.id, mode: 'claude' });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'claude' });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
} catch (err) {
|
||||
await ctx.cleanupSession(session.id, true, 'ralph_loop_start_error');
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
@@ -475,7 +488,7 @@ export function registerRalphRoutes(
|
||||
controller.start();
|
||||
ctx.saveRespawnConfig(session.id, controller.getConfig());
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('respawn:started', {
|
||||
ctx.broadcast(SseEvent.RespawnStarted, {
|
||||
sessionId: session.id,
|
||||
status: controller.getStatus(),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FastifyInstance } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type PersistedRespawnConfig } from '../../types.js';
|
||||
import { RespawnController, type RespawnConfig } from '../../respawn-controller.js';
|
||||
import { RespawnConfigSchema, InteractiveRespawnSchema, RespawnEnableSchema } from '../schemas.js';
|
||||
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';
|
||||
@@ -25,7 +26,12 @@ export function registerRespawnRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
): void {
|
||||
// Get respawn status for a session
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Respawn Status & Config
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Get Respawn Status ==========
|
||||
|
||||
app.get('/api/sessions/:id/respawn', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
@@ -40,7 +46,8 @@ export function registerRespawnRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// Get respawn config (from running controller or pre-saved)
|
||||
// ========== Get Respawn Config ==========
|
||||
|
||||
app.get('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
@@ -58,7 +65,12 @@ export function registerRespawnRoutes(
|
||||
return { success: true, config: null, active: false };
|
||||
});
|
||||
|
||||
// Start respawn controller for a session
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Respawn Start & Stop
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Start Respawn ==========
|
||||
|
||||
app.post('/api/sessions/:id/respawn/start', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
let body: Partial<RespawnConfig> | undefined;
|
||||
@@ -95,12 +107,13 @@ export function registerRespawnRoutes(
|
||||
ctx.saveRespawnConfig(id, controller.getConfig());
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return { success: true, status: controller.getStatus() };
|
||||
});
|
||||
|
||||
// Stop respawn controller for a session
|
||||
// ========== Stop Respawn ==========
|
||||
|
||||
app.post('/api/sessions/:id/respawn/stop', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = ctx.respawnControllers.get(id);
|
||||
@@ -130,12 +143,13 @@ export function registerRespawnRoutes(
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
|
||||
ctx.broadcast('respawn:stopped', { sessionId: id });
|
||||
ctx.broadcast(SseEvent.RespawnStopped, { sessionId: id });
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Update respawn configuration (works with or without running controller)
|
||||
// ========== Update Respawn Config ==========
|
||||
|
||||
app.put('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Validate respawn config to prevent arbitrary field injection
|
||||
@@ -153,7 +167,7 @@ export function registerRespawnRoutes(
|
||||
controller.updateConfig(config);
|
||||
ctx.saveRespawnConfig(id, controller.getConfig());
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() });
|
||||
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: controller.getConfig() });
|
||||
return { success: true, config: controller.getConfig() };
|
||||
}
|
||||
|
||||
@@ -186,11 +200,16 @@ export function registerRespawnRoutes(
|
||||
};
|
||||
ctx.mux.updateRespawnConfig(id, merged);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('respawn:configUpdated', { sessionId: id, config: merged });
|
||||
ctx.broadcast(SseEvent.RespawnConfigUpdated, { sessionId: id, config: merged });
|
||||
return { success: true, config: merged };
|
||||
});
|
||||
|
||||
// Start interactive session WITH respawn enabled
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Composite Actions (interactive-respawn, enable on existing)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Interactive Respawn (start session + respawn in one call) ==========
|
||||
|
||||
app.post('/api/sessions/:id/interactive-respawn', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} };
|
||||
@@ -230,8 +249,8 @@ export function registerRespawnRoutes(
|
||||
mode: session.mode,
|
||||
reason: 'interactive_respawn',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
// Create and start respawn controller
|
||||
const controller = new RespawnController(session, body?.respawnConfig);
|
||||
@@ -247,7 +266,7 @@ export function registerRespawnRoutes(
|
||||
// Persist full session state with respawn config
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -261,7 +280,8 @@ export function registerRespawnRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// Enable respawn on an EXISTING interactive session
|
||||
// ========== Enable Respawn on Existing Session ==========
|
||||
|
||||
app.post('/api/sessions/:id/respawn/enable', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} };
|
||||
@@ -304,7 +324,7 @@ export function registerRespawnRoutes(
|
||||
ctx.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes);
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
ctx.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
ctx.broadcast(SseEvent.RespawnStarted, { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type SessionColor,
|
||||
} from '../../types.js';
|
||||
import { Session } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
SessionNameSchema,
|
||||
@@ -54,6 +55,10 @@ export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||
): void {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Auth
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Logout ==========
|
||||
|
||||
app.post('/api/logout', async (req, reply) => {
|
||||
@@ -66,6 +71,10 @@ export function registerSessionRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session CRUD (list, create, rename, color, delete, detail)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Session Listing ==========
|
||||
|
||||
app.get('/api/sessions', async () => {
|
||||
@@ -146,7 +155,7 @@ export function registerSessionRoutes(
|
||||
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
|
||||
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
||||
const lightState = ctx.getSessionStateWithRespawn(session);
|
||||
ctx.broadcast('session:created', lightState);
|
||||
ctx.broadcast(SseEvent.SessionCreated, lightState);
|
||||
return { success: true, session: lightState };
|
||||
});
|
||||
|
||||
@@ -170,7 +179,7 @@ export function registerSessionRoutes(
|
||||
// Also update the mux session name if applicable
|
||||
ctx.mux.updateSessionName(id, session.name);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
return { success: true, name: session.name };
|
||||
});
|
||||
|
||||
@@ -196,7 +205,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.setColor(body.color as SessionColor);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
return { success: true, color: session.color };
|
||||
});
|
||||
|
||||
@@ -246,6 +255,10 @@ export function registerSessionRoutes(
|
||||
return ctx.getSessionStateWithRespawn(session);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Data (output, ralph state, run summary, active tools)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Get Session Output ==========
|
||||
|
||||
app.get('/api/sessions/:id/output', async (req) => {
|
||||
@@ -328,6 +341,10 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Execution (run prompt, interactive mode, shell mode)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Run Prompt ==========
|
||||
|
||||
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
|
||||
@@ -349,10 +366,10 @@ export function registerSessionRoutes(
|
||||
|
||||
// Run async, don't wait
|
||||
session.runPrompt(prompt).catch((err) => {
|
||||
ctx.broadcast('session:error', { id, error: err.message });
|
||||
ctx.broadcast(SseEvent.SessionError, { id, error: err.message });
|
||||
});
|
||||
|
||||
ctx.broadcast('session:running', { id, prompt });
|
||||
ctx.broadcast(SseEvent.SessionRunning, { id, prompt });
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -391,8 +408,8 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: session.mode,
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
@@ -422,14 +439,18 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: 'shell',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id, mode: 'shell' });
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Terminal I/O (input, resize, buffer)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Send Input ==========
|
||||
|
||||
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
||||
@@ -550,6 +571,10 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Settings (auto-clear, auto-compact, image watcher, flicker filter)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Auto-Clear ==========
|
||||
|
||||
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||||
@@ -567,7 +592,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.setAutoClear(body.enabled, body.threshold);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -597,7 +622,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -661,7 +686,7 @@ export function registerSessionRoutes(
|
||||
|
||||
session.flickerFilterEnabled = body.enabled;
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -671,6 +696,10 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Quick Actions (quick-run, quick-start)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Quick Run ==========
|
||||
|
||||
app.post('/api/run', async (req) => {
|
||||
@@ -717,7 +746,7 @@ export function registerSessionRoutes(
|
||||
reason: 'run_prompt',
|
||||
});
|
||||
|
||||
ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
try {
|
||||
const result = await session.runPrompt(prompt);
|
||||
@@ -786,7 +815,7 @@ export function registerSessionRoutes(
|
||||
await writeHooksConfig(casePath);
|
||||
}
|
||||
|
||||
ctx.broadcast('case:created', { name: caseName, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
@@ -831,7 +860,7 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
reason: 'quick_start',
|
||||
});
|
||||
ctx.broadcast('session:created', ctx.getSessionStateWithRespawn(session));
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
|
||||
// Start in the appropriate mode
|
||||
try {
|
||||
@@ -843,7 +872,7 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode: 'shell',
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id: session.id, mode: 'shell' });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
|
||||
} else {
|
||||
// Both 'claude' and 'opencode' modes use startInteractive()
|
||||
await session.startInteractive();
|
||||
@@ -853,9 +882,9 @@ export function registerSessionRoutes(
|
||||
name: session.name,
|
||||
mode,
|
||||
});
|
||||
ctx.broadcast('session:interactive', { id: session.id, mode });
|
||||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
|
||||
}
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
// Save lastUsedCase to settings for TUI/web sync
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||
@@ -91,6 +92,10 @@ export function registerSystemRoutes(
|
||||
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
|
||||
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// System Status & Health
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Status ==========
|
||||
|
||||
app.get('/api/status', async () => ctx.getLightState());
|
||||
@@ -112,8 +117,7 @@ export function registerSystemRoutes(
|
||||
return { svg, authEnabled: true };
|
||||
}
|
||||
// No auth — just encode the raw tunnel URL
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic optional dependency
|
||||
const QRCode = require('qrcode');
|
||||
const QRCode = await import('qrcode');
|
||||
const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 });
|
||||
return { svg, authEnabled: false };
|
||||
} catch (err) {
|
||||
@@ -121,6 +125,10 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Authentication (QR auth, session revocation)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== QR Auth Route ==========
|
||||
|
||||
app.get('/q/:code', async (req, reply) => {
|
||||
@@ -178,7 +186,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// Broadcast auth notification — desktop sees who authenticated
|
||||
ctx.broadcast('tunnel:qrAuthUsed', {
|
||||
ctx.broadcast(SseEvent.TunnelQrAuthUsed, {
|
||||
ip: clientIp,
|
||||
ua: clientUA,
|
||||
timestamp: Date.now(),
|
||||
@@ -207,6 +215,10 @@ export function registerSystemRoutes(
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI Integrations (OpenCode)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== OpenCode ==========
|
||||
|
||||
app.get('/api/opencode/status', async () => {
|
||||
@@ -217,6 +229,10 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== State & Lifecycle ==========
|
||||
|
||||
app.post('/api/cleanup-state', async () => {
|
||||
@@ -280,6 +296,10 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Configuration & Settings (config, settings, model config, CPU priority)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Config ==========
|
||||
|
||||
app.get('/api/config', async () => {
|
||||
@@ -425,7 +445,7 @@ export function registerSystemRoutes(
|
||||
console.log('Tunnel started via settings change');
|
||||
} else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) {
|
||||
// Tunnel already running — re-emit so the client gets the URL
|
||||
ctx.broadcast('tunnel:started', { url: ctx.tunnelManager.getUrl() });
|
||||
ctx.broadcast(SseEvent.TunnelStarted, { url: ctx.tunnelManager.getUrl() });
|
||||
console.log('Tunnel already running, re-broadcast URL to client');
|
||||
} else if (!tunnelEnabled && ctx.tunnelManager.isRunning()) {
|
||||
ctx.tunnelManager.stop();
|
||||
@@ -507,7 +527,7 @@ export function registerSystemRoutes(
|
||||
|
||||
session.setNice(body);
|
||||
ctx.persistSessionState(session);
|
||||
ctx.broadcast('session:updated', { session: ctx.getSessionStateWithRespawn(session) });
|
||||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -516,6 +536,10 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Subagent Management (window states, parents, monitoring, transcripts)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Subagent Window State Persistence ==========
|
||||
|
||||
app.get('/api/subagent-window-states', async () => {
|
||||
@@ -644,6 +668,10 @@ export function registerSystemRoutes(
|
||||
return { success: true, data: { cleared } };
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Screenshots (upload, list, serve)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== Screenshots ==========
|
||||
|
||||
app.post('/api/screenshots', async (req, reply) => {
|
||||
|
||||
+255
-157
@@ -1,11 +1,28 @@
|
||||
/**
|
||||
* @fileoverview Codeman web server and REST API
|
||||
* @fileoverview Codeman web server — central hub coordinating all subsystems.
|
||||
*
|
||||
* Provides a Fastify-based web server with:
|
||||
* - REST API for session management, respawn control, and monitoring
|
||||
* - Server-Sent Events (SSE) for real-time updates at /api/events
|
||||
* - Static file serving for the web UI
|
||||
* - 60fps terminal streaming with batched updates
|
||||
* Fastify-based web server providing:
|
||||
* - ~111 REST API routes (delegated to `src/web/routes/` domain modules)
|
||||
* - SSE streaming at `/api/events` with backpressure handling
|
||||
* - Static file serving for the web UI (1-year cache in production)
|
||||
* - 60fps terminal streaming via batched PTY output (16-50ms adaptive)
|
||||
*
|
||||
* Coordinates: SessionManager, RespawnController, SubagentWatcher, TeamWatcher,
|
||||
* TranscriptWatcher, ImageWatcher, TunnelManager, PushSubscriptionStore,
|
||||
* PlanOrchestrator, RunSummaryTracker, FileStreamManager.
|
||||
*
|
||||
* Key exports:
|
||||
* - `WebServer` class — implements all port interfaces, extends EventEmitter
|
||||
* - `startWebServer(options)` — factory function to create and start the server
|
||||
*
|
||||
* Implements port interfaces: `SessionPort`, `EventPort`, `ConfigPort`,
|
||||
* `RespawnPort`, `MuxPort`, `FilePort`, `ScheduledPort`, `PushPort`, `TeamPort`
|
||||
* (see `src/web/ports/` for definitions)
|
||||
*
|
||||
* @dependencies All major subsystems (session, respawn-controller, subagent-watcher,
|
||||
* team-watcher, tunnel-manager, state-store, etc.)
|
||||
* @consumedby src/index.ts (entry point), src/cli.ts
|
||||
* @emits SSE events via broadcast() — see sse-events.ts for full registry
|
||||
*
|
||||
* @module web/server
|
||||
*/
|
||||
@@ -70,6 +87,7 @@ import {
|
||||
} from '../types.js';
|
||||
import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
|
||||
import {
|
||||
@@ -96,7 +114,8 @@ import {
|
||||
SESSIONS_LIST_CACHE_TTL,
|
||||
SCHEDULED_CLEANUP_INTERVAL,
|
||||
SCHEDULED_RUN_MAX_AGE,
|
||||
SSE_HEALTH_CHECK_INTERVAL,
|
||||
SSE_HEARTBEAT_INTERVAL,
|
||||
SSE_PADDING_SIZE,
|
||||
SESSION_LIMIT_WAIT_MS,
|
||||
ITERATION_PAUSE_MS,
|
||||
BATCH_FLUSH_THRESHOLD,
|
||||
@@ -110,6 +129,12 @@ import {
|
||||
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.
|
||||
// Pre-computed once at startup to avoid repeated string allocation.
|
||||
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
|
||||
|
||||
/**
|
||||
* Get or generate a self-signed TLS certificate for HTTPS.
|
||||
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
|
||||
@@ -245,6 +270,8 @@ export class WebServer extends EventEmitter {
|
||||
error: (error: Error, sessionId?: string) => void;
|
||||
} | null = null;
|
||||
private tunnelManager: TunnelManager = new TunnelManager();
|
||||
/** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
|
||||
private _isTunnelActive: boolean = false;
|
||||
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
|
||||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
|
||||
@@ -273,10 +300,10 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Set up mux event listeners
|
||||
this.mux.on('sessionCreated', (session) => {
|
||||
this.broadcast('mux:created', session);
|
||||
this.broadcast(SseEvent.MuxCreated, session);
|
||||
});
|
||||
this.mux.on('sessionKilled', (data) => {
|
||||
this.broadcast('mux:killed', data);
|
||||
this.broadcast(SseEvent.MuxKilled, data);
|
||||
});
|
||||
this.mux.on('sessionDied', (data) => {
|
||||
getLifecycleLog().log({
|
||||
@@ -284,10 +311,10 @@ export class WebServer extends EventEmitter {
|
||||
sessionId: (data as { sessionId?: string }).sessionId || 'unknown',
|
||||
extra: data as Record<string, unknown>,
|
||||
});
|
||||
this.broadcast('mux:died', data);
|
||||
this.broadcast(SseEvent.MuxDied, data);
|
||||
});
|
||||
this.mux.on('statsUpdated', (sessions) => {
|
||||
this.broadcast('mux:statsUpdated', sessions);
|
||||
this.broadcast(SseEvent.MuxStatsUpdated, sessions);
|
||||
});
|
||||
|
||||
// Set up subagent watcher listeners
|
||||
@@ -301,16 +328,18 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Set up tunnel manager listeners
|
||||
this.tunnelManager.on('started', (data: { url: string }) => {
|
||||
this.broadcast('tunnel:started', data);
|
||||
this._isTunnelActive = true;
|
||||
this.broadcast(SseEvent.TunnelStarted, data);
|
||||
});
|
||||
this.tunnelManager.on('stopped', () => {
|
||||
this.broadcast('tunnel:stopped', {});
|
||||
this._isTunnelActive = false;
|
||||
this.broadcast(SseEvent.TunnelStopped, {});
|
||||
});
|
||||
this.tunnelManager.on('error', (message: string) => {
|
||||
this.broadcast('tunnel:error', { message });
|
||||
this.broadcast(SseEvent.TunnelError, { message });
|
||||
});
|
||||
this.tunnelManager.on('progress', (data: { message: string }) => {
|
||||
this.broadcast('tunnel:progress', data);
|
||||
this.broadcast(SseEvent.TunnelProgress, data);
|
||||
});
|
||||
|
||||
// QR token rotation — broadcast inline SVG for instant desktop refresh
|
||||
@@ -319,7 +348,7 @@ export class WebServer extends EventEmitter {
|
||||
if (url && process.env.CODEMAN_PASSWORD) {
|
||||
try {
|
||||
const svg = await this.tunnelManager.getQrSvg(url);
|
||||
this.broadcast('tunnel:qrRotated', { svg });
|
||||
this.broadcast(SseEvent.TunnelQrRotated, { svg });
|
||||
} catch {
|
||||
// QR generation failed — skip this rotation
|
||||
}
|
||||
@@ -331,7 +360,7 @@ export class WebServer extends EventEmitter {
|
||||
if (url && process.env.CODEMAN_PASSWORD) {
|
||||
try {
|
||||
const svg = await this.tunnelManager.getQrSvg(url);
|
||||
this.broadcast('tunnel:qrRegenerated', { svg });
|
||||
this.broadcast(SseEvent.TunnelQrRegenerated, { svg });
|
||||
} catch {
|
||||
// QR generation failed — skip
|
||||
}
|
||||
@@ -350,13 +379,13 @@ export class WebServer extends EventEmitter {
|
||||
private setupSubagentWatcherListeners(): void {
|
||||
// Store handlers for cleanup on shutdown
|
||||
this.subagentWatcherHandlers = {
|
||||
discovered: (info: SubagentInfo) => this.broadcast('subagent:discovered', info),
|
||||
updated: (info: SubagentInfo) => this.broadcast('subagent:updated', info),
|
||||
toolCall: (data: SubagentToolCall) => this.broadcast('subagent:tool_call', data),
|
||||
toolResult: (data: SubagentToolResult) => this.broadcast('subagent:tool_result', data),
|
||||
progress: (data: SubagentProgress) => this.broadcast('subagent:progress', data),
|
||||
message: (data: SubagentMessage) => this.broadcast('subagent:message', data),
|
||||
completed: (info: SubagentInfo) => this.broadcast('subagent:completed', info),
|
||||
discovered: (info: SubagentInfo) => this.broadcast(SseEvent.SubagentDiscovered, info),
|
||||
updated: (info: SubagentInfo) => this.broadcast(SseEvent.SubagentUpdated, info),
|
||||
toolCall: (data: SubagentToolCall) => this.broadcast(SseEvent.SubagentToolCall, data),
|
||||
toolResult: (data: SubagentToolResult) => this.broadcast(SseEvent.SubagentToolResult, data),
|
||||
progress: (data: SubagentProgress) => this.broadcast(SseEvent.SubagentProgress, data),
|
||||
message: (data: SubagentMessage) => this.broadcast(SseEvent.SubagentMessage, data),
|
||||
completed: (info: SubagentInfo) => this.broadcast(SseEvent.SubagentCompleted, info),
|
||||
error: (error: Error, agentId?: string) => {
|
||||
console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message);
|
||||
},
|
||||
@@ -396,7 +425,7 @@ export class WebServer extends EventEmitter {
|
||||
private setupImageWatcherListeners(): void {
|
||||
// Store handlers for cleanup on shutdown
|
||||
this.imageWatcherHandlers = {
|
||||
detected: (event: ImageDetectedEvent) => this.broadcast('image:detected', event),
|
||||
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
||||
error: (error: Error, sessionId?: string) => {
|
||||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||||
},
|
||||
@@ -423,10 +452,10 @@ export class WebServer extends EventEmitter {
|
||||
*/
|
||||
private setupTeamWatcherListeners(): void {
|
||||
this.teamWatcherHandlers = {
|
||||
teamCreated: (config: unknown) => this.broadcast('team:created', config),
|
||||
teamUpdated: (config: unknown) => this.broadcast('team:updated', config),
|
||||
teamRemoved: (config: unknown) => this.broadcast('team:removed', config),
|
||||
taskUpdated: (data: unknown) => this.broadcast('team:taskUpdated', data),
|
||||
teamCreated: (config: unknown) => this.broadcast(SseEvent.TeamCreated, config),
|
||||
teamUpdated: (config: unknown) => this.broadcast(SseEvent.TeamUpdated, config),
|
||||
teamRemoved: (config: unknown) => this.broadcast(SseEvent.TeamRemoved, config),
|
||||
taskUpdated: (data: unknown) => this.broadcast(SseEvent.TeamTaskUpdated, data),
|
||||
};
|
||||
|
||||
this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
|
||||
@@ -573,7 +602,16 @@ export class WebServer extends EventEmitter {
|
||||
// Send initial state
|
||||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||||
// Buffers are fetched on-demand when switching tabs
|
||||
this.sendSSE(reply, 'init', this.getLightState());
|
||||
this.sendSSE(reply, SseEvent.Init, this.getLightState());
|
||||
// Flush Cloudflare tunnel buffer with padding — ensures the init event
|
||||
// (and any immediately following events) are delivered without proxy delay.
|
||||
if (this._isTunnelActive) {
|
||||
try {
|
||||
reply.raw.write(SSE_PADDING);
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
}
|
||||
|
||||
req.raw.on('close', () => {
|
||||
this.sseClients.delete(reply);
|
||||
@@ -624,7 +662,7 @@ export class WebServer extends EventEmitter {
|
||||
if (controller) {
|
||||
controller.signalTranscriptComplete();
|
||||
}
|
||||
this.broadcast('transcript:complete', { sessionId, timestamp: Date.now() });
|
||||
this.broadcast(SseEvent.TranscriptComplete, { sessionId, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
watcher.on('transcript:plan_mode', () => {
|
||||
@@ -632,15 +670,15 @@ export class WebServer extends EventEmitter {
|
||||
if (controller) {
|
||||
controller.signalTranscriptPlanMode();
|
||||
}
|
||||
this.broadcast('transcript:plan_mode', { sessionId, timestamp: Date.now() });
|
||||
this.broadcast(SseEvent.TranscriptPlanMode, { sessionId, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
watcher.on('transcript:tool_start', (toolName: string) => {
|
||||
this.broadcast('transcript:tool_start', { sessionId, toolName, timestamp: Date.now() });
|
||||
this.broadcast(SseEvent.TranscriptToolStart, { sessionId, toolName, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
watcher.on('transcript:tool_end', (toolName: string, isError: boolean) => {
|
||||
this.broadcast('transcript:tool_end', {
|
||||
this.broadcast(SseEvent.TranscriptToolEnd, {
|
||||
sessionId,
|
||||
toolName,
|
||||
isError,
|
||||
@@ -790,7 +828,7 @@ export class WebServer extends EventEmitter {
|
||||
controller.removeAllListeners();
|
||||
this.respawnControllers.delete(sessionId);
|
||||
// Notify UI that respawn is stopped for this session
|
||||
this.broadcast('respawn:stopped', { sessionId, reason: 'session_cleanup' });
|
||||
this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'session_cleanup' });
|
||||
}
|
||||
|
||||
// Clear respawn timer
|
||||
@@ -842,7 +880,7 @@ export class WebServer extends EventEmitter {
|
||||
this.store.removeRalphState(sessionId);
|
||||
|
||||
// Broadcast Ralph cleared to update UI
|
||||
this.broadcast('session:ralphLoopUpdate', {
|
||||
this.broadcast(SseEvent.SessionRalphLoopUpdate, {
|
||||
sessionId,
|
||||
state: {
|
||||
enabled: false,
|
||||
@@ -855,7 +893,7 @@ export class WebServer extends EventEmitter {
|
||||
elapsedHours: null,
|
||||
},
|
||||
});
|
||||
this.broadcast('session:ralphTodoUpdate', {
|
||||
this.broadcast(SseEvent.SessionRalphTodoUpdate, {
|
||||
sessionId,
|
||||
todos: [],
|
||||
stats: { total: 0, pending: 0, inProgress: 0, completed: 0 },
|
||||
@@ -925,7 +963,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
this.broadcast('session:deleted', { id: sessionId });
|
||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||
}
|
||||
|
||||
private async setupSessionListeners(session: Session): Promise<void> {
|
||||
@@ -944,49 +982,58 @@ export class WebServer extends EventEmitter {
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
}
|
||||
|
||||
// Store all listener references for explicit cleanup on session delete
|
||||
// This prevents memory leaks from closure references keeping objects alive
|
||||
// Store all listener references for explicit cleanup on session delete.
|
||||
// This prevents memory leaks from closure references keeping objects alive.
|
||||
const listeners: SessionListenerRefs = {
|
||||
// ─── Terminal Output ─────────────────────────────────────
|
||||
// These listeners handle raw PTY output streaming to SSE clients.
|
||||
|
||||
/** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */
|
||||
terminal: (data) => {
|
||||
// Use batching for better performance at high throughput
|
||||
this.batchTerminalData(session.id, data);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */
|
||||
clearTerminal: () => {
|
||||
// Tell clients to clear their terminal (after mux attach)
|
||||
this.broadcast('session:clearTerminal', { id: session.id });
|
||||
this.broadcast(SseEvent.SessionClearTerminal, { id: session.id });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:needsRefresh` — tells clients to reload buffer (e.g., after OpenCode TUI stabilizes) */
|
||||
needsRefresh: () => {
|
||||
// Tell clients to reload the terminal buffer (e.g., after OpenCode TUI stabilizes)
|
||||
this.broadcast('session:needsRefresh', { id: session.id });
|
||||
this.broadcast(SseEvent.SessionNeedsRefresh, { id: session.id });
|
||||
},
|
||||
|
||||
// ─── Session Messages & Errors ──────────────────────────
|
||||
|
||||
/** Broadcasts `session:message` — structured Claude JSON messages (assistant, tool_use, etc.) */
|
||||
message: (msg: ClaudeMessage) => {
|
||||
this.broadcast('session:message', { id: session.id, message: msg });
|
||||
this.broadcast(SseEvent.SessionMessage, { id: session.id, message: msg });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:error` + sends push notification */
|
||||
error: (error) => {
|
||||
this.broadcast('session:error', { id: session.id, error });
|
||||
this.sendPushNotifications('session:error', {
|
||||
this.broadcast(SseEvent.SessionError, { id: session.id, error });
|
||||
this.sendPushNotifications(SseEvent.SessionError, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
error: String(error),
|
||||
});
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordError('Session error', String(error));
|
||||
},
|
||||
|
||||
/** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */
|
||||
completion: (result, cost) => {
|
||||
this.broadcast('session:completion', { id: session.id, result, cost });
|
||||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||||
this.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost });
|
||||
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
||||
this.persistSessionState(session);
|
||||
// Track tokens in run summary (completion event has updated token values)
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordTokens(session.inputTokens, session.outputTokens);
|
||||
},
|
||||
|
||||
// ─── Session Lifecycle ──────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
|
||||
exit: (code) => {
|
||||
getLifecycleLog().log({
|
||||
event: 'exit',
|
||||
@@ -996,8 +1043,8 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
// Wrap in try/catch to ensure cleanup always happens
|
||||
try {
|
||||
this.broadcast('session:exit', { id: session.id, code });
|
||||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||||
this.broadcast(SseEvent.SessionExit, { id: session.id, code });
|
||||
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
||||
this.persistSessionState(session);
|
||||
} catch (err) {
|
||||
console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err);
|
||||
@@ -1091,9 +1138,11 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
|
||||
// ─── Activity State ─────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:working` — Claude started processing */
|
||||
working: () => {
|
||||
this.broadcast('session:working', { id: session.id });
|
||||
// Track in run summary
|
||||
this.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordWorking();
|
||||
@@ -1101,11 +1150,10 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
|
||||
idle: () => {
|
||||
this.broadcast('session:idle', { id: session.id });
|
||||
// Use debounced state update (idle can fire frequently)
|
||||
this.broadcast(SseEvent.SessionIdle, { id: session.id });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordIdle();
|
||||
@@ -1113,78 +1161,87 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
|
||||
// Background task events - use debounced state updates to reduce serialization overhead
|
||||
// ─── Background Task Events ──────────────────────────────
|
||||
// Debounced state updates to reduce serialization overhead.
|
||||
|
||||
/** Broadcasts `task:created` — new background task discovered */
|
||||
taskCreated: (task: BackgroundTask) => {
|
||||
this.broadcast('task:created', { sessionId: session.id, task });
|
||||
this.broadcast(SseEvent.TaskCreated, { sessionId: session.id, task });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/** Batched broadcast of `task:updated` — high-frequency progress updates */
|
||||
taskUpdated: (task: BackgroundTask) => {
|
||||
// Use batching for better performance at high update rates
|
||||
this.batchTaskUpdate(session.id, task);
|
||||
},
|
||||
|
||||
/** Broadcasts `task:completed` — background task finished successfully */
|
||||
taskCompleted: (task: BackgroundTask) => {
|
||||
this.broadcast('task:completed', { sessionId: session.id, task });
|
||||
this.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/** Broadcasts `task:failed` — background task errored */
|
||||
taskFailed: (task: BackgroundTask, error: string) => {
|
||||
this.broadcast('task:failed', { sessionId: session.id, task, error });
|
||||
this.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
// ─── Auto-Operations ────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */
|
||||
autoClear: (data: { tokens: number; threshold: number }) => {
|
||||
this.broadcast('session:autoClear', { sessionId: session.id, ...data });
|
||||
this.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordAutoClear(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => {
|
||||
this.broadcast('session:autoCompact', { sessionId: session.id, ...data });
|
||||
this.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
// Claude Code CLI info parsed from terminal (version, model, account)
|
||||
// ─── CLI Info ────────────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => {
|
||||
this.broadcast('session:cliInfo', { sessionId: session.id, ...data });
|
||||
this.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data });
|
||||
this.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
// Ralph tracking events
|
||||
// ─── Ralph Tracking Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => {
|
||||
this.broadcast('session:ralphLoopUpdate', { sessionId: session.id, state });
|
||||
// Persist Ralph state
|
||||
this.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state });
|
||||
this.store.updateRalphState(session.id, { loop: state });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => {
|
||||
this.broadcast('session:ralphTodoUpdate', { sessionId: session.id, todos });
|
||||
// Persist Ralph state
|
||||
this.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos });
|
||||
this.store.updateRalphState(session.id, { todos });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */
|
||||
ralphCompletionDetected: (phrase: string) => {
|
||||
this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase });
|
||||
this.sendPushNotifications('session:ralphCompletionDetected', {
|
||||
this.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase });
|
||||
this.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
phrase,
|
||||
});
|
||||
// Track in run summary
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) tracker.recordRalphCompletion(phrase);
|
||||
},
|
||||
|
||||
// RALPH_STATUS block events
|
||||
/** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */
|
||||
ralphStatusBlockDetected: (block: import('../types.js').RalphStatusBlock) => {
|
||||
this.broadcast('session:ralphStatusUpdate', { sessionId: session.id, block });
|
||||
// Track in run summary
|
||||
this.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block });
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) {
|
||||
tracker.addEvent(
|
||||
@@ -1196,18 +1253,18 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:circuitBreakerUpdate` — circuit breaker state changed (CLOSED/HALF_OPEN/OPEN) */
|
||||
ralphCircuitBreakerUpdate: (status: import('../types.js').CircuitBreakerStatus) => {
|
||||
this.broadcast('session:circuitBreakerUpdate', { sessionId: session.id, status });
|
||||
// Track state changes in run summary
|
||||
this.broadcast(SseEvent.SessionCircuitBreakerUpdate, { sessionId: session.id, status });
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker && status.state === 'OPEN') {
|
||||
tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason);
|
||||
}
|
||||
},
|
||||
|
||||
/** Broadcasts `session:exitGateMet` — all completion indicators met, ready to exit */
|
||||
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => {
|
||||
this.broadcast('session:exitGateMet', { sessionId: session.id, ...data });
|
||||
// Track in run summary
|
||||
this.broadcast(SseEvent.SessionExitGateMet, { sessionId: session.id, ...data });
|
||||
const tracker = this.runSummaryTrackers.get(session.id);
|
||||
if (tracker) {
|
||||
tracker.addEvent(
|
||||
@@ -1219,17 +1276,22 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
},
|
||||
|
||||
// Bash tool tracking events (for clickable file paths)
|
||||
// ─── Bash Tool Tracking ────────────────────────────────
|
||||
// Used for clickable file paths in the UI.
|
||||
|
||||
/** Broadcasts `session:bashToolStart` — bash tool invocation started */
|
||||
bashToolStart: (tool: ActiveBashTool) => {
|
||||
this.broadcast('session:bashToolStart', { sessionId: session.id, tool });
|
||||
this.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:bashToolEnd` — bash tool invocation completed */
|
||||
bashToolEnd: (tool: ActiveBashTool) => {
|
||||
this.broadcast('session:bashToolEnd', { sessionId: session.id, tool });
|
||||
this.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool });
|
||||
},
|
||||
|
||||
/** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => {
|
||||
this.broadcast('session:bashToolsUpdate', { sessionId: session.id, tools });
|
||||
this.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1271,83 +1333,103 @@ export class WebServer extends EventEmitter {
|
||||
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
||||
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
||||
|
||||
// ─── Respawn State Machine ──────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */
|
||||
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
|
||||
this.broadcast('respawn:stateChanged', { sessionId, state, prevState });
|
||||
// Track in run summary (lazy lookup since tracker may be created after controller)
|
||||
this.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordStateChange(state, `${prevState} → ${state}`);
|
||||
});
|
||||
|
||||
// ─── Respawn Cycle Lifecycle ────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */
|
||||
controller.on('respawnCycleStarted', (cycleNumber: number) => {
|
||||
this.broadcast('respawn:cycleStarted', { sessionId, cycleNumber });
|
||||
this.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */
|
||||
controller.on('respawnCycleCompleted', (cycleNumber: number) => {
|
||||
this.broadcast('respawn:cycleCompleted', { sessionId, cycleNumber });
|
||||
this.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */
|
||||
controller.on('respawnBlocked', (data: { reason: string; details: string }) => {
|
||||
this.broadcast('respawn:blocked', { sessionId, reason: data.reason, details: data.details });
|
||||
this.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details });
|
||||
const sessionForPush = this.sessions.get(sessionId);
|
||||
this.sendPushNotifications('respawn:blocked', {
|
||||
this.sendPushNotifications(SseEvent.RespawnBlocked, {
|
||||
sessionId,
|
||||
sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
|
||||
reason: data.reason,
|
||||
});
|
||||
// Track in run summary (lazy lookup)
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
|
||||
});
|
||||
|
||||
// ─── Respawn Step Progress ──────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */
|
||||
controller.on('stepSent', (step: string, input: string) => {
|
||||
this.broadcast('respawn:stepSent', { sessionId, step, input });
|
||||
this.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:stepCompleted` — respawn step finished */
|
||||
controller.on('stepCompleted', (step: string) => {
|
||||
this.broadcast('respawn:stepCompleted', { sessionId, step });
|
||||
this.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */
|
||||
controller.on('detectionUpdate', (detection: unknown) => {
|
||||
this.broadcast('respawn:detectionUpdate', { sessionId, detection });
|
||||
this.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */
|
||||
controller.on('autoAcceptSent', () => {
|
||||
this.broadcast('respawn:autoAcceptSent', { sessionId });
|
||||
this.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId });
|
||||
});
|
||||
|
||||
// ─── AI Checker Events ──────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */
|
||||
controller.on('aiCheckStarted', () => {
|
||||
this.broadcast('respawn:aiCheckStarted', { sessionId });
|
||||
this.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */
|
||||
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||
this.broadcast('respawn:aiCheckCompleted', {
|
||||
this.broadcast(SseEvent.RespawnAiCheckCompleted, {
|
||||
sessionId,
|
||||
verdict: result.verdict,
|
||||
reasoning: result.reasoning,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
// Track in run summary (lazy lookup)
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordAiCheckResult(result.verdict);
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */
|
||||
controller.on('aiCheckFailed', (error: string) => {
|
||||
this.broadcast('respawn:aiCheckFailed', { sessionId, error });
|
||||
// Track in run summary (lazy lookup)
|
||||
this.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordError('AI check failed', error);
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */
|
||||
controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => {
|
||||
this.broadcast('respawn:aiCheckCooldown', { sessionId, active, endsAt });
|
||||
this.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt });
|
||||
});
|
||||
|
||||
// ─── Plan Checker Events ────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */
|
||||
controller.on('planCheckStarted', () => {
|
||||
this.broadcast('respawn:planCheckStarted', { sessionId });
|
||||
this.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */
|
||||
controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||
this.broadcast('respawn:planCheckCompleted', {
|
||||
this.broadcast(SseEvent.RespawnPlanCheckCompleted, {
|
||||
sessionId,
|
||||
verdict: result.verdict,
|
||||
reasoning: result.reasoning,
|
||||
@@ -1355,34 +1437,43 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:planCheckFailed` — plan check errored */
|
||||
controller.on('planCheckFailed', (error: string) => {
|
||||
this.broadcast('respawn:planCheckFailed', { sessionId, error });
|
||||
this.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error });
|
||||
});
|
||||
|
||||
// Timer tracking events for UI countdown display
|
||||
// ─── Timer Events (UI countdown display) ────────────────
|
||||
|
||||
/** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */
|
||||
controller.on('timerStarted', (timer) => {
|
||||
this.broadcast('respawn:timerStarted', { sessionId, timer });
|
||||
this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */
|
||||
controller.on('timerCancelled', (timerName, reason) => {
|
||||
this.broadcast('respawn:timerCancelled', { sessionId, timerName, reason });
|
||||
this.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:timerCompleted` — timer expired */
|
||||
controller.on('timerCompleted', (timerName) => {
|
||||
this.broadcast('respawn:timerCompleted', { sessionId, timerName });
|
||||
this.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName });
|
||||
});
|
||||
|
||||
// ─── Logging & Errors ───────────────────────────────────
|
||||
|
||||
/** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */
|
||||
controller.on('actionLog', (action) => {
|
||||
this.broadcast('respawn:actionLog', { sessionId, action });
|
||||
this.broadcast(SseEvent.RespawnActionLog, { sessionId, action });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:log` — general respawn log message */
|
||||
controller.on('log', (message: string) => {
|
||||
this.broadcast('respawn:log', { sessionId, message });
|
||||
this.broadcast(SseEvent.RespawnLog, { sessionId, message });
|
||||
});
|
||||
|
||||
/** Broadcasts `respawn:error` — respawn controller error */
|
||||
controller.on('error', (error: Error) => {
|
||||
this.broadcast('respawn:error', { sessionId, error: error.message });
|
||||
// Track in run summary (lazy lookup)
|
||||
this.broadcast(SseEvent.RespawnError, { sessionId, error: error.message });
|
||||
const tracker = getTracker();
|
||||
if (tracker) tracker.recordError('Respawn error', error.message);
|
||||
});
|
||||
@@ -1406,7 +1497,7 @@ export class WebServer extends EventEmitter {
|
||||
controller.stop();
|
||||
controller.removeAllListeners();
|
||||
this.respawnControllers.delete(sessionId);
|
||||
this.broadcast('respawn:stopped', { sessionId, reason: 'duration_expired' });
|
||||
this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' });
|
||||
}
|
||||
this.respawnTimers.delete(sessionId);
|
||||
// Update persisted state (respawn no longer active)
|
||||
@@ -1419,7 +1510,7 @@ export class WebServer extends EventEmitter {
|
||||
);
|
||||
|
||||
this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now });
|
||||
this.broadcast('respawn:timerStarted', { sessionId, durationMinutes, endAt, startedAt: now });
|
||||
this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1478,7 +1569,7 @@ export class WebServer extends EventEmitter {
|
||||
const ctrl = this.respawnControllers.get(session.id);
|
||||
if (ctrl && ctrl.state === 'stopped') {
|
||||
ctrl.start();
|
||||
this.broadcast('respawn:started', { sessionId: session.id });
|
||||
this.broadcast(SseEvent.RespawnStarted, { sessionId: session.id });
|
||||
console.log(`[Server] Restored respawn controller started for session ${session.id}`);
|
||||
}
|
||||
}, delayMs);
|
||||
@@ -1591,7 +1682,7 @@ export class WebServer extends EventEmitter {
|
||||
};
|
||||
|
||||
this.scheduledRuns.set(id, run);
|
||||
this.broadcast('scheduled:created', run);
|
||||
this.broadcast(SseEvent.ScheduledCreated, run);
|
||||
|
||||
// Start the run loop (fire-and-forget with error handling)
|
||||
this.runScheduledLoop(id).catch((err) => {
|
||||
@@ -1600,7 +1691,7 @@ export class WebServer extends EventEmitter {
|
||||
if (failedRun && failedRun.status === 'running') {
|
||||
failedRun.status = 'stopped';
|
||||
failedRun.logs.push(`[${new Date().toISOString()}] Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.broadcast('scheduled:stopped', { id, reason: 'error' });
|
||||
this.broadcast(SseEvent.ScheduledStopped, { id, reason: 'error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1613,7 +1704,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
const addLog = (msg: string) => {
|
||||
run.logs.push(`[${new Date().toISOString()}] ${msg}`);
|
||||
this.broadcast('scheduled:log', { id: runId, log: run.logs[run.logs.length - 1] });
|
||||
this.broadcast(SseEvent.ScheduledLog, { id: runId, log: run.logs[run.logs.length - 1] });
|
||||
};
|
||||
|
||||
while (Date.now() < run.endAt && run.status === 'running') {
|
||||
@@ -1635,7 +1726,7 @@ export class WebServer extends EventEmitter {
|
||||
run.sessionId = session.id;
|
||||
|
||||
addLog(`Starting task iteration with session ${session.id.slice(0, 8)}`);
|
||||
this.broadcast('scheduled:updated', run);
|
||||
this.broadcast(SseEvent.ScheduledUpdated, run);
|
||||
|
||||
// Run the prompt
|
||||
const timeRemaining = Math.round((run.endAt - Date.now()) / 60000);
|
||||
@@ -1646,7 +1737,7 @@ export class WebServer extends EventEmitter {
|
||||
run.totalCost += result.cost;
|
||||
|
||||
addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`);
|
||||
this.broadcast('scheduled:updated', run);
|
||||
this.broadcast(SseEvent.ScheduledUpdated, run);
|
||||
|
||||
// Clean up the session after iteration to prevent memory leaks
|
||||
await this.cleanupSession(session.id, true, 'scheduled_run');
|
||||
@@ -1656,7 +1747,7 @@ export class WebServer extends EventEmitter {
|
||||
await new Promise((r) => setTimeout(r, ITERATION_PAUSE_MS));
|
||||
} catch (err) {
|
||||
addLog(`Error: ${getErrorMessage(err)}`);
|
||||
this.broadcast('scheduled:updated', run);
|
||||
this.broadcast(SseEvent.ScheduledUpdated, run);
|
||||
|
||||
// Clean up the session on error too
|
||||
if (session) {
|
||||
@@ -1678,7 +1769,7 @@ export class WebServer extends EventEmitter {
|
||||
addLog(`Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}`);
|
||||
}
|
||||
|
||||
this.broadcast('scheduled:completed', run);
|
||||
this.broadcast(SseEvent.ScheduledCompleted, run);
|
||||
}
|
||||
|
||||
private async stopScheduledRun(id: string): Promise<void> {
|
||||
@@ -1694,7 +1785,7 @@ export class WebServer extends EventEmitter {
|
||||
run.sessionId = null;
|
||||
}
|
||||
|
||||
this.broadcast('scheduled:stopped', run);
|
||||
this.broadcast(SseEvent.ScheduledStopped, run);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1745,7 +1836,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
for (const id of toDelete) {
|
||||
this.scheduledRuns.delete(id);
|
||||
this.broadcast('scheduled:deleted', { id });
|
||||
this.broadcast(SseEvent.ScheduledDeleted, { id });
|
||||
}
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
@@ -1831,7 +1922,7 @@ 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: session:needsRefresh\ndata: {}\n\n`);
|
||||
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n`);
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
@@ -1844,21 +1935,25 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
private broadcast(event: string, data: unknown): void {
|
||||
// Invalidate caches only on structurally significant events — ones that
|
||||
// change session list content (creation, deletion, or full state refresh).
|
||||
// High-frequency non-structural events (working/idle transitions, completion,
|
||||
// error, respawn state changes) are NOT worth invalidating for because:
|
||||
// 1. The debounced session:updated follows within 500ms with the new state
|
||||
// 2. These caches serve /api/sessions and SSE init — neither is polled rapidly
|
||||
// 3. Invalidating on every working/idle transition makes the 1s TTL useless
|
||||
if (event === 'session:created' || event === 'session:deleted' || event === 'session:updated') {
|
||||
// Skip serialization entirely when no clients are listening
|
||||
if (this.sseClients.size === 0) return;
|
||||
|
||||
// Invalidate caches only on structural changes (creation/deletion).
|
||||
// SessionUpdated fires too frequently (working/idle transitions, completion)
|
||||
// and makes the 1s TTL cache useless — the debounced session:updated follows
|
||||
// within 500ms anyway, and these caches serve /api/sessions and SSE init
|
||||
// which aren't polled rapidly.
|
||||
if (event === SseEvent.SessionCreated || event === SseEvent.SessionDeleted) {
|
||||
this.cachedLightState = null;
|
||||
this.cachedSessionsList = null;
|
||||
}
|
||||
// Performance optimization: serialize JSON once for all clients
|
||||
// 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 : '';
|
||||
let message: string;
|
||||
try {
|
||||
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding;
|
||||
} catch (err) {
|
||||
// Handle circular references or non-serializable values
|
||||
console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
|
||||
@@ -1985,7 +2080,7 @@ export class WebServer extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
|
||||
this.broadcast('task:updated', { sessionId, task });
|
||||
this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
|
||||
}
|
||||
this.taskUpdateBatches.clear();
|
||||
}
|
||||
@@ -2023,7 +2118,7 @@ export class WebServer extends EventEmitter {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
// Single expensive serialization per batch interval
|
||||
this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
|
||||
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
|
||||
}
|
||||
}
|
||||
this.stateUpdatePending.clear();
|
||||
@@ -2036,7 +2131,7 @@ export class WebServer extends EventEmitter {
|
||||
string,
|
||||
{ title: string; urgency: string; actions?: Array<{ action: string; title: string }> }
|
||||
> = {
|
||||
'hook:permission_prompt': {
|
||||
[SseEvent.HookPermissionPrompt]: {
|
||||
title: 'Permission Required',
|
||||
urgency: 'critical',
|
||||
actions: [
|
||||
@@ -2044,12 +2139,12 @@ export class WebServer extends EventEmitter {
|
||||
{ action: 'deny', title: 'Deny' },
|
||||
],
|
||||
},
|
||||
'hook:elicitation_dialog': { title: 'Question Asked', urgency: 'critical' },
|
||||
'hook:idle_prompt': { title: 'Waiting for Input', urgency: 'warning' },
|
||||
'hook:stop': { title: 'Response Complete', urgency: 'info' },
|
||||
'session:error': { title: 'Session Error', urgency: 'critical' },
|
||||
'respawn:blocked': { title: 'Respawn Blocked', urgency: 'critical' },
|
||||
'session:ralphCompletionDetected': { title: 'Task Complete', urgency: 'warning' },
|
||||
[SseEvent.HookElicitationDialog]: { title: 'Question Asked', urgency: 'critical' },
|
||||
[SseEvent.HookIdlePrompt]: { title: 'Waiting for Input', urgency: 'warning' },
|
||||
[SseEvent.HookStop]: { title: 'Response Complete', urgency: 'info' },
|
||||
[SseEvent.SessionError]: { title: 'Session Error', urgency: 'critical' },
|
||||
[SseEvent.RespawnBlocked]: { title: 'Respawn Blocked', urgency: 'critical' },
|
||||
[SseEvent.SessionRalphCompletionDetected]: { title: 'Task Complete', urgency: 'warning' },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -2072,16 +2167,16 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Build body text from event data
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === 'session:error' && data.error) {
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.error).slice(0, 200);
|
||||
} else if (event === 'respawn:blocked' && data.reason) {
|
||||
} else if (event === SseEvent.RespawnBlocked && data.reason) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.reason);
|
||||
} else if (event === 'session:ralphCompletionDetected' && data.phrase) {
|
||||
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.phrase);
|
||||
} else if (event === 'hook:permission_prompt' && data.tool_name) {
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
}
|
||||
@@ -2128,8 +2223,11 @@ export class WebServer extends EventEmitter {
|
||||
if (!socket || socket.destroyed || !socket.writable) {
|
||||
deadClients.push(client);
|
||||
} else {
|
||||
// Send SSE comment as keep-alive (comments start with ':')
|
||||
client.raw.write(':keepalive\n\n');
|
||||
// Send SSE comment as keep-alive. Only add padding when tunnel is
|
||||
// active — it flushes Cloudflare proxy buffers but wastes bandwidth
|
||||
// for direct/Tailscale connections.
|
||||
const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n';
|
||||
client.raw.write(ka);
|
||||
}
|
||||
} catch {
|
||||
// Error accessing socket means client is dead
|
||||
@@ -2213,8 +2311,8 @@ export class WebServer extends EventEmitter {
|
||||
() => {
|
||||
this.cleanupDeadSSEClients();
|
||||
},
|
||||
SSE_HEALTH_CHECK_INTERVAL,
|
||||
{ description: 'SSE client health check' }
|
||||
SSE_HEARTBEAT_INTERVAL,
|
||||
{ description: 'SSE heartbeat + dead client cleanup' }
|
||||
);
|
||||
|
||||
// Start token recording timer (every 5 minutes for long-running sessions)
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* @fileoverview Centralized SSE event type registry — single source of truth.
|
||||
*
|
||||
* All Server-Sent Event type strings used by the backend (`broadcast()` calls)
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* ~90 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Session lifecycle** (17): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
|
||||
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
|
||||
* - **Tasks** (4): created, completed, failed, updated
|
||||
* - **Mux** (4): created, killed, died, statsUpdated
|
||||
* - **Respawn** (17): stateChanged, cycleStarted, aiCheck*, timer*, log, ...
|
||||
* - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed
|
||||
* - **Scheduled** (6): created, updated, completed, stopped, log, deleted
|
||||
* - **Teams** (4): created, updated, removed, taskUpdated
|
||||
* - **Transcript** (4): complete, plan_mode, tool_start, tool_end
|
||||
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
|
||||
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
||||
* - **Image** (1): detected
|
||||
* - **Hooks** (6): idle_prompt, permission_prompt, elicitation_dialog, stop, teammate_idle, task_completed
|
||||
* - **Cases** (2): created, linked
|
||||
*
|
||||
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
|
||||
*
|
||||
* Key export: `SseEvent` namespace object — import for destructured access.
|
||||
*
|
||||
* Usage:
|
||||
* import { SseEvent } from './sse-events.js';
|
||||
* ctx.broadcast(SseEvent.SessionCreated, { id: session.id });
|
||||
*
|
||||
* When adding a new event:
|
||||
* 1. Add the constant here with JSDoc
|
||||
* 2. Add the matching entry in `src/web/public/constants.js` SSE_EVENTS object
|
||||
* 3. Add the frontend listener in the appropriate `addListener()` call
|
||||
*/
|
||||
|
||||
// ─── Core ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Sent to each SSE client on initial connection with full app state. */
|
||||
export const Init = 'init' as const;
|
||||
|
||||
// ─── Session Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
/** New session spawned. */
|
||||
export const SessionCreated = 'session:created' as const;
|
||||
/** Session state changed (status, config, tokens, etc.). */
|
||||
export const SessionUpdated = 'session:updated' as const;
|
||||
/** Session permanently removed. */
|
||||
export const SessionDeleted = 'session:deleted' as const;
|
||||
/** Raw PTY terminal output chunk. */
|
||||
export const SessionTerminal = 'session:terminal' as const;
|
||||
/** Client should re-fetch the full terminal buffer (e.g. after reconnect). */
|
||||
export const SessionNeedsRefresh = 'session:needsRefresh' as const;
|
||||
/** Terminal buffer cleared (e.g. /clear command). */
|
||||
export const SessionClearTerminal = 'session:clearTerminal' as const;
|
||||
/** Claude finished a prompt — includes result and cost. */
|
||||
export const SessionCompletion = 'session:completion' as const;
|
||||
/** Session-level error. */
|
||||
export const SessionError = 'session:error' as const;
|
||||
/** Claude CLI process exited. */
|
||||
export const SessionExit = 'session:exit' as const;
|
||||
/** Session transitioned to idle (waiting for input). */
|
||||
export const SessionIdle = 'session:idle' as const;
|
||||
/** Session transitioned to working (Claude is processing). */
|
||||
export const SessionWorking = 'session:working' as const;
|
||||
/** Auto-clear triggered for the session. */
|
||||
export const SessionAutoClear = 'session:autoClear' as const;
|
||||
/** Auto-compact triggered for the session. */
|
||||
export const SessionAutoCompact = 'session:autoCompact' as const;
|
||||
/** CLI version/model info detected from session output. */
|
||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
||||
/** General session message (e.g. status text). */
|
||||
export const SessionMessage = 'session:message' as const;
|
||||
/** Session entered interactive mode (claude or shell). */
|
||||
export const SessionInteractive = 'session:interactive' as const;
|
||||
/** Prompt sent to session for execution. */
|
||||
export const SessionRunning = 'session:running' as const;
|
||||
|
||||
// ─── Session: Ralph ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Ralph loop state changed (enabled/disabled, iteration count). */
|
||||
export const SessionRalphLoopUpdate = 'session:ralphLoopUpdate' as const;
|
||||
/** Ralph todo items updated. */
|
||||
export const SessionRalphTodoUpdate = 'session:ralphTodoUpdate' as const;
|
||||
/** Ralph completion phrase detected in output. */
|
||||
export const SessionRalphCompletionDetected = 'session:ralphCompletionDetected' as const;
|
||||
/** Ralph status block parsed from output. */
|
||||
export const SessionRalphStatusUpdate = 'session:ralphStatusUpdate' as const;
|
||||
/** Circuit breaker state changed (CLOSED/HALF_OPEN/OPEN). */
|
||||
export const SessionCircuitBreakerUpdate = 'session:circuitBreakerUpdate' as const;
|
||||
/** Exit gate condition met (e.g. completion phrase found). */
|
||||
export const SessionExitGateMet = 'session:exitGateMet' as const;
|
||||
|
||||
// ─── Session: Bash Tools ─────────────────────────────────────────────────────
|
||||
|
||||
/** Bash tool invocation started. */
|
||||
export const SessionBashToolStart = 'session:bashToolStart' as const;
|
||||
/** Bash tool invocation completed. */
|
||||
export const SessionBashToolEnd = 'session:bashToolEnd' as const;
|
||||
/** Active bash tools list changed. */
|
||||
export const SessionBashToolsUpdate = 'session:bashToolsUpdate' as const;
|
||||
|
||||
// ─── Session: Plan ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Plan task status updated. */
|
||||
export const SessionPlanTaskUpdate = 'session:planTaskUpdate' as const;
|
||||
/** Plan checkpoint created. */
|
||||
export const SessionPlanCheckpoint = 'session:planCheckpoint' as const;
|
||||
/** Plan rolled back to a previous version. */
|
||||
export const SessionPlanRollback = 'session:planRollback' as const;
|
||||
/** New task added to plan. */
|
||||
export const SessionPlanTaskAdded = 'session:planTaskAdded' as const;
|
||||
|
||||
// ─── Tasks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Background task created. */
|
||||
export const TaskCreated = 'task:created' as const;
|
||||
/** Background task completed successfully. */
|
||||
export const TaskCompleted = 'task:completed' as const;
|
||||
/** Background task failed. */
|
||||
export const TaskFailed = 'task:failed' as const;
|
||||
/** Background task state updated. */
|
||||
export const TaskUpdated = 'task:updated' as const;
|
||||
|
||||
// ─── Mux (tmux) ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** tmux session created. */
|
||||
export const MuxCreated = 'mux:created' as const;
|
||||
/** tmux session killed. */
|
||||
export const MuxKilled = 'mux:killed' as const;
|
||||
/** tmux session died unexpectedly. */
|
||||
export const MuxDied = 'mux:died' as const;
|
||||
/** tmux session stats refreshed. */
|
||||
export const MuxStatsUpdated = 'mux:statsUpdated' as const;
|
||||
|
||||
// ─── Respawn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Respawn loop started for a session. */
|
||||
export const RespawnStarted = 'respawn:started' as const;
|
||||
/** Respawn loop stopped. */
|
||||
export const RespawnStopped = 'respawn:stopped' as const;
|
||||
/** Respawn state machine transitioned. */
|
||||
export const RespawnStateChanged = 'respawn:stateChanged' as const;
|
||||
/** New respawn cycle started. */
|
||||
export const RespawnCycleStarted = 'respawn:cycleStarted' as const;
|
||||
/** Respawn cycle completed. */
|
||||
export const RespawnCycleCompleted = 'respawn:cycleCompleted' as const;
|
||||
/** Respawn blocked (e.g. by circuit breaker or active teammates). */
|
||||
export const RespawnBlocked = 'respawn:blocked' as const;
|
||||
/** Respawn step sent to session (update prompt, clear, kickstart). */
|
||||
export const RespawnStepSent = 'respawn:stepSent' as const;
|
||||
/** Respawn step completed. */
|
||||
export const RespawnStepCompleted = 'respawn:stepCompleted' as const;
|
||||
/** Idle/completion detection status updated. */
|
||||
export const RespawnDetectionUpdate = 'respawn:detectionUpdate' as const;
|
||||
/** Auto-accept sent for permission prompt. */
|
||||
export const RespawnAutoAcceptSent = 'respawn:autoAcceptSent' as const;
|
||||
/** AI idle check started. */
|
||||
export const RespawnAiCheckStarted = 'respawn:aiCheckStarted' as const;
|
||||
/** AI idle check completed with result. */
|
||||
export const RespawnAiCheckCompleted = 'respawn:aiCheckCompleted' as const;
|
||||
/** AI idle check failed. */
|
||||
export const RespawnAiCheckFailed = 'respawn:aiCheckFailed' as const;
|
||||
/** AI check cooldown state changed. */
|
||||
export const RespawnAiCheckCooldown = 'respawn:aiCheckCooldown' as const;
|
||||
/** Plan completion check started. */
|
||||
export const RespawnPlanCheckStarted = 'respawn:planCheckStarted' as const;
|
||||
/** Plan completion check completed with result. */
|
||||
export const RespawnPlanCheckCompleted = 'respawn:planCheckCompleted' as const;
|
||||
/** Plan completion check failed. */
|
||||
export const RespawnPlanCheckFailed = 'respawn:planCheckFailed' as const;
|
||||
/** Respawn timer started (idle, duration, etc.). */
|
||||
export const RespawnTimerStarted = 'respawn:timerStarted' as const;
|
||||
/** Respawn timer cancelled. */
|
||||
export const RespawnTimerCancelled = 'respawn:timerCancelled' as const;
|
||||
/** Respawn timer completed. */
|
||||
export const RespawnTimerCompleted = 'respawn:timerCompleted' as const;
|
||||
/** Respawn action logged (for monitor UI). */
|
||||
export const RespawnActionLog = 'respawn:actionLog' as const;
|
||||
/** Respawn debug log message. */
|
||||
export const RespawnLog = 'respawn:log' as const;
|
||||
/** Respawn error occurred. */
|
||||
export const RespawnError = 'respawn:error' as const;
|
||||
/** Respawn configuration updated. */
|
||||
export const RespawnConfigUpdated = 'respawn:configUpdated' as const;
|
||||
|
||||
// ─── Subagents ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** New subagent (background agent) discovered. */
|
||||
export const SubagentDiscovered = 'subagent:discovered' as const;
|
||||
/** Subagent state updated. */
|
||||
export const SubagentUpdated = 'subagent:updated' as const;
|
||||
/** Subagent tool call detected. */
|
||||
export const SubagentToolCall = 'subagent:tool_call' as const;
|
||||
/** Subagent tool result received. */
|
||||
export const SubagentToolResult = 'subagent:tool_result' as const;
|
||||
/** Subagent progress update. */
|
||||
export const SubagentProgress = 'subagent:progress' as const;
|
||||
/** Subagent message (assistant text). */
|
||||
export const SubagentMessage = 'subagent:message' as const;
|
||||
/** Subagent finished. */
|
||||
export const SubagentCompleted = 'subagent:completed' as const;
|
||||
|
||||
// ─── Scheduled Runs ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Scheduled run created. */
|
||||
export const ScheduledCreated = 'scheduled:created' as const;
|
||||
/** Scheduled run state updated. */
|
||||
export const ScheduledUpdated = 'scheduled:updated' as const;
|
||||
/** Scheduled run completed. */
|
||||
export const ScheduledCompleted = 'scheduled:completed' as const;
|
||||
/** Scheduled run stopped. */
|
||||
export const ScheduledStopped = 'scheduled:stopped' as const;
|
||||
/** Scheduled run log entry added. */
|
||||
export const ScheduledLog = 'scheduled:log' as const;
|
||||
/** Scheduled run deleted. */
|
||||
export const ScheduledDeleted = 'scheduled:deleted' as const;
|
||||
|
||||
// ─── Teams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Agent team created. */
|
||||
export const TeamCreated = 'team:created' as const;
|
||||
/** Agent team config updated (e.g. new member joined). */
|
||||
export const TeamUpdated = 'team:updated' as const;
|
||||
/** Agent team removed. */
|
||||
export const TeamRemoved = 'team:removed' as const;
|
||||
/** Agent team task updated. */
|
||||
export const TeamTaskUpdated = 'team:taskUpdated' as const;
|
||||
|
||||
// ─── Transcript ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Transcript complete event detected. */
|
||||
export const TranscriptComplete = 'transcript:complete' as const;
|
||||
/** Plan mode detected in transcript. */
|
||||
export const TranscriptPlanMode = 'transcript:plan_mode' as const;
|
||||
/** Tool invocation started in transcript. */
|
||||
export const TranscriptToolStart = 'transcript:tool_start' as const;
|
||||
/** Tool invocation ended in transcript. */
|
||||
export const TranscriptToolEnd = 'transcript:tool_end' as const;
|
||||
|
||||
// ─── Plan Orchestration ──────────────────────────────────────────────────────
|
||||
|
||||
/** Plan generation started. */
|
||||
export const PlanStarted = 'plan:started' as const;
|
||||
/** Plan generation progress update. */
|
||||
export const PlanProgress = 'plan:progress' as const;
|
||||
/** Plan subagent event (research or planner agent). */
|
||||
export const PlanSubagent = 'plan:subagent' as const;
|
||||
/** Plan generation completed. */
|
||||
export const PlanCompleted = 'plan:completed' as const;
|
||||
/** Plan generation cancelled. */
|
||||
export const PlanCancelled = 'plan:cancelled' as const;
|
||||
|
||||
// ─── Tunnel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Cloudflare tunnel started. */
|
||||
export const TunnelStarted = 'tunnel:started' as const;
|
||||
/** Cloudflare tunnel stopped. */
|
||||
export const TunnelStopped = 'tunnel:stopped' as const;
|
||||
/** Tunnel startup progress. */
|
||||
export const TunnelProgress = 'tunnel:progress' as const;
|
||||
/** Tunnel error. */
|
||||
export const TunnelError = 'tunnel:error' as const;
|
||||
/** QR code rotated (new token generated). */
|
||||
export const TunnelQrRotated = 'tunnel:qrRotated' as const;
|
||||
/** QR code force-regenerated. */
|
||||
export const TunnelQrRegenerated = 'tunnel:qrRegenerated' as const;
|
||||
/** QR auth token consumed by a client. */
|
||||
export const TunnelQrAuthUsed = 'tunnel:qrAuthUsed' as const;
|
||||
|
||||
// ─── Image ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** New image file detected (e.g. screenshot upload). */
|
||||
export const ImageDetected = 'image:detected' as const;
|
||||
|
||||
// ─── Hooks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Claude Code hook: session idle, waiting for input. */
|
||||
export const HookIdlePrompt = 'hook:idle_prompt' as const;
|
||||
/** Claude Code hook: tool requesting permission. */
|
||||
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
|
||||
/** Claude Code hook: elicitation dialog (Claude asking a question). */
|
||||
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;
|
||||
/** Claude Code hook: response complete. */
|
||||
export const HookStop = 'hook:stop' as const;
|
||||
/** Claude Code hook: teammate went idle. */
|
||||
export const HookTeammateIdle = 'hook:teammate_idle' as const;
|
||||
/** Claude Code hook: teammate task completed. */
|
||||
export const HookTaskCompleted = 'hook:task_completed' as const;
|
||||
|
||||
// ─── Cases ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** New case directory created. */
|
||||
export const CaseCreated = 'case:created' as const;
|
||||
/** Existing directory linked as a case. */
|
||||
export const CaseLinked = 'case:linked' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* All SSE event types as a namespace object.
|
||||
* Convenient for destructured imports or passing as a group.
|
||||
*/
|
||||
export const SseEvent = {
|
||||
// Core
|
||||
Init,
|
||||
|
||||
// Session lifecycle
|
||||
SessionCreated,
|
||||
SessionUpdated,
|
||||
SessionDeleted,
|
||||
SessionTerminal,
|
||||
SessionNeedsRefresh,
|
||||
SessionClearTerminal,
|
||||
SessionCompletion,
|
||||
SessionError,
|
||||
SessionExit,
|
||||
SessionIdle,
|
||||
SessionWorking,
|
||||
SessionAutoClear,
|
||||
SessionAutoCompact,
|
||||
SessionCliInfo,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
|
||||
// Session: Ralph
|
||||
SessionRalphLoopUpdate,
|
||||
SessionRalphTodoUpdate,
|
||||
SessionRalphCompletionDetected,
|
||||
SessionRalphStatusUpdate,
|
||||
SessionCircuitBreakerUpdate,
|
||||
SessionExitGateMet,
|
||||
|
||||
// Session: Bash tools
|
||||
SessionBashToolStart,
|
||||
SessionBashToolEnd,
|
||||
SessionBashToolsUpdate,
|
||||
|
||||
// Session: Plan
|
||||
SessionPlanTaskUpdate,
|
||||
SessionPlanCheckpoint,
|
||||
SessionPlanRollback,
|
||||
SessionPlanTaskAdded,
|
||||
|
||||
// Tasks
|
||||
TaskCreated,
|
||||
TaskCompleted,
|
||||
TaskFailed,
|
||||
TaskUpdated,
|
||||
|
||||
// Mux
|
||||
MuxCreated,
|
||||
MuxKilled,
|
||||
MuxDied,
|
||||
MuxStatsUpdated,
|
||||
|
||||
// Respawn
|
||||
RespawnStarted,
|
||||
RespawnStopped,
|
||||
RespawnStateChanged,
|
||||
RespawnCycleStarted,
|
||||
RespawnCycleCompleted,
|
||||
RespawnBlocked,
|
||||
RespawnStepSent,
|
||||
RespawnStepCompleted,
|
||||
RespawnDetectionUpdate,
|
||||
RespawnAutoAcceptSent,
|
||||
RespawnAiCheckStarted,
|
||||
RespawnAiCheckCompleted,
|
||||
RespawnAiCheckFailed,
|
||||
RespawnAiCheckCooldown,
|
||||
RespawnPlanCheckStarted,
|
||||
RespawnPlanCheckCompleted,
|
||||
RespawnPlanCheckFailed,
|
||||
RespawnTimerStarted,
|
||||
RespawnTimerCancelled,
|
||||
RespawnTimerCompleted,
|
||||
RespawnActionLog,
|
||||
RespawnLog,
|
||||
RespawnError,
|
||||
RespawnConfigUpdated,
|
||||
|
||||
// Subagents
|
||||
SubagentDiscovered,
|
||||
SubagentUpdated,
|
||||
SubagentToolCall,
|
||||
SubagentToolResult,
|
||||
SubagentProgress,
|
||||
SubagentMessage,
|
||||
SubagentCompleted,
|
||||
|
||||
// Scheduled runs
|
||||
ScheduledCreated,
|
||||
ScheduledUpdated,
|
||||
ScheduledCompleted,
|
||||
ScheduledStopped,
|
||||
ScheduledLog,
|
||||
ScheduledDeleted,
|
||||
|
||||
// Teams
|
||||
TeamCreated,
|
||||
TeamUpdated,
|
||||
TeamRemoved,
|
||||
TeamTaskUpdated,
|
||||
|
||||
// Transcript
|
||||
TranscriptComplete,
|
||||
TranscriptPlanMode,
|
||||
TranscriptToolStart,
|
||||
TranscriptToolEnd,
|
||||
|
||||
// Plan orchestration
|
||||
PlanStarted,
|
||||
PlanProgress,
|
||||
PlanSubagent,
|
||||
PlanCompleted,
|
||||
PlanCancelled,
|
||||
|
||||
// Tunnel
|
||||
TunnelStarted,
|
||||
TunnelStopped,
|
||||
TunnelProgress,
|
||||
TunnelError,
|
||||
TunnelQrRotated,
|
||||
TunnelQrRegenerated,
|
||||
TunnelQrAuthUsed,
|
||||
|
||||
// Image
|
||||
ImageDetected,
|
||||
|
||||
// Hooks
|
||||
HookIdlePrompt,
|
||||
HookPermissionPrompt,
|
||||
HookElicitationDialog,
|
||||
HookStop,
|
||||
HookTeammateIdle,
|
||||
HookTaskCompleted,
|
||||
|
||||
// Cases
|
||||
CaseCreated,
|
||||
CaseLinked,
|
||||
} as const;
|
||||
+187
-1
@@ -12,14 +12,16 @@
|
||||
* 10. Full server integration: GET /q/:code issues cookie + redirects
|
||||
* 11. Session revocation via POST /api/auth/revoke
|
||||
* 12. QR auth bypass in auth middleware
|
||||
* 13. GET /api/tunnel/qr SVG endpoint (auth/no-auth, caching, errors)
|
||||
*
|
||||
* Port: 3162 (qr-auth tests)
|
||||
* Port: 3162 (qr-auth tests), 3163 (qr-svg endpoint tests)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { TunnelManager } from '../src/tunnel-manager.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const QR_AUTH_PORT = 3162;
|
||||
const QR_SVG_PORT = 3163;
|
||||
const TEST_PASS = 'qr-test-pass-xyz';
|
||||
const TEST_USER = 'admin';
|
||||
|
||||
@@ -577,3 +579,187 @@ describe('QR Auth Integration', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== QR SVG Endpoint Tests (GET /api/tunnel/qr) ==========
|
||||
|
||||
describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||
server = new WebServer(QR_SVG_PORT, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${QR_SVG_PORT}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
});
|
||||
|
||||
function getTunnelManager(): TunnelManager {
|
||||
return (server as unknown as { tunnelManager: TunnelManager }).tunnelManager;
|
||||
}
|
||||
|
||||
function simulateTunnelRunning(tm: TunnelManager, url = 'https://test-qr.trycloudflare.com'): void {
|
||||
(tm as unknown as { url: string | null }).url = url;
|
||||
}
|
||||
|
||||
function simulateTunnelStopped(tm: TunnelManager): void {
|
||||
(tm as unknown as { url: string | null }).url = null;
|
||||
}
|
||||
|
||||
it('should return 404 when tunnel is not running', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/tunnel/qr`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return SVG with authEnabled=true when tunnel running + auth configured', async () => {
|
||||
const tm = getTunnelManager();
|
||||
simulateTunnelRunning(tm);
|
||||
tm.startTokenRotation();
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/tunnel/qr`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.authEnabled).toBe(true);
|
||||
expect(data.svg).toContain('<svg');
|
||||
expect(data.svg).toContain('</svg>');
|
||||
} finally {
|
||||
tm.stopTokenRotation();
|
||||
simulateTunnelStopped(tm);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return SVG with authEnabled=false when tunnel running + no auth', async () => {
|
||||
const tm = getTunnelManager();
|
||||
simulateTunnelRunning(tm);
|
||||
const savedPass = process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
try {
|
||||
// Auth middleware was initialized with password (closure), so still need headers.
|
||||
// But route handler checks env var on each request — sees no password → no-auth path.
|
||||
const res = await fetch(`${baseUrl}/api/tunnel/qr`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, savedPass!) },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.authEnabled).toBe(false);
|
||||
expect(data.svg).toContain('<svg');
|
||||
expect(data.svg).toContain('</svg>');
|
||||
} finally {
|
||||
process.env.CODEMAN_PASSWORD = savedPass;
|
||||
simulateTunnelStopped(tm);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 500 when tunnel running + auth set but token rotation not started', async () => {
|
||||
const tm = getTunnelManager();
|
||||
simulateTunnelRunning(tm);
|
||||
// Don't start token rotation — simulates the race condition
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/tunnel/qr`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||
});
|
||||
expect(res.status).toBe(500);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBeDefined();
|
||||
} finally {
|
||||
simulateTunnelStopped(tm);
|
||||
}
|
||||
});
|
||||
|
||||
it('SVG should encode a URL containing the short code path', async () => {
|
||||
const tm = getTunnelManager();
|
||||
const tunnelUrl = 'https://svgtest.trycloudflare.com';
|
||||
simulateTunnelRunning(tm, tunnelUrl);
|
||||
tm.startTokenRotation();
|
||||
try {
|
||||
const code = tm.getCurrentShortCode()!;
|
||||
const svg = await tm.getQrSvg(tunnelUrl);
|
||||
// The SVG encodes `tunnelUrl/q/shortCode` as a QR pattern —
|
||||
// we can't decode the QR, but verify the SVG is well-formed
|
||||
expect(svg).toContain('<svg');
|
||||
expect(svg).toContain('</svg>');
|
||||
expect(code).toMatch(/^[A-Za-z0-9]{6}$/);
|
||||
} finally {
|
||||
tm.stopTokenRotation();
|
||||
simulateTunnelStopped(tm);
|
||||
}
|
||||
});
|
||||
|
||||
it('no-auth SVG should encode the raw tunnel URL', async () => {
|
||||
const tm = getTunnelManager();
|
||||
const tunnelUrl = 'https://raw-url-test.trycloudflare.com';
|
||||
simulateTunnelRunning(tm, tunnelUrl);
|
||||
const savedPass = process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/tunnel/qr`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, savedPass!) },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.svg).toContain('<svg');
|
||||
expect(data.authEnabled).toBe(false);
|
||||
} finally {
|
||||
process.env.CODEMAN_PASSWORD = savedPass;
|
||||
simulateTunnelStopped(tm);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return consistent SVGs across multiple requests (caching)', async () => {
|
||||
const tm = getTunnelManager();
|
||||
simulateTunnelRunning(tm);
|
||||
tm.startTokenRotation();
|
||||
try {
|
||||
const res1 = await fetch(`${baseUrl}/api/tunnel/qr`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||
});
|
||||
const data1 = await res1.json();
|
||||
|
||||
const res2 = await fetch(`${baseUrl}/api/tunnel/qr`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||
});
|
||||
const data2 = await res2.json();
|
||||
|
||||
expect(data1.svg).toBe(data2.svg);
|
||||
} finally {
|
||||
tm.stopTokenRotation();
|
||||
simulateTunnelStopped(tm);
|
||||
}
|
||||
});
|
||||
|
||||
it('SVG should change after token regeneration', async () => {
|
||||
const tm = getTunnelManager();
|
||||
simulateTunnelRunning(tm);
|
||||
tm.startTokenRotation();
|
||||
try {
|
||||
const res1 = await fetch(`${baseUrl}/api/tunnel/qr`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||
});
|
||||
const data1 = await res1.json();
|
||||
|
||||
tm.regenerateQrToken();
|
||||
|
||||
const res2 = await fetch(`${baseUrl}/api/tunnel/qr`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) },
|
||||
});
|
||||
const data2 = await res2.json();
|
||||
|
||||
expect(data1.svg).not.toBe(data2.svg);
|
||||
} finally {
|
||||
tm.stopTokenRotation();
|
||||
simulateTunnelStopped(tm);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+19
-8
@@ -1,16 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Composition } from 'remotion';
|
||||
import { CodemanDemo, TOTAL_FRAMES } from './compositions/CodemanDemo';
|
||||
import { ZerolagDemo, ZEROLAG_TOTAL_FRAMES } from './compositions/ZerolagDemo';
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="CodemanDemo"
|
||||
component={CodemanDemo}
|
||||
durationInFrames={TOTAL_FRAMES}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
<>
|
||||
<Composition
|
||||
id="CodemanDemo"
|
||||
component={CodemanDemo}
|
||||
durationInFrames={TOTAL_FRAMES}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
<Composition
|
||||
id="ZerolagDemo"
|
||||
component={ZerolagDemo}
|
||||
durationInFrames={ZEROLAG_TOTAL_FRAMES}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import { fonts } from '../lib/theme';
|
||||
|
||||
type IOSKeyboardProps = {
|
||||
activeKey?: string;
|
||||
/** How many frames since the key was pressed (for highlight decay) */
|
||||
pressAge?: number;
|
||||
};
|
||||
|
||||
const ROW_1 = ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'];
|
||||
const ROW_2 = ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'];
|
||||
const ROW_3 = ['z', 'x', 'c', 'v', 'b', 'n', 'm'];
|
||||
|
||||
const KEY_H = 42;
|
||||
const KEY_GAP = 6;
|
||||
const ROW_GAP = 11;
|
||||
const SIDE_PAD = 3;
|
||||
|
||||
const KEYBOARD_BG = '#1c1c1e';
|
||||
const KEY_BG = '#3a3a3c';
|
||||
const KEY_BG_ACTIVE = '#636366';
|
||||
const KEY_TEXT = '#fff';
|
||||
const SPECIAL_BG = '#2c2c2e';
|
||||
|
||||
const Key: React.FC<{
|
||||
label: string;
|
||||
width: number;
|
||||
isActive: boolean;
|
||||
pressAge: number;
|
||||
fontSize?: number;
|
||||
}> = ({ label, width, isActive, pressAge, fontSize = 22 }) => {
|
||||
// Highlight decays over 4 frames
|
||||
const highlightOpacity = isActive && pressAge < 4 ? 1 - pressAge / 4 : 0;
|
||||
const bg = highlightOpacity > 0
|
||||
? lerpColor(KEY_BG, KEY_BG_ACTIVE, highlightOpacity)
|
||||
: KEY_BG;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height: KEY_H,
|
||||
borderRadius: 5,
|
||||
background: bg,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize,
|
||||
fontFamily: fonts.ui,
|
||||
color: KEY_TEXT,
|
||||
fontWeight: 300,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function lerpColor(a: string, b: string, t: number): string {
|
||||
const pa = parseInt(a.slice(1), 16);
|
||||
const pb = parseInt(b.slice(1), 16);
|
||||
const r = Math.round(((pa >> 16) & 0xff) * (1 - t) + ((pb >> 16) & 0xff) * t);
|
||||
const g = Math.round(((pa >> 8) & 0xff) * (1 - t) + ((pb >> 8) & 0xff) * t);
|
||||
const bl = Math.round((pa & 0xff) * (1 - t) + (pb & 0xff) * t);
|
||||
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
export const IOSKeyboard: React.FC<IOSKeyboardProps> = ({ activeKey, pressAge = 99 }) => {
|
||||
const isActive = (key: string) =>
|
||||
activeKey !== undefined && key.toLowerCase() === activeKey.toLowerCase();
|
||||
const age = (key: string) => (isActive(key) ? pressAge : 99);
|
||||
|
||||
// Backspace/delete key highlight
|
||||
const deleteHighlight = activeKey === '⌫' && pressAge < 4 ? 1 - pressAge / 4 : 0;
|
||||
const deleteBg = deleteHighlight > 0 ? lerpColor(SPECIAL_BG, KEY_BG_ACTIVE, deleteHighlight) : SPECIAL_BG;
|
||||
|
||||
// Key widths: 10 keys + 9 gaps in ~375px row → each key ~33px
|
||||
const letterKeyW = 33;
|
||||
// Row 2 has 9 keys → same key width but centered with side padding
|
||||
// Row 3 has shift + 7 keys + delete
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
background: KEYBOARD_BG,
|
||||
padding: `${ROW_GAP}px ${SIDE_PAD}px 20px`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: ROW_GAP,
|
||||
}}
|
||||
>
|
||||
{/* Row 1: q-p */}
|
||||
<div style={{ display: 'flex', gap: KEY_GAP, justifyContent: 'center' }}>
|
||||
{ROW_1.map((k) => (
|
||||
<Key key={k} label={k} width={letterKeyW} isActive={isActive(k)} pressAge={age(k)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Row 2: a-l */}
|
||||
<div style={{ display: 'flex', gap: KEY_GAP, justifyContent: 'center' }}>
|
||||
{ROW_2.map((k) => (
|
||||
<Key key={k} label={k} width={letterKeyW} isActive={isActive(k)} pressAge={age(k)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Row 3: shift + z-m + delete */}
|
||||
<div style={{ display: 'flex', gap: KEY_GAP, justifyContent: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 42,
|
||||
height: KEY_H,
|
||||
borderRadius: 5,
|
||||
background: SPECIAL_BG,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="16" viewBox="0 0 20 16" fill="none">
|
||||
<path d="M10 2L17 9H13V14H7V9H3L10 2Z" fill="#fff" />
|
||||
</svg>
|
||||
</div>
|
||||
{ROW_3.map((k) => (
|
||||
<Key key={k} label={k} width={letterKeyW} isActive={isActive(k)} pressAge={age(k)} />
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
width: 42,
|
||||
height: KEY_H,
|
||||
borderRadius: 5,
|
||||
background: deleteBg,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="22" height="16" viewBox="0 0 22 16" fill="none">
|
||||
<path d="M7 1L1 8L7 15H21V1H7Z" stroke="#fff" strokeWidth="1.5" fill="none" />
|
||||
<path d="M12 5L17 10M17 5L12 10" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: 123 / globe / space / return */}
|
||||
<div style={{ display: 'flex', gap: KEY_GAP, justifyContent: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 42,
|
||||
height: KEY_H,
|
||||
borderRadius: 5,
|
||||
background: SPECIAL_BG,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.ui,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
123
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: KEY_H,
|
||||
borderRadius: 5,
|
||||
background: SPECIAL_BG,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<circle cx="10" cy="10" r="8" stroke="#fff" strokeWidth="1.2" />
|
||||
<path d="M4 10H16M10 4C7 7 7 13 10 16M10 4C13 7 13 13 10 16" stroke="#fff" strokeWidth="1" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* Space bar */}
|
||||
<Key
|
||||
label="space"
|
||||
width={186}
|
||||
isActive={isActive(' ')}
|
||||
pressAge={age(' ')}
|
||||
fontSize={15}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 88,
|
||||
height: KEY_H,
|
||||
borderRadius: 5,
|
||||
background: SPECIAL_BG,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.ui,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
return
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { spring, useCurrentFrame, useVideoConfig } from 'remotion';
|
||||
|
||||
/**
|
||||
* Pixel-accurate iPhone 17 Pro frame.
|
||||
*
|
||||
* Dimensions based on iPhone 16 Pro (same form factor):
|
||||
* - Screen: 393×852 CSS points (2622×1206 @3x)
|
||||
* - Corner radius: 55px (device), 50px (screen inner)
|
||||
* - Bezel: ~3.5px (thinnest in any iPhone)
|
||||
* - Dynamic Island: 126×37 pill, centered 13px from top
|
||||
* - Safe area: top 59px, bottom 34px (home indicator)
|
||||
* - Frame: natural titanium (#8a8a8e border)
|
||||
*/
|
||||
|
||||
type IPhone17ProFrameProps = {
|
||||
children: React.ReactNode;
|
||||
/** Disable the spring entrance animation */
|
||||
noAnimation?: boolean;
|
||||
};
|
||||
|
||||
// Device dimensions (CSS points)
|
||||
const SCREEN_W = 393;
|
||||
const SCREEN_H = 852;
|
||||
const BEZEL = 4;
|
||||
const DEVICE_W = SCREEN_W + BEZEL * 2; // 401
|
||||
const DEVICE_H = SCREEN_H + BEZEL * 2; // 860
|
||||
const DEVICE_RADIUS = 55;
|
||||
const SCREEN_RADIUS = 50;
|
||||
|
||||
// Dynamic Island
|
||||
const DI_W = 126;
|
||||
const DI_H = 37;
|
||||
const DI_TOP = 13; // from top of screen
|
||||
const DI_RADIUS = DI_H / 2; // pill shape
|
||||
|
||||
export { SCREEN_W, SCREEN_H };
|
||||
|
||||
export const IPhone17ProFrame: React.FC<IPhone17ProFrameProps> = ({ children, noAnimation }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const scale = noAnimation
|
||||
? 1
|
||||
: spring({ frame, fps, config: { damping: 15, stiffness: 80 } });
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: DEVICE_W,
|
||||
height: DEVICE_H,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center center',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Titanium frame (outer body) */}
|
||||
<div
|
||||
style={{
|
||||
width: DEVICE_W,
|
||||
height: DEVICE_H,
|
||||
borderRadius: DEVICE_RADIUS,
|
||||
background: '#2c2c2e', // dark titanium
|
||||
border: '1.5px solid #48484a', // subtle edge highlight
|
||||
boxShadow: [
|
||||
'0 2px 4px rgba(0,0,0,0.3)', // close shadow
|
||||
'0 12px 40px rgba(0,0,0,0.5)', // mid shadow
|
||||
'0 30px 80px rgba(0,0,0,0.4)', // far shadow
|
||||
'inset 0 1px 0 rgba(255,255,255,0.05)', // top edge gleam
|
||||
].join(', '),
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Side button (right) — power */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -2,
|
||||
top: 180,
|
||||
width: 3,
|
||||
height: 65,
|
||||
borderRadius: '0 2px 2px 0',
|
||||
background: '#48484a',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Side buttons (left) — volume up, down, action */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -2,
|
||||
top: 140,
|
||||
width: 3,
|
||||
height: 28,
|
||||
borderRadius: '2px 0 0 2px',
|
||||
background: '#48484a',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -2,
|
||||
top: 185,
|
||||
width: 3,
|
||||
height: 50,
|
||||
borderRadius: '2px 0 0 2px',
|
||||
background: '#48484a',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -2,
|
||||
top: 250,
|
||||
width: 3,
|
||||
height: 50,
|
||||
borderRadius: '2px 0 0 2px',
|
||||
background: '#48484a',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Screen */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: BEZEL,
|
||||
left: BEZEL,
|
||||
width: SCREEN_W,
|
||||
height: SCREEN_H,
|
||||
borderRadius: SCREEN_RADIUS,
|
||||
overflow: 'hidden',
|
||||
background: '#000',
|
||||
}}
|
||||
>
|
||||
{/* App content */}
|
||||
{children}
|
||||
|
||||
{/* Dynamic Island (on top of everything) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: DI_TOP,
|
||||
left: (SCREEN_W - DI_W) / 2,
|
||||
width: DI_W,
|
||||
height: DI_H,
|
||||
borderRadius: DI_RADIUS,
|
||||
background: '#000',
|
||||
zIndex: 50,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Home indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: BEZEL + 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 134,
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
zIndex: 60,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { useCurrentFrame } from 'remotion';
|
||||
import { colors, fonts } from '../lib/theme';
|
||||
|
||||
type OverlayChar = {
|
||||
char: string;
|
||||
confirmed: boolean;
|
||||
};
|
||||
|
||||
type TerminalScreenProps = {
|
||||
typed: string;
|
||||
cursorVisible: boolean;
|
||||
overlayChars?: OverlayChar[];
|
||||
fontSize?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a terminal area styled exactly like the real Codeman xterm.js terminal.
|
||||
* Background #0d0d0d, Fira Code font, block cursor, Claude Code prompt.
|
||||
*/
|
||||
export const TerminalScreen: React.FC<TerminalScreenProps> = ({
|
||||
typed,
|
||||
cursorVisible,
|
||||
overlayChars,
|
||||
fontSize = 28,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Block cursor — solid, no blink (matches Codeman's cursorBlink: false)
|
||||
const cursorOn = cursorVisible;
|
||||
// But add a subtle blink for video clarity so viewers notice it
|
||||
const cursorOpacity = cursorOn ? (Math.floor(frame / 20) % 2 === 0 ? 0.9 : 0.6) : 0;
|
||||
|
||||
const lineHeight = Math.round(fontSize * 1.35);
|
||||
const charWidth = fontSize * 0.6;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#0d0d0d',
|
||||
position: 'relative',
|
||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Previous terminal output lines (fake history for realism) */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
fontSize: fontSize * 0.65,
|
||||
lineHeight: `${Math.round(fontSize * 0.65 * 1.4)}px`,
|
||||
color: '#495057',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ color: '#339af0' }}>❯</span>
|
||||
<span style={{ color: '#495057' }}> claude --dangerously-skip-permissions</span>
|
||||
</div>
|
||||
<div style={{ color: '#3a3a3a', marginTop: 4 }}>
|
||||
╭────────────────────────────────────╮
|
||||
</div>
|
||||
<div style={{ color: '#3a3a3a' }}>
|
||||
│ Claude Code session active │
|
||||
</div>
|
||||
<div style={{ color: '#3a3a3a' }}>
|
||||
╰────────────────────────────────────╯
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active prompt line — this is where the typing happens */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
fontSize,
|
||||
lineHeight: `${lineHeight}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
{/* Prompt character */}
|
||||
<span style={{ color: '#339af0', fontWeight: 700, marginRight: charWidth * 0.8 }}>❯</span>
|
||||
|
||||
{/* Typed text */}
|
||||
{overlayChars ? (
|
||||
// Zerolag mode: show overlay chars with confirmed/unconfirmed color
|
||||
overlayChars.map((oc, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
color: oc.confirmed ? '#e0e0e0' : '#7a7a7a',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{oc.char}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: '#e0e0e0', letterSpacing: '0.5px' }}>{typed}</span>
|
||||
)}
|
||||
|
||||
{/* Block cursor */}
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: charWidth,
|
||||
height: lineHeight * 0.85,
|
||||
background: `rgba(224, 224, 224, ${cursorOpacity})`,
|
||||
verticalAlign: 'text-bottom',
|
||||
marginLeft: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,422 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Img,
|
||||
interpolate,
|
||||
Sequence,
|
||||
spring,
|
||||
staticFile,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import { colors, fonts } from '../lib/theme';
|
||||
import { IPhone17ProFrame, SCREEN_W, SCREEN_H } from '../components/IPhone17ProFrame';
|
||||
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 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
|
||||
|
||||
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 663
|
||||
|
||||
// ─── 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;
|
||||
|
||||
// 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;
|
||||
const TERMINAL_LEFT = 14;
|
||||
const TERMINAL_FONT = 22; // Large for video readability
|
||||
|
||||
// ─── 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"
|
||||
|
||||
// Remote connection lag: 600ms-1.2s+ per char (18-36+ frames)
|
||||
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,
|
||||
];
|
||||
|
||||
type KeyAction = { frame: number; action: 'type' | 'backspace'; 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++;
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
const TYPING_SCHEDULE = buildSchedule();
|
||||
|
||||
/** Replay actions in order up to current frame, computing the visible text buffer */
|
||||
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;
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
|
||||
// ─── iOS Status Bar (sits in the safe area, flanking Dynamic Island) ───
|
||||
const IOSStatusBar: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 17,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 20,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 30px',
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.ui,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<span>9:41</span>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{/* Signal */}
|
||||
<svg width="17" height="12" viewBox="0 0 17 12">
|
||||
<rect x="0" y="8" width="3" height="4" rx="0.5" fill="#fff" />
|
||||
<rect x="4.5" y="5" width="3" height="7" rx="0.5" fill="#fff" />
|
||||
<rect x="9" y="2" width="3" height="10" rx="0.5" fill="#fff" />
|
||||
<rect x="13.5" y="0" width="3" height="12" rx="0.5" fill="#fff" />
|
||||
</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" />
|
||||
<circle cx="8" cy="11" r="1.5" fill="#fff" />
|
||||
</svg>
|
||||
{/* Battery */}
|
||||
<svg width="27" height="12" viewBox="0 0 27 12">
|
||||
<rect x="0" y="0.5" width="23" height="11" rx="2" stroke="#fff" strokeWidth="1" fill="none" />
|
||||
<rect x="24" y="3.5" width="2.5" height="5" rx="1" fill="#fff" opacity="0.4" />
|
||||
<rect x="1.5" y="2" width="20" height="8" rx="1" fill="#32d74b" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Typing overlay ───
|
||||
const TypingOverlay: React.FC<{
|
||||
typed: string;
|
||||
overlayChars?: { char: string; confirmed: boolean }[];
|
||||
cursorVisible: boolean;
|
||||
}> = ({ typed, overlayChars, 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);
|
||||
const charW = TERMINAL_FONT * 0.62;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: TERMINAL_TOP,
|
||||
left: TERMINAL_LEFT,
|
||||
right: TERMINAL_LEFT,
|
||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||
fontSize: TERMINAL_FONT,
|
||||
lineHeight: `${lineH}px`,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<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={{
|
||||
display: 'inline-block',
|
||||
width: charW,
|
||||
height: lineH * 0.82,
|
||||
background: `rgba(224, 224, 224, ${cursorOpacity})`,
|
||||
verticalAlign: 'text-bottom',
|
||||
marginLeft: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Single phone: iPhone 17 Pro + real screenshot + 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 }) => (
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* iOS status bar in the safe area */}
|
||||
<IOSStatusBar />
|
||||
|
||||
{/* Typing animation */}
|
||||
<TypingOverlay typed={typed} overlayChars={overlayChars} cursorVisible={cursorVisible} />
|
||||
|
||||
{/* iOS keyboard at bottom */}
|
||||
{showKeyboard && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 20 }}>
|
||||
<IOSKeyboard activeKey={activeKey} pressAge={pressAge} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</IPhone17ProFrame>
|
||||
);
|
||||
|
||||
// ─── Label beneath phone ───
|
||||
const PhoneLabel: React.FC<{
|
||||
title: string;
|
||||
detail: string;
|
||||
dotColor: string;
|
||||
detailColor: string;
|
||||
}> = ({ title, detail, dotColor, detailColor }) => (
|
||||
<div style={{ textAlign: 'center', marginTop: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
fontFamily: fonts.ui,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: dotColor,
|
||||
boxShadow: `0 0 12px ${dotColor}`,
|
||||
}}
|
||||
/>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontFamily: fonts.mono, color: detailColor, marginTop: 6, opacity: 0.9 }}>
|
||||
{detail}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Title scene ───
|
||||
const TitleScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleScale = spring({ frame, fps, config: { damping: 15, stiffness: 80 } });
|
||||
const titleOpacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' });
|
||||
const subtitleOpacity = interpolate(frame, [15, 35], [0, 1], { extrapolateRight: 'clamp' });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: colors.bg.dark, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<div style={{ textAlign: 'center', transform: `scale(${titleScale})` }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 80,
|
||||
fontWeight: 700,
|
||||
fontFamily: fonts.ui,
|
||||
color: '#fff',
|
||||
opacity: titleOpacity,
|
||||
letterSpacing: -1.5,
|
||||
}}
|
||||
>
|
||||
Zerolag Input
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 30,
|
||||
fontFamily: fonts.ui,
|
||||
color: colors.text.dim,
|
||||
opacity: subtitleOpacity,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
Local echo for remote terminal sessions
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Outro ───
|
||||
const OutroScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: colors.bg.dark, justifyContent: 'center', alignItems: 'center', opacity }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 56, fontWeight: 700, fontFamily: fonts.ui, color: '#fff' }}>Codeman</div>
|
||||
<div style={{ fontSize: 26, fontFamily: fonts.ui, color: colors.accent.green, marginTop: 10 }}>
|
||||
Zero-latency mobile input
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontFamily: fonts.mono, color: colors.text.muted, marginTop: 20 }}>
|
||||
npm i xterm-zerolag-input
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Typing demo scene ───
|
||||
const TypingDemo: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const laggyTyped = computeVisibleText(frame, true);
|
||||
const zerolagTyped = computeVisibleText(frame, false);
|
||||
|
||||
let activeKey: string | undefined;
|
||||
let pressAge = 99;
|
||||
for (let i = TYPING_SCHEDULE.length - 1; i >= 0; i--) {
|
||||
const ev = TYPING_SCHEDULE[i];
|
||||
if (frame >= ev.frame && frame < ev.frame + 5) {
|
||||
activeKey = ev.char;
|
||||
pressAge = frame - ev.frame;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: colors.bg.dark, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 50,
|
||||
alignItems: 'flex-start',
|
||||
transform: `scale(${PHONE_SCALE})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
>
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Phones entrance ───
|
||||
const PanelsEntrance: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const scale = spring({ frame, fps, config: { damping: 15, stiffness: 80 } });
|
||||
const opacity = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: 'clamp' });
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: colors.bg.dark,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
opacity,
|
||||
transform: `scale(${scale * PHONE_SCALE})`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
||||
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
||||
</div>
|
||||
<div>
|
||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
||||
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main composition ───
|
||||
export const ZerolagDemo: React.FC = () => {
|
||||
return (
|
||||
<AbsoluteFill style={{ background: colors.bg.dark }}>
|
||||
<Sequence from={TITLE_START} durationInFrames={TITLE_DUR}>
|
||||
<TitleScene />
|
||||
</Sequence>
|
||||
<Sequence from={PHONES_START} durationInFrames={PHONES_DUR} premountFor={5}>
|
||||
<PanelsEntrance />
|
||||
</Sequence>
|
||||
<Sequence from={TYPING_START} durationInFrames={TYPING_DUR + HOLD_DUR} premountFor={5}>
|
||||
<TypingDemo />
|
||||
</Sequence>
|
||||
<Sequence from={OUTRO_START} durationInFrames={OUTRO_DUR} premountFor={5}>
|
||||
<OutroScene />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user