Compare commits

...
Author SHA1 Message Date
arkon 4295faefc9 chore: version packages 2026-03-14 18:04:51 +01:00
arkon 93e1ba5110 Merge remote-tracking branch 'origin/feat/ws-terminal-io-upstream' 2026-03-14 18:03:36 +01:00
Ark0N abbbf9e90a Merge pull request #43 from Ark0N/feat/ws-tests
test: add WebSocket terminal I/O route tests
2026-03-14 18:03:04 +01:00
Ark0N 3a41de7b57 Merge pull request #42 from Ark0N/feat/ws-heartbeat
feat: add ping/pong heartbeat to WebSocket connections
2026-03-14 18:03:02 +01:00
Ark0N 8267edc6fe Merge pull request #40 from Spirotot/feat/ws-terminal-io-upstream
feat: WebSocket terminal I/O with server-side DEC 2026 sync
2026-03-14 18:02:55 +01:00
arkonandClaude Opus 4.6 78c568e5f7 test: add automated tests for WebSocket terminal I/O route
16 tests covering session-not-found close code, terminal output with
DEC 2026 sync markers, client input forwarding, resize bounds
validation, malformed message handling, and connection cleanup of
session event listeners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:00:55 +01:00
arkonandClaude Opus 4.6 cc624d2575 feat: add ping/pong heartbeat to WebSocket connections
Detect stale connections that TCP keepalive won't catch for minutes,
especially through tunnels and proxies. Pings every 30s with a 10s
pong timeout — if the client doesn't respond, the socket is terminated
and all timers cleaned up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:58:50 +01:00
arkonandClaude Opus 4.6 5844720525 fix: validate WS resize dimensions to match HTTP route bounds
The HTTP resize route validates via ResizeSchema (cols: 1-500, rows:
1-200, integers only). The WS handler only checked typeof === 'number',
allowing floats, negatives, and extreme values through to ptyProcess.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:57:20 +01:00
Aaron FieldsandClaude Opus 4.6 ceaf4624a1 feat: add WebSocket terminal I/O with server-side DEC 2026 sync
Replace per-keystroke HTTP POST + SSE terminal output with a single
bidirectional WebSocket connection for dramatically lower input latency.
The existing SSE+POST paths remain fully functional as fallback.

Server-side: ws-routes.ts provides /ws/sessions/:id/terminal with 8ms
micro-batching and 16KB flush threshold. Each batch is wrapped in
DEC 2026 synchronized update markers so xterm.js renders atomically —
Ink's DA capability negotiation fails through the PTY→server→WS proxy
chain, so without server-injected markers, cursor-up redraws flicker.

Frontend: _connectWs/_disconnectWs manage per-session WS lifecycle.
Input and resize use WS fast path with HTTP POST fallback. SSE terminal
events are suppressed when WS is active to prevent double rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:02:28 -04:00
arkonandClaude Opus 4.6 a6597e4a9a fix: patch 5 dependency vulnerabilities (basic-ftp, fastify, minimatch, serialize-javascript)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:26:56 +01:00
arkon f869e823af chore: version packages 2026-03-12 23:59:16 +01:00
arkonandClaude Opus 4.6 8d0b179f94 fix: repair 15 pre-existing subagent-watcher test failures
Root causes:
- Mock readline (EventEmitter) lacked .close() method, causing TypeError
  that blocked extractDescriptionFromFile's Promise from ever resolving
- Mock stream lacked .destroy() method (same issue after .close() fix)
- Entry-processing tests shared one readline mock between description
  extraction and tailing — events emitted before tailFile started were lost
- Liveness checker marked agents as 'completed' instead of 'idle' because
  fixed stat timestamps became stale after fake timer advancement

Fixes:
- Add createMockRl() helper with .close() method
- Use { destroy: vi.fn() } for stream mocks
- Use mockReturnValueOnce() for two-readline pattern in 7 entry tests
- Use mockImplementation() for dynamic stat timestamps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:08:39 +01:00
arkonandClaude Opus 4.6 98fa55b7b2 chore: codebase cleanup — remove dead code, consolidate imports, extract constants
- Remove 3 unused exported constants (TRIM_MESSAGES_TO, MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS)
- Consolidate 8 direct util imports into barrel imports (./utils/index.js)
- Extract magic number 8191 to FILE_PEEK_BYTES constant in buffer-limits.ts
- Add explanatory comments to 9 undocumented .catch(() => {}) handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:50:40 +01:00
arkonandClaude Opus 4.6 c46ac30631 fix: hide subagent monitor panel by default
Change showSubagents default from true to false so the subagent
panel doesn't auto-show on page load. Users can still enable it
via Settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:35:27 +01:00
arkonandClaude Opus 4.6 dfcc14bfd2 fix: one-liner restart command that works for background processes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:34:42 +01:00
arkonandClaude Opus 4.6 a068008409 fix: clarify restart instructions — stop first, then start
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:33:34 +01:00
arkonandClaude Opus 4.6 0aa31f100e fix: show restart command when codeman-web is not a systemd service
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:31:19 +01:00
arkonandClaude Opus 4.6 314a160458 feat: auto-restart codeman-web service after update if running
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:23:55 +01:00
arkonandClaude Opus 4.6 e7ee5595c5 feat: auto-detect existing install and run update instead of fresh install
Re-running the install script now detects ~/.codeman/app/.git and
automatically updates instead of re-installing. Removes the separate
`bash -s update` instructions from README since it's no longer needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:09:37 +01:00
arkon 625d4976d3 chore: version packages 2026-03-11 19:37:17 +01:00
arkonandClaude Opus 4.6 d02cddece6 fix: correct claudeSessionId for resumed sessions and clean up DEC sync dead code
Use resumeSessionId for Claude conversation ID when resuming sessions,
increase default font size to 14, extract shared history fetch logic,
and remove unused DEC 2026 sync constants/functions (xterm.js 6.0 handles natively).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:36:58 +01:00
Ark0N abbc4b13fd Merge pull request #39 from sunnyzhouy/master
feat: session resume, xterm.js 6.0 upgrade, and resize fix
2026-03-11 19:20:28 +01:00
arkonandClaude Opus 4.6 a14e47e19c chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:13:49 +01:00
sunnyzhouy 754a966b53 Merge branch 'Ark0N:master' into master 2026-03-12 01:06:14 +08:00
zhouyuan 28dfc279d4 fix: resolve terminal resize scrollback ghost renders
- Switch resize handler to 300ms trailing-edge debounce for single reflow
- Add \x1b[3J (Erase Saved Lines) to clear scrollback reflow debris
- Remove client-side cursor-up flicker filter and DEC 2026 marker
  stripping — xterm.js 6.0 handles synchronized output natively
- Remove server-side DEC 2026 wrapping to prevent premature sync exit
  from non-reference-counted nested markers
2026-03-12 01:04:21 +08:00
arkonandClaude Opus 4.6 da85e9738b fix: iPad tablet toolbar styling and PR #34 refinements
- Scope toolbar bottom-offset to phone breakpoint only (position:fixed);
  prevents double-correction on iPad where toolbar is position:relative
- Extract keyboard accessory bar styles to top-level mobile.css so
  /init, /clear, /compact buttons render correctly on iPad
- Use desktop-style toolbar sizing on tablet (430-768px): smaller font,
  no forced min-height, proper gap between buttons
- Show voice/mic button on tablet (was hidden at <1023px with no
  mobile replacement above 430px)
- Bump CSS cache-bust version to 0.1633

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:59:47 +01:00
Ark0N 8b8907c4ec Merge pull request #34 from arnlaugsson/fix/ipad-safari-toolbar-viewport
fix: toolbar off-screen on iPad Safari with tabs
2026-03-11 01:26:55 +01:00
zhouyuan 2329dab240 feat: upgrade xterm.js 5.3 to 6.0 for native DEC 2026 synchronized output
xterm.js 6.0.0 natively handles DEC mode 2026 (synchronized output),
which renders Ink's cursor-up redraws atomically at the parser level.
This eliminates split-frame rendering that caused table header loss
and garbled overlapping text in Claude CLI sessions.

- Migrate from xterm/xterm-addon-* to @xterm/* scoped packages
- Update build.mjs and postinstall.js vendor paths
- Remove old xterm 5.x dependencies
2026-03-10 14:59:26 +08:00
zhouyuan 31ce7405a6 perf: increase terminal scrollback from 5000 to 20000 lines
Long-running Claude sessions can exceed 5000 lines easily, causing
earlier content to be lost. 20000 lines retains ~4x more history.
2026-03-10 14:36:10 +08:00
zhouyuan 06f7d40c42 feat: reduce default font size and persist tabs across refresh
- Default terminal font 14px → 12px, min 10px → 8px
- Save tab metadata to localStorage on every render
- Restore ended sessions as dimmed tabs after page refresh
- Ended tabs show "Session ended" message on click
2026-03-10 14:30:21 +08:00
zhouyuan 05eba70598 feat: improve session resume reliability and persist user settings
- Filter empty sessions from history API (check for conversation content)
- Add --resume fallback to new session if resume fails (prevents dead panes)
- Pass resumeSessionId through respawnPane for dead pane recovery
- Persist respawn presets and runMode to server settings (cross-device sync)
- Fix mobile touch handling for Recent Sessions dropdown (DOM API + touch CSS)
2026-03-10 14:03:42 +08:00
zhouyuan d27974ff6e chore: update package-lock.json 2026-03-10 02:23:15 +08:00
zhouyuan 3cca5380ba fix: route shell sessions to correct endpoint on tab click
selectSession() was always calling /interactive for restored idle
sessions regardless of mode. Shell sessions now correctly call /shell.
Also add loadHistorySessions and resumeHistorySession to frontend.
2026-03-10 02:23:10 +08:00
zhouyuan 6d7efc13e6 feat: add history session resume UI and API
Add GET /api/history/sessions endpoint that scans Claude conversation
files for resume. Add welcome overlay UI with clickable history items.
Path decoding validates existence via fs.access with HOME fallback.
2026-03-10 02:23:02 +08:00
zhouyuan 63f86807ad feat: add resumeSessionId support for conversation resume after reboot
Add resumeSessionId field throughout the session creation pipeline,
allowing sessions to resume previous Claude conversations via --resume
flag instead of --session-id.
2026-03-10 02:22:53 +08:00
Skúli Arnlaugsson 2e4e646c06 fix: toolbar pushed off-screen on iPad Safari with tabs
On iPad Safari with the tab bar visible, `100vh` extends behind the
browser chrome, pushing the fixed-position toolbar out of view.

- Add `viewport-fit=cover` to viewport meta tag
- Use `100dvh` with `100vh` fallback for body/.app height
- Set `--app-height` CSS variable from `visualViewport.height` via JS
- Offset fixed toolbar on iOS Safari using the layout/visual viewport delta
2026-03-08 23:54:15 +00:00
arkon 507423b776 chore: version packages 2026-03-08 16:06:51 +01:00
arkonandClaude Opus 4.6 67d0b0b538 feat: add tunnel status indicator with control panel in header
Green pulsing dot in the desktop header shows when Cloudflare tunnel is active.
Clicking opens a dropdown panel with tunnel URL, remote client count, auth
sessions, and start/stop/QR/revoke controls. Detects tunnel clients via
Cf-Connecting-Ip header to exclude local connections from the count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:06:15 +01:00
Ark0N 26cfd8b7ef Merge pull request #33 from arnlaugsson/fix/macos-install-platform-deps
fix: move Linux-only native deps to optionalDependencies
2026-03-08 15:55:16 +01:00
Skúli ArnlaugssonandClaude Opus 4.6 208e6bc175 fix: move Linux-only native deps to optionalDependencies
`@remotion/compositor-linux-x64-gnu` and `@rspack/binding-linux-x64-gnu`
are Linux x64 binaries that cause npm install to fail on macOS (arm64)
with EBADPLATFORM. Moving them to optionalDependencies allows npm to
skip them gracefully on unsupported platforms.

Fixes #32

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:59:57 +00:00
arkonandClaude Opus 4.6 6d52b16edc docs: add zerolag demo video to README
Side-by-side comparison of local echo (0ms) vs server echo (600ms-2.7s)
rendered from Remotion ZerolagDemo composition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:43:52 +01:00
arkonandClaude Opus 4.6 4988e85901 docs: add Operation Lightspeed to v0.3.7 changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:37:22 +01:00
arkonandClaude Opus 4.6 e799c83b39 chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:34:24 +01:00
arkonandClaude Opus 4.6 0717cfbfec refactor: codebase cleanup — dead code, regex helper, centralized constants, tests
- Remove unused validateTokenCounts/validateTokensAndCost exports and PlanPhase type alias
- Add execPattern() helper to eliminate 8 repetitive .lastIndex=0 + exec() loops
- Centralize 11 magic number constants into config/ai-defaults.ts and config/server-timing.ts
- Remove stale src/tui from tsconfig.json exclude
- Fix CLAUDE.md inaccuracies (session helpers, app.js line count, module count)
- Add 316 new tests: LRUMap (38), StaleExpirationMap (42), BufferAccumulator (33),
  respawn helpers (142), system-routes expansion (11→61)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:33:44 +01:00
arkonandClaude Opus 4.6 b7b2555dc0 test: add Operation Lightspeed tests — tab switching, local echo, SSE filters
14 new tests covering tab switch SSE reconnect, terminal buffer edge cases
(tail=0, negative, non-numeric, huge values), extractSessionId filtering,
session lifecycle churn, and concurrent SSE client limits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:04:27 +01:00
arkonandClaude Opus 4.6 a609c435fa fix: use TERMINAL_TAIL_SIZE constant and add client-drop recovery
Two hardcoded `256 * 1024` tail sizes in app.js bypassed the
TERMINAL_TAIL_SIZE constant (128KB), causing stale cached browsers
to fetch 256KB buffers even after the constant was reduced to prevent
WebGL GPU stalls. Also adds a self-recovery timer that reloads the
terminal buffer after client-side data drops, preventing permanent
display corruption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-07 07:00:18 +01:00
arkonandClaude Opus 4.6 3268a12e5e fix: Operation Lightspeed review fixes — padding, dead code, tablet WebGL
- Add SSE padding to backpressure drain write for tunnel clients
- Remove dead SessionTerminal from broadcast padding check
- Trim whitespace in SSE session filter query params
- Remove unused _bufferLazyTerminalData scaffolding code
- Skip WebGL on tablets too, not just phones (canvas fallback)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:40:09 +01:00
arkonandClaude Opus 4.6 1f30ed445c perf: Operation Lightspeed — 5 parallel performance optimizations
1. Session-scoped SSE subscriptions: server filters events by session ID,
   clients can subscribe via ?sessions=id1,id2 (backwards-compatible)
2. Lazy xterm.js for subagent windows: terminals created on restore,
   disposed on minimize — saves ~3.75MB DOM at 50 agents
3. Targeted badge updates: badge count changes update the <span> directly
   instead of rebuilding the entire session tab sidebar (O(1) vs O(n))
4. Conditional SSE padding: 8KB Cloudflare padding only on session:terminal
   and session:needsRefresh, not every event (~70% bandwidth reduction)
5. Canvas renderer on mobile: skip WebGL addon on mobile devices to reduce
   GPU pressure and prevent context loss on weaker mobile GPUs

All 5 implemented in parallel via isolated git worktrees, merged conflict-free.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:14:16 +01:00
arkonandClaude Opus 4.6 415f02e680 fix: multi-layer backpressure to prevent terminal write freezes
Add three layers of protection against oversized terminal.write() calls
that freeze Chrome's main thread:

1. SSE entry cap: _onSessionTerminal drops data when total queued bytes
   (pendingWrites + flickerFilterBuffer) exceeds 128KB. Server sends
   session:needsRefresh to recover dropped content.

2. Flush cap: flushPendingWrites splits at DEC 2026 sync segment
   boundaries with 64KB per-frame budget. Excess segments deferred to
   next requestAnimationFrame. Segment-level splitting preserves Ink
   redraw atomicity (no flicker).

3. Reduced tail size: initial buffer fetch reduced to 128KB (from 256KB)
   to limit data volume during tab switch.

Re-enable WebGL renderer — root cause was unbounded terminal.write()
volume, not the GPU renderer itself.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-06 17:10:25 +01:00
arkon d5814947d6 chore: version packages 2026-03-05 23:09:24 +01:00
arkonandClaude Opus 4.6 b620511d0e fix: cap terminal writes at 48KB/frame to prevent page unresponsive freezes
Root cause was NOT WebGL — breadcrumbs showed 141KB single-frame
terminal.write() calls freezing Chrome for 2+ minutes even with the
canvas renderer. During heavy Ink output, multiple SSE terminal events
accumulate between animation frames and flush all at once.

Fix: split flushPendingWrites at DEC 2026 sync segment boundaries with
a 48KB per-frame budget. Each segment is a complete Ink redraw, so
splitting between them preserves atomicity (no flicker). Excess
segments are deferred to the next requestAnimationFrame.

Also re-enable WebGL since it was not the cause — the flush cap
protects both renderers equally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 18:36:27 +01:00
arkon 525f02f502 chore: version packages 2026-03-05 17:59:33 +01:00
arkonandClaude Opus 4.6 e07c59477d fix: disable WebGL renderer to prevent Chrome page unresponsive crashes
Root cause: xterm.js WebGL addon performs synchronous GPU ReadPixels
calls during terminal.write(). When heavy terminal output floods in
(~1MB/4s from active Claude sessions), single-frame writes of 70-105KB
block Chrome's main thread for 10+ seconds, triggering "page
unresponsive" dialogs. This happens both during tab switches (buffer
load + live SSE data competing) and during normal use (Ink redraw
bursts).

Fix: disable WebGL by default, use canvas renderer instead. Canvas
handles the same workloads without GPU stalls. Re-enable with ?webgl
URL param for testing.

Also: gate live SSE terminal writes during the entire selectSession()
buffer load sequence (not just during chunkedTerminalWrite), preventing
live data from competing with historical buffer restoration.

Crash investigation data (from server-side breadcrumb collection):
- 27 flushes totaling 937KB in 4 seconds preceded crash
- Two 105KB and one 104KB single-frame flushes observed
- Crash occurred when user switched tabs during output flood
- Tab froze for 2m54s before recovering
- Backend always stable (0 crashes); pure frontend GPU issue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 17:52:28 +01:00
arkonandClaude Opus 4.6 2b8a522cbd fix: add server-side crash breadcrumbs and remove --drop:console from build
The --drop:console esbuild flag was silently stripping all diagnostic
console.* calls from production builds, making crash investigation
impossible. Remove it temporarily for debugging.

Add server-side crash breadcrumb collection:
- Frontend writes breadcrumbs to localStorage AND POSTs to /api/crash-diag
- Server stores latest breadcrumbs in memory, readable via GET /api/crash-diag
- Granular breadcrumbs in selectSession: CACHE_WRITE, FETCH_START,
  FETCH_DONE, REWRITE, FOCUS, SELECT_DONE
- 2s heartbeat beacon so breadcrumbs survive tab freezes
- text/plain content-type parser for navigator.sendBeacon compatibility

Initial findings from breadcrumbs:
- Crash happens during selectSession() for sessions with large buffers
- Pattern: cached buffer exists from prior visit + live SSE data arriving
- Backend is always stable (0 crashes); this is a pure frontend issue

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 16:44:17 +01:00
arkonandClaude Opus 4.6 4abe055182 feat: add frontend crash diagnostics for page unresponsive investigation
Add global error handlers, long task detection, WebGL context tracking,
and performance timing to identify root cause of intermittent Chrome
"page unresponsive" freezes during session switching and typing.

Diagnostics:
- window error/unhandledrejection handlers ([CRASH-DIAG] prefix)
- PerformanceObserver for long tasks (>200ms main thread blocks)
- WebGL context loss/restore tracking on all canvases
- ?nowebgl URL param to disable WebGL renderer for testing
- Timing on flushPendingWrites, chunkedTerminalWrite, selectSession

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 12:42:32 +01:00
arkon 15a3b3996b chore: version packages 2026-03-05 00:28:13 +01:00
arkonandSigurður Guðbrandsson 1b76e6e2e2 fix: prevent Chrome freeze and shell feedback delay from flicker filter
Bug 1: Every incoming SSE terminal event reset the 50ms flush timer, not
just cursor-up events. During active Claude runs the timer never fired,
accumulating MBs in flickerFilterBuffer that froze Chrome on flush.
Fix: only reset timer on cursor-up events; add 256KB safety valve.

Bug 2: Shell sessions emit cursor-up on every keystroke for readline
prompt redraws, triggering the flicker filter and delaying feedback.
Fix: skip cursor-up filter for shell mode; disable local echo overlay.

Based on PR #31 by @SGudbrandsson.

Co-Authored-By: Sigurður Guðbrandsson <SGudbrandsson@users.noreply.github.com>
2026-03-05 00:27:07 +01:00
arkonandClaude Opus 4.6 f8b81b8478 fix: eliminate WebGL re-render flicker during tab switch
Stop toggling WebGL renderer off/on around large buffer writes in
chunkedTerminalWrite(). The dispose+loadAddon cycle caused visible
re-render flashes and (before the deferred fix) synchronous GPU stalls
from ReadPixels blocking the main thread. Instead, keep WebGL active
and rely on 32KB chunked writes to keep per-frame render work under
~5ms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 16:01:16 +01:00
arkonandClaude Opus 4.6 d79e25d5d8 test: comprehensive CJK wide character test plan for PR #30
Covers all 7 test plan items: Chinese overlap prevention, double-width
spacing, Japanese/Korean input, cursor positioning, line wrapping at
column boundaries, ASCII regression, and teammate terminal panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:29:23 +01:00
arkonandClaude Opus 4.6 b1df5319d5 fix: prevent page unresponsive crashes from WebGL GPU stalls during session switch
Disable WebGL renderer during large buffer loads (>32KB) and fall back to
canvas, which handles bulk ANSI writes without synchronous GPU ReadPixels
calls. Re-enable WebGL after the buffer load completes so live terminal
streaming still benefits from GPU acceleration.

Also reduce chunk size from 128KB to 32KB and use chunked writes for
cached buffer restores instead of synchronous terminal.write().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 13:18:02 +01:00
arkonandClaude Opus 4.6 fc92d8a8a4 fix: restore CJK wide character support in ZerolagInput overlay (#30)
Add charCellWidth/stringCellWidth helpers for Unicode-aware width detection,
fix makeLine to use for...of iteration with visual column positioning, and
fix line splitting in _render to use visual column widths instead of string
length. CJK/fullwidth characters now correctly occupy 2 cell widths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:12:30 +01:00
arkonandClaude Opus 4.6 c7cd4f9e17 fix: prevent page unresponsive crashes from WebGL GPU stalls during session switch
Reduce terminal chunk size from 128KB to 32KB and use double-RAF between
chunks to give the WebGL renderer time to flush GPU operations. Also switch
cached buffer restore from direct terminal.write() to chunked writes —
the synchronous 256KB write was blocking the main thread for 5+ seconds
via synchronous ReadPixels calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 13:09:10 +01:00
arkonandClaude Opus 4.6 9433de75b8 chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:17:38 +01:00
arkon 49e9b9e8c9 chore: version packages 2026-03-03 22:56:40 +01:00
Ark0NandClaude Opus 4.6 2ee9ad72e8 refactor: SSE event handlers, LLM context optimization, @fileoverview docs (#29)
* refactor: extract SSE event handlers into named class methods

Replace ~80 inline addListener closures in connectSSE() with a
declarative _SSE_HANDLER_MAP array that drives registration in a
single loop. Each handler is now a named _on* method on CodemanApp,
making them individually addressable for LLM navigation.

Add SSE_EVENTS constant object in constants.js to eliminate magic
event-type strings scattered across the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: fix inaccuracies in CLAUDE.md

- Fix types barrel path: src/types.ts → src/types/index.ts
- Update app.js line count: ~12K → ~11.5K
- Correct route handler counts (113 → 111, per-group fixes)
- Add code style, ESM gotcha, env vars, route test, lifecycle log docs
- Add Node 22 CI note, test teardown timeout, port range

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add mobile screenshots and QR auth security writeup to README

Add 3 mobile screenshots (landing, idle, active) and expand the
mobile section with QR auth security design details and a
touch-optimized interface subsection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: bundle xterm-zerolag-input as vendor IIFE and add pre-commit hook

Build and postinstall now bundle the local xterm-zerolag-input package
as an IIFE at vendor/xterm-zerolag-input.js with global LocalEchoOverlay
shim. Add git pre-commit hook that runs prettier --check on staged .ts
files to catch format issues before CI.

Also bump constants.js and app.js cache-bust versions to 0.3.0 and add
tunnel upload URL display row in settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add cloudflared install support and interactive launch menu

- Add optional cloudflared dependency detection and installation
  across 6 distro families (macOS, Debian, Fedora, Arch, Alpine, SUSE)
- Add tunnel systemd service setup helper
- Replace post-install instructions with interactive launch menu
  (run now / systemd service / skip)
- Uninstall now cleans up both codeman-web and codeman-tunnel services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: gitignore readme-preview.mjs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: WIP — SSE event constants, @fileoverview docs, CLAUDE.md compression

- Migrate broadcast() string literals → SseEvent.* typed constants
- Add @fileoverview with cross-domain references to all 13 type domain files
- Add @fileoverview to frontend JS modules (constants, mobile, voice, etc.)
- Add section dividers to route files for LLM scanability
- Compress CLAUDE.md: flat file list → domain table, fix counts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: optimize codebase for LLM context window efficiency

CLAUDE.md: 456 → 309 lines (32% reduction)
- Merge Commands into compact table, remove redundant bash block
- Convert Security section to dense table format
- Merge Performance + Resource Limits, Debugging + Troubleshooting
- Compress Tunnel, Memory Leak, Scripts, Screenshots sections
- Remove Key Patterns that duplicate @fileoverview in source files

Backend @fileoverview enhancements (10 priority files):
- session.ts: key methods, events, cross-domain refs
- respawn-controller.ts: state machine, idle detection layers
- ralph-tracker.ts: exports, circuit breaker, events
- ralph-loop.ts: lifecycle, persistence, events
- subagent-watcher.ts: watched patterns, teammate detection
- server.ts: coordination list, port interfaces
- state-store.ts: dual-file persistence, migration
- session-manager.ts: lifecycle methods, mutex guard
- hooks-config.ts: hook events list, categories
- sse-events.ts: category breakdown (~90 events, 17 categories)

Frontend app.js: add 6 section dividers, update @fileoverview line refs

Fix: escape glob `*/` in JSDoc that broke ESLint parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR #29 review bugs

- server.ts: replace hardcoded 'session:needsRefresh' with SseEvent constant
- install.sh: fix Alpine cloudflared install for non-root (download to tmpfile first)
- install.sh: replace Arch pacman (AUR-only) with direct binary download
- index.html: bump all 8 remaining cache-bust versions from v0.2.9 to v0.3.0
- mobile-handlers.js: fix @dependency annotation (keyboard-accessory.js, not constants.js)
- types/push.ts: fix layer number (4, not 5)
- subagent-watcher.ts: fix watched pattern path to include {session} segment
- constants.js: fix SSE_EVENTS count in @fileoverview (~73, not ~65)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:40:18 +01:00
arkonandClaude Opus 4.6 14462f7bfe fix: remove hard-coded rollup-linux-x64-gnu dependency
The @rollup/rollup-linux-x64-gnu native binding was a hard dependency,
causing npm install to fail on arm64 and macOS platforms. Nothing in the
codebase uses rollup directly (build uses esbuild); rollup is only
pulled in transitively by @remotion/cli and manages its own
platform-specific bindings via its own optionalDependencies.

Closes #28

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:07:18 +01:00
arkonandClaude Opus 4.6 efa6487361 fix: eliminate SSE padding overhead and debounce subagent window renders
Two performance fixes for browser hanging during active agent work:

1. SSE padding (8KB per event) now only applied when Cloudflare tunnel is
   active — direct/Tailscale connections skip the padding entirely. Previously
   every broadcast event got 8KB of comment padding even on local connections,
   causing 40-160KB/s of wasted bandwidth during active subagent work.

2. Subagent window content renders (tool_call, progress, message, tool_result)
   now debounced at 100ms per agent via scheduleSubagentWindowRender(). Previously
   each SSE event triggered an immediate DOM rewrite, causing 10-30+ rewrites/sec
   that starved the terminal rendering pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:48:31 +01:00
arkonandClaude Opus 4.6 f7552c7cb5 style: fix prettier formatting in server.ts
Expand one-liner try/catch to multi-line to satisfy prettier check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:48:14 +01:00
arkonandClaude Opus 4.6 2b61a8db1b fix: add SSE padding to flush Cloudflare tunnel buffers for real-time events
Cloudflare quick tunnels buffer small SSE responses, causing tab creation
and other UI events to arrive late on mobile. Adds ~8KB SSE comment padding
(ignored by EventSource) to force the proxy to flush immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:24:14 +01:00
arkonandClaude Opus 4.6 8764a684ac fix: replace require('qrcode') with await import() for ESM compatibility
require() is not defined in ESM modules. The production build (tsc)
outputs ESM, causing 'require is not defined' → 500 → 'QR unavailable'.
Vitest/tsx shimmed require() so tests passed but production was broken.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:04:23 +01:00
arkonandClaude Opus 4.6 211abe60ca test: add 8 integration tests for GET /api/tunnel/qr SVG endpoint
The QR SVG endpoint had zero test coverage for success paths — only
the 404 (tunnel not running) case was tested. This adds tests for
auth/no-auth SVG generation, the 500 error when token rotation isn't
started, SVG caching consistency, and cache invalidation on regeneration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:57:46 +01:00
arkonandClaude Opus 4.6 888a2d8e9a chore: move manual test scripts to test/manual/
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:38:45 +01:00
arkon 87abc0d301 fix: release tag rename — use databaseId and rename before tag deletion 2026-03-01 17:36:54 +01:00
136 changed files with 16456 additions and 4450 deletions
+7 -7
View File
@@ -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
+1
View File
@@ -60,3 +60,4 @@ media-assets/
commands
todo.md
@fix_plan.md
readme-preview.mjs
+110
View File
@@ -1,5 +1,115 @@
# aicodeman
## 0.3.12
### Patch Changes
- Add WebSocket terminal I/O with server-side DEC 2026 synchronized update markers. Replaces per-keystroke HTTP POST + SSE terminal output with a single bidirectional WebSocket connection for dramatically lower input latency. Server-side 8ms micro-batching with 16KB flush threshold groups rapid PTY events into single WS frames wrapped in DEC 2026 markers for flicker-free atomic rendering. Includes 30s ping/pong heartbeat with 10s timeout for stale connection detection through tunnels. Existing SSE + HTTP POST paths remain fully functional as transparent fallback. Resize messages validated to match HTTP route bounds (cols 1-500, rows 1-200, integers only). 16 automated route tests added for WS endpoint. Also patches 5 dependency vulnerabilities (basic-ftp, fastify, minimatch, serialize-javascript).
## 0.3.11
### Patch Changes
- ### Session Resume & History
- Add `resumeSessionId` support for conversation resume after reboot
- Add history session resume UI and API with route shell sessions routing fix
- Improve session resume reliability and persist user settings across refresh
- Correct `claudeSessionId` for resumed sessions
### Terminal & Frontend
- Upgrade xterm.js 5.3 → 6.0 with native DEC 2026 synchronized output
- Increase terminal scrollback from 5,000 to 20,000 lines
- Reduce default font size and persist tab state across refresh
- Resolve terminal resize scrollback ghost renders
- Hide subagent monitor panel by default
### Installer
- Auto-detect existing install and run update instead of fresh install
- Auto-restart codeman-web service after update if running
- Show restart command when codeman-web is not a systemd service
- Fix one-liner restart command for background processes
### Codebase Quality
- Remove dead code, consolidate imports, extract constants
- Repair 15 pre-existing subagent-watcher test failures
- Clean up DEC sync dead code
## 0.3.10
### Patch Changes
- - feat: upgrade xterm.js from 5.3 to 6.0 with native DEC 2026 synchronized output support
- feat: add history session resume UI and API — resume Claude conversations after reboot
- feat: add resumeSessionId support for conversation resume across session restarts
- feat: persist active tabs across page refresh
- feat: improve session resume reliability and persist user settings
- perf: increase terminal scrollback from 5,000 to 20,000 lines
- fix: resolve terminal resize scrollback ghost renders
- fix: route shell sessions to correct endpoint on tab click
- fix: correct claudeSessionId for resumed sessions (use original Claude conversation ID)
- fix: increase default desktop font size from 12 to 14
- refactor: extract shared \_fetchHistorySessions() method to eliminate duplication
- refactor: remove dead DEC 2026 sync code (extractSyncSegments, DEC_SYNC_START/END constants)
## 0.3.9
### Patch Changes
- Add content-hash cache busting for static assets — build step now renames JS/CSS files with MD5 content hashes (e.g. app.js → app.94b71235.js) and rewrites index.html references. HTML served with Cache-Control: no-cache so browsers always revalidate and pick up new hashed filenames after deploys. Hashed assets keep immutable 1-year cache. Eliminates the need for manual hard refresh (Ctrl+Shift+R) after deployments.
Refactor path traversal validation into shared validatePathWithinBase() helper in route-helpers.ts, replacing 6 duplicate inline checks across case-routes, plan-routes, and session-routes.
Deduplicate stripAnsi in bash-tool-parser.ts — use shared utility from utils/index.ts instead of private method.
## 0.3.8
### Patch Changes
- Add tunnel status indicator with control panel — green pulsing dot in header when Cloudflare tunnel is active, dropdown with URL, remote clients, auth sessions, and start/stop/QR/revoke controls
## 0.3.7
### Patch Changes
- Operation Lightspeed: 5 parallel performance optimizations — multi-layer backpressure to prevent terminal write freezes, TERMINAL_TAIL_SIZE constant with client-drop recovery, tab switching SSE gating, and local echo improvements
- Codebase cleanup: remove dead code (unused token validation exports, PlanPhase alias), add execPattern() regex helper to eliminate repetitive .lastIndex resets, centralize 11 magic number constants into config files, fix CLAUDE.md inaccuracies, and add 316 new tests for utilities, respawn helpers, and system-routes
## 0.3.6
### Patch Changes
- Re-enable WebGL renderer with 48KB/frame flush cap protection against GPU stalls
## 0.3.5
### Patch Changes
- Fix Chrome "page unresponsive" crashes caused by xterm.js WebGL renderer GPU stalls during heavy terminal output. Disable WebGL by default (canvas renderer used instead), gate SSE terminal writes during tab switches, and add crash diagnostics with server-side breadcrumb collection.
## 0.3.4
### Patch Changes
- Fix Chrome tab freeze from flicker filter buffer accumulation during active sessions, and fix shell mode feedback delay by excluding shell sessions from cursor-up filter
## 0.3.3
### Patch Changes
- 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
+96 -447
View File
@@ -44,7 +44,7 @@ When user says "COM":
"aicodeman": patch
---
Description of changes
Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag)
CHANGESET
```
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
@@ -52,7 +52,7 @@ When user says "COM":
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.3.0 (must match `package.json`)
**Version**: 0.3.12 (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 flat config (`eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `tools/**`, `remotion/**`.
## Common Gotchas
- **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`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts` | |
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | |
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
| **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` (12 route modules + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` ★ (~12.5K lines) + 9 JS modules (incl. `sw.js` service worker) | |
| **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts |
**Large 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,117 @@ 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 12 route files in `src/web/routes/`: system (35), sessions (24), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1). Each file has `@fileoverview` with endpoint details.
## Adding Features
- **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 =`). Route tests use `app.inject()` (no port needed) — 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`.
**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.
+31 -9
View File
@@ -35,11 +35,6 @@ codeman web
# Open http://localhost:3000 — press Ctrl+Enter to start your first session
```
**Update to latest version:**
```bash
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash -s update
```
<details>
<summary><strong>Run as a background service</strong></summary>
@@ -68,11 +63,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 +89,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
@@ -120,6 +138,10 @@ Watch background agents work in real-time. Codeman monitors agent activity and d
## Zero-Lag Input Overlay
<p align="center">
<img src="docs/images/zerolag-demo.gif" alt="Zerolag Demo — local echo vs server echo side-by-side" width="900">
</p>
When accessing your coding agent remotely (VPN, Tailscale, SSH tunnel), every keystroke normally takes 200-300ms to round-trip. Codeman implements a **Mosh-inspired local echo system** that makes typing feel instant regardless of latency.
A pixel-perfect DOM overlay inside xterm.js renders keystrokes at 0ms. Background forwarding silently sends every character to the PTY in 50ms debounced batches, so Tab completion, `Ctrl+R` history search, and all shell features work normally. When the server echo arrives 200-300ms later, the overlay seamlessly disappears and the real terminal text takes over — the transition is invisible.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

+423
View File
@@ -0,0 +1,423 @@
# Performance Analysis & Optimization Opportunities
**Date**: 2026-03-07
**Scope**: Full-stack performance analysis — backend PTY handling, SSE broadcasting, frontend terminal rendering, local echo overlay, DOM updates, config/scaling limits.
**Constraint**: All recommendations preserve existing functionality including local echo, backpressure, anti-flicker pipeline, and mobile support.
---
## Executive Summary
The codebase is already well-optimized in critical paths. The multi-layer backpressure system, adaptive terminal batching, DEC 2026 sync markers, and incremental state serialization are strong. The main opportunities are in **reducing unnecessary work** (SSE filtering, DOM rebuilds, lazy terminal init) rather than algorithmic changes.
**Top 5 high-impact opportunities:**
| # | Optimization | Impact | Risk | Effort |
|---|-------------|--------|------|--------|
| 1 | Session-scoped SSE subscriptions | Bandwidth -60-80%, CPU -40% | Medium | Medium |
| 2 | Lazy xterm.js for minimized subagent windows | Memory -3.5MB at 50 agents | Low | Low |
| 3 | Targeted badge update (skip full tab rebuild) | Eliminates O(n) reflow on badge change | Low | Low |
| 4 | Conditional SSE padding (tunnel-only, terminal-only) | Bandwidth -70% when tunneled | Low | Low |
| 5 | Canvas renderer on mobile | GPU pressure reduction, battery savings | Low | Low |
---
## 1. SSE Broadcasting
### Current State
- **92 event types** broadcast to all connected clients (max 100)
- Single `JSON.stringify()` per event, shared across all clients (efficient)
- **No per-client filtering** — every client receives every event regardless of which session they're viewing
- 8KB padding appended to **every** event when tunnel is active (forces Cloudflare proxy flush)
- Backpressure: clients marked as backpressured if `reply.raw.write()` returns false; recovery via `session:needsRefresh`
### Bottlenecks
**B1: No session-scoped SSE subscriptions** (`server.ts:1986`)
- Client viewing session A still receives all events for sessions B through T
- With 20 active sessions, ~95% of terminal events are irrelevant to any given client
- Cost: wasted bandwidth, CPU for JSON parsing, and event handler dispatch on client
**B2: Unconditional 8KB padding** (`server.ts:1977`)
- Every event gets 8KB comment padding when tunnel is active
- A `task:updated` event (~200 bytes payload) becomes ~8.2KB
- High-frequency events like `session:terminal` need the padding; low-frequency events like `session:created` don't
### Recommendations
**R1: Session-scoped SSE subscriptions** (High impact)
- Add `?sessions=id1,id2` query param to `/api/events` SSE endpoint
- Server filters events by session ID before broadcasting
- Client subscribes to active session + "global" events (session lifecycle, system)
- Re-subscribes on tab switch (or subscribe to all with client-side filter as fallback)
- **Savings**: ~80% bandwidth reduction for single-session viewers; ~60% for multi-session dashboards
**R2: Tiered SSE padding** (Medium impact)
- Only pad `session:terminal` events and SSE heartbeats (the two that need proxy flush)
- Skip padding for low-frequency structural events (`session:created`, `task:updated`, etc.)
- **Savings**: ~70% padding overhead reduction; terminal events already large enough to flush
---
## 2. Terminal Rendering
### Current State (Well-Optimized)
- **6-layer anti-flicker pipeline**: Server batching (adaptive 16-50ms) → DEC 2026 sync wrap → single JSON serialize → client rAF batching → sync segment parser → chunked buffer loading (32KB/frame)
- **64KB/frame write budget** with DEC 2026 sync-segment awareness (prevents 141KB single-frame freezes)
- **3-layer backpressure**: SSE cap (128KB queued → drop + refresh), frame budget (64KB/frame), chunked restore (32KB/frame)
- WebGL renderer enabled by default with canvas fallback on context loss
- Typical latency: 16-32ms; worst case: ~115ms (50ms server batch + 50ms sync wait + 16ms rAF)
### Bottlenecks
**B3: WebGL on mobile** (`app.js:627-637`)
- Mobile GPUs are weaker; WebGL context loss more likely on low-end devices
- Canvas renderer is sufficient for mobile (typically 1 session, smaller viewport)
**B4: Static scrollback for all sessions** (`app.js:572`)
- Default 5000 lines scrollback for all sessions regardless of activity level
- Heavy output sessions (build logs, test runners) accumulate large scroll buffers
**B5: No addon lazy loading**
- FitAddon, Unicode11Addon, and WebGLAddon all loaded at terminal init
- Unicode11Addon only needed for CJK content; WebGLAddon is large
### Recommendations
**R3: Force canvas renderer on mobile** (Low risk)
- Detect `MobileDetection.isMobile()` and skip WebGL addon loading
- Reduces GPU memory pressure, prevents context loss crashes
- Mobile typically has 1-2 sessions — canvas performance is more than adequate
**R4: Dynamic scrollback based on session activity** (Low risk)
- Active sessions (working state): 5000 lines (current default)
- Inactive/idle sessions: reduce to 2000 lines
- Restore on session select (fetch from server buffer)
- **Savings**: ~60% scrollback memory for idle sessions
**R5: Lazy-load Unicode11Addon** (Low risk)
- Only load when CJK content is detected in terminal output
- Detection: check for characters in CJK Unicode ranges during ANSI stripping (already iterating)
- Most sessions never need it
---
## 3. DOM & Session Tab Rendering
### Current State
- Session tabs use **intelligent incremental updates** with debounced 100ms rendering
- Incremental path: only updates changed properties (classes, textContent, badges) when session list is stable
- Full rebuild path: triggered when sessions added/removed **or badge count changes**
- Subagent windows: per-window xterm.js instances, even when minimized
### Bottlenecks
**B6: Badge count change triggers full tab rebuild** (`app.js:3207-3209`)
- A single subagent badge increment on one tab triggers `_fullRenderSessionTabs()` — rebuilds entire sidebar HTML via `innerHTML =`
- With 20 sessions, this is an O(n) reflow for a single badge number change
- Badge changes are frequent during active subagent work
**B7: Minimized subagent windows retain xterm.js instances** (`subagent-windows.js`)
- 50 subagent windows × ~75KB per xterm.js instance = ~3.75MB DOM memory
- Minimized windows are invisible but their terminals remain in DOM
- xterm.js instances continue processing resize events even when hidden
**B8: `backdrop-filter: blur()` on overlays** (`styles.css:2246-2247, 3098`)
- Forces new stacking context, disables browser compositing optimizations
- 50-100ms layout thrashing on modal open/close
- Only 2 uses, but they're on frequently toggled overlays
### Recommendations
**R6: Targeted badge update without full rebuild** (Low risk)
- When badge count changes but session list is stable, update only the badge `<span>` textContent
- Keep incremental path for badge changes; only use full rebuild for structural changes (add/remove sessions)
- **Savings**: Eliminates O(n) reflow per badge change; reduces to O(1) targeted update
**R7: Lazy xterm.js initialization for subagent windows** (Medium impact)
- Only create xterm.js Terminal instance when window is restored/maximized
- On minimize: serialize terminal buffer, dispose Terminal instance, keep buffer in memory
- On restore: create new Terminal, write buffer back
- **Savings**: ~3.5MB DOM reduction at 50 minimized agents; eliminates hidden resize processing
- **Trade-off**: ~200-500ms restore delay (buffer write), mitigated by chunked loading
**R8: Replace `backdrop-filter: blur()` with `background: rgba()`** (Low risk)
- Use semi-transparent background instead of blur effect
- Or use `will-change: transform` hint if blur is kept
- **Savings**: Eliminates forced recomposition layer; 50-100ms faster overlay open
---
## 4. Backend PTY & State Management
### Current State (Excellent)
- **BufferAccumulator**: Array-based chunking with lazy join on read — avoids O(n) string concatenation
- **ANSI stripping**: Throttled at 150ms intervals with lazy evaluation (not per-chunk)
- **State persistence**: 500ms debounce + incremental JSON caching per session (only dirty sessions re-serialized)
- **Expensive parsers**: Throttled to 150ms window, accumulated data capped at 64KB
- **Memory**: All buffers have hard limits (2MB terminal, 1MB text, 1000 messages, 64KB line buffer)
### Bottlenecks
**B9: Pending clean data cap at 64KB** (`session.ts:1097-1133`)
- Between 150ms processing windows, raw PTY data accumulates in `_pendingCleanData`
- Capped at 64KB — excess data rolls off (old data discarded)
- During heavy output (large build logs), this means parsers may miss content
- Acceptable trade-off for performance, but worth documenting
**B10: `LRUMap.delete()` is O(n) worst case** (`utils/lru-map.ts:137-138`)
- When deleting the newest entry, iterates all keys to find new newest
- Rare in practice (delete is uncommon; set/get are hot paths)
- Could matter during mass cleanup of 500 agents
### Recommendations
**R9: Consider adaptive pending data cap** (Low priority)
- During idle detection (critical to get right), increase cap to 128KB
- During active working state, keep at 64KB (parsers less critical)
- **Benefit**: More accurate idle detection during heavy output
**R10: Track second-newest in LRUMap** (Low priority)
- Maintain a `_secondNewestKey` alongside `_newestKey`
- On delete of newest, promote second-newest without iteration
- Only matters at scale (500+ agents with frequent eviction)
---
## 5. Local Echo & Input Path
### Current State (Well-Designed)
- **DOM overlay approach** — `<span>` elements in `.xterm-screen` at z-index 7, completely independent of `terminal.write()`
- **Render caching**: `_lastRenderKey` includes text, position, column offsets — skips redundant re-renders
- **Input flow**: Char accumulation → Enter triggers flush → 80ms delay before `\r` (ensures text reaches PTY first)
- **Tab completion**: Baseline snapshot → detect buffer change → 300ms fallback timer
- **CJK support**: Per-character width detection with `terminal.unicode.getStringCellWidth()` preferred, manual fallback
- **Prompt detection**: Bottom-up line scan, O(rows) — cached position, column-lock prevents jitter
### Bottlenecks
**B11: tmux send-keys latency** (~50-100ms per input)
- Each `writeViaMux()` spawns a child process (`tmux send-keys`)
- Text and Enter sent separately with 50ms delay between
- For rapid typing: characters batch before Enter, so overhead is per-command not per-keystroke
- **Acceptable trade-off** for session persistence (tmux survives server restarts)
**B12: 80ms delay between text flush and Enter** (`app.js:872-875`)
- Intentional: ensures text reaches PTY before Enter, preventing Ink from processing empty input
- Adds 80ms to perceived Enter-to-response latency
- Could potentially be reduced with acknowledgment-based approach
**B13: Scroll listener on terminal viewport** (`zerolag-input-addon.ts:139`)
- 50ms debounced re-render on scroll — acceptable but fires frequently during heavy output
- Overlay hidden when scrolled up (correct behavior), shown when at bottom
### Recommendations
**R11: Reduce Enter delay from 80ms to 50ms** (Low risk, test carefully)
- The tmux `send-keys` already has 50ms internal delay
- Combined with network latency, 80ms client-side may be excessive
- Test with Ink-heavy sessions (Claude Code's status bar) — if text arrives before Enter at 50ms, reduce
- **Savings**: 30ms perceived latency reduction per command
**R12: Batch tmux send-keys via stdin pipe** (Medium effort, high impact for rapid input)
- Instead of spawning `tmux send-keys` per input, maintain a persistent connection
- Use `tmux -C` (control mode) for programmatic interaction without child process spawning
- **Savings**: Eliminate ~50-100ms process spawn overhead per input
- **Risk**: Control mode has different semantics; needs careful testing with session persistence
**R13: Skip overlay re-render during heavy output scroll** (Low risk)
- When terminal is receiving >10KB/s output, hide overlay entirely (user isn't typing during heavy output)
- Re-show overlay after 500ms of output silence
- **Savings**: Eliminates unnecessary DOM overlay re-renders during build logs / test output
---
## 6. Polling & File Watchers
### Current State
- **SubagentWatcher**: 1s base poll, full scan throttled to every 5s, fs.watch() on known directories
- **TranscriptWatcher**: 1 per session, fs.watch() primary with 1s poll fallback
- **ImageWatcher**: chokidar per session with 100ms stability poll, burst limit 20/10s
- **TeamWatcher**: chokidar primary with 30s poll fallback, LRU caches (50 teams, 200 tasks)
- **RalphTracker**: Todo cleanup every 5 minutes
### Scaling Profile (20 sessions)
| Component | Instances | Frequency | Total ops/sec |
|-----------|-----------|-----------|---------------|
| SubagentWatcher | 1 (global) | Full scan every 5s | 0.2/s |
| TranscriptWatcher | 20 | 1s poll (fallback) | 20/s max |
| ImageWatcher | 20 | 100ms poll (during writes only) | 200/s burst |
| TeamWatcher | 1 (global) | 30s poll (fallback) | 0.03/s |
| SSE heartbeat | 1 (global) | 15s | 0.07/s |
| SSE dead client check | 1 (global) | 30s | 0.03/s |
| Mux stats collection | 1 (global) | 2s | 0.5/s |
| **Total steady-state** | | | **~21/s** |
### Recommendations
**R14: Increase TranscriptWatcher poll interval to 2s** (Low risk)
- Transcript changes are infrequent (new messages every few seconds at most)
- fs.watch() is the primary mechanism; polling is fallback
- **Savings**: Halves fallback filesystem checks (20/s → 10/s for 20 sessions)
**R15: Share chokidar instances for co-located session directories** (Medium effort)
- Sessions in the same parent directory could share a single chokidar watcher with depth:3
- Common case: multiple sessions in `~/projects/foo/` — one watcher covers all
- **Savings**: Reduce chokidar instances from 20 to ~5-10 for typical workloads
---
## 7. Frontend Asset Delivery
### Current State
- **app.js**: 12,027 lines (source) → esbuild minified → gzip/brotli compressed (~30-40KB gzipped)
- **Static caching**: `maxAge: '1y'` via `@fastify/static`
- **Service worker**: Push notification handler only — no asset caching
- **No code splitting**: Single monolithic app.js bundle
### Bottlenecks
**B14: No cache-busting mechanism**
- `maxAge: '1y'` means browsers cache aggressively
- After deployment, users need `Ctrl+Shift+R` to see updates
- No content hash in filenames or ETags for automatic invalidation
**B15: Monolithic app.js**
- All 12K lines loaded on initial page load regardless of which features are used
- Ralph wizard, plan orchestrator UI, team management — all loaded upfront
- Mobile loads the same bundle as desktop
### Recommendations
**R16: Add content hash to asset filenames** (Medium impact)
- Build step: rename `app.js` → `app.[hash].js`
- Generate a manifest or inject hash into HTML template
- Keep `maxAge: '1y'` — cache invalidation happens via filename change
- **Savings**: Eliminates stale cache issues after deployment; removes need for manual hard refresh
**R17: Code-split app.js into core + feature modules** (High effort, medium impact)
- Core (~4K lines): terminal, SSE, session management, tabs, input handling
- Deferred (~8K lines): Ralph wizard, plan UI, team management, subagent windows, image viewer
- Load deferred modules on first use via dynamic `import()` or lazy `<script>` injection
- **Savings**: ~60% reduction in initial load size; faster time-to-interactive
- **Risk**: Complexity increase; need to handle loading states for deferred features
- **Note**: May not be worth the effort given the app is already gzipped to ~30-40KB
---
## 8. CSS Performance
### Current State
- **styles.css**: 7,153 lines with ~45 box-shadow uses, 2 backdrop-filter uses
- Animations: GPU-accelerated keyframes for pulsing alerts, loading spinners
- Z-index layering: well-organized (subagent 1000, plan 1100, log 2000, image 3000, overlay 7)
### Recommendations
**R18: Replace backdrop-filter with opaque overlay** (Low risk, covered in R8)
**R19: Use `contain: content` on subagent windows** (Low risk)
- Add CSS containment to subagent window containers
- Prevents layout changes inside windows from triggering reflow on parent
- Especially valuable with 50 windows: changes in one window won't invalidate others
- ```css
.subagent-window { contain: content; }
```
- **Savings**: Reduces layout recalculation scope from global to per-window
**R20: Use `content-visibility: auto` on off-screen subagent windows** (Low risk)
- Browser skips rendering of off-screen windows entirely
- Combined with `contain-intrinsic-size` to prevent layout shift
- ```css
.subagent-window.minimized { content-visibility: hidden; }
```
- **Savings**: Browser skips paint/layout for minimized windows; complements R7
---
## 9. Memory & Scaling Limits
### Current Budget (20 sessions)
| Component | Per Session | Total | Status |
|-----------|-----------|-------|--------|
| Terminal buffer | 2MB | 40MB | Hard-limited, auto-trim |
| Text output | 1MB | 20MB | Hard-limited, auto-trim |
| Messages | ~1MB | 20MB | Capped at 1000, trims to 800 |
| Respawn buffer | 1MB | 20MB | Hard-limited |
| **Buffers total** | | **100MB** | Acceptable |
| TranscriptWatcher | ~100KB | 2MB | |
| ImageWatcher | ~50KB | 1MB | |
| SubagentWatcher | ~500KB | 500KB | Global |
| Frontend terminal cache | ~256KB | 5MB | LRU, max 20 entries |
| **Total estimated** | | **~110MB** | Comfortable |
### At Max Scale (50 sessions)
- Buffers: ~250MB
- Watchers: ~5MB
- **Total: ~255MB** + Node.js overhead — acceptable on modern hardware
### Potential Leak Vectors (All Mitigated)
- `_shortIdCache` in server — unbounded Map, but entries are tiny (string→string); grows at O(sessions created), not O(events)
- All CleanupManager-registered resources tracked and disposed on session stop
- `isStopped` guard prevents new timers after session cleanup
---
## 10. Implementation Priority Matrix
### Phase 1 — Quick Wins (1-2 hours each, low risk)
| # | Optimization | Files to Change |
|---|-------------|-----------------|
| R6 | Targeted badge update | `app.js` (3207-3209) |
| R3 | Canvas renderer on mobile | `app.js` (627-637) |
| R8 | Replace backdrop-filter blur | `styles.css` (2246, 3098) |
| R19 | CSS containment on subagent windows | `styles.css` |
| R20 | `content-visibility: hidden` on minimized windows | `styles.css` |
### Phase 2 — Medium Effort (half-day each)
| # | Optimization | Files to Change |
|---|-------------|-----------------|
| R2 | Tiered SSE padding | `server.ts` (broadcast function) |
| R7 | Lazy xterm.js for minimized subagents | `subagent-windows.js` |
| R11 | Reduce Enter delay to 50ms | `app.js` (872-875), test with Ink |
| R14 | TranscriptWatcher 2s poll | `transcript-watcher.ts` |
| R16 | Content-hash asset filenames | `build.mjs`, `server.ts` |
### Phase 3 — Larger Initiatives (1-2 days each)
| # | Optimization | Files to Change |
|---|-------------|-----------------|
| R1 | Session-scoped SSE subscriptions | `server.ts`, `app.js` (SSE connect) |
| R5 | Lazy Unicode11Addon loading | `app.js`, build pipeline |
| R12 | Persistent tmux control mode | `tmux-manager.ts` |
| R17 | Code-split app.js | `app.js`, `build.mjs`, HTML template |
### Not Recommended (Low ROI or High Risk)
| # | Why Not |
|---|---------|
| R4 | Dynamic scrollback adds complexity; memory savings marginal vs total budget |
| R9 | Adaptive pending data cap adds state; current 64KB cap rarely matters |
| R10 | LRUMap.delete() O(n) is theoretical; never triggered at current scale |
| R15 | Shared chokidar instances add directory-matching complexity for minimal gain |
---
## Appendix: Key File Locations
| Area | File | Key Lines |
|------|------|-----------|
| SSE broadcast | `src/web/server.ts` | 1961-1989 (broadcast), 1934-1959 (backpressure) |
| Terminal batching | `src/web/server.ts` | 1994-2048 (per-session adaptive batching) |
| Frame budget | `src/web/public/app.js` | 1370-1478 (flushPendingWrites, 64KB cap) |
| Flicker filter | `src/web/public/app.js` | 1176-1255 (50ms sync wait, 256KB safety) |
| Tab rendering | `src/web/public/app.js` | 3108-3357 (incremental + full rebuild) |
| Tab switching | `src/web/public/app.js` | 3560-3760 (cache + chunked load + deferred UI) |
| Local echo | `packages/xterm-zerolag-input/src/` | All files (overlay, prompt, CJK) |
| Local echo integration | `src/web/public/app.js` | 640, 815-988 (input flow) |
| Subagent windows | `src/web/public/subagent-windows.js` | Full file (window mgmt, drag, minimize) |
| State persistence | `src/state-store.ts` | 161-250 (debounced save, incremental JSON) |
| Buffer accumulator | `src/utils/buffer-accumulator.ts` | Full file (array chunks, lazy join) |
| PTY handling | `src/session.ts` | 1046-1133 (data flow), 1173-1230 (parsing) |
| Config limits | `src/config/` | 9 files (buffer, map, timing, auth, etc.) |
| Anti-flicker docs | `docs/terminal-anti-flicker.md` | Architecture reference |
| CSS | `src/web/public/styles.css` | 2246 (backdrop-filter), full file |
| Build pipeline | `scripts/build.mjs` | 59-68 (minify + compress) |
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

+263 -48
View File
@@ -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
}
@@ -1086,7 +1283,16 @@ update() {
npm run build --quiet 2>/dev/null || npm run build
success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo ""
echo -e " ${DIM}Restart codeman web to use the new version.${NC}"
# Auto-restart systemd service if it's running, otherwise tell the user
if systemctl --user is-active codeman-web.service &>/dev/null; then
info "Restarting codeman-web service..."
systemctl --user restart codeman-web.service
success "codeman-web service restarted"
else
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
fi
echo ""
}
@@ -1095,21 +1301,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"
@@ -1156,5 +1364,12 @@ uninstall() {
case "${1:-}" in
update) update ;;
uninstall) uninstall ;;
*) main "$@" ;;
*)
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" ]]; then
print_banner
update
else
main "$@"
fi
;;
esac
+415 -258
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.3.0",
"version": "0.3.12",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -51,6 +51,11 @@
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.0",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"chalk": "^5.3.0",
"chokidar": "^3.6.0",
"commander": "^12.1.0",
@@ -59,10 +64,6 @@
"qrcode": "^1.5.4",
"uuid": "^10.0.0",
"web-push": "^3.6.7",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-unicode11": "^0.6.0",
"xterm-addon-webgl": "^0.16.0",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -72,9 +73,11 @@
"@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",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.18",
"agent-browser": "^0.6.0",
"esbuild": "^0.27.3",
@@ -90,6 +93,10 @@
"typescript-eslint": "^8.0.0",
"vitest": "^4.0.18"
},
"optionalDependencies": {
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
"@rspack/binding-linux-x64-gnu": "^1.7.7"
},
"engines": {
"node": ">=18.0.0"
},
@@ -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;
}
+114 -105
View File
@@ -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
Symlink
+1
View File
@@ -0,0 +1 @@
tools/remotion/public
+73 -11
View File
@@ -8,12 +8,15 @@
* 2. Copy static assets (web/public, templates)
* 3. Build vendor xterm bundles
* 4. Minify frontend assets (app.js, styles.css, mobile.css)
* 5. Compress with gzip + brotli
* 5. Content-hash cache busting (rename assets, rewrite index.html)
* 6. Compress with gzip + brotli
*/
import { execSync } from 'child_process';
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
import { createHash } from 'crypto';
import { fileURLToPath } from 'url';
import { join } from 'path';
import { join, extname, basename, dirname } from 'path';
const ROOT = join(fileURLToPath(import.meta.url), '..', '..');
@@ -26,24 +29,83 @@ function run(label, cmd) {
run('tsc', 'tsc');
run('chmod dist/index.js', 'chmod +x dist/index.js');
// 2. Copy static assets
// 2. Copy static assets (clean first to remove stale hashed files from previous builds)
run('clean public', 'rm -rf dist/web/public');
run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
run('copy web assets', 'cp -r src/web/public dist/web/');
run('copy template', 'cp src/templates/case-template.md dist/templates/');
// 3. Vendor xterm bundles
run('xterm css', 'cp node_modules/xterm/css/xterm.css dist/web/public/vendor/');
run('xterm js', 'npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
run('xterm-addon-fit', 'npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
run('xterm-addon-webgl', 'cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
// 3. Vendor xterm bundles (xterm.js 6.x — @xterm scoped packages)
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
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');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite');
run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite');
// 5. Compress with gzip + brotli
// 5. Content-hash cache busting
console.log('\n[build] content-hash cache busting');
{
const distPublic = join(ROOT, 'dist/web/public');
const HASHABLE = [
'styles.css',
'mobile.css',
'constants.js',
'mobile-handlers.js',
'voice-input.js',
'notification-manager.js',
'keyboard-accessory.js',
'app.js',
'ralph-wizard.js',
'api-client.js',
'subagent-windows.js',
'vendor/xterm-zerolag-input.js',
];
const manifest = {};
for (const file of HASHABLE) {
const filePath = join(distPublic, file);
const content = readFileSync(filePath);
const hash = createHash('md5').update(content).digest('hex').slice(0, 8);
const ext = extname(file);
const base = basename(file, ext);
const dir = dirname(file);
const hashed = dir === '.' ? `${base}.${hash}${ext}` : `${dir}/${base}.${hash}${ext}`;
renameSync(filePath, join(distPublic, hashed));
manifest[file] = hashed;
}
// Rewrite index.html to reference hashed filenames
let html = readFileSync(join(distPublic, 'index.html'), 'utf8');
for (const [original, hashed] of Object.entries(manifest)) {
html = html.replaceAll(`"${original}"`, `"${hashed}"`);
}
writeFileSync(join(distPublic, 'index.html'), html);
console.log(' Hashed files:');
for (const [orig, hashed] of Object.entries(manifest)) {
console.log(` ${orig} -> ${hashed}`);
}
}
// 6. Compress with gzip + brotli
run(
'compress',
`for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do` +
+67 -9
View File
@@ -250,10 +250,10 @@ if (isGlobalInstall) {
} else {
try {
const require = createRequire(import.meta.url);
const xtermDir = join(require.resolve('xterm'), '..', '..');
const fitDir = join(require.resolve('xterm-addon-fit'), '..', '..');
const webglDir = join(require.resolve('xterm-addon-webgl'), '..', '..');
const unicode11Dir = join(require.resolve('xterm-addon-unicode11'), '..', '..');
const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
const vendorDir = join(srcDir, 'web', 'public', 'vendor');
const { mkdirSync, copyFileSync } = await import('fs');
@@ -263,19 +263,47 @@ if (isGlobalInstall) {
// Minify xterm JS for dev vendor dir (npm packages don't ship .min.js)
try {
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(fitDir, 'lib', 'xterm-addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
} catch {
// Fallback: copy unminified
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
copyFileSync(join(fitDir, 'lib', 'xterm-addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
copyFileSync(join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
}
// WebGL addon: copy unminified (matches build script behavior)
copyFileSync(join(webglDir, 'lib', 'xterm-addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
copyFileSync(join(webglDir, 'lib', 'addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
// xterm-zerolag-input: bundle local package as IIFE for <script> tag loading
try {
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
// ----------------------------------------------------------------------------
+3 -6
View File
@@ -28,8 +28,8 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EventEmitter } from 'node:events';
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
import { getAugmentedPath, ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js';
// ========== Security Validation ==========
@@ -534,10 +534,7 @@ export abstract class AiCheckerBase<
// P1-005: Exponential backoff for errors
// Base cooldown * 2^(consecutiveErrors-1), capped at 5 minutes
const backoffMultiplier = Math.pow(2, this.consecutiveErrors - 1);
const backoffCooldownMs = Math.min(
this.config.errorCooldownMs * backoffMultiplier,
5 * 60 * 1000 // Max 5 minutes
);
const backoffCooldownMs = Math.min(this.config.errorCooldownMs * backoffMultiplier, AI_CHECK_MAX_BACKOFF_MS);
this.log(`Exponential backoff: ${Math.round(backoffCooldownMs / 1000)}s (error #${this.consecutiveErrors})`);
this.startCooldown(backoffCooldownMs);
}
+12 -5
View File
@@ -30,7 +30,14 @@ import {
type AiCheckerResultBase,
type AiCheckerStateBase,
} from './ai-checker-base.js';
import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT } from './config/ai-defaults.js';
import {
AI_CHECK_MODEL,
AI_IDLE_CHECK_MAX_CONTEXT,
AI_IDLE_CHECK_TIMEOUT_MS,
AI_IDLE_CHECK_COOLDOWN_MS,
AI_IDLE_CHECK_ERROR_COOLDOWN_MS,
AI_CHECK_MAX_CONSECUTIVE_ERRORS,
} from './config/ai-defaults.js';
// ========== Types ==========
@@ -48,10 +55,10 @@ const DEFAULT_AI_CHECK_CONFIG: AiIdleCheckConfig = {
enabled: true,
model: AI_CHECK_MODEL,
maxContextChars: AI_IDLE_CHECK_MAX_CONTEXT,
checkTimeoutMs: 90000,
cooldownMs: 180000,
errorCooldownMs: 60000,
maxConsecutiveErrors: 3,
checkTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS,
cooldownMs: AI_IDLE_CHECK_COOLDOWN_MS,
errorCooldownMs: AI_IDLE_CHECK_ERROR_COOLDOWN_MS,
maxConsecutiveErrors: AI_CHECK_MAX_CONSECUTIVE_ERRORS,
};
/** Pattern to match IDLE or WORKING as the first word of output */
+12 -5
View File
@@ -29,7 +29,14 @@ import {
type AiCheckerResultBase,
type AiCheckerStateBase,
} from './ai-checker-base.js';
import { AI_CHECK_MODEL, AI_PLAN_CHECK_MAX_CONTEXT } from './config/ai-defaults.js';
import {
AI_CHECK_MODEL,
AI_PLAN_CHECK_MAX_CONTEXT,
AI_PLAN_CHECK_TIMEOUT_MS,
AI_PLAN_CHECK_COOLDOWN_MS,
AI_PLAN_CHECK_ERROR_COOLDOWN_MS,
AI_CHECK_MAX_CONSECUTIVE_ERRORS,
} from './config/ai-defaults.js';
// ========== Types ==========
@@ -47,10 +54,10 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
enabled: true,
model: AI_CHECK_MODEL,
maxContextChars: AI_PLAN_CHECK_MAX_CONTEXT,
checkTimeoutMs: 60000,
cooldownMs: 30000,
errorCooldownMs: 30000,
maxConsecutiveErrors: 3,
checkTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS,
cooldownMs: AI_PLAN_CHECK_COOLDOWN_MS,
errorCooldownMs: AI_PLAN_CHECK_ERROR_COOLDOWN_MS,
maxConsecutiveErrors: AI_CHECK_MAX_CONSECUTIVE_ERRORS,
};
/** Pattern to match PLAN_MODE or NOT_PLAN_MODE as the first word(s) of output */
+2 -11
View File
@@ -15,7 +15,7 @@
import { EventEmitter } from 'node:events';
import { v4 as uuidv4 } from 'uuid';
import { ActiveBashTool } from './types.js';
import { CleanupManager, Debouncer } from './utils/index.js';
import { CleanupManager, Debouncer, stripAnsi } from './utils/index.js';
// ========== Configuration Constants ==========
@@ -462,7 +462,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
* Process a single line of terminal output (raw — will strip ANSI).
*/
private processLine(line: string): void {
const cleanLine = this.stripAnsi(line);
const cleanLine = stripAnsi(line);
this.processCleanLine(cleanLine);
}
@@ -668,15 +668,6 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
return this.deduplicatePaths(rawPaths);
}
/**
* Strip ANSI escape codes from a string.
*/
private stripAnsi(str: string): string {
// Comprehensive ANSI pattern
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
}
/**
* Schedule a debounced update emission.
*/
+44 -4
View File
@@ -1,13 +1,17 @@
/**
* @fileoverview Default model and context limits for AI-powered checkers.
* @fileoverview Default model, context limits, and timing for AI-powered checkers.
*
* Centralizes the AI model identifier and context window sizes used by
* the idle checker, plan checker, respawn controller defaults, and
* respawn route fallbacks. Change the model here when upgrading.
* Centralizes the AI model identifier, context window sizes, and timeout/cooldown
* defaults used by the idle checker, plan checker, respawn controller defaults,
* and respawn route fallbacks. Change values here when tuning AI check behavior.
*
* @module config/ai-defaults
*/
// ============================================================================
// Model & Context
// ============================================================================
/** Default model for AI idle and plan checkers */
export const AI_CHECK_MODEL = 'claude-opus-4-5-20251101';
@@ -16,3 +20,39 @@ export const AI_IDLE_CHECK_MAX_CONTEXT = 16000;
/** Max context chars for plan checker (~2k tokens, plan mode UI is compact) */
export const AI_PLAN_CHECK_MAX_CONTEXT = 8000;
// ============================================================================
// AI Idle Checker Timing
// ============================================================================
/** Timeout for AI idle check (90 seconds — thinking can be slow) */
export const AI_IDLE_CHECK_TIMEOUT_MS = 90_000;
/** Cooldown after WORKING verdict (3 minutes) */
export const AI_IDLE_CHECK_COOLDOWN_MS = 180_000;
/** Cooldown after AI idle check error (1 minute) */
export const AI_IDLE_CHECK_ERROR_COOLDOWN_MS = 60_000;
// ============================================================================
// AI Plan Checker Timing
// ============================================================================
/** Timeout for AI plan check (60 seconds — allows time for thinking) */
export const AI_PLAN_CHECK_TIMEOUT_MS = 60_000;
/** Cooldown after NOT_PLAN_MODE verdict (30 seconds) */
export const AI_PLAN_CHECK_COOLDOWN_MS = 30_000;
/** Cooldown after AI plan check error (30 seconds) */
export const AI_PLAN_CHECK_ERROR_COOLDOWN_MS = 30_000;
// ============================================================================
// Shared AI Checker Limits
// ============================================================================
/** Max consecutive errors before disabling an AI checker */
export const AI_CHECK_MAX_CONSECUTIVE_ERRORS = 3;
/** Maximum exponential backoff cap for AI checker errors (5 minutes) */
export const AI_CHECK_MAX_BACKOFF_MS = 5 * 60 * 1000;
+11 -5
View File
@@ -56,11 +56,6 @@ export const TRIM_TEXT_TO = 768 * 1024; // 768KB
*/
export const MAX_MESSAGES = 1000;
/**
* Number of messages to keep when trimming (80% of max).
*/
export const TRIM_MESSAGES_TO = 800;
// ============================================================================
// Line Buffer Limits
// ============================================================================
@@ -85,3 +80,14 @@ export const MAX_RESPAWN_BUFFER_SIZE = 1 * 1024 * 1024; // 1MB
* Size to trim respawn buffer to when max is exceeded.
*/
export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB
// ============================================================================
// File Peek Limits
// ============================================================================
/**
* Maximum bytes to read when peeking at the beginning of a file.
* Used with `createReadStream({ end })` (inclusive) to read the first 8KB,
* which is enough to extract metadata from the first few JSONL lines.
*/
export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
+22 -2
View File
@@ -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
@@ -66,3 +73,16 @@ export const MAX_CONSECUTIVE_ERRORS = 5;
/** Error counter reset interval — forgives errors after quiet period (ms) */
export const ERROR_RESET_MS = 60_000;
// ============================================================================
// Common Cleanup Intervals
// ============================================================================
/** Standard 1-minute cleanup/check interval used by multiple subsystems (ms) */
export const CLEANUP_CHECK_INTERVAL_MS = 60_000;
/** Standard 1-hour max age for stale/completed data (ms) */
export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000;
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
-6
View File
@@ -11,11 +11,5 @@
/** Max input length per API request (bytes) */
export const MAX_INPUT_LENGTH = 64 * 1024;
/** Max terminal columns for resize requests */
export const MAX_TERMINAL_COLS = 500;
/** Max terminal rows for resize requests */
export const MAX_TERMINAL_ROWS = 200;
/** Max session name length (chars) */
export const MAX_SESSION_NAME_LENGTH = 128;
+3 -2
View File
@@ -16,6 +16,7 @@ import { existsSync, statSync, realpathSync } from 'node:fs';
import { resolve, relative, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { EventEmitter } from 'node:events';
import { CLEANUP_CHECK_INTERVAL_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// ========== Configuration Constants ==========
@@ -39,7 +40,7 @@ const MAX_STREAMS_PER_SESSION = 5;
* Inactivity timeout for streams (5 minutes).
* Streams with no data for this long will be auto-closed.
*/
const STREAM_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
const STREAM_INACTIVITY_TIMEOUT_MS = INACTIVITY_TIMEOUT_MS;
// ========== Types ==========
@@ -129,7 +130,7 @@ export class FileStreamManager extends EventEmitter {
constructor() {
super();
// Start cleanup timer for inactive streams
this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), 60 * 1000);
this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), CLEANUP_CHECK_INTERVAL_MS);
}
// ========== Public Methods ==========
+21 -6
View File
@@ -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';
+4
View File
@@ -61,6 +61,8 @@ export interface CreateSessionOptions {
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
}
/** Options for respawning a dead pane. */
@@ -73,6 +75,8 @@ export interface RespawnPaneOptions {
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
}
/**
+2 -2
View File
@@ -521,7 +521,7 @@ export class PlanOrchestrator {
} finally {
// Always clean up session and progress interval — centralizing here
// prevents the race where cancel() and catch both try to manage the set
await session.stop().catch(() => {});
await session.stop().catch(() => {}); // Ignore - session cleanup is best-effort in finally block
this.runningSessions.delete(session);
clearInterval(progressInterval);
}
@@ -651,7 +651,7 @@ export class PlanOrchestrator {
} finally {
// Always clean up session and progress interval — centralizing here
// prevents the race where cancel() and catch both try to manage the set
await session.stop().catch(() => {});
await session.stop().catch(() => {}); // Ignore - session cleanup is best-effort in finally block
this.runningSessions.delete(session);
clearInterval(progressInterval);
}
+7 -8
View File
@@ -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.
+6 -4
View File
@@ -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.
+3 -4
View File
@@ -9,6 +9,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { execPattern } from './utils/index.js';
// Pattern to extract completion phrase from CLAUDE.md
// Matches <promise>PHRASE</promise> with optional whitespace
@@ -83,9 +84,7 @@ export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfi
};
// Parse each YAML line
let match;
YAML_LINE_PATTERN.lastIndex = 0;
while ((match = YAML_LINE_PATTERN.exec(yaml)) !== null) {
execPattern(YAML_LINE_PATTERN, yaml, (match) => {
const key = match[1].toLowerCase();
const value = match[2].trim();
@@ -103,7 +102,7 @@ export function parseRalphLoopConfigFromContent(content: string): RalphLoopConfi
config.completionPromise = value.toUpperCase();
break;
}
}
});
return config;
}
+14 -4
View File
@@ -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
*/
+2 -1
View File
@@ -10,6 +10,7 @@
*/
import { EventEmitter } from 'node:events';
import { CLEANUP_CHECK_INTERVAL_MS } from './config/server-timing.js';
/**
* RalphStallDetector - Detects iteration stalls in the Ralph loop.
@@ -57,7 +58,7 @@ export class RalphStallDetector extends EventEmitter {
// Check every minute
this._iterationStallTimer = setInterval(() => {
this.checkIterationStall();
}, 60 * 1000);
}, CLEANUP_CHECK_INTERVAL_MS);
}
/**
+62 -32
View File
@@ -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,8 @@ import {
todoContentHash,
stringSimilarity,
Debouncer,
CleanupManager,
execPattern,
} from './utils/index.js';
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
@@ -47,6 +64,7 @@ import type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js
import { RalphFixPlanWatcher, generateFixPlanMarkdown, importFixPlanMarkdown } from './ralph-fix-plan-watcher.js';
import { RalphStallDetector } from './ralph-stall-detector.js';
import { RalphStatusParser } from './ralph-status-parser.js';
import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
// Re-export sub-module types for backward compatibility
export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js';
@@ -56,17 +74,24 @@ export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js
/**
* Todo items older than this duration (in milliseconds) will be auto-expired.
* Default: 1 hour (60 * 60 * 1000)
* Default: 1 hour
*/
const TODO_EXPIRY_MS = 60 * 60 * 1000;
const TODO_EXPIRY_MS = STALE_DATA_MAX_AGE_MS;
/**
* Minimum interval between 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 = INACTIVITY_TIMEOUT_MS;
/**
* Similarity threshold for todo deduplication.
* Todos with similarity >= this value are considered duplicates.
@@ -502,6 +527,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 +549,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',
});
}
/**
@@ -1634,35 +1668,32 @@ export class RalphTracker extends EventEmitter {
let match: RegExpExecArray | null;
if (hasCheckbox) {
TODO_CHECKBOX_PATTERN.lastIndex = 0;
while ((match = TODO_CHECKBOX_PATTERN.exec(line)) !== null) {
execPattern(TODO_CHECKBOX_PATTERN, line, (match) => {
const checked = match[1].toLowerCase() === 'x';
const content = match[2].trim();
const status: RalphTodoStatus = checked ? 'completed' : 'pending';
this.upsertTodo(content, status);
updated = true;
}
});
}
if (hasTodoIndicator) {
TODO_INDICATOR_PATTERN.lastIndex = 0;
while ((match = TODO_INDICATOR_PATTERN.exec(line)) !== null) {
execPattern(TODO_INDICATOR_PATTERN, line, (match) => {
const icon = match[1];
const content = match[2].trim();
const status = this.iconToStatus(icon);
this.upsertTodo(content, status);
updated = true;
}
});
}
if (hasStatus) {
TODO_STATUS_PATTERN.lastIndex = 0;
while ((match = TODO_STATUS_PATTERN.exec(line)) !== null) {
execPattern(TODO_STATUS_PATTERN, line, (match) => {
const content = match[1].trim();
const status = match[2] as RalphTodoStatus;
this.upsertTodo(content, status);
updated = true;
}
});
}
if (hasNativeCheckbox) {
@@ -1683,8 +1714,7 @@ export class RalphTracker extends EventEmitter {
}
if (hasCheckmark) {
TODO_TASK_CREATED_PATTERN.lastIndex = 0;
while ((match = TODO_TASK_CREATED_PATTERN.exec(line)) !== null) {
execPattern(TODO_TASK_CREATED_PATTERN, line, (match) => {
const taskNum = parseInt(match[1], 10);
const content = match[2].trim();
if (content.length >= 5) {
@@ -1693,10 +1723,9 @@ export class RalphTracker extends EventEmitter {
this.upsertTodo(content, 'pending');
updated = true;
}
}
});
TODO_TASK_SUMMARY_PATTERN.lastIndex = 0;
while ((match = TODO_TASK_SUMMARY_PATTERN.exec(line)) !== null) {
execPattern(TODO_TASK_SUMMARY_PATTERN, line, (match) => {
const taskNum = parseInt(match[1], 10);
const content = match[2].trim();
if (content.length >= 5) {
@@ -1707,10 +1736,9 @@ export class RalphTracker extends EventEmitter {
this.upsertTodo(this._taskNumberToContent.get(taskNum) || content, 'pending');
updated = true;
}
}
});
TODO_TASK_STATUS_PATTERN.lastIndex = 0;
while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) {
execPattern(TODO_TASK_STATUS_PATTERN, line, (match) => {
const taskNum = parseInt(match[1], 10);
const statusStr = match[2].trim();
const status: RalphTodoStatus =
@@ -1720,7 +1748,7 @@ export class RalphTracker extends EventEmitter {
this.upsertTodo(content, status);
updated = true;
}
}
});
if (!updated) {
TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0;
@@ -2189,6 +2217,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 +2362,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();
+37 -21
View File
@@ -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
*/
@@ -40,8 +49,7 @@ import { Session } from './session.js';
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
import type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
import {
isCompletionMessage,
@@ -53,7 +61,15 @@ import {
import { RespawnAdaptiveTiming } from './respawn-adaptive-timing.js';
import { RespawnCycleMetricsTracker } from './respawn-metrics.js';
import { calculateHealthScore, shouldSkipClear, type HealthInputs } from './respawn-health.js';
import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT, AI_PLAN_CHECK_MAX_CONTEXT } from './config/ai-defaults.js';
import {
AI_CHECK_MODEL,
AI_IDLE_CHECK_MAX_CONTEXT,
AI_PLAN_CHECK_MAX_CONTEXT,
AI_IDLE_CHECK_TIMEOUT_MS,
AI_IDLE_CHECK_COOLDOWN_MS,
AI_PLAN_CHECK_TIMEOUT_MS,
AI_PLAN_CHECK_COOLDOWN_MS,
} from './config/ai-defaults.js';
import type {
RespawnCycleMetrics,
RespawnAggregateMetrics,
@@ -550,13 +566,13 @@ const DEFAULT_CONFIG: RespawnConfig = {
aiIdleCheckEnabled: true, // use AI to confirm idle state
aiIdleCheckModel: AI_CHECK_MODEL,
aiIdleCheckMaxContext: AI_IDLE_CHECK_MAX_CONTEXT,
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
aiIdleCheckTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS,
aiIdleCheckCooldownMs: AI_IDLE_CHECK_COOLDOWN_MS,
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
aiPlanCheckModel: AI_CHECK_MODEL,
aiPlanCheckMaxContext: AI_PLAN_CHECK_MAX_CONTEXT,
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
aiPlanCheckTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS,
aiPlanCheckCooldownMs: AI_PLAN_CHECK_COOLDOWN_MS,
stuckStateDetectionEnabled: true, // detect stuck states
stuckStateWarningMs: 300000, // 5 minutes warning threshold
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
+2 -1
View File
@@ -22,6 +22,7 @@ import {
RunSummaryStats,
createInitialRunSummaryStats,
} from './types.js';
import { CLEANUP_CHECK_INTERVAL_MS } from './config/server-timing.js';
/** Maximum events to keep per session (FIFO trimming) */
const MAX_EVENTS = 1000;
@@ -36,7 +37,7 @@ const TOKEN_MILESTONE_INTERVAL = 50000;
const STATE_STUCK_WARNING_MS = 10 * 60 * 1000; // 10 minutes
/** State stuck check interval (ms) */
const STATE_STUCK_CHECK_INTERVAL = 60 * 1000; // 1 minute
const STATE_STUCK_CHECK_INTERVAL = CLEANUP_CHECK_INTERVAL_MS;
/**
* Tracks events and statistics for a session's run summary.
+1 -1
View File
@@ -9,7 +9,7 @@
*/
import type { ClaudeMode } from './types.js';
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
import { getAugmentedPath } from './utils/index.js';
/**
* Build Claude CLI permission flags based on the configured mode.
+17 -5
View File
@@ -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
*/
+44 -27
View File
@@ -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
*/
@@ -35,8 +48,14 @@ import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js';
import {
BufferAccumulator,
ANSI_ESCAPE_PATTERN_FULL,
TOKEN_PATTERN,
SPINNER_PATTERN,
MAX_SESSION_TOKENS,
execPattern,
} from './utils/index.js';
import {
MAX_TERMINAL_BUFFER_SIZE,
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
@@ -305,6 +324,7 @@ export class Session extends EventEmitter {
// OpenCode configuration (only for mode === 'opencode')
private _openCodeConfig: OpenCodeConfig | undefined;
private _resumeSessionId: string | undefined;
// Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default';
@@ -363,6 +383,8 @@ export class Session extends EventEmitter {
allowedTools?: string;
/** OpenCode configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
}
) {
super();
@@ -379,12 +401,10 @@ export class Session extends EventEmitter {
this.createdAt = config.createdAt || Date.now();
this.mode = config.mode || 'claude';
this._name = config.name || '';
this._resumeSessionId = config.resumeSessionId;
this._lastActivityAt = this.createdAt;
// Set claudeSessionId immediately — Codeman always passes --session-id ${this.id}
// to Claude CLI, so the Claude session ID always matches the Codeman session ID.
// This ensures subagent matching works even for recovered sessions (where
// startInteractive() hasn't been called yet).
this._claudeSessionId = this.id;
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = config.resumeSessionId || this.id;
this._mux = config.mux || null;
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
this._muxSession = config.muxSession || null;
@@ -773,6 +793,7 @@ export class Session extends EventEmitter {
cliAccountType: this._cliAccountType || undefined,
cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
};
}
@@ -891,6 +912,7 @@ export class Session extends EventEmitter {
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
});
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
@@ -917,6 +939,7 @@ export class Session extends EventEmitter {
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
});
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
@@ -936,9 +959,8 @@ export class Session extends EventEmitter {
}
);
// Set claudeSessionId immediately since we passed --session-id to Claude
// The mux manager passes --session-id ${sessionId} to Claude
this._claudeSessionId = this.id;
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = this._resumeSessionId || this.id;
} catch (spawnErr) {
console.error('[Session] Failed to spawn PTY for mux attachment:', spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
@@ -1023,9 +1045,8 @@ export class Session extends EventEmitter {
}
}
// Set the claudeSessionId immediately since we passed --session-id
// This ensures subagent matching works without waiting for JSON messages
this._claudeSessionId = this.id;
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = this._resumeSessionId || this.id;
this._pid = this.ptyProcess.pid;
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
@@ -1679,16 +1700,12 @@ export class Session extends EventEmitter {
// Quick pre-check: skip expensive regex if no common tool patterns present
if (!cleanLine.includes('(') || !cleanLine.includes(')')) return;
// Reset regex lastIndex for global pattern
TASK_TOOL_PATTERN.lastIndex = 0;
let match;
while ((match = TASK_TOOL_PATTERN.exec(cleanLine)) !== null) {
execPattern(TASK_TOOL_PATTERN, cleanLine, (match) => {
const description = match[2].trim();
if (description && description.length > 0) {
this._taskCache.add(Date.now(), description);
}
}
});
}
/**
+17 -7
View File
@@ -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
*/
+29 -7
View File
@@ -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),
* config/buffer-limits (FILE_PEEK_BYTES), utils (CleanupManager, KeyedDebouncer)
* @consumedby web/server (SSE broadcast), session (subagent-session correlation)
* @emits subagent:discovered, subagent:updated, subagent:tool_call, subagent:tool_result,
* subagent:progress, subagent:message, subagent:completed
*
* @module subagent-watcher
*/
import { EventEmitter } from 'node:events';
@@ -14,6 +34,8 @@ import { join, basename } from 'node:path';
import { execFile } from 'node:child_process';
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js';
import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
import { FILE_PEEK_BYTES } from './config/buffer-limits.js';
import { CleanupManager, KeyedDebouncer } from './utils/index.js';
// ========== Types ==========
@@ -131,7 +153,7 @@ const POLL_INTERVAL_MS = 1000; // Base poll interval (lightweight checks)
const FULL_SCAN_EVERY_N_POLLS = 5; // Full directory traversal every 5th poll (5s)
const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s
const FILE_ALIVE_THRESHOLD_MS = 30000; // File mtime within 30s = agent alive (primary check)
const STALE_COMPLETED_MAX_AGE_MS = 60 * 60 * 1000; // Remove completed agents older than 1 hour
const STALE_COMPLETED_MAX_AGE_MS = STALE_DATA_MAX_AGE_MS; // Remove completed agents older than 1 hour
const STALE_IDLE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // Remove idle agents older than 4 hours
const STARTUP_MAX_FILE_AGE_MS = 4 * 60 * 60 * 1000; // Only load files modified in last 4 hours on startup
@@ -988,7 +1010,7 @@ export class SubagentWatcher extends EventEmitter {
private async extractDescriptionFromFile(filePath: string): Promise<string | undefined> {
try {
// Only read the first 8KB — more than enough for 5 JSONL lines
const stream = createReadStream(filePath, { end: 8191 });
const stream = createReadStream(filePath, { end: FILE_PEEK_BYTES });
const rl = createInterface({ input: stream });
return await new Promise<string | undefined>((resolve) => {
@@ -1120,10 +1142,10 @@ export class SubagentWatcher extends EventEmitter {
if (this.fileAgentContext.has(filePath)) {
// Known file — handle content change
this.handleFileChange(filePath).catch(() => {});
this.handleFileChange(filePath).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
} else {
// New file — register it
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {});
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
}
});
});
+2 -1
View File
@@ -22,6 +22,7 @@
import { EventEmitter } from 'node:events';
import { assertNever } from './utils/index.js';
import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
// ========== Configuration Constants ==========
@@ -36,7 +37,7 @@ const MAX_COMPLETED_TASKS = 100;
* Entries older than this are cleaned up to prevent unbounded growth.
* Default: 1 hour
*/
const PENDING_TOOL_USE_MAX_AGE_MS = 60 * 60 * 1000;
const PENDING_TOOL_USE_MAX_AGE_MS = STALE_DATA_MAX_AGE_MS;
/**
* Maximum number of pending tool uses to allow.
+5 -5
View File
@@ -61,7 +61,7 @@ export class TeamWatcher extends EventEmitter {
persistent: false,
});
const teamsHandler = () => this.pollAsync().catch(() => {});
const teamsHandler = () => this.pollAsync().catch(() => {}); // Ignore - poll errors are non-fatal, next poll will retry
this.teamsWatcher.on('add', teamsHandler);
this.teamsWatcher.on('change', teamsHandler);
this.teamsWatcher.on('unlink', teamsHandler);
@@ -82,8 +82,8 @@ export class TeamWatcher extends EventEmitter {
persistent: false,
});
this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {}));
this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {}));
this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {})); // Ignore - poll errors are non-fatal, next poll will retry
this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {})); // Ignore - poll errors are non-fatal, next poll will retry
this.tasksWatcher.on('error', (err) => {
console.warn('[TeamWatcher] chokidar tasks watcher error:', err);
});
@@ -95,11 +95,11 @@ export class TeamWatcher extends EventEmitter {
stop(): void {
// Close chokidar watchers
if (this.teamsWatcher) {
this.teamsWatcher.close().catch(() => {});
this.teamsWatcher.close().catch(() => {}); // Ignore - watcher cleanup is best-effort during shutdown
this.teamsWatcher = null;
}
if (this.tasksWatcher) {
this.tasksWatcher.close().catch(() => {});
this.tasksWatcher.close().catch(() => {}); // Ignore - watcher cleanup is best-effort during shutdown
this.tasksWatcher = null;
}
if (this.pollTimer) {
+39 -10
View File
@@ -40,8 +40,7 @@ import {
type SessionMode,
type OpenCodeConfig,
} from './types.js';
import { wrapWithNice } from './utils/nice-wrapper.js';
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js';
import type {
TerminalMultiplexer,
MuxSession,
@@ -50,11 +49,6 @@ import type {
RespawnPaneOptions,
} from './mux-interface.js';
// Claude CLI PATH resolution — shared utility
import { findClaudeDir } from './utils/claude-cli-resolver.js';
// OpenCode CLI PATH resolution
import { resolveOpenCodeDir } from './utils/opencode-cli-resolver.js';
// ============================================================================
// Timing Constants
// ============================================================================
@@ -200,12 +194,24 @@ function buildSpawnCommand(options: {
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
resumeSessionId?: string;
}): string {
if (options.mode === 'claude') {
// Validate model to prevent command injection
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
// fall back to a new session with --session-id so the pane doesn't die.
const safeResumeId =
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
if (safeResumeId) {
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
return `${resumeCmd} || ${fallbackCmd}`;
}
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
}
if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig);
@@ -370,7 +376,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* In test mode: creates an in-memory session only (no real tmux session).
*/
async createSession(options: CreateSessionOptions): Promise<MuxSession> {
const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
const {
sessionId,
workingDir,
mode,
name,
niceConfig,
model,
claudeMode,
allowedTools,
openCodeConfig,
resumeSessionId,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;
if (!isValidMuxName(muxName)) {
@@ -433,6 +450,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
resumeSessionId,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -605,7 +623,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* preserving the session and its scrollback buffer.
*/
async respawnPane(options: RespawnPaneOptions): Promise<number | null> {
const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
const {
sessionId,
workingDir,
mode,
niceConfig,
model,
claudeMode,
allowedTools,
openCodeConfig,
resumeSessionId,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
const muxName = session.muxName;
@@ -641,6 +669,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode,
allowedTools,
openCodeConfig,
resumeSessionId,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
+1 -2
View File
@@ -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,
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 */
+15 -5
View File
@@ -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 — '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 */
@@ -8,9 +21,6 @@ export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed'
/** TDD phase categories */
export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review';
/** Development phase in TDD cycle (alias for TddPhase) */
export type PlanPhase = TddPhase;
/**
* A single plan item for plan orchestration.
* Moved here from plan-orchestrator.ts to break circular dependency.
@@ -29,7 +39,7 @@ export interface PlanItem {
lastError?: string;
completedAt?: number;
complexity?: 'low' | 'medium' | 'high';
tddPhase?: PlanPhase;
tddPhase?: TddPhase;
pairedWith?: string;
reviewChecklist?: string[];
}
+14 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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).
*/
/**
+16 -1
View File
@@ -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`.
*/
/**
+25 -1
View File
@@ -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';
@@ -121,6 +143,8 @@ export interface SessionState {
cliLatestVersion?: string;
/** OpenCode-specific configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
}
/**
+15 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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`).
*/
/**
+2 -1
View File
@@ -20,8 +20,9 @@ export {
createAnsiPatternSimple,
stripAnsi,
SAFE_PATH_PATTERN,
execPattern,
} from './regex-patterns.js';
export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js';
export { MAX_SESSION_TOKENS } from './token-validation.js';
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
+12
View File
@@ -79,3 +79,15 @@ export function stripAnsi(text: string): string {
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/;
/**
* Execute a global regex pattern against data, calling the callback for each match.
* Automatically resets lastIndex before execution.
*/
export function execPattern(pattern: RegExp, data: string, callback: (match: RegExpExecArray) => void): void {
pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(data)) !== null) {
callback(match);
}
}
+1 -57
View File
@@ -1,7 +1,6 @@
/**
* @fileoverview Token validation utilities.
* @fileoverview Token validation constants.
*
* Centralizes token count validation logic used across the codebase.
* Claude's context window is ~200k tokens, so 500k is a generous upper bound.
*
* @module utils/token-validation
@@ -12,58 +11,3 @@
* Claude's context is ~200k, so 500k is a safe upper bound for validation.
*/
export const MAX_SESSION_TOKENS = 500_000;
/**
* Validates token counts are within acceptable bounds.
* Rejects negative values and values exceeding MAX_SESSION_TOKENS.
*
* @param inputTokens - Input token count to validate
* @param outputTokens - Output token count to validate
* @returns Object with isValid flag and optional error reason
*/
export function validateTokenCounts(inputTokens: number, outputTokens: number): { isValid: boolean; reason?: string } {
if (inputTokens < 0 || outputTokens < 0) {
return {
isValid: false,
reason: `Negative token values: input=${inputTokens}, output=${outputTokens}`,
};
}
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
return {
isValid: false,
reason: `Token values exceed maximum (${MAX_SESSION_TOKENS}): input=${inputTokens}, output=${outputTokens}`,
};
}
return { isValid: true };
}
/**
* Validates token counts and cost for restoration/persistence.
* Returns true if all values are valid.
*
* @param inputTokens - Input token count
* @param outputTokens - Output token count
* @param cost - Cost value (must be non-negative)
* @returns Object with isValid flag and optional error reason
*/
export function validateTokensAndCost(
inputTokens: number,
outputTokens: number,
cost: number
): { isValid: boolean; reason?: string } {
const tokenValidation = validateTokenCounts(inputTokens, outputTokens);
if (!tokenValidation.isValid) {
return tokenValidation;
}
if (cost < 0) {
return {
isValid: false,
reason: `Negative cost value: ${cost}`,
};
}
return { isValid: true };
}
+1
View File
@@ -11,4 +11,5 @@ export interface EventPort {
batchTerminalData(sessionId: string, data: string): void;
broadcastSessionStateDebounced(sessionId: string): void;
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
getSseClientCount(): number;
}
+12
View File
@@ -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)
+2335 -1206
View File
File diff suppressed because it is too large Load Diff
+137 -70
View File
@@ -1,8 +1,28 @@
/**
* @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, respawn
* preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml,
* getEventCoords, scheduleBackground, urlBase64ToUint8Array).
*
* @globals {function} urlBase64ToUint8Array - VAPID key conversion for Web Push
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
* @globals {function} 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,12 +36,12 @@ function urlBase64ToUint8Array(base64String) {
return outputArray;
}
// ============================================================================
// ═══════════════════════════════════════════════════════════════
// Constants
// ============================================================================
// ═══════════════════════════════════════════════════════════════
// Default terminal scrollback (can be changed via settings)
const DEFAULT_SCROLLBACK = 5000;
const DEFAULT_SCROLLBACK = 20000;
// Timing constants
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
@@ -31,8 +51,8 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 128 * 1024; // 128KB chunks for terminal data
const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 128 * 1024; // 128KB tail for initial load
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
@@ -58,14 +78,8 @@ function scheduleBackground(fn) {
else { requestAnimationFrame(fn); }
}
// DEC mode 2026 - Synchronized Output
// Wrap terminal writes with these markers to prevent partial-frame flicker.
// Terminal buffers all output between markers and renders atomically.
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
// xterm.js doesn't support DEC 2026 natively, so we implement buffering ourselves.
const DEC_SYNC_START = '\x1b[?2026h';
const DEC_SYNC_END = '\x1b[?2026l';
// Pre-compiled regex for stripping DEC 2026 markers (single pass instead of two replaceAll calls)
// DEC mode 2026 marker stripping — xterm.js 6.0 handles sync natively,
// but server-sent terminal buffers may still contain markers from Claude CLI.
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
// Built-in respawn configuration presets
@@ -157,9 +171,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.
@@ -176,59 +296,6 @@ function getEventCoords(e) {
return { clientX: e.clientX, clientY: e.clientY };
}
/**
* Process data containing DEC 2026 sync markers.
* Strips markers and returns segments that should be written atomically.
* Each returned segment represents content between SYNC_START and SYNC_END.
* Content outside sync blocks is returned as-is.
*
* @param {string} data - Raw terminal data with potential sync markers
* @returns {string[]} - Array of content segments to write (markers stripped)
*/
function extractSyncSegments(data) {
const segments = [];
let remaining = data;
while (remaining.length > 0) {
const startIdx = remaining.indexOf(DEC_SYNC_START);
if (startIdx === -1) {
// No more sync blocks, return rest as-is
if (remaining.length > 0) {
segments.push(remaining);
}
break;
}
// Content before sync block (if any)
if (startIdx > 0) {
segments.push(remaining.slice(0, startIdx));
}
// Find matching end marker
const afterStart = remaining.slice(startIdx + DEC_SYNC_START.length);
const endIdx = afterStart.indexOf(DEC_SYNC_END);
if (endIdx === -1) {
// No end marker found - sync block continues in next chunk
// Include the start marker so it can be handled when more data arrives
segments.push(remaining.slice(startIdx));
break;
}
// Extract synchronized content (without markers)
const syncContent = afterStart.slice(0, endIdx);
if (syncContent.length > 0) {
segments.push(syncContent);
}
// Continue with content after end marker
remaining = afterStart.slice(endIdx + DEC_SYNC_END.length);
}
return segments;
}
// HTML escape utility (shared by NotificationManager, CodemanApp, and ralph-wizard.js)
const _htmlEscapeMap = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const _htmlEscapePattern = /[&<>"']/g;
+28 -14
View File
@@ -2,15 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="description" content="Claude Code session manager with web interface">
<meta name="theme-color" content="#0a0a0a">
<meta name="google" content="notranslate">
<link rel="manifest" href="manifest.json">
<title>Codeman</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
<link rel="stylesheet" href="styles.css?v=0.1631">
<link rel="stylesheet" href="mobile.css?v=0.1631" media="(max-width: 1023px)">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="mobile.css" media="(max-width: 1023px)">
<!-- xterm.css loaded async — terminal won't display until xterm.js runs anyway -->
<link rel="preload" href="vendor/xterm.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="vendor/xterm.css"></noscript>
@@ -20,12 +20,12 @@
<script defer src="vendor/xterm-addon-fit.min.js"></script>
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
<script defer src="vendor/xterm-zerolag-input.js?v=0.2.9"></script>
<script defer src="vendor/xterm-zerolag-input.js"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;background:#0a0a0a}
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#0a0a0a}
.skeleton-header{height:40px;background:#111;border-bottom:1px solid #1a1a2e;display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
@@ -59,6 +59,9 @@
</div>
<div class="header-right">
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
<span class="tunnel-dot"></span>
</button>
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
<span class="connection-dot" id="connectionDot"></span>
<span class="connection-text" id="connectionText"></span>
@@ -253,6 +256,10 @@
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
</div>
<div class="history-sessions" id="historySessions" style="display:none">
<h3 class="history-title">Resume Conversation</h3>
<div class="history-list" id="historyList"></div>
</div>
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop &rarr;</button>
</div>
@@ -323,6 +330,9 @@
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
<span class="run-mode-dot opencode"></span>OpenCode
</button>
<div class="run-mode-sep"></div>
<div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div>
</div>
</div>
<div class="tab-count-group" title="Instance count">
@@ -961,6 +971,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 +1684,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"></script>
<script defer src="mobile-handlers.js"></script>
<script defer src="voice-input.js"></script>
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="app.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
</body>
</html>
+27 -4
View File
@@ -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).
+50 -7
View File
@@ -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.
@@ -74,14 +98,33 @@ const MobileDetection = {
}
},
/** Set --app-height CSS variable from visual viewport.
* On iPad Safari with tabs, 100vh extends behind the tab bar.
* visualViewport.height reflects the actual visible area. */
updateAppHeight() {
const vh = window.visualViewport?.height || window.innerHeight;
document.documentElement.style.setProperty('--app-height', `${vh}px`);
},
/** Initialize mobile detection and set up resize listener */
init() {
this.updateBodyClass();
this.updateAppHeight();
// Update --app-height on viewport resize (orientation, tab bar toggle)
if (window.visualViewport) {
this._appHeightHandler = () => this.updateAppHeight();
window.visualViewport.addEventListener('resize', this._appHeightHandler);
}
// Debounced resize handler
let resizeTimeout;
this._resizeHandler = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => this.updateBodyClass(), 100);
resizeTimeout = setTimeout(() => {
this.updateBodyClass();
this.updateAppHeight();
}, 100);
};
window.addEventListener('resize', this._resizeHandler);
@@ -109,9 +152,9 @@ const MobileDetection = {
}
};
// ============================================================================
// ═══════════════════════════════════════════════════════════════
// Mobile Keyboard Handler
// ============================================================================
// ═══════════════════════════════════════════════════════════════
/**
* KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears.
@@ -387,9 +430,9 @@ const KeyboardHandler = {
}
};
// ============================================================================
// ═══════════════════════════════════════════════════════════════
// Mobile Swipe Handler
// ============================================================================
// ═══════════════════════════════════════════════════════════════
/**
* SwipeHandler - Detects horizontal swipes on terminal to switch sessions.
+126 -1
View File
@@ -234,6 +234,35 @@ html.mobile-init .file-browser-panel {
.subagent-window-body {
font-size: 0.7rem;
}
/* Show desktop voice button on tablet (hidden by max-width:1023px in styles.css,
mobile .btn-voice-mobile only shows at <430px) */
.toolbar-center .btn-toolbar.btn-voice {
display: flex !important;
}
/* Toolbar — use desktop-style sizing on tablet (plenty of room at 430-768px) */
.toolbar {
padding: 0 0.5rem;
gap: 0.5rem;
}
.toolbar-left,
.toolbar-right {
gap: 0.5rem;
}
/* Instance count controls are hidden on tablet, so toolbar-group needs gap
to space out Run / Stop / Run Shell (desktop uses gap:0 because -1+ separates them) */
.toolbar-group {
gap: 0.5rem;
}
.btn-toolbar {
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
min-height: unset;
}
}
/* ============================================================================
@@ -498,6 +527,13 @@ html.mobile-init .file-browser-panel {
will-change: transform;
}
/* iOS Safari with tab bar: position: fixed uses the layout viewport which
extends behind the browser chrome. Offset the toolbar upward by the delta
between 100vh (layout) and --app-height (visual). */
.ios-device.safari-browser .toolbar {
bottom: calc(var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
}
/* Show case selector in center */
.toolbar-center {
display: flex !important;
@@ -626,12 +662,20 @@ html.mobile-init .file-browser-panel {
bottom: 100%;
left: 0;
margin-bottom: 6px;
min-width: 140px;
min-width: 160px;
max-width: 80vw;
}
.run-mode-option {
padding: 10px 12px;
font-size: 0.8rem;
cursor: pointer;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
}
.run-mode-history {
-webkit-overflow-scrolling: touch;
touch-action: manipulation;
}
/* Stop button - visible on mobile, icon-only */
@@ -1895,6 +1939,87 @@ html.mobile-init .file-browser-panel {
}
/* ============================================================================
Keyboard Accessory Bar — all mobile/tablet sizes
Visual styles extracted from phone breakpoint so they apply on iPad too.
Phone-specific positioning (position: fixed) remains in @media (max-width: 430px).
============================================================================ */
.keyboard-accessory-bar {
display: none;
height: 44px;
background: #1a1a1a;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 6px 8px;
gap: 8px;
align-items: center;
justify-content: center;
z-index: 51;
}
.keyboard-accessory-bar.visible {
display: flex;
}
.accessory-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 12px;
background: #2a2a2a;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #e5e5e5;
font-size: 0.65rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.accessory-btn.confirming {
background: #6b4f00;
border-color: #b8860b;
color: #ffd54f;
}
.accessory-btn:active {
background: #3a3a3a;
}
.accessory-btn svg {
width: 14px;
height: 14px;
}
.accessory-btn-arrow {
padding: 6px 10px;
background: #1e3a5f;
border-color: rgba(59, 130, 246, 0.3);
color: #93c5fd;
}
.accessory-btn-arrow:active {
background: #2563eb;
}
.accessory-btn-dismiss {
padding: 8px 14px;
background: #2a2a2a;
border: 1.5px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
color: #e5e5e5;
}
.accessory-btn-dismiss svg {
width: 22px;
height: 22px;
stroke-width: 3;
}
.accessory-btn-dismiss:active {
background: #3a3a3a;
}
/* ============================================================================
iOS Safari Specific Fixes
============================================================================ */
+27
View File
@@ -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
+30 -6
View File
@@ -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;
+354 -1
View File
@@ -116,6 +116,8 @@ body {
background: var(--bg-dark);
color: var(--text);
height: 100vh;
height: 100dvh;
height: var(--app-height, 100dvh);
overflow: hidden;
}
@@ -124,6 +126,8 @@ body {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
height: var(--app-height, 100dvh);
}
/* Compact Header */
@@ -266,6 +270,8 @@ body {
will-change: opacity;
}
.session-tab .tab-status.error { background: var(--red); }
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
.session-tab[data-ended] { opacity: 0.55; }
/* Session color coding - left border indicator */
.session-tab[data-color="red"] { border-left: 3px solid var(--session-red); }
@@ -444,6 +450,249 @@ body {
50% { opacity: 0.3; }
}
/* Tunnel Indicator */
.tunnel-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
padding: 0;
position: relative;
}
.tunnel-indicator:hover {
background: var(--bg-hover);
}
.tunnel-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 6px #22c55e, 0 0 12px rgba(34, 197, 94, 0.4);
animation: tunnel-glow 2s ease-in-out infinite;
}
.tunnel-indicator.connecting .tunnel-dot {
background: #f59e0b;
box-shadow: 0 0 6px #f59e0b, 0 0 12px rgba(245, 158, 11, 0.4);
animation: tunnel-glow-amber 1s ease-in-out infinite;
}
@keyframes tunnel-glow {
0%, 100% { box-shadow: 0 0 6px #22c55e, 0 0 12px rgba(34, 197, 94, 0.4); }
50% { box-shadow: 0 0 8px #22c55e, 0 0 18px rgba(34, 197, 94, 0.6); }
}
@keyframes tunnel-glow-amber {
0%, 100% { box-shadow: 0 0 6px #f59e0b, 0 0 12px rgba(245, 158, 11, 0.4); opacity: 1; }
50% { box-shadow: 0 0 4px #f59e0b; opacity: 0.4; }
}
/* Tunnel Panel Dropdown */
.tunnel-panel {
position: fixed;
top: 36px;
right: 12px;
width: 320px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 1000;
overflow: hidden;
}
.tunnel-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.tunnel-panel-header h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.tunnel-panel-status {
font-size: 11px;
font-weight: 400;
color: #22c55e;
}
.tunnel-panel-status.offline {
color: var(--text-muted);
}
.tunnel-panel-body {
padding: 10px 14px;
}
.tunnel-panel-section {
margin-bottom: 12px;
}
.tunnel-panel-section:last-child {
margin-bottom: 0;
}
.tunnel-panel-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 6px;
}
.tunnel-panel-url {
font-family: 'SF Mono', Monaco, monospace;
font-size: 11px;
color: var(--accent-hover);
word-break: break-all;
cursor: pointer;
padding: 6px 8px;
background: var(--bg-input);
border-radius: 4px;
transition: background 0.15s;
}
.tunnel-panel-url:hover {
background: var(--bg-hover);
}
.tunnel-panel-stat {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
font-size: 12px;
color: var(--text-dim);
}
.tunnel-panel-stat-value {
color: var(--text);
font-family: 'SF Mono', Monaco, monospace;
font-size: 11px;
}
.tunnel-panel-session {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--bg-input);
border-radius: 4px;
margin-bottom: 4px;
font-size: 11px;
color: var(--text-dim);
}
.tunnel-panel-session-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
}
.tunnel-panel-session-info {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tunnel-panel-session-method {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: var(--bg-hover);
color: var(--text-muted);
text-transform: uppercase;
flex-shrink: 0;
}
.tunnel-panel-actions {
display: flex;
gap: 6px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.tunnel-panel-btn {
flex: 1;
padding: 6px 0;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
text-align: center;
transition: all 0.15s;
}
.tunnel-panel-btn.btn-start {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
color: #22c55e;
}
.tunnel-panel-btn.btn-start:hover {
background: rgba(34, 197, 94, 0.2);
}
.tunnel-panel-btn.btn-stop {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.tunnel-panel-btn.btn-stop:hover {
background: rgba(239, 68, 68, 0.2);
}
.tunnel-panel-btn.btn-qr {
background: var(--bg-input);
color: var(--text-dim);
}
.tunnel-panel-btn.btn-qr:hover {
background: var(--bg-hover);
color: var(--text);
}
.tunnel-panel-btn.btn-revoke {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
font-size: 11px;
}
.tunnel-panel-btn.btn-revoke:hover {
background: rgba(239, 68, 68, 0.2);
}
.tunnel-panel-empty {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
padding: 4px 0;
}
.header-tokens {
font-size: 0.75rem;
font-weight: 500;
@@ -1837,6 +2086,70 @@ body {
text-align: center;
}
/* History sessions panel (welcome overlay) */
.history-sessions {
margin-top: 1.5rem;
width: 100%;
max-width: 560px;
}
.history-title {
font-size: 0.85rem;
color: var(--text-dim);
margin-bottom: 0.5rem;
font-weight: 500;
text-align: left;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 240px;
overflow-y: auto;
}
.history-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
text-align: left;
}
.history-item:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.history-item-dir {
flex: 1;
font-size: 0.8rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item-meta {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
}
.history-item-size {
font-size: 0.7rem;
color: var(--text-dim);
white-space: nowrap;
min-width: 45px;
text-align: right;
}
.welcome-hint {
color: var(--text-muted);
font-size: 0.8rem;
@@ -2017,7 +2330,8 @@ body {
border-radius: 6px;
padding: 4px;
z-index: 1000;
min-width: 150px;
min-width: 180px;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.run-mode-menu.active {
@@ -2054,6 +2368,40 @@ body {
.run-mode-dot.claude { background: #3b82f6; }
.run-mode-dot.opencode { background: #10b981; }
.run-mode-sep {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.run-mode-header {
padding: 4px 10px 2px;
font-size: 0.72em;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.run-mode-history {
max-height: 200px;
overflow-y: auto;
}
.run-mode-option .hist-dir {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-mode-option .hist-meta {
font-size: 0.85em;
color: var(--text-muted);
white-space: nowrap;
}
.run-mode-hist-empty {
padding: 6px 10px;
color: var(--text-muted);
font-style: italic;
}
/* Shell Button - Special styling */
.btn-toolbar.btn-shell {
background: linear-gradient(135deg, #1a2e1a 0%, #234023 100%);
@@ -4204,6 +4552,7 @@ kbd {
right: 0;
width: 340px;
height: calc(100vh - var(--header-height));
height: calc(100dvh - var(--header-height));
background: var(--bg-card);
border-left: 1px solid var(--border);
z-index: 10001;
@@ -5572,6 +5921,7 @@ kbd {
left: 0;
width: 100vw;
height: 100vh;
height: 100dvh;
pointer-events: none;
z-index: 999; /* Below windows (1000+), above panels (91) */
}
@@ -5799,6 +6149,7 @@ kbd {
right: 20px;
width: 280px;
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
max-height: 600px;
min-width: 200px;
min-height: 300px;
@@ -5825,6 +6176,8 @@ kbd {
background: var(--bg-input);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
cursor: move;
user-select: none;
}
.file-browser-title {
+164 -30
View File
@@ -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.
@@ -99,7 +121,7 @@ Object.assign(CodemanApp.prototype, {
if (!windowData.minimized) {
openWindows.push({
agentId,
position: windowData.position || null
position: windowData.position || null,
});
}
}
@@ -141,8 +163,7 @@ Object.assign(CodemanApp.prototype, {
// Use the PERSISTENT parent map (THE source of truth)
// Fall back to saved sessionId only if it exists in current sessions
const parentFromMap = this.subagentParentMap.get(agentId);
const correctSessionId = parentFromMap ||
(this.sessions.has(savedSessionId) ? savedSessionId : null);
const correctSessionId = parentFromMap || (this.sessions.has(savedSessionId) ? savedSessionId : null);
if (correctSessionId) {
// Ensure the parent map has this association
@@ -162,7 +183,7 @@ Object.assign(CodemanApp.prototype, {
// Restore open windows (for recent, non-completed agents only)
const now = Date.now();
const maxAgeMs = 10 * 60 * 1000; // 10 minutes - don't restore windows for old agents
for (const { agentId, position } of (states.open || [])) {
for (const { agentId, position } of states.open || []) {
const agent = this.subagents.get(agentId);
// Only restore window if agent exists, is recent, and is still active/idle
const agentAge = agent?.startedAt ? now - agent.startedAt : Infinity;
@@ -198,7 +219,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 +457,86 @@ Object.assign(CodemanApp.prototype, {
}
},
// ========== Subagent Floating Windows ==========
// ═══════════════════════════════════════════════════════════════
// Lazy Terminal Lifecycle
// ═══════════════════════════════════════════════════════════════
//
// Teammate terminal windows use xterm.js Terminal instances that consume
// ~75KB of DOM memory each. With 50 agents minimized, that's ~3.75MB of
// invisible terminals. To avoid this, we dispose the Terminal when a
// window is minimized and lazily re-create it when restored.
//
// Flow:
// minimize → _disposeTeammateTerminalForMinimize() → sets _lazyTerminal flag
// restore → _restoreTeammateTerminalFromLazy() → re-creates Terminal
// create (hidden/minimized) → skip initTeammateTerminal, set _lazyTerminal
//
// The tmux pane buffer is re-fetched from the API on restore. Regular
// (non-teammate) subagent windows use activity HTML and are unaffected
// by this optimization.
/**
* Dispose a teammate terminal when its window is minimized.
* Saves pane metadata so the terminal can be re-created on restore.
* No-op if the window has no teammate terminal.
*/
_disposeTeammateTerminalForMinimize(agentId) {
const termData = this.teammateTerminals.get(agentId);
if (!termData) return; // Not a teammate terminal window
const windowData = this.subagentWindows.get(agentId);
// Save pane metadata needed to re-create the terminal on restore
if (windowData) {
windowData._lazyTerminal = true;
windowData._lazyPaneTarget = termData.paneTarget;
windowData._lazySessionId = termData.sessionId;
}
// Dispose the resize observer
if (termData.resizeObserver) {
termData.resizeObserver.disconnect();
}
// Dispose the xterm.js Terminal instance (frees DOM nodes and internal buffers)
if (termData.terminal) {
try {
termData.terminal.dispose();
} catch {}
}
// Remove from teammateTerminals map so renderSubagentWindowContent won't skip this window
// (the activity HTML can serve as a lightweight placeholder while minimized)
this.teammateTerminals.delete(agentId);
},
/**
* Re-create a teammate terminal when its window is restored from minimized state.
* Fetches the current pane buffer from the API (tmux is the source of truth).
* No-op if the window doesn't have the _lazyTerminal flag.
*/
_restoreTeammateTerminalFromLazy(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (!windowData || !windowData._lazyTerminal) return;
const paneTarget = windowData._lazyPaneTarget;
const sessionId = windowData._lazySessionId;
// Clear lazy state
windowData._lazyTerminal = false;
windowData._lazyPaneTarget = null;
windowData._lazySessionId = null;
if (!paneTarget || !sessionId) return;
// Re-create the terminal using the same initTeammateTerminal flow
const paneInfo = { paneTarget, sessionId };
this.initTeammateTerminal(agentId, paneInfo, windowData.element);
},
// ═══════════════════════════════════════════════════════════════
// Subagent Floating Windows
// ═══════════════════════════════════════════════════════════════
openSubagentWindow(agentId) {
// If window already exists, focus it
@@ -469,7 +571,7 @@ Object.assign(CodemanApp.prototype, {
// Only open windows for agents that belong to a Codeman-managed session tab.
// Agents from external Claude sessions (not tracked by Codeman) should not pop up.
if (agent.sessionId) {
const hasMatchingTab = Array.from(this.sessions.values()).some(s => s.claudeSessionId === agent.sessionId);
const hasMatchingTab = Array.from(this.sessions.values()).some((s) => s.claudeSessionId === agent.sessionId);
if (!hasMatchingTab) return;
}
@@ -574,9 +676,7 @@ Object.assign(CodemanApp.prototype, {
}
// Get parent TAB element for spawn animation
const parentTab = parentSessionId
? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`)
: null;
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
// Create window element
const win = document.createElement('div');
@@ -585,17 +685,19 @@ Object.assign(CodemanApp.prototype, {
win.style.zIndex = ++this.subagentWindowZIndex;
// Build parent header if we have parent info
const parentHeader = parentSessionId && parentSessionName
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
const parentHeader =
parentSessionId && parentSessionName
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
<span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentSessionName)}</span>
</div>`
: '';
: '';
const teammateInfo = this.getTeammateInfo(agent);
const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7));
const windowTitle = teammateInfo ? teammateInfo.name : agent.description || agentId.substring(0, 7);
const maxTitleLen = isMobile ? 30 : 50;
const truncatedTitle = windowTitle.length > maxTitleLen ? windowTitle.substring(0, maxTitleLen) + '...' : windowTitle;
const truncatedTitle =
windowTitle.length > maxTitleLen ? windowTitle.substring(0, maxTitleLen) + '...' : windowTitle;
const modelBadge = agent.modelShort
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
: '';
@@ -669,7 +771,18 @@ Object.assign(CodemanApp.prototype, {
// Render content — check if this teammate has a tmux pane
const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null;
if (paneInfo) {
this.initTeammateTerminal(agentId, paneInfo, win);
if (shouldHide) {
// Window starts hidden — defer terminal creation until visible (lazy init).
// Saves ~75KB of DOM memory per hidden teammate terminal window.
const windowEntry = this.subagentWindows.get(agentId);
if (windowEntry) {
windowEntry._lazyTerminal = true;
windowEntry._lazyPaneTarget = paneInfo.paneTarget;
windowEntry._lazySessionId = paneInfo.sessionId;
}
} else {
this.initTeammateTerminal(agentId, paneInfo, win);
}
} else {
this.renderSubagentWindowContent(agentId);
}
@@ -729,6 +842,10 @@ Object.assign(CodemanApp.prototype, {
this.setAgentParentSessionId(agentId, parentSessionId);
}
// Dispose teammate terminal on minimize to free DOM/memory (~75KB per instance).
// The terminal will be lazily re-created on restore via initTeammateTerminal().
this._disposeTeammateTerminalForMinimize(agentId);
// Always minimize to tab
windowData.element.style.display = 'none';
windowData.minimized = true;
@@ -803,7 +920,9 @@ Object.assign(CodemanApp.prototype, {
for (const [, termData] of this.teammateTerminals) {
if (termData.resizeObserver) termData.resizeObserver.disconnect();
if (termData.terminal) {
try { termData.terminal.dispose(); } catch {}
try {
termData.terminal.dispose();
} catch {}
}
}
this.teammateTerminals.clear();
@@ -933,6 +1052,13 @@ Object.assign(CodemanApp.prototype, {
windowData.hidden = false;
}
windowData.minimized = false;
// Lazily re-create teammate terminal if it was disposed on minimize.
// Only re-create when the window is actually becoming visible.
if (shouldShow && windowData._lazyTerminal) {
this._restoreTeammateTerminalFromLazy(agentId);
}
this.updateConnectionLines();
// Restack all visible mobile windows so restored ones don't overlap
this.relayoutMobileSubagentWindows();
@@ -986,19 +1112,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 +1149,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
@@ -1033,7 +1167,7 @@ Object.assign(CodemanApp.prototype, {
if (!dropdown || dropdown.classList.contains('open')) return;
// Close other dropdowns first
document.querySelectorAll('.subagent-dropdown.open').forEach(d => {
document.querySelectorAll('.subagent-dropdown.open').forEach((d) => {
d.classList.remove('open', 'pinned');
if (d.parentElement === document.body && d._originalParent) {
d._originalParent.appendChild(d);
@@ -1055,8 +1189,8 @@ Object.assign(CodemanApp.prototype, {
// Schedule hide after delay (allows moving mouse to dropdown)
scheduleHideSubagentDropdown(badgeEl) {
this._subagentHideTimeout = setTimeout(() => {
const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') ||
document.querySelector('.subagent-dropdown.open');
const dropdown =
badgeEl?.querySelector?.('.subagent-dropdown') || document.querySelector('.subagent-dropdown.open');
if (dropdown && !dropdown.classList.contains('pinned')) {
dropdown.classList.remove('open');
if (dropdown._originalParent) {
+15
View File
@@ -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.
+26 -2
View File
@@ -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.
+18 -3
View File
@@ -5,11 +5,12 @@
* that replaces ~43 inline not-found checks across route handlers.
*/
import { join } from 'node:path';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { Session } from '../session.js';
import { ApiErrorCode, createErrorResponse } from '../types.js';
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';
@@ -17,6 +18,20 @@ import type { EventPort } from './ports/event-port.js';
export const CASES_DIR = join(homedir(), 'codeman-cases');
export const SETTINGS_PATH = join(homedir(), '.codeman', 'settings.json');
/**
* Validates that a path component doesn't escape the base directory.
* Returns the resolved full path, or null if the path is a traversal attempt.
*/
export function validatePathWithinBase(name: string, baseDir: string): string | null {
const fullPath = resolve(join(baseDir, name));
const resolvedBase = resolve(baseDir);
const relPath = relative(resolvedBase, fullPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return null;
}
return fullPath;
}
// Maximum hook data size (prevents oversized SSE broadcasts)
const MAX_HOOK_DATA_SIZE = 8 * 1024;
@@ -131,7 +146,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 +161,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,
});
+25 -39
View File
@@ -7,18 +7,23 @@
import { FastifyInstance } from 'fastify';
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR } from '../route-helpers.js';
import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
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[] = [];
@@ -69,13 +74,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
const { name, description } = result.data;
const casePath = join(CASES_DIR, name);
// Security: Path traversal protection - use relative path check
const resolvedPath = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
const casePath = validatePathWithinBase(name, CASES_DIR);
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
@@ -95,7 +95,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 +152,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));
@@ -162,11 +162,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
app.get('/api/cases/:name', async (req) => {
const { name } = req.params as { name: string };
// Security: Path traversal protection
const resolvedPath = resolve(join(CASES_DIR, name));
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
if (!validatePathWithinBase(name, CASES_DIR)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
@@ -205,11 +201,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
app.get('/api/cases/:name/fix-plan', async (req) => {
const { name } = req.params as { name: string };
// Security: Path traversal protection
const resolvedPath = resolve(join(CASES_DIR, name));
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
if (!validatePathWithinBase(name, CASES_DIR)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
@@ -321,17 +313,16 @@ 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 };
let casePath = join(CASES_DIR, caseName);
// Security: Path traversal protection - use relative path check
const resolvedCase = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedCase);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
let casePath = validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
@@ -385,21 +376,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// Cache disabled to ensure fresh prompts when starting new plan generations
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
let casePath = join(CASES_DIR, caseName);
let casePath = validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// Prevent browser caching - prompts change between plan generations
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
reply.header('Pragma', 'no-cache');
reply.header('Expires', '0');
// Security: Path traversal protection for case name - use relative path check
const resolvedCase = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedCase);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// Check linked cases if path doesn't exist
if (!existsSync(casePath)) {
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
+1
View File
@@ -14,3 +14,4 @@ export { registerSessionRoutes } from './session-routes.js';
export { registerRespawnRoutes } from './respawn-routes.js';
export { registerRalphRoutes } from './ralph-routes.js';
export { registerPlanRoutes } from './plan-routes.js';
export { registerWsRoutes } from './ws-routes.js';
+38 -30
View File
@@ -5,7 +5,7 @@
*/
import { FastifyInstance } from 'fastify';
import { join, resolve, relative, isAbsolute } from 'node:path';
import { join } from 'node:path';
import { existsSync, rmSync } from 'node:fs';
import { Session } from '../../session.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
@@ -17,11 +17,16 @@ import {
PlanTaskUpdateSchema,
PlanTaskAddSchema,
} from '../schemas.js';
import { findSessionOrFail, CASES_DIR } from '../route-helpers.js';
import { findSessionOrFail, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
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) {
@@ -227,12 +232,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Determine output directory for saving wizard results
let outputDir: string | undefined;
if (caseName) {
const casePath = join(CASES_DIR, caseName);
// Security: Path traversal protection - use relative path check
const resolvedCase = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedCase);
if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) {
const casePath = validatePathWithinBase(caseName, CASES_DIR);
if (casePath && existsSync(casePath)) {
outputDir = join(casePath, 'ralph-wizard');
// Clear old ralph-wizard directory to ensure fresh prompts for each generation
@@ -257,7 +258,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 +266,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 +281,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 +293,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 +312,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 +324,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 +342,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 +352,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 +389,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 +405,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 +423,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 +439,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 +461,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 };
});
}
+20 -7
View File
@@ -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(),
});
@@ -492,7 +505,7 @@ export function registerRalphRoutes(
settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {});
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch {
/* non-critical */
}
+53 -20
View File
@@ -7,10 +7,19 @@ 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';
import { AI_CHECK_MODEL, AI_IDLE_CHECK_MAX_CONTEXT, AI_PLAN_CHECK_MAX_CONTEXT } from '../../config/ai-defaults.js';
import {
AI_CHECK_MODEL,
AI_IDLE_CHECK_MAX_CONTEXT,
AI_PLAN_CHECK_MAX_CONTEXT,
AI_IDLE_CHECK_TIMEOUT_MS,
AI_IDLE_CHECK_COOLDOWN_MS,
AI_PLAN_CHECK_TIMEOUT_MS,
AI_PLAN_CHECK_COOLDOWN_MS,
} from '../../config/ai-defaults.js';
/** No-op EventPort used to suppress broadcasts during pre-start ralph configuration. */
const noopEventPort: EventPort = {
@@ -19,13 +28,19 @@ const noopEventPort: EventPort = {
batchTerminalData: () => {},
broadcastSessionStateDebounced: () => {},
batchTaskUpdate: () => {},
getSseClientCount: () => 0,
};
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 +55,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 +74,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 +116,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 +152,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 +176,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() };
}
@@ -174,23 +197,32 @@ export function registerRespawnRoutes(
aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? AI_CHECK_MODEL,
aiIdleCheckMaxContext:
config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? AI_IDLE_CHECK_MAX_CONTEXT,
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000,
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000,
aiIdleCheckTimeoutMs:
config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? AI_IDLE_CHECK_TIMEOUT_MS,
aiIdleCheckCooldownMs:
config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? AI_IDLE_CHECK_COOLDOWN_MS,
aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true,
aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? AI_CHECK_MODEL,
aiPlanCheckMaxContext:
config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? AI_PLAN_CHECK_MAX_CONTEXT,
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000,
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000,
aiPlanCheckTimeoutMs:
config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? AI_PLAN_CHECK_TIMEOUT_MS,
aiPlanCheckCooldownMs:
config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? AI_PLAN_CHECK_COOLDOWN_MS,
durationMinutes: currentConfig?.durationMinutes,
};
ctx.mux.updateRespawnConfig(id, merged);
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 +262,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 +279,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 +293,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 +337,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,
+139 -27
View File
@@ -5,7 +5,7 @@
*/
import { FastifyInstance } from 'fastify';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { join, dirname } from 'node:path';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import {
@@ -17,6 +17,7 @@ import {
type SessionColor,
} from '../../types.js';
import { Session } from '../../session.js';
import { SseEvent } from '../sse-events.js';
import {
CreateSessionSchema,
SessionNameSchema,
@@ -31,7 +32,7 @@ import {
QuickRunSchema,
QuickStartSchema,
} from '../schemas.js';
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js';
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, validatePathWithinBase } from '../route-helpers.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -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 () => {
@@ -135,6 +144,7 @@ export function registerSessionRoutes(
claudeMode: claudeModeConfig.claudeMode,
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
resumeSessionId: body.resumeSessionId,
});
ctx.addSession(session);
@@ -146,7 +156,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 +180,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 +206,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 +256,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 +342,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 +367,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 +409,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 +440,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 +572,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 +593,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 +623,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 +687,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 +697,10 @@ export function registerSessionRoutes(
};
});
// ═══════════════════════════════════════════════════════════════
// Quick Actions (quick-run, quick-start)
// ═══════════════════════════════════════════════════════════════
// ========== Quick Run ==========
app.post('/api/run', async (req) => {
@@ -717,7 +747,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);
@@ -759,13 +789,8 @@ export function registerSessionRoutes(
}
}
const casePath = join(CASES_DIR, caseName);
// Security: Path traversal protection - use relative path check
const resolvedPath = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
const casePath = validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
@@ -786,7 +811,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 +856,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 +868,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 +878,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 {
@@ -893,4 +918,91 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// History — list past Claude conversations for resume
// ═══════════════════════════════════════════════════════════════
app.get('/api/history/sessions', async () => {
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
const results: Array<{
sessionId: string;
workingDir: string;
projectKey: string;
sizeBytes: number;
lastModified: string;
}> = [];
try {
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const projPath = join(projectsDir, projDir);
const stat = await fs.stat(projPath).catch(() => null);
if (!stat?.isDirectory()) continue;
// Decode project key to working dir. The encoding replaces '/' with '-',
// which is lossy when path components contain '-'. Do naive decode first,
// then verify it exists. Fall back to HOME if the decoded path is invalid.
const naiveDecode = projDir.replace(/^-/, '/').replace(/-/g, '/');
const dirExists = await fs
.access(naiveDecode)
.then(() => true)
.catch(() => false);
const workingDir = dirExists ? naiveDecode : process.env.HOME || '/tmp';
const entries = await fs.readdir(projPath);
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const sessionId = entry.replace('.jsonl', '');
// Only valid UUIDs
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
const filePath = join(projPath, entry);
const fileStat = await fs.stat(filePath).catch(() => null);
if (!fileStat) continue;
// Skip files too small to contain real conversation (metadata-only sessions
// like file-history-snapshot entries are typically < 4KB)
if (fileStat.size < 4000) continue;
// Quick content check: verify actual conversation data exists.
// Sessions with only file-history-snapshot or hook_progress entries have
// no "user"/"assistant" messages and will fail claude --resume.
// Files > 50KB are almost certainly real conversations (skip the read).
if (fileStat.size < 50000) {
try {
const fd = await fs.open(filePath, 'r');
const buf = Buffer.alloc(16384);
const { bytesRead } = await fd.read(buf, 0, 16384, 0);
await fd.close();
const head = buf.toString('utf8', 0, bytesRead);
if (
!head.includes('"type":"user"') &&
!head.includes('"type":"assistant"') &&
!head.includes('"type":"summary"')
) {
continue; // No conversation content — skip
}
} catch {
continue;
}
}
results.push({
sessionId,
workingDir,
projectKey: projDir,
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
});
}
}
} catch {
// Projects dir may not exist
}
// Sort by lastModified descending
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
return { sessions: results.slice(0, 50) };
});
}
+50 -5
View File
@@ -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());
@@ -99,6 +104,23 @@ export function registerSystemRoutes(
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
app.get('/api/tunnel/info', async () => {
const status = ctx.tunnelManager.getStatus();
const sseClients = ctx.getSseClientCount();
const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = [];
if (ctx.authSessions) {
for (const [, record] of ctx.authSessions) {
sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method });
}
}
return {
...status,
sseClients,
authEnabled: !!process.env.CODEMAN_PASSWORD,
authSessions: sessions,
};
});
app.get('/api/tunnel/qr', async (_req, reply) => {
const url = ctx.tunnelManager.getUrl();
if (!url) {
@@ -112,8 +134,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 +142,10 @@ export function registerSystemRoutes(
}
});
// ═══════════════════════════════════════════════════════════════
// Authentication (QR auth, session revocation)
// ═══════════════════════════════════════════════════════════════
// ========== QR Auth Route ==========
app.get('/q/:code', async (req, reply) => {
@@ -178,7 +203,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 +232,10 @@ export function registerSystemRoutes(
return { success: true };
});
// ═══════════════════════════════════════════════════════════════
// CLI Integrations (OpenCode)
// ═══════════════════════════════════════════════════════════════
// ========== OpenCode ==========
app.get('/api/opencode/status', async () => {
@@ -217,6 +246,10 @@ export function registerSystemRoutes(
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
// ========== State & Lifecycle ==========
app.post('/api/cleanup-state', async () => {
@@ -280,6 +313,10 @@ export function registerSystemRoutes(
};
});
// ═══════════════════════════════════════════════════════════════
// Configuration & Settings (config, settings, model config, CPU priority)
// ═══════════════════════════════════════════════════════════════
// ========== Config ==========
app.get('/api/config', async () => {
@@ -425,7 +462,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 +544,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 +553,10 @@ export function registerSystemRoutes(
};
});
// ═══════════════════════════════════════════════════════════════
// Subagent Management (window states, parents, monitoring, transcripts)
// ═══════════════════════════════════════════════════════════════
// ========== Subagent Window State Persistence ==========
app.get('/api/subagent-window-states', async () => {
@@ -644,6 +685,10 @@ export function registerSystemRoutes(
return { success: true, data: { cleared } };
});
// ═══════════════════════════════════════════════════════════════
// Screenshots (upload, list, serve)
// ═══════════════════════════════════════════════════════════════
// ========== Screenshots ==========
app.post('/api/screenshots', async (req, reply) => {
+179
View File
@@ -0,0 +1,179 @@
/**
* @fileoverview WebSocket terminal I/O route.
*
* Provides a low-latency bidirectional channel for terminal input/output,
* bypassing the HTTP POST + SSE path that adds per-request middleware overhead.
* Auth is checked once on the WebSocket upgrade handshake (cookies are included
* automatically by the browser). After upgrade, the connection is raw — no
* per-message middleware processing.
*
* Additive: the existing HTTP POST /api/sessions/:id/input and SSE session:terminal
* paths remain fully functional. The frontend opts into WS when available and
* falls back transparently.
*
* Terminal output is micro-batched at 8ms to group Ink's rapid cursor-up redraws
* into single frames, preventing flicker from split ANSI sequences. This matches
* the SSE path's server-side batching (16-50ms) but at a shorter interval since
* WS has no Traefik buffering overhead.
*
* Protocol (all JSON text frames):
* Server -> Client:
* {"t":"o","d":"..."} — terminal output
* {"t":"c"} — clear terminal
* {"t":"r"} — needs refresh (reload buffer)
* Client -> Server:
* {"t":"i","d":"..."} — input (keystroke or paste)
* {"t":"z","c":N,"r":N} — resize terminal
*/
import { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import type { SessionPort } from '../ports/session-port.js';
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
const WS_BATCH_INTERVAL_MS = 8;
/** Flush immediately when batch exceeds this size (bytes) for responsiveness. */
const WS_BATCH_FLUSH_THRESHOLD = 16384;
/** How often to ping each WebSocket client (ms). Detects stale connections that
* TCP keepalive won't catch for minutes, especially through tunnels/proxies. */
const WS_PING_INTERVAL_MS = 30_000;
/** If pong isn't received within this window after a ping, terminate the socket. */
const WS_PONG_TIMEOUT_MS = 10_000;
/** DEC 2026 synchronized update markers. Wrapping output in these tells xterm.js
* to buffer all content and render atomically in a single frame — eliminates
* flicker from cursor-up redraws that Ink sends without its own sync markers
* (DA capability negotiation fails through the PTY→server→WS proxy chain). */
const DEC_2026_START = '\x1b[?2026h';
const DEC_2026_END = '\x1b[?2026l';
export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
app.get<{ Params: { id: string } }>('/ws/sessions/:id/terminal', { websocket: true }, (socket: WebSocket, req) => {
const { id } = req.params;
const session = ctx.sessions.get(id);
if (!session) {
socket.close(4004, 'Session not found');
return;
}
// Per-connection micro-batch state
let batchChunks: string[] = [];
let batchSize = 0;
let batchTimer: ReturnType<typeof setTimeout> | null = null;
const flushBatch = () => {
batchTimer = null;
if (batchChunks.length === 0 || socket.readyState !== 1) {
batchChunks = [];
batchSize = 0;
return;
}
const data = batchChunks.join('');
batchChunks = [];
batchSize = 0;
socket.send(`{"t":"o","d":${JSON.stringify(DEC_2026_START + data + DEC_2026_END)}}`);
};
// Attach message handler synchronously BEFORE any async work
// (@fastify/websocket requirement to avoid dropped messages).
socket.on('message', (raw) => {
try {
const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') {
if (msg.d.length > MAX_INPUT_LENGTH) return;
session.write(msg.d);
} else if (
msg.t === 'z' &&
Number.isInteger(msg.c) &&
Number.isInteger(msg.r) &&
msg.c >= 1 &&
msg.c <= 500 &&
msg.r >= 1 &&
msg.r <= 200
) {
session.resize(msg.c, msg.r);
}
} catch {
// Ignore malformed messages
}
});
// Terminal output -> micro-batched WS send
const onTerminal = (data: string) => {
batchChunks.push(data);
batchSize += data.length;
// Flush immediately for large batches (responsiveness during bulk output)
if (batchSize > WS_BATCH_FLUSH_THRESHOLD) {
if (batchTimer) {
clearTimeout(batchTimer);
}
flushBatch();
return;
}
// Start timer if not already running
if (!batchTimer) {
batchTimer = setTimeout(flushBatch, WS_BATCH_INTERVAL_MS);
}
};
const onClearTerminal = () => {
if (socket.readyState === 1) {
socket.send('{"t":"c"}');
}
};
const onNeedsRefresh = () => {
if (socket.readyState === 1) {
socket.send('{"t":"r"}');
}
};
session.on('terminal', onTerminal);
session.on('clearTerminal', onClearTerminal);
session.on('needsRefresh', onNeedsRefresh);
// Heartbeat: detect stale connections (especially through tunnels where
// TCP RST can take minutes to propagate).
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
let alive = true;
socket.on('pong', () => {
alive = true;
if (pongTimeout) {
clearTimeout(pongTimeout);
pongTimeout = null;
}
});
const pingInterval = setInterval(() => {
if (!alive) {
// Previous ping never got a pong — connection is dead
socket.terminate();
return;
}
alive = false;
socket.ping();
pongTimeout = setTimeout(() => {
socket.terminate();
}, WS_PONG_TIMEOUT_MS);
}, WS_PING_INTERVAL_MS);
socket.on('close', () => {
clearInterval(pingInterval);
if (pongTimeout) clearTimeout(pongTimeout);
if (batchTimer) clearTimeout(batchTimer);
batchChunks = [];
session.off('terminal', onTerminal);
session.off('clearTerminal', onClearTerminal);
session.off('needsRefresh', onNeedsRefresh);
});
});
}
+31 -1
View File
@@ -8,7 +8,7 @@
*/
import { z } from 'zod';
import { SAFE_PATH_PATTERN } from '../utils/regex-patterns.js';
import { SAFE_PATH_PATTERN } from '../utils/index.js';
// ========== Path Validation ==========
@@ -125,6 +125,12 @@ export const CreateSessionSchema = z.object({
name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema,
openCodeConfig: OpenCodeConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-f0-9-]+$/, 'resumeSessionId must be a valid UUID')
.optional(),
});
/**
@@ -315,6 +321,30 @@ export const SettingsUpdateSchema = z
insertMode: z.string().max(20).optional(),
})
.optional(),
// Run mode preference (cross-device sync)
runMode: z.string().max(20).optional(),
// Custom respawn presets (cross-device sync, replaces localStorage-only storage)
respawnPresets: z
.array(
z.object({
id: z.string().max(100),
name: z.string().max(100),
config: z.object({
idleTimeoutMs: z.number().optional(),
updatePrompt: z.string().max(5000).optional(),
interStepDelayMs: z.number().optional(),
sendClear: z.boolean().optional(),
sendInit: z.boolean().optional(),
kickstartPrompt: z.string().max(5000).optional(),
autoAcceptPrompts: z.boolean().optional(),
}),
durationMinutes: z.number().optional(),
builtIn: z.boolean().optional(),
createdAt: z.number().optional(),
})
)
.max(20)
.optional(),
})
.strict();
+377 -181
View File
File diff suppressed because it is too large Load Diff
+447
View File
@@ -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;
+279
View File
@@ -0,0 +1,279 @@
import { describe, it, expect, vi } from 'vitest';
import { BufferAccumulator } from '../src/utils/buffer-accumulator.js';
describe('BufferAccumulator', () => {
describe('construction', () => {
it('creates with simple size parameters', () => {
const buf = new BufferAccumulator(1000, 800);
expect(buf.isEmpty).toBe(true);
expect(buf.length).toBe(0);
expect(buf.value).toBe('');
});
it('creates with BufferConfig object', () => {
const buf = new BufferAccumulator({ maxSize: 1000, trimSize: 800 });
expect(buf.isEmpty).toBe(true);
});
it('creates with BufferConfig including onTrim', () => {
const onTrim = vi.fn();
const buf = new BufferAccumulator({ maxSize: 100, trimSize: 50, onTrim });
buf.append('x'.repeat(101));
expect(onTrim).toHaveBeenCalled();
});
});
describe('append', () => {
it('appends data and updates length', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello');
expect(buf.length).toBe(5);
expect(buf.isEmpty).toBe(false);
buf.append(' world');
expect(buf.length).toBe(11);
expect(buf.value).toBe('hello world');
});
it('ignores empty strings', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('');
expect(buf.length).toBe(0);
expect(buf.isEmpty).toBe(true);
});
it('handles multiple chunks efficiently', () => {
const buf = new BufferAccumulator(10000, 8000);
for (let i = 0; i < 100; i++) {
buf.append(`chunk${i}`);
}
expect(buf.length).toBeGreaterThan(0);
const value = buf.value;
expect(value).toContain('chunk0');
expect(value).toContain('chunk99');
});
});
describe('value', () => {
it('returns empty string when empty', () => {
const buf = new BufferAccumulator(1000, 800);
expect(buf.value).toBe('');
});
it('returns single chunk without joining', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('single');
expect(buf.value).toBe('single');
});
it('consolidates chunks on access', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('a');
buf.append('b');
buf.append('c');
expect(buf.value).toBe('abc');
// Access again should be same (consolidated)
expect(buf.value).toBe('abc');
});
});
describe('clear', () => {
it('resets buffer to empty', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('some data');
buf.clear();
expect(buf.isEmpty).toBe(true);
expect(buf.length).toBe(0);
expect(buf.value).toBe('');
});
});
describe('set', () => {
it('replaces buffer content', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('old data');
buf.set('new data');
expect(buf.value).toBe('new data');
expect(buf.length).toBe(8);
});
it('handles empty string', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('some data');
buf.set('');
expect(buf.isEmpty).toBe(true);
expect(buf.length).toBe(0);
});
});
describe('tail', () => {
it('returns last N characters', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world');
expect(buf.tail(5)).toBe('world');
});
it('returns entire buffer when N >= length', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('short');
expect(buf.tail(100)).toBe('short');
});
it('returns empty string on empty buffer', () => {
const buf = new BufferAccumulator(1000, 800);
expect(buf.tail(5)).toBe('');
});
});
describe('endsWith', () => {
it('returns true when buffer ends with suffix', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world');
expect(buf.endsWith('world')).toBe(true);
});
it('returns false when buffer does not end with suffix', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world');
expect(buf.endsWith('hello')).toBe(false);
});
it('returns true for empty suffix', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello');
expect(buf.endsWith('')).toBe(true);
});
it('returns false when suffix is longer than buffer', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hi');
expect(buf.endsWith('hello world')).toBe(false);
});
});
describe('contains', () => {
it('finds string pattern in buffer', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world foo bar');
expect(buf.contains('world')).toBe(true);
expect(buf.contains('baz')).toBe(false);
});
it('finds regex pattern in buffer', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world 123');
expect(buf.contains(/\d+/)).toBe(true);
expect(buf.contains(/[A-Z]{3}/)).toBe(false);
});
it('searches within last N characters when fromEnd provided', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('hello world');
expect(buf.contains('hello', 5)).toBe(false); // last 5 = 'world'
expect(buf.contains('world', 5)).toBe(true);
});
it('regex search with fromEnd', () => {
const buf = new BufferAccumulator(1000, 800);
buf.append('abc 123 xyz');
expect(buf.contains(/\d+/, 4)).toBe(false); // last 4 = ' xyz'
expect(buf.contains(/\d+/, 8)).toBe(true); // last 8 = '123 xyz'
});
});
describe('trimming', () => {
it('trims when maxSize is exceeded', () => {
const buf = new BufferAccumulator(100, 50);
buf.append('x'.repeat(60));
buf.append('y'.repeat(50)); // total 110, triggers trim
expect(buf.length).toBeLessThanOrEqual(100);
// After trim, should keep most recent data
expect(buf.value).toContain('y');
});
it('calls onTrim callback with trimmed byte count', () => {
const onTrim = vi.fn();
const buf = new BufferAccumulator({ maxSize: 100, trimSize: 50, onTrim });
buf.append('x'.repeat(101)); // triggers trim
expect(onTrim).toHaveBeenCalledTimes(1);
expect(onTrim).toHaveBeenCalledWith(expect.any(Number));
const trimmedBytes = onTrim.mock.calls[0][0] as number;
expect(trimmedBytes).toBeGreaterThan(0);
});
it('keeps most recent data after trim', () => {
const buf = new BufferAccumulator(100, 50);
buf.append('A'.repeat(60));
buf.append('B'.repeat(50)); // total 110, triggers trim to ~50 chars
const value = buf.value;
// The end should be B's
expect(value.endsWith('B')).toBe(true);
});
it('advances past first newline within 4KB to avoid mid-ANSI-escape', () => {
const buf = new BufferAccumulator(100, 80);
// Create content that when trimmed, has a newline near the start of the kept portion
const prefix = 'A'.repeat(30);
const newlineSection = 'X'.repeat(10) + '\n' + 'Y'.repeat(10);
const suffix = 'B'.repeat(60);
buf.append(prefix + newlineSection + suffix);
// After trimming, the buffer should start after the first newline in the kept portion
const value = buf.value;
// Should not start with partial X's before the newline
if (value.includes('\n')) {
// If the newline fell within the kept window, it should be skipped
expect(value.startsWith('X')).toBe(false);
}
});
it('handles trim with no newline in first 4KB', () => {
const buf = new BufferAccumulator(100, 50);
// No newlines at all
buf.append('A'.repeat(110));
// Should still trim without error
expect(buf.length).toBeLessThanOrEqual(100);
});
it('does not call onTrim when trimmedBytes is 0 or negative', () => {
const onTrim = vi.fn();
// trimSize equal to maxSize means nothing actually gets trimmed in bytes
// But this scenario is unusual; just test that onTrim is only called with positive values
const buf = new BufferAccumulator({ maxSize: 50, trimSize: 50, onTrim });
buf.append('x'.repeat(51)); // triggers trim, but trimSize == 50 so 1 byte trimmed
// The trim logic: full.length(51) - trimSize(50) = 1 > 0, so onTrim IS called
expect(onTrim).toHaveBeenCalledWith(expect.any(Number));
});
});
describe('edge cases', () => {
it('works with very small maxSize', () => {
const buf = new BufferAccumulator(5, 3);
buf.append('abcdef'); // 6 chars, triggers trim to 3
expect(buf.length).toBeLessThanOrEqual(5);
});
it('handles rapid appends', () => {
const buf = new BufferAccumulator(10000, 8000);
for (let i = 0; i < 1000; i++) {
buf.append('data');
}
expect(buf.length).toBeLessThanOrEqual(10000);
expect(buf.length).toBeGreaterThan(0);
});
it('set after trim works correctly', () => {
const buf = new BufferAccumulator(100, 50);
buf.append('x'.repeat(110)); // triggers trim
buf.set('fresh start');
expect(buf.value).toBe('fresh start');
expect(buf.length).toBe(11);
});
it('clear after trim works correctly', () => {
const buf = new BufferAccumulator(100, 50);
buf.append('x'.repeat(110)); // triggers trim
buf.clear();
expect(buf.isEmpty).toBe(true);
expect(buf.value).toBe('');
});
});
});
+356
View File
@@ -0,0 +1,356 @@
import { describe, it, expect, vi } from 'vitest';
import { LRUMap } from '../src/utils/lru-map.js';
describe('LRUMap', () => {
describe('construction', () => {
it('creates an empty map with maxSize', () => {
const map = new LRUMap<string, number>({ maxSize: 5 });
expect(map.size).toBe(0);
expect(map.maxEntries).toBe(5);
expect(map.freeSlots).toBe(5);
});
it('accepts an onEvict callback', () => {
const onEvict = vi.fn();
const map = new LRUMap<string, number>({ maxSize: 2, onEvict });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3); // evicts 'a'
expect(onEvict).toHaveBeenCalledWith('a', 1);
});
});
describe('set/get', () => {
it('stores and retrieves values', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
expect(map.get('a')).toBe(1);
expect(map.get('b')).toBe(2);
});
it('returns undefined for missing keys', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.get('nonexistent')).toBeUndefined();
});
it('overwrites existing key and refreshes position', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.set('a', 10); // refresh 'a' position
expect(map.get('a')).toBe(10);
expect(map.keysInOrder()).toEqual(['b', 'a']);
});
it('supports chaining on set', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
const result = map.set('a', 1).set('b', 2);
expect(result).toBe(map);
expect(map.size).toBe(2);
});
});
describe('has', () => {
it('returns true for existing keys', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
expect(map.has('a')).toBe(true);
});
it('returns false for missing keys', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.has('a')).toBe(false);
});
it('does not refresh position', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.has('a'); // should NOT refresh 'a'
expect(map.keysInOrder()).toEqual(['a', 'b']);
});
});
describe('delete', () => {
it('removes an existing key', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
expect(map.delete('a')).toBe(true);
expect(map.has('a')).toBe(false);
expect(map.size).toBe(0);
});
it('returns false for non-existent key', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.delete('nonexistent')).toBe(false);
});
it('updates newestKey when deleting the newest entry', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
map.delete('c'); // delete newest
expect(map.newest()).toEqual(['b', 2]);
});
it('handles deleting the only entry', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.delete('a');
expect(map.newest()).toBeUndefined();
expect(map.oldest()).toBeUndefined();
});
});
describe('clear', () => {
it('removes all entries', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.clear();
expect(map.size).toBe(0);
expect(map.newest()).toBeUndefined();
expect(map.oldest()).toBeUndefined();
});
});
describe('eviction', () => {
it('evicts oldest entry when exceeding maxSize', () => {
const map = new LRUMap<string, number>({ maxSize: 2 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3); // evicts 'a'
expect(map.has('a')).toBe(false);
expect(map.has('b')).toBe(true);
expect(map.has('c')).toBe(true);
expect(map.size).toBe(2);
});
it('evicts multiple entries to stay within maxSize', () => {
const map = new LRUMap<string, number>({ maxSize: 1 });
map.set('a', 1);
map.set('b', 2); // evicts 'a'
expect(map.size).toBe(1);
expect(map.has('a')).toBe(false);
expect(map.get('b')).toBe(2);
});
it('calls onEvict for each evicted entry', () => {
const onEvict = vi.fn();
const map = new LRUMap<string, number>({ maxSize: 2, onEvict });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3); // evicts 'a'
map.set('d', 4); // evicts 'b'
expect(onEvict).toHaveBeenCalledTimes(2);
expect(onEvict).toHaveBeenCalledWith('a', 1);
expect(onEvict).toHaveBeenCalledWith('b', 2);
});
it('get() refreshes position and changes eviction order', () => {
const map = new LRUMap<string, number>({ maxSize: 2 });
map.set('a', 1);
map.set('b', 2);
map.get('a'); // refresh 'a' — now 'b' is oldest
map.set('c', 3); // evicts 'b', not 'a'
expect(map.has('a')).toBe(true);
expect(map.has('b')).toBe(false);
expect(map.has('c')).toBe(true);
});
});
describe('peek', () => {
it('returns value without refreshing position', () => {
const map = new LRUMap<string, number>({ maxSize: 2 });
map.set('a', 1);
map.set('b', 2);
expect(map.peek('a')).toBe(1);
// 'a' should still be oldest since peek doesn't refresh
map.set('c', 3); // should evict 'a'
expect(map.has('a')).toBe(false);
});
it('returns undefined for missing keys', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.peek('nonexistent')).toBeUndefined();
});
});
describe('oldest/newest', () => {
it('returns undefined on empty map', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.oldest()).toBeUndefined();
expect(map.newest()).toBeUndefined();
});
it('returns correct oldest and newest', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
expect(map.oldest()).toEqual(['a', 1]);
expect(map.newest()).toEqual(['c', 3]);
});
it('newest updates after get()', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.get('a'); // 'a' becomes newest
expect(map.newest()).toEqual(['a', 1]);
expect(map.oldest()).toEqual(['b', 2]);
});
it('newest updates after set() overwrites', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
map.set('a', 10); // 'a' refreshed to newest
expect(map.newest()).toEqual(['a', 10]);
});
});
describe('expireOlderThan', () => {
it('removes entries older than cutoff', () => {
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
const now = Date.now();
map.set('old1', { ts: now - 10000 });
map.set('old2', { ts: now - 8000 });
map.set('new1', { ts: now - 1000 });
const evicted = map.expireOlderThan(5000, (v) => v.ts);
expect(evicted).toBe(2);
expect(map.size).toBe(1);
expect(map.has('new1')).toBe(true);
});
it('calls onEvict for expired entries', () => {
const onEvict = vi.fn();
const map = new LRUMap<string, { ts: number }>({ maxSize: 5, onEvict });
const now = Date.now();
map.set('old', { ts: now - 10000 });
map.set('new', { ts: now - 100 });
map.expireOlderThan(5000, (v) => v.ts);
expect(onEvict).toHaveBeenCalledTimes(1);
expect(onEvict).toHaveBeenCalledWith('old', { ts: now - 10000 });
});
it('returns 0 when nothing to expire', () => {
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
const now = Date.now();
map.set('a', { ts: now });
expect(map.expireOlderThan(5000, (v) => v.ts)).toBe(0);
});
it('handles empty map', () => {
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
expect(map.expireOlderThan(5000, (v) => v.ts)).toBe(0);
});
it('updates newestKey when all entries expired', () => {
const map = new LRUMap<string, { ts: number }>({ maxSize: 5 });
const now = Date.now();
map.set('a', { ts: now - 20000 });
map.set('b', { ts: now - 10000 });
map.expireOlderThan(5000, (v) => v.ts);
expect(map.newest()).toBeUndefined();
expect(map.size).toBe(0);
});
});
describe('keysInOrder/valuesInOrder', () => {
it('returns keys from oldest to newest', () => {
const map = new LRUMap<string, number>({ maxSize: 5 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
expect(map.keysInOrder()).toEqual(['a', 'b', 'c']);
});
it('returns values from oldest to newest', () => {
const map = new LRUMap<string, number>({ maxSize: 5 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
expect(map.valuesInOrder()).toEqual([1, 2, 3]);
});
it('reflects refreshed order', () => {
const map = new LRUMap<string, number>({ maxSize: 5 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3);
map.get('a'); // 'a' moves to end
expect(map.keysInOrder()).toEqual(['b', 'c', 'a']);
expect(map.valuesInOrder()).toEqual([2, 3, 1]);
});
it('returns empty arrays for empty map', () => {
const map = new LRUMap<string, number>({ maxSize: 5 });
expect(map.keysInOrder()).toEqual([]);
expect(map.valuesInOrder()).toEqual([]);
});
});
describe('maxEntries/freeSlots', () => {
it('freeSlots decreases as entries are added', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
expect(map.freeSlots).toBe(3);
map.set('a', 1);
expect(map.freeSlots).toBe(2);
map.set('b', 2);
expect(map.freeSlots).toBe(1);
map.set('c', 3);
expect(map.freeSlots).toBe(0);
});
it('freeSlots does not go below 0 after eviction', () => {
const map = new LRUMap<string, number>({ maxSize: 2 });
map.set('a', 1);
map.set('b', 2);
map.set('c', 3); // evicts 'a'
expect(map.freeSlots).toBe(0);
expect(map.size).toBe(2);
});
});
describe('iteration', () => {
it('iterates entries via for..of', () => {
const map = new LRUMap<string, number>({ maxSize: 3 });
map.set('a', 1);
map.set('b', 2);
const entries: [string, number][] = [];
for (const [k, v] of map) {
entries.push([k, v]);
}
expect(entries).toEqual([
['a', 1],
['b', 2],
]);
});
});
describe('edge cases', () => {
it('works with maxSize of 1', () => {
const map = new LRUMap<string, number>({ maxSize: 1 });
map.set('a', 1);
expect(map.get('a')).toBe(1);
map.set('b', 2);
expect(map.has('a')).toBe(false);
expect(map.get('b')).toBe(2);
expect(map.size).toBe(1);
});
it('handles non-string keys', () => {
const map = new LRUMap<number, string>({ maxSize: 3 });
map.set(1, 'one');
map.set(2, 'two');
expect(map.get(1)).toBe('one');
// get(1) refreshes key 1, so order is now [2, 1]
expect(map.keysInOrder()).toEqual([2, 1]);
});
});
});

Some files were not shown because too many files have changed in this diff Show More